import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { CONTENT_TYPE_OVERRIDES, isAllowedMime, presignGet, } from "@factory/storage"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { ApiError } from "../../common/errors.js"; import { actorIsAdminKey, actorLevel, assertCan, assertRole, type Level } from "../../lib/authorize.js"; import { loadEnv } from "../../config/env.js"; import { deletePrefix, filesBucketConfig, makeFilesClient, makeThumbsClient, signExpirySeconds } from "./s3.js"; import type { ListFilesInput, RenameFileInput, RollbackFileInput, SetPermissionsInput, } from "./dto.js"; import { adjustWorkspaceStorageBytes } from "./quota.js"; import { toFileRecord } from "./record.js"; /** * TASK-0101/0104 — file reads + lifecycle: * reads — metadata (fresh signed URLs), download (302, attachment), * preview (302, inline), list, rename * versions — list, rollback (key swap), delete-old-versions * trash — soft delete, restore, purge (objects + thumbs + rows), list * perms — file-level Permission rows (GET/PUT), explicit NONE = deny * * Authorization: every operation resolves the EFFECTIVE level through * `effectiveLevel` with the FULL named chain (file + its page, TASK-0104): * an explicit file grant wins by specificity, an explicit NONE on the file * OR its page is a hard deny (overrides role/inheritance), everything else * inherits the page/block access. Reads need ≥ VIEW; mutations need ≥ EDIT * (rollback/permissions need ≥ EDIT on the file; purge needs FULL_ACCESS or * a workspace Admin/Owner role). All denials are audited * (`file.download_denied` carries actor + ip + path) per AGENTS.md §27. * * Signed URLs: 900 s (S3_SIGN_EXPIRY_SECONDS), signed for EXACTLY ONE object * key (+ the disposition override, which is part of the signature). SVG is * served as `attachment` with a text/plain content type (the storage layer's * CONTENT_TYPE_OVERRIDES — the stored-XSS vector is closed here, not in the * UI). VIRUS_DETECTED files are never signed (403 FILE_QUARANTINED). * * Storage quota (TASK-0104 §4): `Workspace.usageStorageBytes` = Σ * FileVersion.size over non-deleted files, recomputed transactionally on * delete-old-versions / purge (the only two operations that DELETE stored * objects); complete() increments it (uploads.service). See `quota.ts`. */ @Injectable() export class FilesService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, ) {} /** `GET /files/:fileId` — metadata + fresh signed download/preview URLs. */ async get(fileId: string, userId: string, req: Request) { // `allowQuarantined`: the DETAIL endpoint returns the record (status, // scanReport) for a `VIRUS_DETECTED` file so the client can render the // "Blocked by virus scan" card (docs/api/files.md §8.5: 200, no signed // URLs). The bytes are still never served: `download`/`preview` (§8.6) // keep the 403 `FILE_QUARANTINED`. const file = await this.requireReadable(fileId, userId, req, false, true); if (!file) throw ApiError.notFound("File not found."); const versions = await this.prisma.fileVersion.findMany({ where: { fileId }, orderBy: { version: "asc" }, select: { id: true, version: true, size: true, createdAt: true }, }); const versionList = versions.map((v) => ({ id: v.id, version: v.version, size: v.size, createdAt: v.createdAt.toISOString(), })); // A quarantined file is returned WITHOUT signed URLs (never signed, ever) // so the client can still render the "Blocked by virus scan" card from // `status`/`scanReport` — the fields are omitted, not null. if (file.status === "VIRUS_DETECTED") { return { ...toFileRecord(file, versions.length || 1), versions: versionList }; } const urls = this.signedUrls(file); return { ...toFileRecord(file, versions.length || 1), downloadUrl: urls.downloadUrl, previewUrl: urls.previewUrl, expiresAt: urls.expiresAt, versions: versionList, }; } /** * `GET /files/:fileId/download` → 302 to a signed GET (900 s, * `response-content-disposition: attachment; filename="…"` SIGNED into the * URL). Audits `file.downloaded`; denials → 403 + `file.download_denied`. */ async download(fileId: string, userId: string, req: Request): Promise<{ location: string; expiresAt: string }> { const file = await this.requireReadable(fileId, userId, req, true); if (!file) throw ApiError.notFound("File not found."); const urls = this.signedUrls(file, "attachment"); await this.audit.audit(req, req.auth, { action: "file.downloaded", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, size: file.size, mimeType: file.mimeType }, }); return { location: urls.downloadUrl, expiresAt: urls.expiresAt }; } /** `GET /files/:fileId/preview` → 302 signed GET, inline disposition * (image/video/audio/pdf). SVG stays attachment-only (override table). */ async preview(fileId: string, userId: string, req: Request): Promise<{ location: string; expiresAt: string }> { const file = await this.requireReadable(fileId, userId, req, true); if (!file) throw ApiError.notFound("File not found."); const urls = this.signedUrls(file, "inline"); return { location: urls.previewUrl, expiresAt: urls.expiresAt }; } /** * `GET /files?scope=…` — server-side permission-filtered list. * * Keyset (cursor) pagination: the opaque `cursor` encodes the last row of * the previous page as `{id, }`; the next page is the rows * strictly after it in the requested sort (ties broken by `id`, the * stable unique tiebreaker). This is O(limit) regardless of page depth and * is correct under concurrent inserts/deletes (unlike `OFFSET`). * * TODO(TASK-0104): permission-filter (explicit file grants / NONE deny on a * per-page basis) once the file scope lands in effectiveLevel. For now the * workspace VIEW floor (assertCan) is the gate — every workspace member sees * workspace files, which matches the role-as-floor model (a non-member has * no role → NONE → 403, so the workspace list is already member-only). */ async list(workspaceId: string, userId: string, query: ListFilesInput) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const sort = query.sort; const col: "name" | "size" | "createdAt" | "updatedAt" = sort === "name" ? "name" : sort === "size" ? "size" : sort === "date" ? "createdAt" : "updatedAt"; const dir: "asc" | "desc" = sort === "name" ? "asc" : "desc"; const after = query.cursor ? decodeCursor(query.cursor) : null; // `size` is an Int column → the cursor value must be a number for the // `lt`/`gt` filter; name/dates are compared as strings. const cv = after ? (col === "size" ? Number(after.sortValue) : after.sortValue) : null; const files = await this.prisma.file.findMany({ where: { workspaceId, deletedAt: null, ...(query.scope === "page" && query.pageId ? { pageId: query.pageId } : {}), ...(query.status ? { status: query.status } : {}), ...(query.search ? { name: { contains: query.search, mode: "insensitive" as const } } : {}), ...(query.kind ? { mimeType: { in: mimesForKind(query.kind) } } : {}), ...(after ? { // Keyset "after" predicate: strictly past the cursor's sortValue, // or on the same sortValue with an id past the cursor's id. // `size` is an Int column → coerce; name/dates stay strings. OR: [ { [col]: dir === "asc" ? { gt: cv } : { lt: cv } }, { [col]: cv, id: dir === "asc" ? { gt: after.id } : { lt: after.id } }, ], } : {}), }, orderBy: [{ [col]: dir }, { id: dir }], take: query.limit, }); // Enrich: uploader name, page name, version count (plain refs → batch // lookups, not relations). Keep the response lean (no raw `key`). const uploaderIds = [...new Set(files.map((f) => f.uploadedBy))]; const pageIds = [...new Set(files.map((f) => f.pageId).filter(Boolean))] as string[]; const [users, pages, versionCounts] = await Promise.all([ this.prisma.user.findMany({ where: { id: { in: uploaderIds } }, select: { id: true, name: true }, }), this.prisma.page.findMany({ where: { id: { in: pageIds } }, select: { id: true, title: true }, }), this.prisma.fileVersion.groupBy({ by: ["fileId"], where: { fileId: { in: files.map((f) => f.id) } }, _count: { _all: true }, }), ]); const userName = new Map(users.map((u) => [u.id, u.name])); const pageName = new Map(pages.map((p) => [p.id, p.title])); const versionCount = new Map(versionCounts.map((v) => [v.fileId, v._count._all])); const last = files[files.length - 1]; const nextCursor = files.length === query.limit && last ? encodeCursor({ id: last.id, // Date columns → ISO (what the Prisma DateTime filter expects); // name → the string; size → the decimal string (Number() on read). sortValue: col === "createdAt" || col === "updatedAt" ? (last[col] as Date).toISOString() : String(last[col]) }) : null; return { data: files.map((f) => ({ ...toFileRecord(f, versionCount.get(f.id) ?? 1), uploadedByName: userName.get(f.uploadedBy) ?? null, pageName: f.pageId ? (pageName.get(f.pageId) ?? null) : null, versionCount: versionCount.get(f.id) ?? 1, })), nextCursor, }; } /** `PATCH /files/:id` — rename only (size/mime are immutable). */ async rename(fileId: string, userId: string, input: RenameFileInput, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("File not found."); await this.requireFileLevel(file, userId, "EDIT"); if (!isAllowedMime(file.mimeType)) { // The record is what it is; rename is still allowed (the type gate is // an upload-time control, not a read-time one). } const updated = await this.prisma.file.update({ where: { id: file.id }, data: { name: input.name }, }); await this.audit.audit(req, req.auth, { action: "file.renamed", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { from: file.name, to: input.name }, }); // TASK-0104 §8: the rename must converge in the search index WITHOUT // waiting for the 15-min reconcile (the doc's title is File.name). The // worker's search consumer re-upserts the doc on `file.updated` // (buildFileDoc reads the fresh name; a deleted row returns null → no-op). await this.events.publish("file.updated", { workspaceId: file.workspaceId, fileId: file.id, name: updated.name, }); return toFileRecord(updated, 1); } // ── TASK-0104: versions ──────────────────────────────────────────────── /** `GET /files/:id/versions` — list FileVersion rows (ascending). */ async versions(fileId: string, userId: string, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("File not found."); await this.requireFileLevel(file, userId, "VIEW"); const rows = await this.prisma.fileVersion.findMany({ where: { fileId }, orderBy: { version: "asc" }, include: { user: { select: { id: true, name: true } } }, }); const currentVersion = (await this.prisma.fileVersion.findFirst({ where: { fileId, key: file.key }, orderBy: { version: "desc" }, }))?.version ?? null; // The raw `key` is NEVER returned (spec §Security: only signed URLs leave // the server; `FileRecord` excludes it). The client needs version/size/ // created/current — the current version's content is reached via the // detail's fresh signed URLs, not a per-version key. return rows.map((v) => ({ id: v.id, version: v.version, size: v.size, current: v.version === currentVersion, createdBy: v.createdBy ?? null, createdByName: v.user?.name ?? null, createdAt: v.createdAt.toISOString(), })); } /** * `POST /files/:id/rollback` — make version `n` current. KEY SWAP, not a * copy: `File.key/size` are re-pointed at version n's existing object * (no new object, no re-scan — the bytes already passed the pipeline). * Requires ≥ EDIT on the file (spec: FULL_ACCESS on the file grant is the * UI affordance; the effective-level floor is EDIT, matching rename). */ async rollback(fileId: string, userId: string, input: RollbackFileInput, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("File not found."); await this.requireFileLevel(file, userId, "EDIT"); const target = await this.prisma.fileVersion.findUnique({ where: { fileId_version: { fileId: file.id, version: input.version } }, }); if (!target) { throw new ApiError("NOT_FOUND", 404, `Version ${input.version} does not exist for this file.`, [ { reason: "version_not_found", version: input.version }, ]); } const updated = await this.prisma.file.update({ where: { id: file.id }, data: { key: target.key, size: target.size }, }); await this.audit.audit(req, req.auth, { action: "file.rolled_back", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, version: input.version, fromKey: file.key, toKey: target.key }, }); return toFileRecord(updated, input.version); } /** * `POST /files/:id/versions/prune` — "Delete old versions" (admin action): * delete every FileVersion row + its object + thumbnail EXCEPT the current * version's row/object. Requires ≥ EDIT on the file (the UI gates it behind * the permissions dialog / admin confirm). Quota counter recomputed in the * same transaction (the pruned bytes leave the stored total). */ async deleteOldVersions(fileId: string, userId: string, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("File not found."); await this.requireFileLevel(file, userId, "EDIT"); const s3 = makeFilesClient(); const s3Thumbs = makeThumbsClient(); const pruned = await this.prisma.$transaction(async (tx) => { const rows = await tx.fileVersion.findMany({ where: { fileId: file.id } }); // Keep the row(s) whose key is the file's CURRENT key (usually exactly // one; a rollback can leave the current key on an older version). const keep = rows.filter((r) => r.key === file.key); const drop = rows.filter((r) => r.key !== file.key); for (const r of drop) { await s3.deleteObject(r.key).catch(() => undefined); } // Thumbnails are version-less in the key (thumb files/{ws}/{fileId}/…), // so prune only deletes the CURRENT thumbnail set — but the current set // is what stays visible; old-version thumbnails share the current // thumbnailKey (the worker rewrites it on finalize), so there is no // per-version thumb to delete. (Recorded: thumbs are current-only.) if (drop.length > 0) { const droppedBytes = BigInt(drop.reduce((sum, r) => sum + r.size, 0)); await tx.fileVersion.deleteMany({ where: { id: { in: drop.map((r) => r.id) } } }); // Event counter: the pruned bytes leave the stored total (transactional). await adjustWorkspaceStorageBytes(tx, file.workspaceId, -droppedBytes); } return drop.length; }); if (pruned === 0) return { pruned: 0 }; await this.audit.audit(req, req.auth, { action: "file.versions_pruned", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, pruned }, }); return { pruned }; } // ── TASK-0104: trash (soft delete / restore / purge) ─────────────────── /** * `DELETE /files/:id` (or `?purge=true`) — trash a file (soft) or purge it * (hard). Soft: sets `deletedAt` (object + versions retained — restore is * possible), emits `file.deleted` (search index removal), audit * `file.deleted`. Requires ≥ EDIT on the file (the file's page or file * grant, per spec §3). */ async remove(fileId: string, userId: string, req: Request, purge = false) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file) throw ApiError.notFound("File not found."); if (purge) { // Purge works on BOTH active and trashed files (the trash view purges // trashed rows); a missing row is the only 404. await this.purge(file, userId, req); return { purged: true }; } if (file.deletedAt) { throw ApiError.conflict("File is already in the trash.", [{ reason: "already_trashed" }]); } await this.requireFileLevel(file, userId, "EDIT"); await this.prisma.file.update({ where: { id: file.id }, data: { deletedAt: new Date() } }); await this.audit.audit(req, req.auth, { action: "file.deleted", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, size: file.size, key: file.key }, }); await this.events.publish("file.deleted", { workspaceId: file.workspaceId, fileId: file.id, name: file.name, size: file.size, blockId: file.blockId, pageId: file.pageId, }); return { trashed: true, deletedAt: new Date().toISOString() }; } /** * `POST /files/:id/restore` — bring a trashed file back: clears `deletedAt`, * re-emits `file.uploaded` with `restored: true` (search re-index), audit * `file.restored`. No quota change (the bytes were retained while trashed). * Same permission as delete (≥ EDIT on the file). */ async restore(fileId: string, userId: string, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || !file.deletedAt) throw ApiError.notFound("File not found (or not in trash)."); await this.requireFileLevel(file, userId, "EDIT"); await this.prisma.file.update({ where: { id: file.id }, data: { deletedAt: null } }); await this.audit.audit(req, req.auth, { action: "file.restored", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, size: file.size, key: file.key }, }); await this.events.publish("file.uploaded", { workspaceId: file.workspaceId, fileId: file.id, name: file.name, size: file.size, mimeType: file.mimeType, blockId: file.blockId, pageId: file.pageId, restored: true, }); return toFileRecord(file, 1); } /** * Purge (hard delete) — the ONLY path that deletes stored objects. * Deletes the current object + every version object + the thumbnail set + * the `File` and `FileVersion` rows; audit `file.purged`. Requires * FULL_ACCESS on the file (effective level) OR the workspace Admin/Owner * role (spec §3 "FULL_ACCESS or workspace Admin/Owner"). */ private async purge(file: { id: string; workspaceId: string; name: string; key: string; pageId: string | null }, userId: string, req: Request): Promise { // FULL_ACCESS via the file+page effective level, OR the workspace role // (Admin/Owner) — a workspace admin can purge any file in the workspace // from the trash view without a per-file grant. `deletedAt` rows ARE // purgeable (that is the trash view's main use); a missing row is 404. const fileRow = await this.prisma.file.findUnique({ where: { id: file.id } }); if (!fileRow) throw ApiError.notFound("File not found (or already purged)."); // TASK-0127: actor-aware — an API key's purge right comes from its // SCOPE (ADMIN → FULL_ACCESS), never the creator's membership. const level = await actorLevel(this.prisma, { workspaceId: fileRow.workspaceId, userId, resource: { fileId: fileRow.id, pageId: fileRow.pageId ?? undefined }, }); const isRoleAdmin = actorIsAdminKey(fileRow.workspaceId) || (await this.workspaceRole(fileRow.workspaceId, userId)); if (level !== "FULL_ACCESS" && !isRoleAdmin) { await this.audit.audit(req, req.auth, { action: "file.purge_denied", resourceType: "file", resourceId: file.id, workspaceId: fileRow.workspaceId, metadata: { name: file.name, level, reason: "purge_requires_full_access_or_admin" }, }); throw new ApiError("FORBIDDEN", 403, "Purging requires FULL_ACCESS on the file or a workspace Admin/Owner role.", [ { reason: "purge_requires_full_access_or_admin", have: level }, ]); } const s3 = makeFilesClient(); const s3Thumbs = makeThumbsClient(); const workspaceId = fileRow.workspaceId; const thumbPrefix = `thumbs/${workspaceId}/${file.id}/`; let versionCount = 0; await this.prisma.$transaction(async (tx) => { const rows = await tx.fileVersion.findMany({ where: { fileId: file.id } }); versionCount = rows.length; for (const r of rows) { await s3.deleteObject(r.key).catch(() => undefined); } // The WHOLE thumbnail set, not just the single recorded `thumbnailKey`: // a file can own several artifacts (160/480/1280 for an image) under // `thumbs/{ws}/{fileId}/{size}/…`; deleting only the 480 primary would // orphan the rest (spec §3 "…+ thumbnails + DB rows"). await deletePrefix(s3Thumbs, thumbPrefix).catch(() => 0); await tx.fileVersion.deleteMany({ where: { fileId: file.id } }); await tx.file.delete({ where: { id: file.id } }); // Event counter: every retained version's bytes leave the stored total. const purgedBytes = BigInt(rows.reduce((sum, r) => sum + r.size, 0)); if (purgedBytes > 0n) await adjustWorkspaceStorageBytes(tx, workspaceId, -purgedBytes); }); await this.audit.audit(req, req.auth, { action: "file.purged", resourceType: "file", resourceId: file.id, workspaceId, metadata: { name: file.name, key: fileRow.key, thumbPrefix, versions: versionCount }, }); } /** `GET /files/trash` — workspace files with `deletedAt` set. */ async trashList(workspaceId: string, userId: string) { await assertCan(this.prisma, { workspaceId, userId }, "VIEW"); const files = await this.prisma.file.findMany({ where: { workspaceId, deletedAt: { not: null } }, orderBy: { deletedAt: "desc" }, take: 200, }); const uploaderIds = [...new Set(files.map((f) => f.uploadedBy))]; const users = await this.prisma.user.findMany({ where: { id: { in: uploaderIds } }, select: { id: true, name: true } }); const userName = new Map(users.map((u) => [u.id, u.name])); return files.map((f) => ({ ...toFileRecord(f, 1), deletedAt: f.deletedAt?.toISOString() ?? null, uploadedByName: userName.get(f.uploadedBy) ?? null, })); } // ── TASK-0104: file-level permission overrides ───────────────────────── /** `GET /files/:id/permissions` — the file's explicit grants. */ async listPermissions(fileId: string, userId: string, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("File not found."); await this.requireFileLevel(file, userId, "EDIT"); const grants = await this.prisma.permission.findMany({ where: { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id }, }); return this.hydrateGrants(file.workspaceId, grants); } /** * `PUT /files/:id/permissions` — replace the file's explicit grants with * `grants` (add/remove). `level` may be NONE (explicit deny). Each change * audits `file.permission_changed` and emits `permission.changed` * (search re-index). Requires ≥ EDIT on the file. */ async setPermissions(fileId: string, userId: string, input: SetPermissionsInput, req: Request) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) throw ApiError.notFound("File not found."); await this.requireFileLevel(file, userId, "EDIT"); const wanted = new Set(input.grants.map((g) => `${g.granteeType}:${g.granteeId}:${g.level}`)); const existing = await this.prisma.permission.findMany({ where: { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id }, }); const existingSet = new Set(existing.map((g) => `${g.granteeType}:${g.granteeId}:${g.level}`)); // Remove rows that are not in the wanted set. for (const g of existing) { if (!wanted.has(`${g.granteeType}:${g.granteeId}:${g.level}`)) { await this.prisma.permission.delete({ where: { id: g.id } }); } } // Add rows that are new. for (const g of input.grants) { if (!existingSet.has(`${g.granteeType}:${g.granteeId}:${g.level}`)) { await this.prisma.permission.upsert({ where: { workspaceId_granteeType_granteeId_resourceType_resourceId: { workspaceId: file.workspaceId, granteeType: g.granteeType, granteeId: g.granteeId, resourceType: "file", resourceId: file.id, }, }, update: { level: g.level }, create: { workspaceId: file.workspaceId, granteeType: g.granteeType, granteeId: g.granteeId, resourceType: "file", resourceId: file.id, level: g.level, }, }); } } await this.audit.audit(req, req.auth, { action: "file.permission_changed", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { name: file.name, grants: input.grants }, }); // TASK-0133 audit catalog: a file-grant change IS a permission change — // fire the catalog's `permission.changed` (required metadata: `change`, // `targetId`) in addition to the legacy row. Only when the set actually // changed (idempotent re-sends are not a security-relevant change). if (wanted.size !== existingSet.size || [...wanted].some((k) => !existingSet.has(k))) { await this.audit.audit(req, req.auth, { action: "permission.changed", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { change: "file_grants", targetId: file.id, grants: input.grants }, }); } await this.events.publish("permission.changed", { workspaceId: file.workspaceId, resourceType: "file", resourceId: file.id, userId, }); return this.listPermissions(file.id, userId, req); } // ── internals ──────────────────────────────────────────────────────── /** * Resolve the effective level of `userId` for THIS FILE (file + its page) * and assert ≥ `need`. The file scope is what gives explicit file grants * (and explicit NONE denies) their effect — a page-scoped walk alone would * ignore file-level overrides (TASK-0104 §2/§5). */ private async requireFileLevel( file: { id: string; workspaceId: string; pageId: string | null }, userId: string, need: Exclude ): Promise { return assertCan(this.prisma, { workspaceId: file.workspaceId, userId, resource: { fileId: file.id, pageId: file.pageId ?? undefined }, }, need); } /** The workspace role for `userId` (null when not a member). */ private async workspaceRole(workspaceId: string, userId: string): Promise { const m = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } }, }); return !!m && (m.role === "ADMIN" || m.role === "OWNER"); } /** Hydrate Permission rows with grantee display names (user/team/role). */ private async hydrateGrants( workspaceId: string, grants: { id: string; granteeType: string; granteeId: string; level: string; inheritsFrom: string | null }[] ) { const userIds = grants.filter((g) => g.granteeType === "user").map((g) => g.granteeId); const teamIds = grants.filter((g) => g.granteeType === "team").map((g) => g.granteeId); const [users, teams] = await Promise.all([ userIds.length ? this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, email: true } }) : [], teamIds.length ? this.prisma.team.findMany({ where: { id: { in: teamIds } }, select: { id: true, name: true } }) : [], ]); const userName = new Map(users.map((u) => [u.id, u.name])); const userEmail = new Map(users.map((u) => [u.id, u.email])); const teamName = new Map(teams.map((t) => [t.id, t.name])); return grants.map((g) => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, level: g.level, inheritsFrom: g.inheritsFrom, granteeName: g.granteeType === "user" ? userName.get(g.granteeId) ?? g.granteeId : g.granteeType === "team" ? teamName.get(g.granteeId) ?? g.granteeId : g.granteeId, granteeEmail: g.granteeType === "user" ? userEmail.get(g.granteeId) ?? null : null, })); } /** * Resolve the file and enforce ≥ VIEW. Returns null (→ 404) when absent. * `auditDenied` records `file.download_denied` on a permission failure. * `allowQuarantined` (detail endpoint only): a `VIRUS_DETECTED` file is * still RESOLVED (so the client can render the "Blocked by virus scan" * card, docs/api/files.md §8.5) but is never signed — the byte-serving * endpoints (`download`/`preview`, §8.6) keep the 403 `FILE_QUARANTINED`. */ private async requireReadable( fileId: string, userId: string, req: Request, auditDenied = false, allowQuarantined = false, ) { const file = await this.prisma.file.findUnique({ where: { id: fileId } }); if (!file || file.deletedAt) return null; // VIRUS_DETECTED: quarantine — never signed, ever (spec TASK-0102 §Security). if (file.status === "VIRUS_DETECTED" && !allowQuarantined) { if (auditDenied) { await this.audit.audit(req, req.auth, { action: "file.download_denied", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { reason: "quarantined", name: file.name }, }); } throw new ApiError("FILE_QUARANTINED", 403, "This file was blocked by the virus scan and cannot be downloaded.", [{ reason: "file_quarantined" }]); } try { // TASK-0104: resolve with the FULL named chain — the file itself plus // its page. `effectiveLevel` gives the file grant the most specificity, // an explicit NONE on the file OR its page is a hard deny (returned as // NONE, so VIEW is never satisfied), and otherwise the level inherits // from the page/block (the Phase-3 walk). Passing only `pageId` here was // the pre-TASK-0104 behavior (no file scope, no NONE deny). await assertCan(this.prisma, { workspaceId: file.workspaceId, userId, resource: { fileId: file.id, pageId: file.pageId ?? undefined }, }, "VIEW"); return file; } catch (err) { if (auditDenied) { await this.audit.audit(req, req.auth, { action: "file.download_denied", resourceType: "file", resourceId: file.id, workspaceId: file.workspaceId, metadata: { reason: "no_view", name: file.name, path: req.originalUrl }, }); } throw err; } } /** Fresh 900 s signed URLs for the file's current object key. */ private signedUrls( file: { key: string; name: string; mimeType: string }, disposition: "attachment" | "inline" = "attachment", ): { downloadUrl: string; previewUrl: string; expiresAt: string } { const env = loadEnv(); const expiresSec = signExpirySeconds(env); const now = new Date(); const config = filesBucketConfig(env); // SVG: attachment-only + text/plain (the storage-layer override — // no inline render, ever). const override = CONTENT_TYPE_OVERRIDES[file.mimeType]; const disp = (d: "attachment" | "inline") => `${override ? "attachment" : d}; filename="${escapeQuotes(file.name)}"`; const dl = presignGet({ config, key: file.key, expiresSec, now, responseContentDisposition: disp("attachment"), }); const pv = presignGet({ config, key: file.key, expiresSec, now, responseContentDisposition: disp("inline"), }); return { downloadUrl: dl.url, previewUrl: pv.url, expiresAt: new Date(now.getTime() + expiresSec * 1000).toISOString(), }; } } function escapeQuotes(name: string): string { return name.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } // ── keyset cursor (opaque to the client) ──────────────────────────────────── // base64url(JSON {id, sortValue}). `sortValue` is the stringified sort-column // value of the last row (dates → ISO string, sizes → decimal). interface FileCursor { id: string; sortValue: string; } function encodeCursor(c: FileCursor): string { return Buffer.from(JSON.stringify(c), "utf8").toString("base64url"); } function decodeCursor(raw: string): FileCursor | null { try { const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")); if ( parsed && typeof parsed.id === "string" && typeof parsed.sortValue === "string" && parsed.id.length > 0 ) { return parsed as FileCursor; } return null; } catch { // A corrupt/foreign cursor must not 500 the list — treat as "first page". return null; } } const KIND_MIMES: Record = { image: ["image/png", "image/jpeg", "image/webp", "image/gif", "image/svg+xml"], video: ["video/mp4", "video/webm", "video/quicktime"], audio: ["audio/mpeg", "audio/wav", "audio/mp4", "audio/x-wav"], pdf: ["application/pdf"], doc: [ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/markdown", "text/plain", "text/csv", ], other: ["application/zip", "application/octet-stream"], }; function mimesForKind(kind: string): string[] { return KIND_MIMES[kind] ?? []; }