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..0b31b0a 100644 --- a/apps/api/src/middleware/rateLimit.ts +++ b/apps/api/src/middleware/rateLimit.ts @@ -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(); + } + } +} 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 10c737d..2d266a1 100644 --- a/apps/api/src/routes/uploads.ts +++ b/apps/api/src/routes/uploads.ts @@ -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); + } +}); diff --git a/apps/api/src/services/storageService.ts b/apps/api/src/services/storageService.ts new file mode 100644 index 0000000..452005a --- /dev/null +++ b/apps/api/src/services/storageService.ts @@ -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 = { + "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, + }; +} diff --git a/apps/mobile/components/AddPostPanel.tsx b/apps/mobile/components/AddPostPanel.tsx index ed24b12..d0ead8d 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,71 @@ 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 UPLOAD_TIMEOUT_MS = 30000; const CATEGORIES = [ - "Architecture", - "Art", - "Culture", - "Food", "Lifestyle", - "Nature", "Technology", "Travel", + "Food", + "Art", + "Music", + "Fitness", + "Other", ]; +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; + 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."; +} + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return {message}; +} + interface Props { onClose: () => void; } @@ -51,13 +100,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 +130,149 @@ 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 (isSubmitting) return; + if (!hasUnsavedInput) { + animateClose(); + return; + } + Alert.alert("Discard post?", "Your changes will be lost.", [ + { text: "Cancel", style: "cancel" }, + { text: "Discard", style: "destructive", onPress: animateClose }, + ]); + }, [isSubmitting, 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; + + if (!signRes.success) { + throw new Error("Failed to prepare image upload. Please try again."); + } + + const { signedUrl, publicUrl } = signRes.data; + + if (!imageUri) { + throw new Error("Please select an image before publishing."); + } + + const fileRes = await fetch(imageUri); + const blob = await fileRes.blob(); + + 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."); + } + + 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) { + 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) @@ -101,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)(); @@ -127,7 +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( + mimeType && SUPPORTED_IMAGE_TYPES.has(mimeType) ? mimeType : "image/jpeg", + ); } }, []); @@ -135,7 +323,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 ( @@ -251,9 +443,14 @@ export default function AddPostPanel({ onClose }: Props) { placeholder="Give your post a title..." placeholderTextColor={colors.textTertiary} value={title} - onChangeText={setTitle} + onChangeText={(text) => { + setTitle(text); + setFieldErrors((prev) => ({ ...prev, title: undefined })); + }} + maxLength={120} returnKeyType="next" /> + {/* ── Description ──────────────────────────── */} @@ -277,10 +474,14 @@ 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" /> + {/* ── Category ─────────────────────────────── */} @@ -333,6 +534,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 +575,14 @@ export default function AddPostPanel({ onClose }: Props) { })} )} + + {submitError && ( + + {submitError} + + )} - - - Publish Post - + {isSubmitting ? ( + + ) : ( + <> + + + Publish Post + + + )} 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/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({