Skip to content

Commit 981e7c7

Browse files
committed
Api improvements
1 parent 776b416 commit 981e7c7

28 files changed

Lines changed: 1316 additions & 405 deletions

Caddyfile

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,6 @@ www.underlay.org {
1010
handle /api/* {
1111
reverse_proxy 127.0.0.1:3001
1212
}
13-
handle /assets/* {
14-
reverse_proxy 127.0.0.1:3001
15-
}
1613
handle /uploads/* {
1714
reverse_proxy 127.0.0.1:3001
1815
}
@@ -30,9 +27,6 @@ dev.underlay.org {
3027
handle /api/* {
3128
reverse_proxy 127.0.0.1:3000
3229
}
33-
handle /assets/* {
34-
reverse_proxy 127.0.0.1:3000
35-
}
3630
handle /uploads/* {
3731
reverse_proxy 127.0.0.1:3000
3832
}

docker-compose.local.yml

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,35 +35,6 @@ services:
3535
memory: 1g
3636
cpus: "1.0"
3737

38-
minio:
39-
image: minio/minio:latest
40-
command: server /data --console-address ":9001"
41-
environment:
42-
MINIO_ROOT_USER: minioadmin
43-
MINIO_ROOT_PASSWORD: minioadmin
44-
ports:
45-
- "9000:9000"
46-
- "9001:9001"
47-
volumes:
48-
- miniodata-dev:/data
49-
networks:
50-
- dev
51-
52-
createbucket:
53-
image: minio/mc:latest
54-
depends_on:
55-
minio:
56-
condition: service_started
57-
networks:
58-
- dev
59-
entrypoint: >
60-
/bin/sh -c "
61-
sleep 2;
62-
mc alias set local http://minio:9000 minioadmin minioadmin;
63-
mc mb local/underlay --ignore-existing;
64-
exit 0;
65-
"
66-
6738
app:
6839
build:
6940
context: .
@@ -79,8 +50,6 @@ services:
7950
depends_on:
8051
postgres:
8152
condition: service_healthy
82-
minio:
83-
condition: service_started
8453
volumes:
8554
- ./src:/app/src
8655
- ./public:/app/public
@@ -107,4 +76,3 @@ networks:
10776

10877
volumes:
10978
pgdata-dev:
110-
miniodata-dev:

package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@fastify/cookie": "^11.0.0",
3333
"@fastify/cors": "^11.0.0",
3434
"@fastify/multipart": "^10.0.0",
35+
"@fastify/rate-limit": "^10.3.0",
3536
"@lucide/astro": "^1.11.0",
3637
"@sinclair/typebox": "^0.34.0",
3738
"ajv": "^8.17.0",

public/.well-known/ai.txt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,26 @@ There are two auth methods:
2323
POST /api/accounts/logout clears the session.
2424
GET /api/accounts/me returns the current user (works with either auth method).
2525

26-
Public endpoints (browsing collections, reading public versions) require no auth.
27-
Write endpoints require a key with "write" scope or an active session.
28-
Delete endpoints require "admin" scope.
26+
All GET requests are public — no auth required to read public data.
27+
All write requests (POST, PATCH, PUT, DELETE) require authentication.
28+
If a Bearer token is provided but invalid, the request is rejected immediately (401).
29+
30+
## Rate Limits
31+
32+
Requests are rate-limited per IP (unauthenticated) or per account (authenticated).
33+
34+
| Auth status | Limit |
35+
|-------------------|---------------|
36+
| Unauthenticated | 60 req/min |
37+
| Authenticated | 5,000 req/min |
38+
39+
Rate limit headers are included on every response:
40+
- X-RateLimit-Limit: max requests in the window
41+
- X-RateLimit-Remaining: requests left
42+
- X-RateLimit-Reset: seconds until the window resets
43+
44+
When exceeded, you'll get a 429 response with a Retry-After header.
45+
To get the higher limit, authenticate with an API key (recommended for any automated access).
2946

3047
---
3148

src/api/plugins/auth.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,36 @@ declare module "fastify" {
1414
}
1515

1616
async function authPlugin(app: FastifyInstance) {
17-
// API key auth via Bearer token
1817
app.decorateRequest("accountId", undefined);
1918
app.decorateRequest("apiKeyScope", undefined);
2019
app.decorateRequest("apiKeyCollectionId", undefined);
2120
app.decorateRequest("sessionUserId", undefined);
2221

23-
app.addHook("onRequest", async (request: FastifyRequest) => {
22+
// Paths that never require auth (even for POST)
23+
const publicPaths = new Set([
24+
"/api/health",
25+
"/api/accounts/signup",
26+
"/api/accounts/login",
27+
"/api/accounts/forgot-password",
28+
"/api/accounts/reset-password",
29+
]);
30+
31+
const internalToken = process.env.INTERNAL_API_TOKEN ?? "internal-dev-token";
32+
33+
app.addHook("onRequest", async (request: FastifyRequest, reply: FastifyReply) => {
34+
// Internal service calls from Astro SSR
35+
const internalHeader = request.headers["x-internal-token"];
36+
if (internalHeader === internalToken) {
37+
request.apiKeyScope = "read";
38+
return;
39+
}
40+
41+
// API key auth via Bearer token
2442
const auth = request.headers.authorization;
2543
if (auth?.startsWith("Bearer ")) {
2644
const token = auth.slice(7);
2745
const keys = await db.select().from(schema.apiKeys);
46+
let matched = false;
2847
for (const key of keys) {
2948
const match = await bcrypt.compare(token, key.keyHash);
3049
if (match) {
@@ -35,9 +54,14 @@ async function authPlugin(app: FastifyInstance) {
3554
.update(schema.apiKeys)
3655
.set({ lastUsedAt: new Date() })
3756
.where(eq(schema.apiKeys.id, key.id));
38-
return;
57+
matched = true;
58+
break;
3959
}
4060
}
61+
if (!matched) {
62+
return reply.status(401).send({ error: "Invalid API key", statusCode: 401 });
63+
}
64+
return;
4165
}
4266

4367
// Session cookie auth
@@ -62,6 +86,16 @@ async function authPlugin(app: FastifyInstance) {
6286
// Invalid or expired cookie — ignore silently
6387
}
6488
}
89+
90+
// Public GETs are allowed without auth (rate-limited by IP)
91+
if (request.method === "GET") return;
92+
93+
// All writes (POST/PATCH/PUT/DELETE) require auth, except public paths
94+
if (!request.accountId) {
95+
const path = request.url.split("?")[0];
96+
if (publicPaths.has(path)) return;
97+
return reply.status(401).send({ error: "Authentication required", statusCode: 401 });
98+
}
6599
});
66100
}
67101

src/api/routes/accounts.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { requireAuth } from "../plugins/auth.js";
77
import { uploadToS3, deleteS3Objects, listS3Objects } from "../../lib/s3.js";
88
import { sendEmail } from "../../lib/email.js";
99

10+
/** Base URL for public assets (avatars, etc.) */
11+
const ASSETS_BASE_URL = process.env.ASSETS_BASE_URL ?? "https://assets.underlay.org";
12+
1013
const RESERVED_SLUGS = new Set([
1114
"explore", "docs", "connect", "blog", "dashboard", "settings",
1215
"api", "login", "signup", "admin", "about", "help", "support",
@@ -323,13 +326,12 @@ export async function accountRoutes(app: FastifyInstance) {
323326

324327
await uploadToS3(key, buffer, data.mimetype);
325328

326-
const avatarUrl = `/api/files/avatars/${request.accountId}/${key.split("/").pop()}`;
327329
await db
328330
.update(schema.accounts)
329-
.set({ avatarUrl })
331+
.set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` })
330332
.where(eq(schema.accounts.id, request.accountId!));
331333

332-
return { ok: true, avatarUrl };
334+
return { ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` };
333335
},
334336
);
335337

@@ -1070,10 +1072,9 @@ export async function accountRoutes(app: FastifyInstance) {
10701072

10711073
await uploadToS3(key, buffer, data.mimetype);
10721074

1073-
const avatarUrl = `/api/files/avatars/${org.id}/${key.split("/").pop()}`;
1074-
await db.update(schema.accounts).set({ avatarUrl }).where(eq(schema.accounts.id, org.id));
1075+
await db.update(schema.accounts).set({ avatarUrl: `${ASSETS_BASE_URL}/${key}` }).where(eq(schema.accounts.id, org.id));
10751076

1076-
return { ok: true, avatarUrl };
1077+
return { ok: true, avatarUrl: `${ASSETS_BASE_URL}/${key}` };
10771078
},
10781079
);
10791080

