diff --git a/apps/api/src/group-files/group-files.controller.ts b/apps/api/src/group-files/group-files.controller.ts index 92cd932..33abcb6 100644 --- a/apps/api/src/group-files/group-files.controller.ts +++ b/apps/api/src/group-files/group-files.controller.ts @@ -163,6 +163,46 @@ export class GroupFilesController { }); } + /** Segment-aligned multipart upload; upload IDs come from S3. */ + @Post("direct/multipart/init") + @RequirePermission("media.view") + async directMultipartInit( + @Req() request: Request & AuthenticatedRequest, + @Body() body: unknown, + ): Promise> { + const session = requireSession(request); + const dto = requireObject(body, "body"); + return this.files.initDirectMultipartUpload(session.workspaceId, + dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"), + { userId: session.userId, role: session.role }, { + filename: requireString(dto.filename, "filename"), + mimeType: requireString(dto.mimeType, "mimeType"), + sizeBytes: requireNumber(dto.sizeBytes, "sizeBytes"), + personal: dto.personal, folderId: dto.folderId, + }); + } + + @Post("direct/multipart/complete") + @RequirePermission("media.view") + async directMultipartComplete( + @Req() request: Request & AuthenticatedRequest, + @Body() body: unknown, + ): Promise { + const session = requireSession(request); + const dto = requireObject(body, "body"); + return this.files.completeDirectMultipartUpload(session.workspaceId, + { userId: session.userId, role: session.role }, { + uploadId: requireString(dto.uploadId, "uploadId"), + objectKey: requireString(dto.objectKey, "objectKey"), + groupId: dto.groupId == null ? null : requireUuidValue(dto.groupId, "groupId"), + personal: dto.personal, folderId: dto.folderId, + filename: requireString(dto.filename, "filename"), + mimeType: requireString(dto.mimeType, "mimeType"), + plainSizeBytes: requireNumber(dto.plainSizeBytes, "plainSizeBytes"), + sha256: requireString(dto.sha256, "sha256"), parts: dto.parts, + }); + } + /** Same ACL/availability checks as the streaming route; direct objects only. */ @Post(":id/direct-download") @RequirePermission("media.view") diff --git a/apps/api/src/group-files/group-files.service.test.ts b/apps/api/src/group-files/group-files.service.test.ts index bb4a523..b2c1307 100644 --- a/apps/api/src/group-files/group-files.service.test.ts +++ b/apps/api/src/group-files/group-files.service.test.ts @@ -5,7 +5,7 @@ import { GUARDS_METADATA } from "@nestjs/common/constants.js"; import { FilePlaybackController, GroupFilesController } from "./group-files.controller.js"; import { RbacGuard } from "../rbac/rbac.guard.js"; import { FileRangeNotSatisfiableException } from "./playback-range.js"; -import { directStorageDisabled, type StorageService } from "../storage/storage.js"; +import { DIRECT_SEGMENT_BYTES, directSealedSize, directStorageDisabled, type StorageService } from "../storage/storage.js"; import { hashShareToken } from "./share-token.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BadRequestException, ConflictException, ForbiddenException, GoneException, NotFoundException } from "@nestjs/common"; @@ -363,6 +363,102 @@ describe("GroupFilesService invariants", () => { }); +describe("direct multipart uploads", () => { + const actor = { userId: "user-1", role: "Publisher" }; + const objectKey = "direct/multipart/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.enc"; + const plainSizeBytes = DIRECT_SEGMENT_BYTES + 1; + const input = { + uploadId: "opaque-s3-upload-id+/=", objectKey, groupId: row.group_id, + filename: "clip.mp4", mimeType: "video/mp4", plainSizeBytes, sha256: "a".repeat(64), + parts: [{ ETag: '"etag-1"', PartNumber: 1 }, { ETag: '"etag-2"', PartNumber: 2 }], + }; + let storage: StorageService; + let service: GroupFilesService; + + beforeEach(() => { + behavior = () => ({ rows: [row], rowCount: 1 }); + storage = { + ...directStorageDisabled(), putContent: vi.fn(), getObject: vi.fn(), hasContent: vi.fn(), presign: vi.fn(), + directPublicJwk: vi.fn(async () => ({ kty: "EC", crv: "P-256", x: "x", y: "y" } as const)), + createMultipart: vi.fn(async () => ({ uploadId: input.uploadId })), + presignPart: vi.fn(async (_key, _upload, part) => `https://storage.example/part/${part}`), + completeMultipart: vi.fn(async () => undefined), abortMultipart: vi.fn(async () => undefined), + headObject: vi.fn(async () => ({ exists: true, contentLength: directSealedSize(plainSizeBytes) })), + }; + service = new GroupFilesService({ storage }); + vi.spyOn(service, "registerArchiveEntry").mockResolvedValue({ id: row.id, claimKey: "claim" }); + }); + + it("initializes the full 2 GiB as 205 ordered part URLs", async () => { + const result = await service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, { + filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 2 * 1024 ** 3, + }); + expect(result.partCount).toBe(205); + expect(result.partUrls).toHaveLength(205); + expect(result.uploadId).toBe(input.uploadId); + expect(result.objectKey).toMatch(/^direct\/multipart\/[a-f0-9-]+\.enc$/); + expect(storage.createMultipart).toHaveBeenCalledWith(result.objectKey, "application/octet-stream"); + expect(storage.presignPart).toHaveBeenNthCalledWith(205, result.objectKey, input.uploadId, 205, 900); + }); + + it("completes before HEAD and registers plaintext bytes after exact size verification", async () => { + await service.completeDirectMultipartUpload(row.workspace_id, actor, input); + expect(storage.completeMultipart).toHaveBeenCalledWith(objectKey, input.uploadId, input.parts); + expect(vi.mocked(storage.completeMultipart).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(storage.headObject).mock.invocationCallOrder[0]!); + expect(service.registerArchiveEntry).toHaveBeenCalledWith(row.workspace_id, expect.objectContaining({ + byteSize: plainSizeBytes, storageKey: objectKey, groupId: row.group_id, sha256: input.sha256, + })); + expect(storage.abortMultipart).not.toHaveBeenCalled(); + }); + + it.each([ + [], [input.parts[0]], [...input.parts, { ETag: "extra", PartNumber: 3 }], + [{ ETag: "", PartNumber: 1 }, input.parts[1]], + [{ ETag: "one", PartNumber: 1 }, { ETag: "two", PartNumber: 1 }], + [input.parts[1], input.parts[0]], null, + ].map((parts) => ({ parts })))("rejects an invalid ETag list and aborts before completion: $parts", async ({ parts }) => { + await expect(service.completeDirectMultipartUpload(row.workspace_id, actor, { ...input, parts })) + .rejects.toBeInstanceOf(BadRequestException); + expect(storage.abortMultipart).toHaveBeenCalledWith(objectKey, input.uploadId); + expect(storage.completeMultipart).not.toHaveBeenCalled(); + expect(service.registerArchiveEntry).not.toHaveBeenCalled(); + }); + + it.each([null, directSealedSize(plainSizeBytes) - 1, directSealedSize(plainSizeBytes) + 1, plainSizeBytes])( + "rejects missing or inexact sealed size %s without registering", async (contentLength) => { + vi.mocked(storage.headObject).mockResolvedValue({ exists: contentLength !== null, contentLength }); + await expect(service.completeDirectMultipartUpload(row.workspace_id, actor, input)).rejects.toMatchObject({ status: 400 }); + expect(storage.abortMultipart).toHaveBeenCalledWith(objectKey, input.uploadId); + expect(service.registerArchiveEntry).not.toHaveBeenCalled(); + }); + + it("aborts when presigning a part fails", async () => { + vi.mocked(storage.presignPart).mockRejectedValue(new Error("unavailable")); + await expect(service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, { + filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: plainSizeBytes, + })).rejects.toMatchObject({ status: 503 }); + expect(storage.abortMultipart).toHaveBeenCalledWith(expect.stringMatching(/^direct\/multipart\//), input.uploadId); + }); + + it("rejects an oversize file before allocating an upload", async () => { + await expect(service.initDirectMultipartUpload(row.workspace_id, row.group_id, actor, { + filename: "clip.mp4", mimeType: "video/mp4", sizeBytes: 2 * 1024 ** 3 + 1, + })).rejects.toMatchObject({ status: 400 }); + expect(storage.createMultipart).not.toHaveBeenCalled(); + }); + + it("keeps multipart plaintext metadata intact for range playback", async () => { + behavior = () => ({ rows: [{ ...row, byte_size: String(plainSizeBytes), storage_key: objectKey }], rowCount: 1 }); + const result = await service.openRangePlayback(row.workspace_id, actor.userId, actor.role, row.id, plainSizeBytes - 1, 1); + const { token } = result.ticket; + storage.readDirectRange = vi.fn(async () => Buffer.from("x")); + const slice = await service.readPlaybackTicket(token, String(plainSizeBytes - 1), String(plainSizeBytes - 1)); + expect(slice.total).toBe(plainSizeBytes); + expect(storage.readDirectRange).toHaveBeenCalledWith(objectKey, plainSizeBytes - 1, 1); + }); +}); + describe("range playback capabilities", () => { const payload = Buffer.from("0123456789"); let service: GroupFilesService; diff --git a/apps/api/src/group-files/group-files.service.ts b/apps/api/src/group-files/group-files.service.ts index 42f3b7d..58ab4fd 100644 --- a/apps/api/src/group-files/group-files.service.ts +++ b/apps/api/src/group-files/group-files.service.ts @@ -28,7 +28,7 @@ import { appendTenantAudit, type AuditEvent } from "../audit/audit.service.js"; import { withSystem, withTenant } from "../db/client.js"; import { isUuid, parseLimit } from "../http/validation.js"; import { decodeFileListCursor, encodeFileListCursor } from "./file-cursor.js"; -import { S3Storage, directPlaintextSize, s3ConfigFromEnv, type StorageService } from "../storage/storage.js"; +import { S3Storage, DIRECT_SEGMENT_BYTES, directSealedSize, directPlaintextSize, s3ConfigFromEnv, type MultipartPart, type StorageService } from "../storage/storage.js"; import type { PayloadDownload } from "./verify-stream.js"; import { hashShareToken, mintShareToken } from "./share-token.js"; import { playbackRange, requestedPlaybackRange } from "./playback-range.js"; @@ -86,6 +86,13 @@ export interface DirectUploadInit { putUrl: string; } +export interface DirectMultipartInit { + uploadId: string; + objectKey: string; + partCount: number; + partUrls: string[]; +} + /** POST /files/:id/direct-download response: URL + the unwrapped CEK. */ export interface DirectDownloadTicket { url: string; @@ -122,6 +129,8 @@ export const DIRECT_MAX_BYTES = 2_147_483_648; const DIRECT_PUT_TTL_SECONDS = 900; const DIRECT_GET_TTL_SECONDS = 600; const DIRECT_KEY_PREFIX = "direct/"; +// This namespace records plaintext byte_size, unlike legacy single-PUT entries. +const DIRECT_MULTIPART_KEY_PREFIX = "direct/multipart/"; const DIRECT_OBJECT_SUFFIX = ".enc"; /** MIME allowlist for direct uploads: prefix match on the declared type. */ @@ -694,7 +703,8 @@ export class GroupFilesService { if (!item.file.storageKey?.startsWith(DIRECT_KEY_PREFIX)) { throw new BadRequestException("range reads are only available for direct objects"); } - // /direct/complete stores the HEAD-verified sealed frame size. Its fixed + if (item.file.storageKey.startsWith(DIRECT_MULTIPART_KEY_PREFIX)) return item; + // Legacy /direct/complete stores the HEAD-verified sealed frame size. Its fixed // 135-byte header and 32-byte segment overhead must not count as media. try { return { ...item, sizeBytes: directPlaintextSize(item.sizeBytes) }; @@ -996,6 +1006,99 @@ export class GroupFilesService { return { uploadId, objectKey, putUrl }; } + async initDirectMultipartUpload( + workspaceId: string, + groupId: string | null, + actor: ActorRef, + input: { filename: string; mimeType: string; sizeBytes: number; personal?: unknown; folderId?: unknown }, + ): Promise { + const scope = parseWriteScope({ groupId, personal: input.personal }, actor.userId); + validateDirectPlainSize(input.sizeBytes); + validateDirectFilename(input.filename); + validateDirectMime(input.mimeType); + await this.validateUploadScope(workspaceId, scope, input.folderId, actor); + const storage = this.storage(); + if ((await storage.directPublicJwk()) === null) { + throw new ServiceUnavailableException("DIRECT_EC_PRIVATE_KEY is not configured"); + } + const objectKey = `${DIRECT_MULTIPART_KEY_PREFIX}${randomUUID()}${DIRECT_OBJECT_SUFFIX}`; + let uploadId: string | undefined; + try { + ({ uploadId } = await storage.createMultipart(objectKey, "application/octet-stream")); + const partCount = Math.ceil(input.sizeBytes / DIRECT_SEGMENT_BYTES); + const partUrls = await Promise.all(Array.from({ length: partCount }, (_, i) => + storage.presignPart(objectKey, uploadId!, i + 1, DIRECT_PUT_TTL_SECONDS))); + return { uploadId, objectKey, partCount, partUrls }; + } catch { + if (uploadId !== undefined) await storage.abortMultipart(objectKey, uploadId).catch(() => undefined); + throw new ServiceUnavailableException("file storage is unavailable"); + } + } + + async completeDirectMultipartUpload( + workspaceId: string, + actor: ActorRef, + input: { uploadId: string; objectKey: string; groupId?: string | null; personal?: unknown; folderId?: unknown; filename: string; mimeType: string; plainSizeBytes: number; sha256: string; parts: unknown }, + ): Promise { + // S3 upload IDs are opaque strings, not UUIDs. Only our generated object namespace is accepted. + if (typeof input.uploadId !== "string" || input.uploadId.length < 1 || input.uploadId.length > 2048) { + throw new BadRequestException("uploadId must be a nonempty S3 upload ID"); + } + if (typeof input.objectKey !== "string" || !input.objectKey.startsWith(DIRECT_MULTIPART_KEY_PREFIX) + || !input.objectKey.endsWith(DIRECT_OBJECT_SUFFIX) + || !isUuid(input.objectKey.slice(DIRECT_MULTIPART_KEY_PREFIX.length, -DIRECT_OBJECT_SUFFIX.length))) { + throw new BadRequestException("objectKey must be direct/multipart/.enc"); + } + const scope = parseWriteScope(input, actor.userId); + const folderId = await this.validateUploadScope(workspaceId, scope, input.folderId, actor); + const storage = this.storage(); + try { + validateDirectPlainSize(input.plainSizeBytes); + const safe = validateDirectFilename(input.filename); + const mimeType = validateDirectMime(input.mimeType); + if (typeof input.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(input.sha256)) { + throw new BadRequestException("sha256 must be lowercase hex (64 chars)"); + } + const partCount = Math.ceil(input.plainSizeBytes / DIRECT_SEGMENT_BYTES); + if (!Array.isArray(input.parts) || input.parts.length !== partCount) { + throw new BadRequestException("ETag count does not match expected segment count"); + } + const parts: MultipartPart[] = input.parts.map((part: unknown, i: number) => { + if (typeof part !== "object" || part === null) throw new BadRequestException("invalid multipart part"); + const { ETag, PartNumber } = part as Record; + if (typeof ETag !== "string" || ETag.trim() === "" || ETag.length > 256 || PartNumber !== i + 1) { + throw new BadRequestException("parts must have nonempty ETags and consecutive PartNumbers"); + } + return { ETag, PartNumber }; + }); + await storage.completeMultipart(input.objectKey, input.uploadId, parts).catch((err: unknown) => { + if (err instanceof Error && ["InvalidPart", "InvalidPartOrder", "EntityTooSmall"].includes(err.name)) { + throw new BadRequestException("multipart ETags or part sizes do not match"); + } + throw new ServiceUnavailableException("file storage is unavailable"); + }); + const head = await storage.headObject(input.objectKey).catch(() => { + throw new ServiceUnavailableException("file storage is unavailable"); + }); + if (!head.exists || head.contentLength !== directSealedSize(input.plainSizeBytes)) { + throw new BadRequestException("uploaded object is missing or its sealed size does not match plainSizeBytes"); + } + const username = await this.usernameFor(workspaceId, actor.userId); + const { id } = await this.registerArchiveEntry(workspaceId, { + groupId: scope.groupId, ownerId: scope.ownerId, folderId, createdBy: actor.userId, + roomId: null, eventId: null, origin: { kind: "upload" }, originKind: "upload", + senderMxid: `@${username}:tmbh.local`, originalName: input.filename, safeName: safe, + mime: mimeType, mimeClass: mimeClassForDeclared(mimeType), byteSize: input.plainSizeBytes, + sha256: input.sha256, ocisResourceId: input.objectKey, ocisResourceVersion: null, + storageKey: input.objectKey, createdAt: new Date(), + }); + return await this.getFile(workspaceId, actor.userId, actor.role, id); + } catch (err) { + await storage.abortMultipart(input.objectKey, input.uploadId).catch(() => undefined); + throw err; + } + } + /** * Commit a completed direct upload. The object is HEAD-checked (exists, exact * declared size) before the row is written, so metadata can never point at a @@ -1264,6 +1367,12 @@ function validateDirectFilename(filename: string): string { return safe; } +function validateDirectPlainSize(size: number): void { + if (!Number.isSafeInteger(size) || size < 1 || size > DIRECT_MAX_BYTES) { + throw new BadRequestException(`sizeBytes must be an integer between 1 and ${DIRECT_MAX_BYTES}`); + } +} + /** Declared MIME must sit under one of the allowed prefixes (no sniffing). */ function validateDirectMime(mimeType: string): string { if (typeof mimeType !== "string" || mimeType.trim() === "" || mimeType.length > 255) { diff --git a/apps/api/src/media/media.service.ts b/apps/api/src/media/media.service.ts index 31515a2..8ffbf87 100644 --- a/apps/api/src/media/media.service.ts +++ b/apps/api/src/media/media.service.ts @@ -50,8 +50,16 @@ export function sniffMediaAsset(bytes: Buffer): SniffedMedia | null { if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") { return { kind: "image", mime: "image/webp" }; } - // mp4: "ftyp" at offset 4 - if (bytes.subarray(4, 8).toString("latin1") === "ftyp") return { kind: "video", mime: "video/mp4" }; + // mp4: "ftyp" at offset 4, but only ISOBMFF video brands — HEIC/AVIF photos + // and QuickTime .mov share the box and would be mislabelled as video/mp4. + if (bytes.subarray(4, 8).toString("latin1") === "ftyp") { + const brand = bytes.subarray(8, 12).toString("latin1").toLowerCase(); + if (brand.startsWith("hei") || brand.startsWith("avif") || brand.startsWith("avis") || brand.startsWith("mif1") || brand.startsWith("msf1")) { + return { kind: "image", mime: "image/avif" }; + } + if (brand.startsWith("qt")) return { kind: "video", mime: "video/quicktime" }; + return { kind: "video", mime: "video/mp4" }; + } // webm/mkv: EBML magic 1A 45 DF A3 if (bytes.subarray(0, 4).toString("hex") === "1a45dfa3") return { kind: "video", mime: "video/webm" }; return null; diff --git a/apps/api/src/oidc/oidc.config.ts b/apps/api/src/oidc/oidc.config.ts index 460f663..30b6cd4 100644 --- a/apps/api/src/oidc/oidc.config.ts +++ b/apps/api/src/oidc/oidc.config.ts @@ -161,11 +161,14 @@ export function validateAuthorize(params: AuthorizeParams): AuthorizeValidation } /** `redirectUri?error=…&error_description=…&state=…` for a redirectable failure. */ -export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string): string { +export function errorRedirect(redirectUri: string, state: string | null, error: string, description: string, issuer?: string): string { const url = new URL(redirectUri); url.searchParams.set("error", error); url.searchParams.set("error_description", description); if (state !== null) url.searchParams.set("state", state); + // RFC 9207 §2: iss is required on error responses too when the server + // advertises authorization_response_iss_parameter_supported. + if (issuer !== undefined) url.searchParams.set("iss", issuer); return url.toString(); } diff --git a/apps/api/src/oidc/oidc.service.ts b/apps/api/src/oidc/oidc.service.ts index 7de836c..a8805c2 100644 --- a/apps/api/src/oidc/oidc.service.ts +++ b/apps/api/src/oidc/oidc.service.ts @@ -223,7 +223,7 @@ export class OidcService implements OnModuleInit { const params = authorizeParamsFrom(query); const result = validateAuthorize(params); if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`); - if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description); + if (!result.ok) return errorRedirect(result.redirectUri, result.state, result.error, result.description, this.config().issuer); const consent = new URL(config.consentUrl); for (const name of AUTHORIZE_PARAM_NAMES) { const value = params[name]; @@ -235,9 +235,14 @@ export class OidcService implements OnModuleInit { /** POST /oidc/authorize/consent — the session's user approves (auto, first-party) and gets a code. */ async consent(workspaceId: string, userId: string, body: unknown, sid: string | null = null): Promise<{ redirectTo: string }> { const config = this.config(); + // The session must still be alive: a logged-out (or sid-less) bearer must + // never mint codes or downstream tokens, even inside its TTL. + if (sid === null || !(await this.deps.refreshStore.sessionAlive(workspaceId, sid))) { + throw new UnauthorizedException("the hub session has ended — sign in again"); + } const result = validateAuthorize(authorizeParamsFrom(body)); if (!result.ok && !result.redirect) throw new BadRequestException(`${result.error}: ${result.description}`); - if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description) }; + if (!result.ok) return { redirectTo: errorRedirect(result.redirectUri, result.state, result.error, result.description, this.config().issuer) }; const request = result.request; if (!isFirstPartyClient(request.clientId)) { // No consent UI exists yet: anything but a first-party client fails closed. diff --git a/apps/api/src/oidc/oidc.test.ts b/apps/api/src/oidc/oidc.test.ts index 5f6f09f..e87ad2b 100644 --- a/apps/api/src/oidc/oidc.test.ts +++ b/apps/api/src/oidc/oidc.test.ts @@ -79,6 +79,8 @@ function harness(): { service: OidcService; users: FakeUsers; clock: { now: numb const vault = new Vault(); const store = new MemoryRefreshStore(); const sessions = store.liveSessions; + store.liveSessions.add(`${WORKSPACE}:test-sid`); + store.liveSessions.add(`${WORKSPACE}:test-sid`); const service = new OidcService({ config: () => config, keyring: (c) => OidcKeyring.open(new FileKeyStore(c.keyDir), vault), @@ -91,7 +93,7 @@ function harness(): { service: OidcService; users: FakeUsers; clock: { now: numb } async function issueCode(service: OidcService, challenge: string, extra: Record = {}): Promise { - const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra)); + const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, extra), "test-sid"); const url = new URL(redirectTo); const code = url.searchParams.get("code"); if (code === null) throw new Error(`no code in redirect: ${url.searchParams.get("error") ?? ""}`); @@ -228,7 +230,8 @@ describe("OidcService", () => { it("auto-consents for client_id=web: the session gets a code straight back, no consent screen", async () => { const { service } = harness(); const { challenge } = pkce(); - const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge)); + await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); // sid-less bearers cannot mint + const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid"); const url = new URL(redirectTo); expect(`${url.origin}${url.pathname}`).toBe(WEB_REDIRECT_URI); expect(url.searchParams.get("code")).toMatch(/^[A-Za-z0-9_-]{43}$/); @@ -239,19 +242,19 @@ describe("OidcService", () => { it("only issues codes to active members with a usable, unreserved username", async () => { const { service, users } = harness(); const { challenge } = pkce(); - await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException); - await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.consent(WORKSPACE, "33333333-3333-4333-8333-333333333333", authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.consent("44444444-4444-4444-8444-444444444444", ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(UnauthorizedException); // cross-workspace sid is not live users.members.set(ADA.id, { ...ADA, username: "admin" }); - await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException); users.members.set(ADA.id, { ...ADA, username: "Ada" }); - await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(ForbiddenException); + await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge), "test-sid")).rejects.toBeInstanceOf(ForbiddenException); }); it("re-validates relayed params: a forged redirect_uri from the consent page is refused", async () => { const { service } = harness(); const { challenge } = pkce(); - await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }))).rejects.toBeInstanceOf(BadRequestException); - const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" })); + await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { redirect_uri: "https://evil.example/cb" }), "test-sid")).rejects.toBeInstanceOf(BadRequestException); + const { redirectTo } = await service.consent(WORKSPACE, ADA.id, authorizeParams(challenge, { code_challenge_method: "plain" }), "test-sid"); expect(new URL(redirectTo).searchParams.has("code")).toBe(false); }); @@ -388,11 +391,10 @@ describe("OidcService refresh tokens (phase 2)", () => { expect(rows.filter((row) => row.consumedAt === null)).toHaveLength(1); }); - it("a code from a pre-phase2 session (no sid) mints no refresh token", async () => { - const h = harness(); - const { verifier, challenge } = pkce(); - const tokens = await exchange(h.service, await issueCode(h.service, challenge), verifier); - expect(tokens.refresh_token).toBeUndefined(); + it("a consent without a session id (pre-phase2 client) is rejected outright", async () => { + const { service } = harness(); + const { challenge } = pkce(); + await expect(service.consent(WORKSPACE, ADA.id, authorizeParams(challenge))).rejects.toBeInstanceOf(UnauthorizedException); }); it("rejects a revoked (logged-out) session's refresh token and revokes the chain", async () => { diff --git a/apps/api/src/storage/storage.test.ts b/apps/api/src/storage/storage.test.ts index eea5bb2..3b296c6 100644 --- a/apps/api/src/storage/storage.test.ts +++ b/apps/api/src/storage/storage.test.ts @@ -44,10 +44,27 @@ vi.mock("@aws-sdk/client-s3", () => { }; }); -import { DIRECT_SEGMENT_BYTES, S3Storage, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js"; +import { DIRECT_SEGMENT_BYTES, S3Storage, directSealedSize, directPlaintextSize, extensionFor, keyForHash, open, seal, sealForDirect, sha256 } from "./storage.js"; const TEST_KEK = createHash("sha256").update("test-kek").digest(); +describe("SPENC2 sealed size", () => { + it.each([ + [1, 168], + [DIRECT_SEGMENT_BYTES - 1, 135 + DIRECT_SEGMENT_BYTES - 1 + 32], + [DIRECT_SEGMENT_BYTES, 135 + DIRECT_SEGMENT_BYTES + 32], + [DIRECT_SEGMENT_BYTES + 1, 135 + DIRECT_SEGMENT_BYTES + 1 + 64], + [2 * 1024 ** 3, 135 + 2 * 1024 ** 3 + 205 * 32], + ])("computes exact size for %i plaintext bytes", (plain, sealed) => { + expect(directSealedSize(plain)).toBe(sealed); + expect(directPlaintextSize(sealed)).toBe(plain); + }); + + it.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])("rejects invalid plaintext size %s", (size) => { + expect(() => directSealedSize(size)).toThrow(RangeError); + }); +}); + describe("storage key scheme (pure)", () => { it("maps content types to extensions, unknown -> bin", () => { expect(extensionFor("image/png")).toBe("png"); diff --git a/apps/api/src/storage/storage.ts b/apps/api/src/storage/storage.ts index 61f5d01..400a2e4 100644 --- a/apps/api/src/storage/storage.ts +++ b/apps/api/src/storage/storage.ts @@ -1,12 +1,16 @@ import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto"; import { ServiceUnavailableException } from "@nestjs/common"; import { + AbortMultipartUploadCommand, + CompleteMultipartUploadCommand, CreateBucketCommand, + CreateMultipartUploadCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, S3ServiceException, + UploadPartCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; @@ -44,6 +48,10 @@ export interface StorageService { directPublicJwk(): Promise<{ kty: "EC"; crv: "P-256"; x: string; y: string } | null>; /** Direct-to-R2: presigned PUT for the browser to push a SPENC2 frame. */ presignPut(key: string, contentType: string, ttlSeconds: number): Promise; + createMultipart(key: string, contentType: string): Promise<{ uploadId: string }>; + presignPart(key: string, uploadId: string, partNumber: number, ttlSeconds: number): Promise; + completeMultipart(key: string, uploadId: string, parts: MultipartPart[]): Promise; + abortMultipart(key: string, uploadId: string): Promise; /** Direct-to-R2: existence + exact size, used to gate /files/direct/complete. */ headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }>; /** Direct-to-R2: unwrap the frame's CEK (header only) for /files/:id/direct-download. */ @@ -56,10 +64,15 @@ export interface StorageService { readDirectRange(key: string, start: number, length: number): Promise; } +export interface MultipartPart { + ETag: string; + PartNumber: number; +} + /** The direct-only members of StorageService, as one spreadable group. */ export type DirectStorageMembers = Pick< StorageService, - "directPublicJwk" | "presignPut" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange" + "directPublicJwk" | "presignPut" | "createMultipart" | "presignPart" | "completeMultipart" | "abortMultipart" | "headObject" | "openDirectHeader" | "openDirectHeaderBlob" | "getSealedObject" | "readDirectRange" >; /** @@ -74,6 +87,10 @@ export function directStorageDisabled(): DirectStorageMembers { return { directPublicJwk: async () => null, presignPut: unavailable, + createMultipart: unavailable, + presignPart: unavailable, + completeMultipart: unavailable, + abortMultipart: unavailable, headObject: unavailable, openDirectHeader: unavailable, openDirectHeaderBlob: unavailable, @@ -169,6 +186,16 @@ const DIRECT_WRAP_INFO = Buffer.from("tmbh-direct-cek-wrap", "ascii"); /** Segment plaintext ceiling. Mirrors the web side, which seals 10 MiB chunks. */ export const DIRECT_SEGMENT_BYTES = 10 * 1024 * 1024; +/** Exact SPENC2 size: fixed header plus one length, nonce and tag per segment. */ +export function directSealedSize(plainSizeBytes: number): number { + if (!Number.isSafeInteger(plainSizeBytes) || plainSizeBytes < 1) { + throw new RangeError("storage: invalid SPENC2 plaintext size"); + } + const size = SEGMENTS_OFFSET + plainSizeBytes + Math.ceil(plainSizeBytes / DIRECT_SEGMENT_BYTES) * 32; + if (!Number.isSafeInteger(size)) throw new RangeError("storage: invalid SPENC2 sealed size"); + return size; +} + /** Direct upload metadata records sealed bytes; HTTP ranges address plaintext. */ export function directPlaintextSize(sealedBytes: number): number { const count = Math.ceil((sealedBytes - SEGMENTS_OFFSET) / (DIRECT_SEGMENT_BYTES + 32)); @@ -521,6 +548,30 @@ export class S3Storage implements StorageService { return getSignedUrl(this.client, new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), { expiresIn: ttlSeconds }); } + async createMultipart(key: string, contentType: string): Promise<{ uploadId: string }> { + const result = await this.client.send(new CreateMultipartUploadCommand({ + Bucket: this.bucket, Key: key, ContentType: contentType, + })); + if (!result.UploadId) throw new Error("storage: multipart upload has no upload ID"); + return { uploadId: result.UploadId }; + } + + async presignPart(key: string, uploadId: string, partNumber: number, ttlSeconds: number): Promise { + return getSignedUrl(this.client, new UploadPartCommand({ + Bucket: this.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber, + }), { expiresIn: ttlSeconds }); + } + + async completeMultipart(key: string, uploadId: string, parts: MultipartPart[]): Promise { + await this.client.send(new CompleteMultipartUploadCommand({ + Bucket: this.bucket, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts }, + })); + } + + async abortMultipart(key: string, uploadId: string): Promise { + await this.client.send(new AbortMultipartUploadCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId })); + } + async headObject(key: string): Promise<{ exists: boolean; contentLength: number | null }> { try { const res = await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key })); diff --git a/apps/api/src/video/video.service.test.ts b/apps/api/src/video/video.service.test.ts index d513cca..7d5a939 100644 --- a/apps/api/src/video/video.service.test.ts +++ b/apps/api/src/video/video.service.test.ts @@ -170,7 +170,7 @@ describe("video byte ingest and processing-job state machine", () => { const { service, storage } = serviceWithDatabase({}, async () => ({})); await expect(service.uploadVideoBytes(workspaceId, Buffer.alloc(0), "video/mp4")).rejects.toMatchObject({ status: 400 }); await expect(service.uploadVideoBytes(workspaceId, Buffer.from("image"), "image/png")).rejects.toMatchObject({ status: 400 }); - await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("100 MiB upload limit"); + await expect(service.uploadVideoBytes(workspaceId, Buffer.allocUnsafe(MAX_VIDEO_UPLOAD_BYTES + 1), "video/mp4")).rejects.toThrow("video exceeds the chunked upload limit"); expect(storage.putContent).not.toHaveBeenCalled(); }); });