Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@journal/db": "*",
"@journal/shared": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.110.0",
"cors": "^2.8.6",
"dotenv": "^16.4.5",
"express": "^5.0.1",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const envSchema = z.object({
CLERK_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
CLERK_SECRET_KEY: z.string().startsWith("sk_"),
CLERK_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
SUPABASE_URL: z.string().url(),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
CORS_ORIGIN: z
.string()
.transform((val) =>
Expand Down
9 changes: 7 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import { mcpServer } from "./mcp/server.js";
const app = express();

app.use(helmet());
app.use(cors({ origin: env.CORS_ORIGIN, exposedHeaders: ["WWW-Authenticate"] }));
app.use(
cors({ origin: env.CORS_ORIGIN, exposedHeaders: ["WWW-Authenticate"] }),
);

// Mounted before express.json() — the webhook route needs the raw request
// body to verify the Svix signature.
Expand All @@ -44,7 +46,10 @@ app.get(
protectedResourceHandlerClerk({ scopes_supported: ["email", "profile"] }),
);
// still needed for clients that only support older versions of the MCP spec
app.get("/.well-known/oauth-authorization-server", authServerMetadataHandlerClerk);
app.get(
"/.well-known/oauth-authorization-server",
authServerMetadataHandlerClerk,
);

app.use(errorHandler);

Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/lib/supabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createClient } from "@supabase/supabase-js";
import { env } from "../config/env.js";

// Service-role client: bypasses RLS, server-only, never exposed to the client.
export const supabase = createClient(
env.SUPABASE_URL,
env.SUPABASE_SERVICE_ROLE_KEY,
);
31 changes: 31 additions & 0 deletions apps/api/src/middleware/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,34 @@ export async function createPostRateLimit(
}
}
}

// Tighter, per-user limit for upload-signing — each signed URL grants a
// write into Storage, so this guards against abuse of that write access.
const uploadSignLimiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: "rl:upload-sign",
points: 10, // requests
duration: 60, // per 60s
});

export async function uploadSignRateLimit(
req: Request,
res: Response,
next: NextFunction,
) {
const { userId } = getAuth(req);

try {
await uploadSignLimiter.consume(userId ?? req.ip ?? "unknown");
next();
} catch (err) {
if (!(err instanceof Error)) {
res.status(429).json({ error: "Too many requests" });
} else {
// Limiter infra failure (e.g. Redis unreachable), not a rate-limit
// denial — fail open so a Redis blip doesn't block uploads outright.
console.error("uploadSignRateLimit: limiter error, failing open", err);
next();
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
10 changes: 10 additions & 0 deletions apps/api/src/middleware/requireAuth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextFunction, Request, Response } from "express";
import { getAuth } from "@clerk/express";
import { HttpError } from "../lib/httpError.js";

/**
* @deprecated Use `getAuth(req)` / `getUserId(req)` directly in route handlers instead.
Expand All @@ -14,6 +15,15 @@ export function requireAuth(req: Request, res: Response, next: NextFunction) {
next();
}

// Throws if the request isn't authenticated — the shared guard route
// handlers call inline (instead of the deprecated `requireAuth` middleware
// above) so a single `try { ... } catch (err) { next(err) }` block covers
// both auth and validation errors uniformly.
export function requireAuthed(req: Request): void {
const { isAuthenticated } = getAuth(req);
if (!isAuthenticated) throw new HttpError(401, "Unauthorized");
}

// Throws so routes don't need to repeat the "userId could be null" check.
// Only call this after confirming the request is authenticated (e.g. inside
// a route mounted behind `requireAuth`, or after checking `isAuthenticated`).
Expand Down
11 changes: 2 additions & 9 deletions apps/api/src/routes/posts.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
import { Router, type Request } from "express";
import { getAuth } from "@clerk/express";
import { Router } from "express";
import {
createPostSchema,
updatePostSchema,
feedQuerySchema,
postIdParamSchema,
userIdParamSchema,
} from "@journal/shared";
import { getUserId } from "../middleware/requireAuth.js";
import { getUserId, requireAuthed } from "../middleware/requireAuth.js";
import { authRateLimit, createPostRateLimit } from "../middleware/rateLimit.js";
import { HttpError } from "../lib/httpError.js";
import * as postService from "../services/postService.js";

export const postsRouter = Router();

function requireAuthed(req: Request) {
const { isAuthenticated } = getAuth(req);
if (!isAuthenticated) throw new HttpError(401, "Unauthorized");
}

postsRouter.get("/", async (req, res, next) => {
try {
const { limit, offset } = feedQuerySchema.parse(req.query);
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/routes/uploads.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
import { Router } from "express";
import { signUploadSchema } from "@journal/shared";
import { getUserId, requireAuthed } from "../middleware/requireAuth.js";
import { uploadSignRateLimit } from "../middleware/rateLimit.js";
import * as storageService from "../services/storageService.js";

export const uploadsRouter = Router();

uploadsRouter.post("/sign", uploadSignRateLimit, async (req, res, next) => {
try {
requireAuthed(req);
const input = signUploadSchema.parse(req.body);
const data = await storageService.createSignedUpload(
getUserId(req),
input,
);
res.status(201).json({ success: true, data });
} catch (err) {
next(err);
}
});
Comment thread
OumB2021 marked this conversation as resolved.
39 changes: 39 additions & 0 deletions apps/api/src/services/storageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { randomUUID } from "node:crypto";
import { supabase } from "../lib/supabase.js";
import { HttpError } from "../lib/httpError.js";
import type { SignUploadInput } from "@journal/shared";

const BUCKET = "post-images";

const EXTENSIONS: Record<SignUploadInput["contentType"], string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
};

export async function createSignedUpload(
userId: string,
input: SignUploadInput,
) {
const path = `${userId}/${randomUUID()}.${EXTENSIONS[input.contentType]}`;

const { data, error } = await supabase.storage
.from(BUCKET)
.createSignedUploadUrl(path);

if (error || !data) {
console.error("storageService: createSignedUploadUrl failed", error);
throw new HttpError(502, "Failed to create signed upload URL");
}

const {
data: { publicUrl },
} = supabase.storage.from(BUCKET).getPublicUrl(path);

return {
signedUrl: data.signedUrl,
token: data.token,
path,
publicUrl,
};
}
Comment thread
OumB2021 marked this conversation as resolved.
Loading
Loading