From 27f8da1d16fcd80236c4817edc91831303a44df6 Mon Sep 17 00:00:00 2001 From: Oumb2021 Date: Sun, 5 Jul 2026 00:15:13 -0400 Subject: [PATCH 1/2] image upload feature --- apps/api/package.json | 1 + apps/api/src/config/env.ts | 2 + apps/api/src/index.ts | 9 +- apps/api/src/lib/supabase.ts | 8 + apps/api/src/middleware/rateLimit.ts | 28 +++ apps/api/src/routes/uploads.ts | 27 ++- apps/api/src/services/storageService.ts | 38 ++++ apps/mobile/components/AddPostPanel.tsx | 243 +++++++++++++++++++++--- apps/mobile/lib/date 2.ts | 7 + apps/mobile/store/feedStore 2.ts | 83 ++++++++ package-lock.json | 94 +++++++++ packages/shared/src/index.ts | 9 + 12 files changed, 518 insertions(+), 31 deletions(-) create mode 100644 apps/api/src/lib/supabase.ts create mode 100644 apps/api/src/services/storageService.ts create mode 100644 apps/mobile/lib/date 2.ts create mode 100644 apps/mobile/store/feedStore 2.ts diff --git a/apps/api/package.json b/apps/api/package.json index f905eae..cc25d75 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index c12bd46..9dede3d 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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) => diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1d055b3..97f7ea0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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. @@ -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); diff --git a/apps/api/src/lib/supabase.ts b/apps/api/src/lib/supabase.ts new file mode 100644 index 0000000..184d2fa --- /dev/null +++ b/apps/api/src/lib/supabase.ts @@ -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, +); diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index 1b4d474..43515df 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -86,3 +86,31 @@ 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 { + next(err); + } + } +} diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts index 10c737d..f497e8b 100644 --- a/apps/api/src/routes/uploads.ts +++ b/apps/api/src/routes/uploads.ts @@ -1,3 +1,28 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; +import { getAuth } from "@clerk/express"; +import { signUploadSchema } from "@journal/shared"; +import { getUserId } from "../middleware/requireAuth.js"; +import { uploadSignRateLimit } from "../middleware/rateLimit.js"; +import { HttpError } from "../lib/httpError.js"; +import * as storageService from "../services/storageService.js"; export const uploadsRouter = Router(); + +function requireAuthed(req: Request) { + const { isAuthenticated } = getAuth(req); + if (!isAuthenticated) throw new HttpError(401, "Unauthorized"); +} + +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); + } +}); diff --git a/apps/api/src/services/storageService.ts b/apps/api/src/services/storageService.ts new file mode 100644 index 0000000..46523dc --- /dev/null +++ b/apps/api/src/services/storageService.ts @@ -0,0 +1,38 @@ +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 = { + "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) { + 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, + }; +} diff --git a/apps/mobile/components/AddPostPanel.tsx b/apps/mobile/components/AddPostPanel.tsx index ed24b12..643b5e6 100644 --- a/apps/mobile/components/AddPostPanel.tsx +++ b/apps/mobile/components/AddPostPanel.tsx @@ -11,6 +11,8 @@ import { KeyboardAvoidingView, Platform, StyleSheet, + Alert, + ActivityIndicator, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { Image } from "expo-image"; @@ -24,24 +26,58 @@ import Animated, { } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { useRouter } from "expo-router"; +import { createPostSchema } from "@journal/shared"; import { useTheme } from "@/theme/useTheme"; import { useToastStore } from "@/store/toastStore"; +import { useFeedStore } from "@/store/feedStore"; +import { useApi } from "@/lib/api"; import setHexOpacity from "@/utils/opacity-change"; const SCREEN_WIDTH = Dimensions.get("window").width; const MAX_DESC = 200; const CATEGORIES = [ - "Architecture", - "Art", - "Culture", - "Food", "Lifestyle", - "Nature", "Technology", "Travel", + "Food", + "Art", + "Music", + "Fitness", + "Other", ]; +const fieldValidationSchema = createPostSchema.omit({ image_url: true }); + +interface FieldErrors { + title?: string; + body?: string; + category?: string; +} + +interface SignUploadResponse { + success: boolean; + data: { signedUrl: string; publicUrl: string }; +} + +interface CreatePostResponse { + success: boolean; + data: { id: string }; +} + +function extractErrorMessage(err: unknown): string { + if (err instanceof Error) { + try { + const parsed = JSON.parse(err.message); + if (parsed && typeof parsed.error === "string") return parsed.error; + } catch { + // Not a JSON error body — fall back to the raw message below. + } + return err.message || "Something went wrong. Please try again."; + } + return "Something went wrong. Please try again."; +} + interface Props { onClose: () => void; } @@ -51,13 +87,19 @@ export default function AddPostPanel({ onClose }: Props) { const insets = useSafeAreaInsets(); const router = useRouter(); const { show: showToast } = useToastStore(); + const api = useApi(); + const fetchPosts = useFeedStore((state) => state.fetchPosts); const [modalVisible, setModalVisible] = useState(true); const [imageUri, setImageUri] = useState(null); + const [imageMimeType, setImageMimeType] = useState(null); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [category, setCategory] = useState(""); const [categoryOpen, setCategoryOpen] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); const disableColor = setHexOpacity(colors.interactiveBg, 0.4); const translateX = useSharedValue(SCREEN_WIDTH); @@ -75,21 +117,124 @@ export default function AddPostPanel({ onClose }: Props) { router.navigate("/(tabs)/home"); }, [router]); - const handleClose = useCallback(() => { + const animateClose = useCallback(() => { setCategoryOpen(false); translateX.value = withTiming(SCREEN_WIDTH, { duration: 250 }, () => { runOnJS(dismiss)(); }); }, [translateX, dismiss]); - const handlePublish = useCallback(() => { - setCategoryOpen(false); - translateX.value = withTiming(SCREEN_WIDTH, { duration: 250 }, () => { - runOnJS(dismiss)(); - runOnJS(showToast)("Post published successfully!"); - runOnJS(goHome)(); + const hasUnsavedInput = + !!imageUri || + title.trim().length > 0 || + description.trim().length > 0 || + !!category; + + const handleClose = useCallback(() => { + if (!hasUnsavedInput) { + animateClose(); + return; + } + Alert.alert("Discard post?", "Your changes will be lost.", [ + { text: "Cancel", style: "cancel" }, + { text: "Discard", style: "destructive", onPress: animateClose }, + ]); + }, [hasUnsavedInput, animateClose]); + + const validateFields = useCallback(() => { + const result = fieldValidationSchema.safeParse({ + title: title.trim(), + body: description.trim() || undefined, + category, }); - }, [translateX, dismiss, showToast, goHome]); + + if (!result.success) { + const errors: FieldErrors = {}; + for (const issue of result.error.issues) { + const key = issue.path[0] as keyof FieldErrors; + if (!errors[key]) errors[key] = issue.message; + } + setFieldErrors(errors); + return false; + } + + setFieldErrors({}); + return true; + }, [title, description, category]); + + const handlePublish = useCallback(async () => { + if (isSubmitting) return; + if (!validateFields()) return; + + setSubmitError(null); + setIsSubmitting(true); + try { + const contentType = imageMimeType ?? "image/jpeg"; + const signRes = (await api("/uploads/sign", { + method: "POST", + body: JSON.stringify({ contentType }), + })) as SignUploadResponse; + + const { signedUrl, publicUrl } = signRes.data; + + const fileRes = await fetch(imageUri!); + const blob = await fileRes.blob(); + + const uploadRes = await fetch(signedUrl, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: blob, + }); + console.log("uploadRes: ", uploadRes); + if (!uploadRes.ok) { + throw new Error("Failed to upload image. Please try again."); + } + + const payload = createPostSchema.parse({ + title: title.trim(), + body: description.trim() || undefined, + category, + image_url: publicUrl, + }); + + const postRes = (await api("/posts", { + method: "POST", + body: JSON.stringify(payload), + })) as CreatePostResponse; + + if (!postRes.success) { + throw new Error("Failed to publish post. Please try again."); + } + + await fetchPosts(api, true); + + setCategoryOpen(false); + translateX.value = withTiming(SCREEN_WIDTH, { duration: 250 }, () => { + runOnJS(dismiss)(); + runOnJS(showToast)("Post published successfully!"); + runOnJS(goHome)(); + }); + } catch (err) { + console.log("error: ", err); + setSubmitError(extractErrorMessage(err)); + } finally { + setIsSubmitting(false); + } + }, [ + isSubmitting, + validateFields, + imageMimeType, + imageUri, + title, + description, + category, + api, + fetchPosts, + translateX, + dismiss, + showToast, + goHome, + ]); const panGesture = Gesture.Pan() .activeOffsetX(15) @@ -128,6 +273,7 @@ export default function AddPostPanel({ onClose }: Props) { if (!result.canceled && result.assets[0]) { setImageUri(result.assets[0].uri); + setImageMimeType(result.assets[0].mimeType ?? "image/jpeg"); } }, []); @@ -135,7 +281,8 @@ export default function AddPostPanel({ onClose }: Props) { if (text.length <= MAX_DESC) setDescription(text); }, []); - const canPublish = !!imageUri && title.trim().length > 0 && !!category; + const canPublish = + !!imageUri && title.trim().length > 0 && !!category && !isSubmitting; return ( { + setTitle(text); + setFieldErrors((prev) => ({ ...prev, title: undefined })); + }} + maxLength={120} returnKeyType="next" /> + {fieldErrors.title && ( + + {fieldErrors.title} + + )} {/* ── Description ──────────────────────────── */} @@ -277,10 +433,18 @@ export default function AddPostPanel({ onClose }: Props) { placeholder="Share what inspired this post..." placeholderTextColor={colors.textTertiary} value={description} - onChangeText={handleDescChange} + onChangeText={(text) => { + handleDescChange(text); + setFieldErrors((prev) => ({ ...prev, body: undefined })); + }} multiline textAlignVertical="top" /> + {fieldErrors.body && ( + + {fieldErrors.body} + + )} {/* ── Category ─────────────────────────────── */} @@ -333,6 +497,10 @@ export default function AddPostPanel({ onClose }: Props) { onPress={() => { setCategory(cat); setCategoryOpen(false); + setFieldErrors((prev) => ({ + ...prev, + category: undefined, + })); }} className="flex-row items-center justify-between px-[14px] py-[13px]" style={[ @@ -370,8 +538,18 @@ export default function AddPostPanel({ onClose }: Props) { })} )} + {fieldErrors.category && ( + + {fieldErrors.category} + + )} + {submitError && ( + + {submitError} + + )} - - - Publish Post - + {isSubmitting ? ( + + ) : ( + <> + + + Publish Post + + + )} diff --git a/apps/mobile/lib/date 2.ts b/apps/mobile/lib/date 2.ts new file mode 100644 index 0000000..1b1593e --- /dev/null +++ b/apps/mobile/lib/date 2.ts @@ -0,0 +1,7 @@ +export function formatPostDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} diff --git a/apps/mobile/store/feedStore 2.ts b/apps/mobile/store/feedStore 2.ts new file mode 100644 index 0000000..d5e08d4 --- /dev/null +++ b/apps/mobile/store/feedStore 2.ts @@ -0,0 +1,83 @@ +import { create } from "zustand"; + +export interface ApiPost { + id: string; + authorId: string; + title: string; + body: string | null; + imageUrl: string; + category: string; + likesCount: number; + createdAt: string; + author: { + id: string; + name: string; + avatarUrl: string | null; + }; +} + +type Api = (path: string, init?: RequestInit) => Promise; + +interface FeedResponse { + success: boolean; + data: ApiPost[]; +} + +const PAGE_SIZE = 20; + +interface FeedState { + posts: ApiPost[]; + isLoading: boolean; + isRefreshing: boolean; + error: string | null; + hasMore: boolean; + offset: number; + fetchPosts: (api: Api, refresh?: boolean) => Promise; +} + +export const useFeedStore = create((set, get) => ({ + posts: [], + isLoading: false, + isRefreshing: false, + error: null, + hasMore: true, + offset: 0, + + fetchPosts: async (api, refresh = false) => { + const state = get(); + if (state.isLoading || state.isRefreshing) return; + if (!refresh && !state.hasMore) return; + + set( + refresh + ? { isRefreshing: true, error: null } + : { isLoading: true, error: null }, + ); + + const offset = refresh ? 0 : state.offset; + + try { + const res = (await api( + `/posts?limit=${PAGE_SIZE}&offset=${offset}`, + )) as FeedResponse; + + if (!res.success || !res.data) { + throw new Error("Failed to load posts"); + } + + set((prev) => ({ + posts: refresh ? res.data : [...prev.posts, ...res.data], + hasMore: res.data.length === PAGE_SIZE, + offset: offset + res.data.length, + isLoading: false, + isRefreshing: false, + })); + } catch (err) { + set({ + error: err instanceof Error ? err.message : "Failed to load posts", + isLoading: false, + isRefreshing: false, + }); + } + }, +})); diff --git a/package-lock.json b/package-lock.json index 7a8382c..4253455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,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", @@ -7315,6 +7316,90 @@ "node": ">=12.16" } }, + "node_modules/@supabase/auth-js": { + "version": "2.110.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.0.tgz", + "integrity": "sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.110.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.0.tgz", + "integrity": "sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz", + "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.110.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.0.tgz", + "integrity": "sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.110.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.0.tgz", + "integrity": "sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "0.4.4", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.110.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.0.tgz", + "integrity": "sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.110.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.0.tgz", + "integrity": "sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.110.0", + "@supabase/functions-js": "2.110.0", + "@supabase/postgrest-js": "2.110.0", + "@supabase/realtime-js": "2.110.0", + "@supabase/storage-js": "2.110.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/@swc/helpers": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", @@ -12773,6 +12858,15 @@ "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", "license": "BSD-3-Clause" }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1ff581b..5242c63 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -26,6 +26,15 @@ export const postSchema = createPostSchema.extend({ }); export type Post = z.infer; +// ---- Uploads ---- + +export const signUploadSchema = z + .object({ + contentType: z.enum(["image/jpeg", "image/png", "image/webp"]), + }) + .strict(); +export type SignUploadInput = z.infer; + // ---- Users ---- export const updateProfileSchema = z.object({ From e58c848f5c58402bbeb96798742f889589d6f332 Mon Sep 17 00:00:00 2001 From: Oumb2021 Date: Sun, 5 Jul 2026 11:27:17 -0400 Subject: [PATCH 2/2] coderabbit suggested changes --- apps/api/src/middleware/rateLimit.ts | 5 +- apps/api/src/middleware/requireAuth.ts | 10 +++ apps/api/src/routes/posts.ts | 11 +--- apps/api/src/routes/uploads.ts | 11 +--- apps/api/src/services/storageService.ts | 1 + apps/mobile/components/AddPostPanel.tsx | 85 +++++++++++++++++-------- apps/mobile/lib/date 2.ts | 7 -- apps/mobile/lib/date.ts | 2 +- apps/mobile/store/feedStore 2.ts | 83 ------------------------ 9 files changed, 79 insertions(+), 136 deletions(-) delete mode 100644 apps/mobile/lib/date 2.ts delete mode 100644 apps/mobile/store/feedStore 2.ts diff --git a/apps/api/src/middleware/rateLimit.ts b/apps/api/src/middleware/rateLimit.ts index 43515df..0b31b0a 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -110,7 +110,10 @@ export async function uploadSignRateLimit( if (!(err instanceof Error)) { res.status(429).json({ error: "Too many requests" }); } else { - next(err); + // 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(); } } } diff --git a/apps/api/src/middleware/requireAuth.ts b/apps/api/src/middleware/requireAuth.ts index 5933a1f..bca958e 100644 --- a/apps/api/src/middleware/requireAuth.ts +++ b/apps/api/src/middleware/requireAuth.ts @@ -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. @@ -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`). diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts index 7cc0dd3..9df3114 100644 --- a/apps/api/src/routes/posts.ts +++ b/apps/api/src/routes/posts.ts @@ -1,5 +1,4 @@ -import { Router, type Request } from "express"; -import { getAuth } from "@clerk/express"; +import { Router } from "express"; import { createPostSchema, updatePostSchema, @@ -7,18 +6,12 @@ import { 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); diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts index f497e8b..2d266a1 100644 --- a/apps/api/src/routes/uploads.ts +++ b/apps/api/src/routes/uploads.ts @@ -1,18 +1,11 @@ -import { Router, type Request } from "express"; -import { getAuth } from "@clerk/express"; +import { Router } from "express"; import { signUploadSchema } from "@journal/shared"; -import { getUserId } from "../middleware/requireAuth.js"; +import { getUserId, requireAuthed } from "../middleware/requireAuth.js"; import { uploadSignRateLimit } from "../middleware/rateLimit.js"; -import { HttpError } from "../lib/httpError.js"; import * as storageService from "../services/storageService.js"; export const uploadsRouter = Router(); -function requireAuthed(req: Request) { - const { isAuthenticated } = getAuth(req); - if (!isAuthenticated) throw new HttpError(401, "Unauthorized"); -} - uploadsRouter.post("/sign", uploadSignRateLimit, async (req, res, next) => { try { requireAuthed(req); diff --git a/apps/api/src/services/storageService.ts b/apps/api/src/services/storageService.ts index 46523dc..452005a 100644 --- a/apps/api/src/services/storageService.ts +++ b/apps/api/src/services/storageService.ts @@ -22,6 +22,7 @@ export async function createSignedUpload( .createSignedUploadUrl(path); if (error || !data) { + console.error("storageService: createSignedUploadUrl failed", error); throw new HttpError(502, "Failed to create signed upload URL"); } diff --git a/apps/mobile/components/AddPostPanel.tsx b/apps/mobile/components/AddPostPanel.tsx index 643b5e6..d0ead8d 100644 --- a/apps/mobile/components/AddPostPanel.tsx +++ b/apps/mobile/components/AddPostPanel.tsx @@ -35,6 +35,7 @@ import setHexOpacity from "@/utils/opacity-change"; const SCREEN_WIDTH = Dimensions.get("window").width; const MAX_DESC = 200; +const UPLOAD_TIMEOUT_MS = 30000; const CATEGORIES = [ "Lifestyle", @@ -49,6 +50,13 @@ const CATEGORIES = [ const fieldValidationSchema = createPostSchema.omit({ image_url: true }); +// The signed-upload endpoint only accepts these — cropping (allowsEditing: +// true) always re-encodes the picked image to JPEG on disk, but the picker +// can still report the original asset's mimeType (e.g. "image/heic" on +// iOS), so anything outside this set is normalized to JPEG rather than +// trusted as-is. +const SUPPORTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); + interface FieldErrors { title?: string; body?: string; @@ -78,6 +86,11 @@ function extractErrorMessage(err: unknown): string { return "Something went wrong. Please try again."; } +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return {message}; +} + interface Props { onClose: () => void; } @@ -131,6 +144,7 @@ export default function AddPostPanel({ onClose }: Props) { !!category; const handleClose = useCallback(() => { + if (isSubmitting) return; if (!hasUnsavedInput) { animateClose(); return; @@ -139,7 +153,7 @@ export default function AddPostPanel({ onClose }: Props) { { text: "Cancel", style: "cancel" }, { text: "Discard", style: "destructive", onPress: animateClose }, ]); - }, [hasUnsavedInput, animateClose]); + }, [isSubmitting, hasUnsavedInput, animateClose]); const validateFields = useCallback(() => { const result = fieldValidationSchema.safeParse({ @@ -175,17 +189,42 @@ export default function AddPostPanel({ onClose }: Props) { body: JSON.stringify({ contentType }), })) as SignUploadResponse; + if (!signRes.success) { + throw new Error("Failed to prepare image upload. Please try again."); + } + const { signedUrl, publicUrl } = signRes.data; - const fileRes = await fetch(imageUri!); + if (!imageUri) { + throw new Error("Please select an image before publishing."); + } + + const fileRes = await fetch(imageUri); const blob = await fileRes.blob(); - const uploadRes = await fetch(signedUrl, { - method: "PUT", - headers: { "Content-Type": contentType }, - body: blob, - }); - console.log("uploadRes: ", uploadRes); + const uploadController = new AbortController(); + const uploadTimeout = setTimeout( + () => uploadController.abort(), + UPLOAD_TIMEOUT_MS, + ); + let uploadRes: Response; + try { + uploadRes = await fetch(signedUrl, { + method: "PUT", + headers: { "Content-Type": contentType }, + body: blob, + signal: uploadController.signal, + }); + } catch (uploadErr) { + if (uploadErr instanceof Error && uploadErr.name === "AbortError") { + throw new Error( + "Image upload timed out. Please check your connection and try again.", + ); + } + throw uploadErr; + } finally { + clearTimeout(uploadTimeout); + } if (!uploadRes.ok) { throw new Error("Failed to upload image. Please try again."); } @@ -215,7 +254,6 @@ export default function AddPostPanel({ onClose }: Props) { runOnJS(goHome)(); }); } catch (err) { - console.log("error: ", err); setSubmitError(extractErrorMessage(err)); } finally { setIsSubmitting(false); @@ -246,7 +284,8 @@ export default function AddPostPanel({ onClose }: Props) { }) .onEnd((e) => { const shouldDismiss = - e.translationX > SCREEN_WIDTH * 0.3 || e.velocityX > 500; + !isSubmitting && + (e.translationX > SCREEN_WIDTH * 0.3 || e.velocityX > 500); if (shouldDismiss) { translateX.value = withTiming(SCREEN_WIDTH, { duration: 250 }, () => { runOnJS(dismiss)(); @@ -272,8 +311,11 @@ export default function AddPostPanel({ onClose }: Props) { }); if (!result.canceled && result.assets[0]) { + const mimeType = result.assets[0].mimeType; setImageUri(result.assets[0].uri); - setImageMimeType(result.assets[0].mimeType ?? "image/jpeg"); + setImageMimeType( + mimeType && SUPPORTED_IMAGE_TYPES.has(mimeType) ? mimeType : "image/jpeg", + ); } }, []); @@ -315,14 +357,17 @@ export default function AddPostPanel({ onClose }: Props) { @@ -405,11 +450,7 @@ export default function AddPostPanel({ onClose }: Props) { maxLength={120} returnKeyType="next" /> - {fieldErrors.title && ( - - {fieldErrors.title} - - )} + {/* ── Description ──────────────────────────── */} @@ -440,11 +481,7 @@ export default function AddPostPanel({ onClose }: Props) { multiline textAlignVertical="top" /> - {fieldErrors.body && ( - - {fieldErrors.body} - - )} + {/* ── Category ─────────────────────────────── */} @@ -538,11 +575,7 @@ export default function AddPostPanel({ onClose }: Props) { })} )} - {fieldErrors.category && ( - - {fieldErrors.category} - - )} + {submitError && ( diff --git a/apps/mobile/lib/date 2.ts b/apps/mobile/lib/date 2.ts deleted file mode 100644 index 1b1593e..0000000 --- a/apps/mobile/lib/date 2.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function formatPostDate(iso: string): string { - return new Date(iso).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); -} diff --git a/apps/mobile/lib/date.ts b/apps/mobile/lib/date.ts index 1b1593e..171902a 100644 --- a/apps/mobile/lib/date.ts +++ b/apps/mobile/lib/date.ts @@ -1,5 +1,5 @@ export function formatPostDate(iso: string): string { - return new Date(iso).toLocaleDateString("en-US", { + return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric", diff --git a/apps/mobile/store/feedStore 2.ts b/apps/mobile/store/feedStore 2.ts deleted file mode 100644 index d5e08d4..0000000 --- a/apps/mobile/store/feedStore 2.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { create } from "zustand"; - -export interface ApiPost { - id: string; - authorId: string; - title: string; - body: string | null; - imageUrl: string; - category: string; - likesCount: number; - createdAt: string; - author: { - id: string; - name: string; - avatarUrl: string | null; - }; -} - -type Api = (path: string, init?: RequestInit) => Promise; - -interface FeedResponse { - success: boolean; - data: ApiPost[]; -} - -const PAGE_SIZE = 20; - -interface FeedState { - posts: ApiPost[]; - isLoading: boolean; - isRefreshing: boolean; - error: string | null; - hasMore: boolean; - offset: number; - fetchPosts: (api: Api, refresh?: boolean) => Promise; -} - -export const useFeedStore = create((set, get) => ({ - posts: [], - isLoading: false, - isRefreshing: false, - error: null, - hasMore: true, - offset: 0, - - fetchPosts: async (api, refresh = false) => { - const state = get(); - if (state.isLoading || state.isRefreshing) return; - if (!refresh && !state.hasMore) return; - - set( - refresh - ? { isRefreshing: true, error: null } - : { isLoading: true, error: null }, - ); - - const offset = refresh ? 0 : state.offset; - - try { - const res = (await api( - `/posts?limit=${PAGE_SIZE}&offset=${offset}`, - )) as FeedResponse; - - if (!res.success || !res.data) { - throw new Error("Failed to load posts"); - } - - set((prev) => ({ - posts: refresh ? res.data : [...prev.posts, ...res.data], - hasMore: res.data.length === PAGE_SIZE, - offset: offset + res.data.length, - isLoading: false, - isRefreshing: false, - })); - } catch (err) { - set({ - error: err instanceof Error ? err.message : "Failed to load posts", - isLoading: false, - isRefreshing: false, - }); - } - }, -}));