feat(collab): restore cross-account sharing and reliable realtime sync
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "DrawingShareLink" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"drawingId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DrawingShareGrant" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"drawingId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"shareLinkId" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId");
|
||||
@@ -27,6 +27,7 @@ model User {
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
drawingShareGrants DrawingShareGrant[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -67,6 +68,8 @@ model Drawing {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
collectionId String?
|
||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||
shareLinks DrawingShareLink[]
|
||||
shareGrants DrawingShareGrant[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -74,6 +77,38 @@ model Drawing {
|
||||
@@index([userId, collectionId, updatedAt])
|
||||
}
|
||||
|
||||
model DrawingShareLink {
|
||||
id String @id @default(uuid())
|
||||
drawingId String
|
||||
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
|
||||
role String
|
||||
token String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
grants DrawingShareGrant[]
|
||||
|
||||
@@unique([drawingId, role])
|
||||
@@index([drawingId])
|
||||
}
|
||||
|
||||
model DrawingShareGrant {
|
||||
id String @id @default(uuid())
|
||||
drawingId String
|
||||
drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade)
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
shareLinkId String
|
||||
shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
|
||||
role String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([drawingId, userId, shareLinkId])
|
||||
@@index([drawingId, userId])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model Library {
|
||||
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||
items String @default("[]") // Stored as JSON string array of library items
|
||||
|
||||
@@ -282,7 +282,7 @@ app.use(
|
||||
cors({
|
||||
origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)),
|
||||
credentials: true,
|
||||
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file", "x-share-token"],
|
||||
exposedHeaders: ["x-csrf-token", "x-request-id"],
|
||||
})
|
||||
);
|
||||
@@ -574,6 +574,7 @@ apiApp.get("/health", (req, res) => {
|
||||
|
||||
registerDashboardRoutes(apiApp, {
|
||||
prisma,
|
||||
authModeService,
|
||||
requireAuth,
|
||||
asyncHandler,
|
||||
parseJsonField,
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
toInternalTrashCollectionId,
|
||||
toPublicTrashCollectionId,
|
||||
} from "./trash";
|
||||
import {
|
||||
ensureShareLinkForRole,
|
||||
isAtLeastRole,
|
||||
isShareRole,
|
||||
rotateShareLinkForRole,
|
||||
resolveDrawingAccess,
|
||||
} from "../../server/drawingAccess";
|
||||
|
||||
const getRouteIdParam = (value: string | string[] | undefined): string | null => {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value;
|
||||
@@ -16,12 +23,29 @@ const getRouteIdParam = (value: string | string[] | undefined): string | null =>
|
||||
return null;
|
||||
};
|
||||
|
||||
const getShareTokenFromRequest = (req: express.Request): string | undefined => {
|
||||
const value = req.headers["x-share-token"];
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim().length > 0) {
|
||||
return value[0].trim();
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const payloadHasOnlySceneFields = (payload: Record<string, unknown>): boolean => {
|
||||
const allowed = new Set(["elements", "appState", "files", "preview", "version"]);
|
||||
return Object.keys(payload).every((key) => allowed.has(key));
|
||||
};
|
||||
|
||||
export const registerDrawingRoutes = (
|
||||
app: express.Express,
|
||||
deps: DashboardRouteDeps
|
||||
) => {
|
||||
const {
|
||||
prisma,
|
||||
authModeService,
|
||||
requireAuth,
|
||||
asyncHandler,
|
||||
parseJsonField,
|
||||
@@ -152,6 +176,7 @@ export const registerDrawingRoutes = (
|
||||
if (shouldIncludeData) {
|
||||
responsePayload = (drawings as any[]).map((d: any) => ({
|
||||
...d,
|
||||
accessRole: "owner",
|
||||
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
||||
elements: parseJsonField(d.elements, []),
|
||||
appState: parseJsonField(d.appState, {}),
|
||||
@@ -160,6 +185,7 @@ export const registerDrawingRoutes = (
|
||||
} else {
|
||||
responsePayload = (drawings as any[]).map((d: any) => ({
|
||||
...d,
|
||||
accessRole: "owner",
|
||||
collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id),
|
||||
}));
|
||||
}
|
||||
@@ -177,27 +203,246 @@ export const registerDrawingRoutes = (
|
||||
return res.send(body);
|
||||
}));
|
||||
|
||||
app.get("/drawings/shared", requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const authEnabled = await authModeService.getAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const { search, includeData, limit, offset, sortField, sortDirection } = req.query;
|
||||
const searchTerm =
|
||||
typeof search === "string" && search.trim().length > 0 ? search.trim() : undefined;
|
||||
|
||||
const shouldIncludeData =
|
||||
typeof includeData === "string"
|
||||
? includeData.toLowerCase() === "true" || includeData === "1"
|
||||
: false;
|
||||
|
||||
const parsedSortField: SortField =
|
||||
sortField === "name" || sortField === "createdAt" || sortField === "updatedAt"
|
||||
? sortField
|
||||
: "updatedAt";
|
||||
const parsedSortDirection: SortDirection =
|
||||
sortDirection === "asc" || sortDirection === "desc"
|
||||
? sortDirection
|
||||
: parsedSortField === "name"
|
||||
? "asc"
|
||||
: "desc";
|
||||
|
||||
const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined;
|
||||
const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined;
|
||||
const parsedLimit =
|
||||
rawLimit !== undefined && Number.isFinite(rawLimit)
|
||||
? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE)
|
||||
: undefined;
|
||||
const parsedOffset =
|
||||
rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined;
|
||||
|
||||
const baseRows = await prisma.$queryRaw<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
collectionId: string | null;
|
||||
preview: string | null;
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
elements: string;
|
||||
appState: string;
|
||||
files: string;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
ownerEmail: string;
|
||||
roleRank: number;
|
||||
}>>(Prisma.sql`
|
||||
SELECT
|
||||
d."id" AS id,
|
||||
d."name" AS name,
|
||||
d."collectionId" AS "collectionId",
|
||||
d."preview" AS preview,
|
||||
d."version" AS version,
|
||||
d."createdAt" AS "createdAt",
|
||||
d."updatedAt" AS "updatedAt",
|
||||
d."elements" AS elements,
|
||||
d."appState" AS "appState",
|
||||
d."files" AS files,
|
||||
u."id" AS "ownerId",
|
||||
u."name" AS "ownerName",
|
||||
u."email" AS "ownerEmail",
|
||||
MAX(CASE g."role" WHEN 'editor' THEN 2 WHEN 'viewer' THEN 1 ELSE 0 END) AS "roleRank"
|
||||
FROM "DrawingShareGrant" g
|
||||
JOIN "Drawing" d ON d."id" = g."drawingId"
|
||||
JOIN "User" u ON u."id" = d."userId"
|
||||
WHERE g."userId" = ${req.user.id}
|
||||
AND d."userId" <> ${req.user.id}
|
||||
AND g."role" IN ('viewer', 'editor')
|
||||
${searchTerm ? Prisma.sql`AND d."name" LIKE ${`%${searchTerm}%`}` : Prisma.empty}
|
||||
GROUP BY d."id", u."id"
|
||||
`);
|
||||
|
||||
const sortedRows = [...baseRows].sort((a, b) => {
|
||||
const direction = parsedSortDirection === "asc" ? 1 : -1;
|
||||
if (parsedSortField === "name") {
|
||||
return a.name.localeCompare(b.name) * direction;
|
||||
}
|
||||
if (parsedSortField === "createdAt") {
|
||||
return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * direction;
|
||||
}
|
||||
return (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * direction;
|
||||
});
|
||||
|
||||
const totalCount = sortedRows.length;
|
||||
const start = parsedOffset ?? 0;
|
||||
const end = parsedLimit !== undefined ? start + parsedLimit : undefined;
|
||||
const pageRows = sortedRows.slice(start, end);
|
||||
|
||||
const payload = pageRows.map((row) => {
|
||||
const accessRole = Number(row.roleRank) >= 2 ? "editor" : "viewer";
|
||||
const base = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
preview: row.preview,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
collectionId: null,
|
||||
accessRole,
|
||||
owner: {
|
||||
id: row.ownerId,
|
||||
name: row.ownerName,
|
||||
email: row.ownerEmail,
|
||||
},
|
||||
} as Record<string, unknown>;
|
||||
|
||||
if (shouldIncludeData) {
|
||||
base.elements = parseJsonField(row.elements, []);
|
||||
base.appState = parseJsonField(row.appState, {});
|
||||
base.files = parseJsonField(row.files, {});
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
return res.json({
|
||||
drawings: payload,
|
||||
totalCount,
|
||||
limit: parsedLimit,
|
||||
offset: parsedOffset,
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/drawings/:id/share-links", requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const authEnabled = await authModeService.getAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const id = getRouteIdParam(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
||||
}
|
||||
|
||||
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } });
|
||||
if (!drawing) {
|
||||
return res.status(404).json({ error: "Drawing not found" });
|
||||
}
|
||||
|
||||
const [viewer, editor] = await Promise.all([
|
||||
ensureShareLinkForRole(prisma, id, "viewer"),
|
||||
ensureShareLinkForRole(prisma, id, "editor"),
|
||||
]);
|
||||
|
||||
return res.json({
|
||||
drawingId: id,
|
||||
viewerToken: viewer.token,
|
||||
editorToken: editor.token,
|
||||
});
|
||||
}));
|
||||
|
||||
app.post("/drawings/:id/share-links/:role/rotate", requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const authEnabled = await authModeService.getAuthEnabled();
|
||||
if (!authEnabled) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const id = getRouteIdParam(req.params.id);
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
||||
}
|
||||
|
||||
const roleParam = getRouteIdParam(req.params.role);
|
||||
if (!isShareRole(roleParam)) {
|
||||
return res.status(400).json({ error: "Validation error", message: "Invalid share role" });
|
||||
}
|
||||
|
||||
const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } });
|
||||
if (!drawing) {
|
||||
return res.status(404).json({ error: "Drawing not found" });
|
||||
}
|
||||
|
||||
const rotated = await rotateShareLinkForRole(prisma, id, roleParam);
|
||||
|
||||
if (config.enableAuditLogging) {
|
||||
await logAuditEvent({
|
||||
userId: req.user.id,
|
||||
action: "drawing_share_link_rotated",
|
||||
resource: `drawing:${id}`,
|
||||
ipAddress: req.ip || req.connection.remoteAddress || undefined,
|
||||
userAgent: req.headers["user-agent"] || undefined,
|
||||
details: { drawingId: id, role: roleParam },
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
role: roleParam,
|
||||
drawingId: id,
|
||||
token: rotated.token,
|
||||
});
|
||||
}));
|
||||
|
||||
app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => {
|
||||
if (!req.user) return res.status(401).json({ error: "Unauthorized" });
|
||||
|
||||
const id = getRouteIdParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
||||
const drawing = await prisma.drawing.findFirst({
|
||||
where: {
|
||||
id,
|
||||
userId: req.user.id,
|
||||
},
|
||||
|
||||
const access = await resolveDrawingAccess({
|
||||
prisma,
|
||||
drawingId: id,
|
||||
userId: req.user.id,
|
||||
shareToken: getShareTokenFromRequest(req),
|
||||
});
|
||||
if (!drawing) {
|
||||
|
||||
if (!access) {
|
||||
return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" });
|
||||
}
|
||||
|
||||
if (access.tokenRedeemedRole && config.enableAuditLogging) {
|
||||
await logAuditEvent({
|
||||
userId: req.user.id,
|
||||
action: "drawing_share_token_redeemed",
|
||||
resource: `drawing:${id}`,
|
||||
ipAddress: req.ip || req.connection.remoteAddress || undefined,
|
||||
userAgent: req.headers["user-agent"] || undefined,
|
||||
details: { drawingId: id, role: access.tokenRedeemedRole },
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
...drawing,
|
||||
collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user.id),
|
||||
elements: parseJsonField(drawing.elements, []),
|
||||
appState: parseJsonField(drawing.appState, {}),
|
||||
files: parseJsonField(drawing.files, {}),
|
||||
...access.drawing,
|
||||
accessRole: access.role,
|
||||
collectionId:
|
||||
access.role === "owner"
|
||||
? toPublicTrashCollectionId(access.drawing.collectionId, req.user.id)
|
||||
: null,
|
||||
elements: parseJsonField(access.drawing.elements, []),
|
||||
appState: parseJsonField(access.drawing.appState, {}),
|
||||
files: parseJsonField(access.drawing.files, {}),
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -254,6 +499,7 @@ export const registerDrawingRoutes = (
|
||||
|
||||
return res.json({
|
||||
...newDrawing,
|
||||
accessRole: "owner",
|
||||
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
||||
elements: parseJsonField(newDrawing.elements, []),
|
||||
appState: parseJsonField(newDrawing.appState, {}),
|
||||
@@ -266,10 +512,13 @@ export const registerDrawingRoutes = (
|
||||
|
||||
const id = getRouteIdParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
||||
const existingDrawing = await prisma.drawing.findFirst({
|
||||
where: { id, userId: req.user.id },
|
||||
|
||||
const access = await resolveDrawingAccess({
|
||||
prisma,
|
||||
drawingId: id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
if (!existingDrawing) return res.status(404).json({ error: "Drawing not found" });
|
||||
if (!access) return res.status(404).json({ error: "Drawing not found" });
|
||||
|
||||
const parsed = drawingUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -288,6 +537,17 @@ export const registerDrawingRoutes = (
|
||||
files?: Record<string, unknown>;
|
||||
version?: number;
|
||||
};
|
||||
|
||||
const payloadRecord = payload as unknown as Record<string, unknown>;
|
||||
|
||||
if (!isAtLeastRole(access.role, "editor")) {
|
||||
return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" });
|
||||
}
|
||||
|
||||
if (access.role === "editor" && !payloadHasOnlySceneFields(payloadRecord)) {
|
||||
return res.status(403).json({ error: "Forbidden", message: "Editors can only update scene content" });
|
||||
}
|
||||
|
||||
const trashCollectionId = getUserTrashCollectionId(req.user.id);
|
||||
const isSceneUpdate =
|
||||
payload.elements !== undefined ||
|
||||
@@ -304,6 +564,9 @@ export const registerDrawingRoutes = (
|
||||
if (payload.preview !== undefined) data.preview = payload.preview;
|
||||
|
||||
if (payload.collectionId !== undefined) {
|
||||
if (access.role !== "owner") {
|
||||
return res.status(403).json({ error: "Forbidden", message: "Only the owner can move drawings" });
|
||||
}
|
||||
if (payload.collectionId === "trash") {
|
||||
await ensureTrashCollection(prisma, req.user.id);
|
||||
(data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId;
|
||||
@@ -318,7 +581,10 @@ export const registerDrawingRoutes = (
|
||||
}
|
||||
}
|
||||
|
||||
const updateWhere: Prisma.DrawingWhereInput = { id, userId: req.user.id };
|
||||
const updateWhere: Prisma.DrawingWhereInput = { id };
|
||||
if (access.role === "owner") {
|
||||
updateWhere.userId = req.user.id;
|
||||
}
|
||||
if (isSceneUpdate && payload.version !== undefined) {
|
||||
updateWhere.version = payload.version;
|
||||
}
|
||||
@@ -330,7 +596,7 @@ export const registerDrawingRoutes = (
|
||||
if (updateResult.count === 0) {
|
||||
if (isSceneUpdate && payload.version !== undefined) {
|
||||
const latestDrawing = await prisma.drawing.findFirst({
|
||||
where: { id, userId: req.user.id },
|
||||
where: { id },
|
||||
select: { version: true },
|
||||
});
|
||||
return res.status(409).json({
|
||||
@@ -344,7 +610,7 @@ export const registerDrawingRoutes = (
|
||||
}
|
||||
|
||||
const updatedDrawing = await prisma.drawing.findFirst({
|
||||
where: { id, userId: req.user.id },
|
||||
where: { id },
|
||||
});
|
||||
if (!updatedDrawing) {
|
||||
return res.status(404).json({ error: "Drawing not found" });
|
||||
@@ -353,7 +619,11 @@ export const registerDrawingRoutes = (
|
||||
|
||||
return res.json({
|
||||
...updatedDrawing,
|
||||
collectionId: toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id),
|
||||
accessRole: access.role,
|
||||
collectionId:
|
||||
access.role === "owner"
|
||||
? toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id)
|
||||
: null,
|
||||
elements: parseJsonField(updatedDrawing.elements, []),
|
||||
appState: parseJsonField(updatedDrawing.appState, {}),
|
||||
files: parseJsonField(updatedDrawing.files, {}),
|
||||
@@ -395,20 +665,26 @@ export const registerDrawingRoutes = (
|
||||
|
||||
const id = getRouteIdParam(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" });
|
||||
const original = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } });
|
||||
if (!original) return res.status(404).json({ error: "Original drawing not found" });
|
||||
let duplicatedCollectionId = original.collectionId;
|
||||
if (isTrashCollectionId(original.collectionId, req.user.id)) {
|
||||
|
||||
const access = await resolveDrawingAccess({
|
||||
prisma,
|
||||
drawingId: id,
|
||||
userId: req.user.id,
|
||||
});
|
||||
if (!access) return res.status(404).json({ error: "Original drawing not found" });
|
||||
|
||||
let duplicatedCollectionId = access.role === "owner" ? access.drawing.collectionId : null;
|
||||
if (access.role === "owner" && isTrashCollectionId(access.drawing.collectionId, req.user.id)) {
|
||||
await ensureTrashCollection(prisma, req.user.id);
|
||||
duplicatedCollectionId = getUserTrashCollectionId(req.user.id);
|
||||
}
|
||||
|
||||
const newDrawing = await prisma.drawing.create({
|
||||
data: {
|
||||
name: `${original.name} (Copy)`,
|
||||
elements: original.elements,
|
||||
appState: original.appState,
|
||||
files: original.files,
|
||||
name: `${access.drawing.name} (Copy)`,
|
||||
elements: access.drawing.elements,
|
||||
appState: access.drawing.appState,
|
||||
files: access.drawing.files,
|
||||
userId: req.user.id,
|
||||
collectionId: duplicatedCollectionId,
|
||||
version: 1,
|
||||
@@ -418,6 +694,7 @@ export const registerDrawingRoutes = (
|
||||
|
||||
return res.json({
|
||||
...newDrawing,
|
||||
accessRole: "owner",
|
||||
collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id),
|
||||
elements: parseJsonField(newDrawing.elements, []),
|
||||
appState: parseJsonField(newDrawing.appState, {}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import { z } from "zod";
|
||||
import { Prisma, PrismaClient } from "../../generated/client";
|
||||
import { AuthModeService } from "../../auth/authMode";
|
||||
|
||||
export type SortField = "name" | "createdAt" | "updatedAt";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
@@ -30,6 +31,7 @@ type LogAuditEvent = (params: {
|
||||
|
||||
export type DashboardRouteDeps = {
|
||||
prisma: PrismaClient;
|
||||
authModeService: AuthModeService;
|
||||
requireAuth: express.RequestHandler;
|
||||
asyncHandler: <T = void>(
|
||||
fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<T>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { Prisma, PrismaClient } from "../generated/client";
|
||||
|
||||
export const SHARE_ROLES = ["viewer", "editor"] as const;
|
||||
export type ShareRole = (typeof SHARE_ROLES)[number];
|
||||
export type DrawingAccessRole = "owner" | ShareRole;
|
||||
|
||||
const SHARE_ROLE_SET = new Set<string>(SHARE_ROLES);
|
||||
|
||||
export const isShareRole = (value: unknown): value is ShareRole =>
|
||||
typeof value === "string" && SHARE_ROLE_SET.has(value);
|
||||
|
||||
const roleRank = (role: DrawingAccessRole): number => {
|
||||
if (role === "owner") return 3;
|
||||
if (role === "editor") return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const isAtLeastRole = (
|
||||
grantedRole: DrawingAccessRole,
|
||||
requiredRole: DrawingAccessRole
|
||||
): boolean => roleRank(grantedRole) >= roleRank(requiredRole);
|
||||
|
||||
export const generateShareToken = (): string => randomBytes(24).toString("hex");
|
||||
|
||||
export type DrawingAccessResult = {
|
||||
drawing: {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
elements: string;
|
||||
appState: string;
|
||||
files: string;
|
||||
preview: string | null;
|
||||
version: number;
|
||||
collectionId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
role: DrawingAccessRole;
|
||||
tokenRedeemedRole?: ShareRole;
|
||||
};
|
||||
|
||||
type ResolveDrawingAccessParams = {
|
||||
prisma: PrismaClient;
|
||||
drawingId: string;
|
||||
userId: string;
|
||||
shareToken?: string;
|
||||
};
|
||||
|
||||
type RawShareLink = {
|
||||
id: string;
|
||||
drawingId: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
type RawShareGrant = {
|
||||
role: string;
|
||||
};
|
||||
|
||||
const upsertShareGrant = async (
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
drawingId: string;
|
||||
userId: string;
|
||||
shareLinkId: string;
|
||||
role: ShareRole;
|
||||
}
|
||||
): Promise<void> => {
|
||||
const now = new Date().toISOString();
|
||||
await prisma.$executeRaw`
|
||||
INSERT OR IGNORE INTO "DrawingShareGrant"
|
||||
("id", "drawingId", "userId", "shareLinkId", "role", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(${randomUUID()}, ${params.drawingId}, ${params.userId}, ${params.shareLinkId}, ${params.role}, ${now}, ${now})
|
||||
`;
|
||||
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "DrawingShareGrant"
|
||||
SET "role" = ${params.role}, "updatedAt" = ${now}
|
||||
WHERE "drawingId" = ${params.drawingId}
|
||||
AND "userId" = ${params.userId}
|
||||
AND "shareLinkId" = ${params.shareLinkId}
|
||||
`;
|
||||
};
|
||||
|
||||
export const resolveDrawingAccess = async ({
|
||||
prisma,
|
||||
drawingId,
|
||||
userId,
|
||||
shareToken,
|
||||
}: ResolveDrawingAccessParams): Promise<DrawingAccessResult | null> => {
|
||||
const drawing = await prisma.drawing.findUnique({
|
||||
where: { id: drawingId },
|
||||
});
|
||||
if (!drawing) return null;
|
||||
|
||||
if (drawing.userId === userId) {
|
||||
return { drawing, role: "owner" };
|
||||
}
|
||||
|
||||
let tokenRedeemedRole: ShareRole | undefined;
|
||||
const token = typeof shareToken === "string" && shareToken.trim().length > 0
|
||||
? shareToken.trim()
|
||||
: undefined;
|
||||
|
||||
if (token) {
|
||||
const links = await prisma.$queryRaw<RawShareLink[]>`
|
||||
SELECT "id", "drawingId", "role"
|
||||
FROM "DrawingShareLink"
|
||||
WHERE "token" = ${token}
|
||||
LIMIT 1
|
||||
`;
|
||||
const link = links[0];
|
||||
|
||||
if (link && link.drawingId === drawingId && isShareRole(link.role)) {
|
||||
await upsertShareGrant(prisma, {
|
||||
drawingId,
|
||||
userId,
|
||||
shareLinkId: link.id,
|
||||
role: link.role,
|
||||
});
|
||||
tokenRedeemedRole = link.role;
|
||||
}
|
||||
}
|
||||
|
||||
const grants = await prisma.$queryRaw<RawShareGrant[]>`
|
||||
SELECT "role"
|
||||
FROM "DrawingShareGrant"
|
||||
WHERE "drawingId" = ${drawingId}
|
||||
AND "userId" = ${userId}
|
||||
`;
|
||||
|
||||
const hasEditor = grants.some((grant) => grant.role === "editor");
|
||||
if (hasEditor) return { drawing, role: "editor", tokenRedeemedRole };
|
||||
|
||||
const hasViewer = grants.some((grant) => grant.role === "viewer");
|
||||
if (hasViewer) return { drawing, role: "viewer", tokenRedeemedRole };
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const ensureShareLinkForRole = async (
|
||||
prisma: PrismaClient,
|
||||
drawingId: string,
|
||||
role: ShareRole
|
||||
): Promise<{ id: string; token: string }> => {
|
||||
const rows = await prisma.$queryRaw<Array<{ id: string; token: string }>>`
|
||||
SELECT "id", "token"
|
||||
FROM "DrawingShareLink"
|
||||
WHERE "drawingId" = ${drawingId}
|
||||
AND "role" = ${role}
|
||||
LIMIT 1
|
||||
`;
|
||||
const existing = rows[0];
|
||||
if (existing) return existing;
|
||||
|
||||
const token = generateShareToken();
|
||||
const now = new Date().toISOString();
|
||||
const id = randomUUID();
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO "DrawingShareLink"
|
||||
("id", "drawingId", "role", "token", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(${id}, ${drawingId}, ${role}, ${token}, ${now}, ${now})
|
||||
`;
|
||||
|
||||
return { id, token };
|
||||
};
|
||||
|
||||
export const rotateShareLinkForRole = async (
|
||||
prisma: PrismaClient,
|
||||
drawingId: string,
|
||||
role: ShareRole
|
||||
): Promise<{ token: string }> => {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT "id"
|
||||
FROM "DrawingShareLink"
|
||||
WHERE "drawingId" = ${drawingId}
|
||||
AND "role" = ${role}
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const token = generateShareToken();
|
||||
|
||||
if (!existing[0]) {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO "DrawingShareLink"
|
||||
("id", "drawingId", "role", "token", "createdAt", "updatedAt")
|
||||
VALUES
|
||||
(${randomUUID()}, ${drawingId}, ${role}, ${token}, ${now}, ${now})
|
||||
`;
|
||||
return { token };
|
||||
}
|
||||
|
||||
const linkId = existing[0].id;
|
||||
|
||||
await tx.$executeRaw`
|
||||
DELETE FROM "DrawingShareGrant"
|
||||
WHERE "drawingId" = ${drawingId}
|
||||
AND "shareLinkId" = ${linkId}
|
||||
`;
|
||||
|
||||
await tx.$executeRaw`
|
||||
UPDATE "DrawingShareLink"
|
||||
SET "token" = ${token}, "updatedAt" = ${now}
|
||||
WHERE "id" = ${linkId}
|
||||
`;
|
||||
|
||||
return { token };
|
||||
});
|
||||
};
|
||||
|
||||
export const getSharedRoleMap = async (
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
drawingIds: string[]
|
||||
): Promise<Map<string, ShareRole>> => {
|
||||
const roleByDrawingId = new Map<string, ShareRole>();
|
||||
if (drawingIds.length === 0) return roleByDrawingId;
|
||||
|
||||
const idsSql = Prisma.join(drawingIds);
|
||||
const rows = await prisma.$queryRaw<Array<{ drawingId: string; role: string }>>(
|
||||
Prisma.sql`
|
||||
SELECT "drawingId", "role"
|
||||
FROM "DrawingShareGrant"
|
||||
WHERE "userId" = ${userId}
|
||||
AND "drawingId" IN (${idsSql})
|
||||
`
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
if (!isShareRole(row.role)) continue;
|
||||
const current = roleByDrawingId.get(row.drawingId);
|
||||
if (!current || (current === "viewer" && row.role === "editor")) {
|
||||
roleByDrawingId.set(row.drawingId, row.role);
|
||||
}
|
||||
}
|
||||
|
||||
return roleByDrawingId;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { Server } from "socket.io";
|
||||
import { PrismaClient } from "../generated/client";
|
||||
import { AuthModeService } from "../auth/authMode";
|
||||
import { ACCESS_TOKEN_COOKIE_NAME, parseCookieHeader } from "../auth/cookies";
|
||||
import { isAtLeastRole, resolveDrawingAccess } from "./drawingAccess";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -110,7 +111,7 @@ export const registerSocketHandlers = ({
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
const authenticatedUserId = socketUserMap.get(socket.id);
|
||||
const authorizedDrawingIds = new Set<string>();
|
||||
const authorizedDrawingRoles = new Map<string, "owner" | "editor" | "viewer">();
|
||||
|
||||
socket.on(
|
||||
"join-room",
|
||||
@@ -123,20 +124,22 @@ export const registerSocketHandlers = ({
|
||||
}) => {
|
||||
try {
|
||||
if (authenticatedUserId) {
|
||||
const drawing = await prisma.drawing.findFirst({
|
||||
where: { id: drawingId, userId: authenticatedUserId },
|
||||
select: { id: true },
|
||||
const drawing = await resolveDrawingAccess({
|
||||
prisma,
|
||||
drawingId,
|
||||
userId: authenticatedUserId,
|
||||
});
|
||||
|
||||
if (!drawing) {
|
||||
socket.emit("error", { message: "You do not have access to this drawing" });
|
||||
return;
|
||||
}
|
||||
|
||||
authorizedDrawingRoles.set(drawingId, drawing.role);
|
||||
}
|
||||
|
||||
const roomId = `drawing_${drawingId}`;
|
||||
socket.join(roomId);
|
||||
authorizedDrawingIds.add(drawingId);
|
||||
|
||||
let trustedUserId =
|
||||
typeof user?.id === "string" && user.id.trim().length > 0
|
||||
@@ -179,7 +182,7 @@ export const registerSocketHandlers = ({
|
||||
|
||||
socket.on("cursor-move", (data) => {
|
||||
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
|
||||
if (!drawingId || !authorizedDrawingIds.has(drawingId)) {
|
||||
if (!drawingId || !authorizedDrawingRoles.has(drawingId)) {
|
||||
return;
|
||||
}
|
||||
const roomId = `drawing_${drawingId}`;
|
||||
@@ -188,7 +191,11 @@ export const registerSocketHandlers = ({
|
||||
|
||||
socket.on("element-update", (data) => {
|
||||
const drawingId = typeof data?.drawingId === "string" ? data.drawingId : null;
|
||||
if (!drawingId || !authorizedDrawingIds.has(drawingId)) {
|
||||
if (!drawingId) {
|
||||
return;
|
||||
}
|
||||
const role = authorizedDrawingRoles.get(drawingId);
|
||||
if (!role || !isAtLeastRole(role, "editor")) {
|
||||
return;
|
||||
}
|
||||
const roomId = `drawing_${drawingId}`;
|
||||
@@ -198,7 +205,7 @@ export const registerSocketHandlers = ({
|
||||
socket.on(
|
||||
"user-activity",
|
||||
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
|
||||
if (!authorizedDrawingIds.has(drawingId)) {
|
||||
if (!authorizedDrawingRoles.has(drawingId)) {
|
||||
return;
|
||||
}
|
||||
const roomId = `drawing_${drawingId}`;
|
||||
|
||||
Reference in New Issue
Block a user