feat: add space ownership check and authorization guard for routes

This commit is contained in:
2025-09-11 00:16:06 -03:00
parent 718b5acedd
commit c9a6401137
2 changed files with 60 additions and 6 deletions

View File

@@ -278,6 +278,27 @@ export async function deleteSpace(args: {
await databases.deleteDocument(DATABASE_ID, COLLECTION_ID, existing.$id); 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<boolean> {
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<Models.Document>;
return (res.documents?.length ?? 0) > 0;
}
/** /**
* Upload an image file to Appwrite Storage and return its identifiers. * 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. * Applies user-level read/update/delete permissions so only the owner can access it by default.

View File

@@ -21,6 +21,7 @@ import {
type RemoteSnapshot, type RemoteSnapshot,
upsertSpaceSnapshot, upsertSpaceSnapshot,
} from "@/lib/appwrite-db"; } from "@/lib/appwrite-db";
import { doesUserOwnSpace } from "@/lib/appwrite-db";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { AIImageShapeUtil, AITextResultShapeUtil } from "@/lib/tldraw/ai-shapes"; import { AIImageShapeUtil, AITextResultShapeUtil } from "@/lib/tldraw/ai-shapes";
import { createImageShapeFromFile, setupFileDropHandler, generateText } from "@/lib/tldraw/processing"; import { createImageShapeFromFile, setupFileDropHandler, generateText } from "@/lib/tldraw/processing";
@@ -44,6 +45,7 @@ function SpaceRoute() {
const { id } = Route.useSearch(); const { id } = Route.useSearch();
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
const navigate = Route.useNavigate(); const navigate = Route.useNavigate();
const [authorized, setAuthorized] = useState<boolean | null>(null);
useEffect(() => { useEffect(() => {
if (!((session || isPending) && id)) { if (!((session || isPending) && id)) {
@@ -53,6 +55,32 @@ function SpaceRoute() {
} }
}, [session, isPending, navigate, id]); }, [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 // Create a stable store instance once
const store = useMemo( const store = useMemo(
() => () =>
@@ -71,11 +99,11 @@ function SpaceRoute() {
// Track latest saved version hash to suppress redundant saves // Track latest saved version hash to suppress redundant saves
const lastSavedRef = useRef<string>(""); const lastSavedRef = useRef<string>("");
// Load initial snapshot from Appwrite // Load initial snapshot from Appwrite (only after authorization)
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
async function load(_id: string | undefined) { async function load(_id: string | undefined) {
if (!(_id && session)) { if (!(_id && session && authorized)) {
return; return;
} }
const remote = await getLatestSpaceSnapshot(_id, session.$id); const remote = await getLatestSpaceSnapshot(_id, session.$id);
@@ -95,15 +123,20 @@ function SpaceRoute() {
} }
} }
setStoreWithStatus({ status: "not-synced", store }); setStoreWithStatus({ status: "not-synced", store });
if (authorized) {
load(id); load(id);
}
return () => { return () => {
cancelled = true; 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 <Loader />; return <Loader />;
} }
// 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 // Provide a custom Toolbar that injects our upload buttons into the bottom toolbar
const components: TLComponents = { const components: TLComponents = {
@@ -318,7 +351,7 @@ function SpaceRoute() {
let timeout: number | undefined; let timeout: number | undefined;
const unlisten = editor.store.listen( const unlisten = editor.store.listen(
() => { () => {
if (!(id && session)) { if (!(id && session && authorized)) {
return; return;
} }
window.clearTimeout(timeout); window.clearTimeout(timeout);