From c9a6401137f0038b5657bfb0948edf645fdcda9a Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Thu, 11 Sep 2025 00:16:06 -0300 Subject: [PATCH] feat: add space ownership check and authorization guard for routes --- apps/web/src/lib/appwrite-db.ts | 21 +++++++++++++++ apps/web/src/routes/space.tsx | 45 ++++++++++++++++++++++++++++----- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/appwrite-db.ts b/apps/web/src/lib/appwrite-db.ts index a388e81..0f316c1 100644 --- a/apps/web/src/lib/appwrite-db.ts +++ b/apps/web/src/lib/appwrite-db.ts @@ -278,6 +278,27 @@ export async function deleteSpace(args: { await databases.deleteDocument(DATABASE_ID, COLLECTION_ID, existing.$id); } +/** + * Check whether a space exists and is owned by the current (or provided) user. + */ +export async function doesUserOwnSpace( + spaceId: string, + userId?: string +): Promise { + ensureEnv(); + const uid = userId ?? (await getCurrentUserId()); + if (!uid) { + return false; + } + + const res = (await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ + Query.equal("spaceId", spaceId), + Query.equal("userId", uid), + ])) as Models.DocumentList; + + return (res.documents?.length ?? 0) > 0; +} + /** * Upload an image file to Appwrite Storage and return its identifiers. * Applies user-level read/update/delete permissions so only the owner can access it by default. diff --git a/apps/web/src/routes/space.tsx b/apps/web/src/routes/space.tsx index c9ba688..9e84881 100644 --- a/apps/web/src/routes/space.tsx +++ b/apps/web/src/routes/space.tsx @@ -21,6 +21,7 @@ import { type RemoteSnapshot, upsertSpaceSnapshot, } from "@/lib/appwrite-db"; +import { doesUserOwnSpace } from "@/lib/appwrite-db"; import { authClient } from "@/lib/auth-client"; import { AIImageShapeUtil, AITextResultShapeUtil } from "@/lib/tldraw/ai-shapes"; import { createImageShapeFromFile, setupFileDropHandler, generateText } from "@/lib/tldraw/processing"; @@ -44,6 +45,7 @@ function SpaceRoute() { const { id } = Route.useSearch(); const { data: session, isPending } = authClient.useSession(); const navigate = Route.useNavigate(); + const [authorized, setAuthorized] = useState(null); useEffect(() => { if (!((session || isPending) && id)) { @@ -53,6 +55,32 @@ function SpaceRoute() { } }, [session, isPending, navigate, id]); + // Check that the space exists and is owned by the current user BEFORE anything else + useEffect(() => { + let cancelled = false; + async function check() { + if (!(id && session)) return; + setAuthorized(null); + try { + const ok = await doesUserOwnSpace(id, session.$id); + if (!cancelled) setAuthorized(!!ok); + } catch { + if (!cancelled) setAuthorized(false); + } + } + check(); + return () => { + cancelled = true; + }; + }, [id, session]); + + // If unauthorized, redirect to dashboard + useEffect(() => { + if (authorized === false) { + navigate({ to: "/dashboard" }); + } + }, [authorized, navigate]); + // Create a stable store instance once const store = useMemo( () => @@ -71,11 +99,11 @@ function SpaceRoute() { // Track latest saved version hash to suppress redundant saves const lastSavedRef = useRef(""); - // Load initial snapshot from Appwrite + // Load initial snapshot from Appwrite (only after authorization) useEffect(() => { let cancelled = false; async function load(_id: string | undefined) { - if (!(_id && session)) { + if (!(_id && session && authorized)) { return; } const remote = await getLatestSpaceSnapshot(_id, session.$id); @@ -95,15 +123,20 @@ function SpaceRoute() { } } setStoreWithStatus({ status: "not-synced", store }); - load(id); + if (authorized) { + load(id); + } return () => { cancelled = true; }; - }, [id, session, store]); + }, [id, session, store, authorized]); - if (!(id && session)) { + // Show loader while waiting for session/id or auth check + if (!(id && session) || authorized === null) { return ; } + // If explicitly unauthorized, render nothing (we redirect) + if (authorized === false) return null; // Provide a custom Toolbar that injects our upload buttons into the bottom toolbar const components: TLComponents = { @@ -318,7 +351,7 @@ function SpaceRoute() { let timeout: number | undefined; const unlisten = editor.store.listen( () => { - if (!(id && session)) { + if (!(id && session && authorized)) { return; } window.clearTimeout(timeout);