src/api/server.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Fastify from "fastify";
22
import cookie from "@fastify/cookie";
33
import cors from "@fastify/cors";
44
import multipart from "@fastify/multipart";
5+
import rateLimit from "@fastify/rate-limit";
56

67
import authPlugin from "./plugins/auth.js";
78
import { healthRoutes } from "./routes/health.js";
@@ -29,6 +30,23 @@ export async function buildApp() {
2930
credentials: true,
3031
});
3132

33+
// Rate limiting: unauthenticated gets 60/min, authenticated gets 5000/min
34+
await app.register(rateLimit, {
35+
max: (request) => request.accountId ? 5000 : 60,
36+
timeWindow: "1 minute",
37+
keyGenerator: (request) => {
38+
if (request.accountId) return `acct:${request.accountId}`;
39+
return request.ip;
40+
},
41+
hook: "preHandler", // runs after onRequest (auth), so accountId is set
42+
errorResponseBuilder: (_request, context) => ({
43+
statusCode: 429,
44+
error: "Too Many Requests",
45+
message: `Rate limit exceeded. Try again in ${Math.ceil(context.ttl / 1000)} seconds.`,
46+
retryAfter: Math.ceil(context.ttl / 1000),
47+
}),
48+
});
49+
3250
await app.register(multipart, {
3351
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB for large files
3452
});

0 commit comments

Comments
 (0)