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
137 changes: 136 additions & 1 deletion apps/web/src/app/api/uploadthing/core.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { excalidrawFileIdSchema } from "@drawstuff/collaboration/asset";
import {
ASSET_CRYPTO_VERSION,
excalidrawFileIdSchema,
MAX_ASSET_CIPHERTEXT_BYTES,
MIN_ASSET_CIPHERTEXT_BYTES,
} from "@drawstuff/collaboration/asset";
import {
roomAuthGenerationSchema,
roomRoleCanEditScene,
} from "@drawstuff/collaboration/room-auth";
import { FILE_UPLOAD_MAX_BYTES } from "@/config/app-constants";
import { getMaxFileSizeString } from "@/lib/utils";
import {
commitRoomAssetUpload,
type AssetUploadOutcome,
} from "@/server/collab/assets";
import { resolveRoomAccess } from "@/server/collab/rooms";
import { db } from "@/server/db";
import { QUERIES } from "@/server/db/queries";
import { z } from "zod";
import { getServerSession } from "@/lib/auth/server";
Expand Down Expand Up @@ -235,6 +250,126 @@ export const uploadRouter = {
};
}),

/**
* 共編 room 資產上傳(Plan 17)。身份是 room + 授權世代 + Excalidraw file id。
*
* 上傳的位元組是**客戶端封裝好的密文**:明文的 data URL 與 MIME type 都在密文裡,
* 由 room key 衍生的 asset key 保護,而 room key 只存在於 URL fragment 與瀏覽器
* 記憶體。這條路徑因此對 payload 完全不透明——伺服器不解、不驗、也不記錄它。
*
* 授權檢查做兩次而不是一次,因為兩者擋的是不同的事:middleware 在上傳前擋掉沒有
* 權限的人(不讓他把位元組送進 storage),webhook 在寫入前再檢一次,擋掉「上傳期間
* 權限被撤銷或世代被轉動」。第二次檢查在 room lock 的交易內,與每一個 room
* lifecycle mutation 同一套順序(Plan 13)。
*/
collaborationAssetUploader: f({
blob: {
maxFileSize: getMaxFileSizeString(MAX_ASSET_CIPHERTEXT_BYTES),
maxFileCount: 1,
},
})
.input(
z.object({
roomId: z.string().min(1).max(64),
/**
* 密文封裝時所用的世代。要求它仍然是當前世代:封裝綁定了世代,存到別的
* 世代底下只會產生沒有人能解開的一列。
*/
authGeneration: roomAuthGenerationSchema,
// 一次一個檔案:身份必須顯式隨上傳帶入,檔名不是身份。
excalidrawFileId: excalidrawFileIdSchema,
cryptoVersion: z.literal(ASSET_CRYPTO_VERSION),
}),
)
.middleware(async ({ input }) => {
const session = await getServerSession();
if (!session) throw new Error("Unauthorized");
const access = await resolveRoomAccess(db, {
roomId: input.roomId,
userId: session.user.id,
now: new Date(),
});
if (access.status !== "ok") throw new Error("Forbidden");
// Viewer 可以讀資產,但不能替 room 新增 durable 狀態;relay 對它的即時
// 變更也是拒絕的,這裡擋的是同一件事的另一道門。
if (!roomRoleCanEditScene(access.role)) throw new Error("Forbidden");
if (access.room.authGeneration !== input.authGeneration) {
throw new Error("Stale room generation");
}
return {
userId: session.user.id,
roomId: access.room.roomId,
authGeneration: input.authGeneration,
excalidrawFileId: input.excalidrawFileId,
cryptoVersion: input.cryptoVersion,
} as const;
})
.onUploadComplete(async ({ metadata, file }) => {
const context = {
roomId: metadata.roomId,
excalidrawFileId: metadata.excalidrawFileId,
};
const discard = async (reason: string): Promise<void> => {
const ok = await deleteFileWithRetry(file.key, { ...context, reason });
if (!ok) await enqueueDeferredCleanup(file.key, reason, context);
};

// 大小上下界在寫入前檢查:sealed envelope 有固定 overhead,長度落在區間外的
// 物件不可能是這個 room 的資產密文。
if (
file.size < MIN_ASSET_CIPHERTEXT_BYTES ||
file.size > MAX_ASSET_CIPHERTEXT_BYTES
) {
await discard("asset-size-out-of-range");
throw new Error("Collaboration asset size is out of range");
}

let outcome: AssetUploadOutcome;
try {
outcome = await commitRoomAssetUpload(db, {
roomId: metadata.roomId,
userId: metadata.userId,
authGeneration: metadata.authGeneration,
fileId: metadata.excalidrawFileId,
storage: {
cryptoVersion: metadata.cryptoVersion,
utFileKey: file.key,
url: file.ufsUrl,
byteLength: file.size,
},
now: new Date(),
});
} catch (error) {
// 只記錯誤名稱,不記整個 error:Drizzle 的 `DrizzleQueryError` 的 message 內含
// `params:`,也就是這次 insert 的 `url` 與 storage key——那是取得密文的
// capability,不能因為一次寫入失敗就被複製進應用日誌。
console.error("Error recording collaboration asset:", {
...context,
error: error instanceof Error ? error.name : "unknown",
});
await discard("db-write-failed");
throw new Error("Failed to record collaboration asset");
}

if (outcome !== "recorded") {
// 這個世代已經有同一個 file id:同一個 id 就是同一份明文,既有那筆一樣有效,
// 剛上傳的物件沒有引用者。被拒絕或超出額度的情況同樣沒有引用者。
await discard(`asset-${outcome}`);
if (outcome === "rejected") {
throw new Error("Not allowed to upload this collaboration asset");
}
if (outcome === "budget-exceeded") {
throw new Error("This collaboration room has too many assets");
}
}

// 回傳刻意不含 URL:URL 是取得密文的能力,只由 `collaborationAsset.resolve`
// 在授權後發給成員,不透過上傳回應擴散。清理失敗時共用的
// `deleteFileWithRetry`/`enqueueDeferredCleanup` 仍會記下 storage key——那是
// 處理孤兒物件唯一可用的線索,且它指向的內容只有密文。
return { uploadedBy: metadata.userId, fileKey: file.key };
}),

// 新增:場景縮圖上傳(不做內容去重,最後寫入生效)
sceneThumbnailUploader: f({
blob: {
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/hooks/excalidraw/use-collaboration-room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
claimCanvasForRoom,
releaseCanvasRoom,
} from "@/lib/collab/canvas-room-marker";
import { uploadCollaborationAsset } from "@/lib/collab/asset-upload";
import type { BaselineOutcome } from "@/lib/collab/collaboration-session";
import {
startCollaborationRoomSession,
Expand Down Expand Up @@ -357,6 +358,16 @@ export function useCollaborationRoom(options: {
put: (input) =>
utilsRef.current.client.collaborationSnapshot.put.mutate(input),
},
// Same shape, and for the same reason: the store needs two plain async
// functions, one to find out where a room's ciphertext lives and one to
// put ciphertext there. Neither can read what it carries.
assetApi: {
resolve: (input, signal) =>
utilsRef.current.client.collaborationAsset.resolve.query(input, {
signal,
}),
upload: uploadCollaborationAsset,
},
wrapRemoteApply,
canSyncScene: () => canvasBelongsToRoom(joined.roomId),
onBaselineResolved: (outcome) => {
Expand Down
Loading
Loading