diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index c3f1ec1..e62ef60 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,5 @@ import { randomBytes, randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; +import { createReadStream, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { @@ -51,11 +51,17 @@ import { ModelRuntimeSseEventSchema, FailedEmptyTrashRequestSchema, FailedEmptyTrashResponseSchema, + ExportFormatSchema, GenerationProjectItemSchema, + LatestExportItemSchema, + LatestExportMultipartBodySchema, + LatestExportParamsSchema, + LatestExportSaveResponseSchema, ProjectDetailResponseSchema, ProjectEditableStateSchema, ProjectIdSchema, ProjectImageItemSchema, + ProjectImageParamsSchema, ProjectListQuerySchema, ProjectListResponseSchema, ProjectParamsSchema, @@ -94,6 +100,8 @@ import { type LoginCompleteRequest, type LoginSendRequest, type FailedEmptyTrashRequest, + type LatestExportParams, + type ProjectImageParams, type GenerationCreateHeaders, type GenerationParams, type ProjectListQuery, @@ -136,6 +144,8 @@ import type { import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js"; import { ProjectError } from "./project-errors.js"; import type { ProjectService } from "./projects.js"; +import { LatestExportError } from "./latest-export-errors.js"; +import type { LatestExportService } from "./latest-exports.js"; import { RegistrationError, registrationFieldError, @@ -162,6 +172,7 @@ export interface CreateAppOptions { credits?: CreditService; eventHub?: EventHub; generations?: GenerationSubmissionService; + latestExports?: LatestExportService; networkBoundary?: NetworkBoundaryOptions; publicAssets?: PublicAssetResolver; projects?: ProjectService; @@ -251,6 +262,45 @@ function projectFailure(reply: FastifyReply, correlationId: string, error: unkno return reply.code(mapping[error.code]).send(null); } +function latestExportResponse(item: ReturnType | Awaited>) { + return { + byte_size: item.byteSize, + created_at: item.createdAt, + download_url: item.downloadUrl, + export_id: item.exportId, + format: item.format, + pixel_height: item.pixelHeight, + pixel_width: item.pixelWidth, + sha256: item.sha256, + state_version: item.stateVersion, + status: "saved" as const, + }; +} + +function latestExportFailure(reply: FastifyReply, correlationId: string, error: unknown) { + if (error instanceof LatestExportError) { + return reply.code(error.code === "not_found" ? 404 : error.code === "conflict" ? 409 : 400).send(null); + } + if (error && typeof error === "object" && "code" in error && error.code === "STORAGE_CAPACITY_EXCEEDED") { + const details = "details" in error && error.details && typeof error.details === "object" + ? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number } + : undefined; + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { capacity_status: details?.capacity_status ?? "full", remaining_bytes: details?.remaining_bytes ?? 0 }, + })); + } + if (error && typeof error === "object" && "code" in error && error.code === "storage_unavailable") { + return reply.code(507).send(createErrorEnvelope({ + code: "STORAGE_CAPACITY_EXCEEDED", + correlationId, + details: { capacity_status: "unavailable", remaining_bytes: 0 }, + })); + } + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); +} + function creditFailure(reply: FastifyReply, correlationId: string, error: unknown) { if (!(error instanceof CreditError)) { return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId })); @@ -475,6 +525,17 @@ function projectDetailResponse(project: ProjectDetailView) { generation_id: image.generationId, image_id: image.imageId, })), + latest_exports: project.latestExports.map((item) => ({ + byte_size: item.byteSize, + created_at: item.createdAt, + download_url: item.downloadUrl, + export_id: item.exportId, + format: item.format, + pixel_height: item.pixelHeight, + pixel_width: item.pixelWidth, + sha256: item.sha256, + state_version: item.stateVersion, + })), pixel_height: project.pixelHeight, pixel_width: project.pixelWidth, save_status: project.saveStatus, @@ -619,6 +680,12 @@ export async function createApp(options: CreateAppOptions = {}) { ProjectParamsSchema, GenerationProjectItemSchema, ProjectImageItemSchema, + ExportFormatSchema, + LatestExportItemSchema, + LatestExportMultipartBodySchema, + LatestExportParamsSchema, + LatestExportSaveResponseSchema, + ProjectImageParamsSchema, ProjectDetailResponseSchema, ProjectEditableStateSchema, ProjectRenameRequestSchema, @@ -1708,6 +1775,140 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.put( + "/api/v1/projects/:projectId/latest-exports/:format", + { + attachValidation: true, + schema: { + body: Type.Optional(Type.Ref(LatestExportMultipartBodySchema)), + consumes: ["multipart/form-data"], + headers: Type.Ref(CsrfHeadersSchema), + operationId: "saveLatestExport", + params: Type.Ref(LatestExportParamsSchema), + response: { + 200: Type.Ref(LatestExportSaveResponseSchema), + 400: Type.Null(), + 401: Type.Ref(ErrorEnvelopeSchema), + 403: Type.Ref(ErrorEnvelopeSchema), + 404: Type.Null(), + 409: Type.Null(), + 503: Type.Ref(ErrorEnvelopeSchema), + 507: Type.Ref(ErrorEnvelopeSchema), + }, + tags: ["Projects"], + }, + validatorCompiler: () => (data) => ({ value: data }), + }, + async (request, reply) => { + if (request.validationError || !request.isMultipart()) return reply.code(400).send(null); + if (!options.registration || !options.latestExports) { + return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + } + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const csrfToken = headerValue(request.headers["x-csrf-token"]); + if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token }); + const params = request.params as LatestExportParams; + const fields = new Map(); + let saved: Awaited> | undefined; + for await (const part of request.parts()) { + if (part.type === "field") { + if (saved || fields.has(part.fieldname) || typeof part.value !== "string") throw new LatestExportError("invalid"); + fields.set(part.fieldname, part.value); + continue; + } + if (saved || part.fieldname !== "export_file" || !part.filename) throw new LatestExportError("invalid"); + const format = fields.get("format"); + const expectedMime = params.format === "png" ? "image/png" : "image/jpeg"; + if (format !== params.format || part.mimetype !== expectedMime) throw new LatestExportError("invalid"); + saved = await options.latestExports.saveLatest({ + byteSize: Number(fields.get("byte_size")), + content: part.file, + exportId: fields.get("export_id") ?? "", + format: params.format, + ownerId: owner.userId, + pixelHeight: Number(fields.get("pixel_height")), + pixelWidth: Number(fields.get("pixel_width")), + projectId: params.projectId, + sha256: fields.get("sha256") ?? "", + stateVersion: Number(fields.get("state_version")), + }); + if (part.file.truncated) throw new LatestExportError("invalid"); + } + if (!saved) throw new LatestExportError("invalid"); + return latestExportResponse(saved); + } catch (error) { + return error instanceof RegistrationError + ? registrationFailure(reply, request.id, error) + : latestExportFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/projects/:projectId/latest-exports/:format", + { + attachValidation: true, + schema: { + operationId: "downloadLatestExport", + params: Type.Ref(LatestExportParamsSchema), + produces: ["application/octet-stream"], + response: { 200: Type.String({ format: "binary" }), 400: Type.Null(), 401: Type.Ref(ErrorEnvelopeSchema), 404: Type.Null(), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const params = request.params as LatestExportParams; + const item = options.latestExports.getLatest(session.userId, params.projectId, params.format); + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", `attachment; filename="dada-latest.${params.format === "jpg" ? "jpg" : "png"}"`); + reply.type(item.mimeType); + return reply.send(createReadStream(item.path)); + } catch (error) { + return latestExportFailure(reply, request.id, error); + } + }, + ); + + app.get( + "/api/v1/private-assets/projects/:projectId/images/:imageId", + { + attachValidation: true, + schema: { + operationId: "downloadOriginalGeneration", + params: Type.Ref(ProjectImageParamsSchema), + produces: ["application/octet-stream"], + response: { 200: Type.String({ format: "binary" }), 400: Type.Null(), 401: Type.Ref(ErrorEnvelopeSchema), 404: Type.Null(), 503: Type.Ref(ErrorEnvelopeSchema) }, + tags: ["Projects"], + }, + }, + async (request, reply) => { + if (request.validationError) return reply.code(400).send(null); + if (!options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id })); + const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName); + const session = token ? options.registration.readUserSession(token) : undefined; + if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + try { + const params = request.params as ProjectImageParams; + const item = options.latestExports.getOriginal(session.userId, params.projectId, params.imageId); + const extension = item.mime_type === "image/jpeg" ? "jpg" : item.mime_type === "image/webp" ? "webp" : "png"; + reply.header("Cache-Control", "private, no-store"); + reply.header("Content-Disposition", `attachment; filename="dada-original.${extension}"`); + reply.type(item.mime_type); + return reply.send(createReadStream(item.path)); + } catch (error) { + return latestExportFailure(reply, request.id, error); + } + }, + ); + app.put( "/api/v1/projects/:projectId/state", { diff --git a/apps/api/src/latest-export-errors.ts b/apps/api/src/latest-export-errors.ts new file mode 100644 index 0000000..f76a8a1 --- /dev/null +++ b/apps/api/src/latest-export-errors.ts @@ -0,0 +1,5 @@ +export class LatestExportError extends Error { + constructor(readonly code: "invalid" | "not_found" | "conflict") { + super(code); + } +} diff --git a/apps/api/src/latest-exports.ts b/apps/api/src/latest-exports.ts new file mode 100644 index 0000000..9ab3bb2 --- /dev/null +++ b/apps/api/src/latest-exports.ts @@ -0,0 +1,263 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createRequire } from "node:module"; +import type { Readable } from "node:stream"; + +import type BetterSqlite3 from "better-sqlite3"; + +import { ManagedStorage } from "./managed-storage.js"; +import { stableJson } from "./projects.js"; +import { LatestExportError } from "./latest-export-errors.js"; +export { LatestExportError } from "./latest-export-errors.js"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3") as typeof BetterSqlite3; + +export type ExportFormat = "jpg" | "png"; + +interface ExportRow { + byte_size: number; + created_at: number; + export_id: string; + format: ExportFormat; + managed_file_id: string; + pixel_height: number; + pixel_width: number; + project_id: string; + sha256: string; + state_version: number; +} + +interface ManagedFileRow { + byte_size: number; + file_id: string; + mime_type: string; + relative_path: string; + sha256: string; +} + +type LatestExportView = ReturnType; + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +function validSha256(value: string) { + return /^[0-9a-f]{64}$/.test(value); +} + +export class LatestExportService { + readonly database: BetterSqlite3.Database; + private readonly clock: () => number; + private readonly storage: ManagedStorage; + + constructor(input: { clock?: () => number; databasePath: string; storage: ManagedStorage }) { + this.clock = input.clock ?? Date.now; + this.storage = input.storage; + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined); + this.database.pragma("journal_mode = WAL"); + this.database.pragma("foreign_keys = ON"); + this.database.pragma("busy_timeout = 5000"); + this.migrate(); + } + + close() { + this.database.close(); + } + + async saveLatest(input: { + byteSize: number; + content: Readable; + exportId: string; + format: ExportFormat; + ownerId: string; + pixelHeight: number; + pixelWidth: number; + projectId: string; + sha256: string; + stateVersion: number; + }) { + this.validateInput(input); + const requestHash = createHash("sha256").update(stableJson({ + byte_size: input.byteSize, export_id: input.exportId, format: input.format, + owner_id: input.ownerId, + pixel_height: input.pixelHeight, pixel_width: input.pixelWidth, project_id: input.projectId, + sha256: input.sha256, state_version: input.stateVersion, + })).digest("hex"); + const replay = this.database.prepare("SELECT owner_id, request_hash, response_json FROM latest_export_receipts WHERE export_id = ?") + .get(input.exportId) as { owner_id: string; request_hash: string; response_json: string | null } | undefined; + if (replay) { + if (replay.owner_id !== input.ownerId || replay.request_hash !== requestHash) throw new LatestExportError("conflict"); + const replayHash = createHash("sha256"); + let replayBytes = 0; + for await (const chunk of input.content) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + replayBytes += bytes.byteLength; + replayHash.update(bytes); + } + if (replayBytes !== input.byteSize || replayHash.digest("hex") !== input.sha256) throw new LatestExportError("conflict"); + return replay.response_json ? JSON.parse(replay.response_json) as LatestExportView : this.readByExportId(input.ownerId, input.exportId); + } + this.assertWritableProject(input); + const stored = await this.storage.commitStream({ + content: input.content, + expectedMimeType: input.format === "png" ? "image/png" : "image/jpeg", + expectedSha256: input.sha256, + fileKind: "export", + fileName: `latest.${input.format === "jpg" ? "jpg" : "png"}`, + operationId: input.exportId, + ownerRef: input.ownerId, + projectedWriteBytes: input.byteSize, + }); + try { + if (stored.bytes !== input.byteSize) throw new LatestExportError("invalid"); + const createdAt = this.clock(); + const response = this.view({ + byte_size: stored.bytes, + created_at: createdAt, + export_id: input.exportId, + format: input.format, + managed_file_id: stored.file_id, + pixel_height: input.pixelHeight, + pixel_width: input.pixelWidth, + project_id: input.projectId, + sha256: stored.sha256, + state_version: input.stateVersion, + }); + const transaction = this.database.transaction(() => { + this.assertWritableProject(input); + const previous = this.database.prepare("SELECT managed_file_id FROM latest_exports WHERE project_id = ? AND format = ?") + .get(input.projectId, input.format) as { managed_file_id: string } | undefined; + this.database.prepare(` + INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) + VALUES (?, ?, 'export', ?) + `).run(input.projectId, stored.file_id, createdAt); + this.database.prepare(` + INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) + VALUES (?, ?, 'project', ?) + `).run(`project:${input.projectId}:${stored.file_id}`, stored.file_id, iso(createdAt)); + this.database.prepare(` + INSERT INTO latest_exports ( + project_id, format, export_id, managed_file_id, state_version, sha256, + byte_size, pixel_width, pixel_height, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, format) DO UPDATE SET + export_id = excluded.export_id, managed_file_id = excluded.managed_file_id, + state_version = excluded.state_version, sha256 = excluded.sha256, + byte_size = excluded.byte_size, pixel_width = excluded.pixel_width, + pixel_height = excluded.pixel_height, created_at = excluded.created_at + `).run( + input.projectId, input.format, input.exportId, stored.file_id, input.stateVersion, + stored.sha256, stored.bytes, input.pixelWidth, input.pixelHeight, createdAt, + ); + this.database.prepare("INSERT INTO latest_export_receipts (export_id, owner_id, request_hash, response_json, created_at) VALUES (?, ?, ?, ?, ?)") + .run(input.exportId, input.ownerId, requestHash, stableJson(response), createdAt); + if (previous && previous.managed_file_id !== stored.file_id) this.retireReplacedFile(previous.managed_file_id, createdAt); + }); + transaction.immediate(); + return response; + } catch (error) { + this.storage.retireManagedFile(stored.file_id, "compensation"); + throw error; + } + } + + getLatest(ownerId: string, projectId: string, format: ExportFormat) { + const row = this.database.prepare(` + SELECT le.*, mf.mime_type, mf.relative_path + FROM latest_exports le + JOIN projects p ON p.project_id = le.project_id + JOIN managed_files mf ON mf.file_id = le.managed_file_id AND mf.status = 'committed' + WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND le.format = ? + `).get(ownerId, projectId, format) as (ExportRow & ManagedFileRow) | undefined; + if (!row) throw new LatestExportError("not_found"); + const path = this.storage.resolveManagedFile(row.managed_file_id); + if (!path) throw new LatestExportError("not_found"); + return { ...this.view(row), mimeType: row.mime_type, path }; + } + + getOriginal(ownerId: string, projectId: string, imageId: string) { + const row = this.database.prepare(` + SELECT mf.file_id, mf.relative_path, mf.byte_size, mf.mime_type, mf.sha256 + FROM project_images pi + JOIN projects p ON p.project_id = pi.project_id + JOIN managed_files mf ON mf.file_id = pi.image_id AND mf.status = 'committed' + WHERE p.owner_id = ? AND p.project_id = ? AND p.status <> 'purged' AND pi.image_id = ? + `).get(ownerId, projectId, imageId) as ManagedFileRow | undefined; + if (!row) throw new LatestExportError("not_found"); + const path = this.storage.resolveManagedFile(row.file_id); + if (!path) throw new LatestExportError("not_found"); + return { ...row, path }; + } + + private assertWritableProject(input: Pick[0], "ownerId" | "pixelHeight" | "pixelWidth" | "projectId" | "stateVersion">) { + const project = this.database.prepare(` + SELECT state_version, pixel_width, pixel_height FROM projects + WHERE owner_id = ? AND project_id = ? AND status = 'active' + `).get(input.ownerId, input.projectId) as { pixel_height: number; pixel_width: number; state_version: number } | undefined; + if (!project) throw new LatestExportError("not_found"); + if (project.state_version !== input.stateVersion) throw new LatestExportError("conflict"); + if (project.pixel_width !== input.pixelWidth || project.pixel_height !== input.pixelHeight) throw new LatestExportError("invalid"); + } + + private readByExportId(ownerId: string, exportId: string) { + const row = this.database.prepare(` + SELECT le.* FROM latest_exports le JOIN projects p ON p.project_id = le.project_id + WHERE p.owner_id = ? AND le.export_id = ? + `).get(ownerId, exportId) as ExportRow | undefined; + if (!row) throw new LatestExportError("not_found"); + return this.view(row); + } + + private retireReplacedFile(fileId: string, timestamp: number) { + const file = this.database.prepare("SELECT relative_path, byte_size FROM managed_files WHERE file_id = ? AND status = 'committed'") + .get(fileId) as { byte_size: number; relative_path: string } | undefined; + if (!file) return; + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(iso(timestamp), fileId); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL) + `).run(randomUUID(), fileId, file.relative_path, file.byte_size, iso(timestamp)); + } + + private validateInput(input: Parameters[0]) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(input.exportId) || !["jpg", "png"].includes(input.format) + || !validSha256(input.sha256) || ![input.byteSize, input.pixelHeight, input.pixelWidth, input.stateVersion].every((value) => Number.isSafeInteger(value) && value > 0)) { + throw new LatestExportError("invalid"); + } + } + + private view(row: ExportRow) { + return { + byteSize: row.byte_size, + createdAt: iso(row.created_at), + downloadUrl: `/api/v1/projects/${row.project_id}/latest-exports/${row.format}`, + exportId: row.export_id, + format: row.format, + pixelHeight: row.pixel_height, + pixelWidth: row.pixel_width, + projectId: row.project_id, + sha256: row.sha256, + stateVersion: row.state_version, + }; + } + + private migrate() { + this.database.exec(` + CREATE TABLE IF NOT EXISTS latest_export_receipts ( + export_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + response_json TEXT CHECK (response_json IS NULL OR json_valid(response_json)), + created_at INTEGER NOT NULL + ); + `); + const columns = this.database.prepare("PRAGMA table_info(latest_export_receipts)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "response_json")) { + this.database.exec("ALTER TABLE latest_export_receipts ADD COLUMN response_json TEXT"); + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index dd52ff2..0003c22 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -7,6 +7,7 @@ import { createApp } from "./app.js"; import { readBrowserSupportRelease } from "./browser-support.js"; import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js"; import { ManagedStorage } from "./managed-storage.js"; +import { LatestExportService } from "./latest-exports.js"; import { CreditService } from "./credits.js"; import { ProjectService } from "./projects.js"; import { RegistrationService } from "./registration.js"; @@ -19,6 +20,8 @@ const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin" let registration: RegistrationService | undefined; let projects: ProjectService | undefined; let credits: CreditService | undefined; +let storage: ManagedStorage | undefined; +let latestExports: LatestExportService | undefined; const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { const clients = initializeApiCredentialClients(await receiveApiCredentials()); @@ -39,8 +42,14 @@ if (credentialChannelEnabled) { }); projects = new ProjectService({ databasePath }); credits = new CreditService({ databasePath }); + storage = new ManagedStorage({ dataRoot, databasePath }); + latestExports = new LatestExportService({ databasePath, storage }); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); } catch (error) { + latestExports?.close(); + latestExports = undefined; + storage?.close(); + storage = undefined; credits?.close(); credits = undefined; projects?.close(); @@ -57,6 +66,7 @@ const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json")) const app = await createApp({ ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), + ...(latestExports ? { latestExports } : {}), ...(projects ? { projects } : {}), ...(registration ? { registration } : {}), }); @@ -70,9 +80,9 @@ const controlPipeIndex = process.argv.indexOf("--dada-control-pipe"); if (controlPipeIndex >= 0) { const controlPipe = process.argv[controlPipeIndex + 1]; if (!controlPipe) throw new Error("Supervisor control pipe name is required."); - let storage: ManagedStorage | undefined; const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + latestExports?.close(); credits?.close(); projects?.close(); registration?.close(); @@ -80,7 +90,7 @@ if (controlPipeIndex >= 0) { }); try { const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath); - storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") }); + if (!storage) storage = new ManagedStorage({ dataRoot, databasePath: join(dataRoot, "db", "dada.sqlite3") }); const logger = new StructuredJsonlLogger({ component: "api", directory: join(dataRoot, "logs", "api"), diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 0c122f4..4854ad4 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -645,6 +645,26 @@ export class ManagedStorage { return row ? resolvePathWithinRoot(this.dataRoot, row.relative_path) : undefined; } + retireManagedFile(fileId: string, reason: "compensation" | "purge" = "compensation") { + const transaction = this.database.transaction(() => { + const file = this.database.prepare(` + SELECT file_id, relative_path, byte_size FROM managed_files + WHERE file_id = ? AND status = 'committed' + `).get(fileId) as { byte_size: number; file_id: string; relative_path: string } | undefined; + if (!file) return; + const retiredAt = now(); + this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(fileId); + this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ?").run(retiredAt, fileId); + this.database.prepare(` + INSERT OR IGNORE INTO file_cleanup_queue ( + cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed, + reason, status, created_at, completed_at, last_error + ) VALUES (?, ?, ?, ?, 1, ?, 'pending', ?, NULL, NULL) + `).run(randomUUID(), fileId, file.relative_path, file.byte_size, reason, retiredAt); + }); + transaction.immediate(); + } + addAssetReference(fileId: string, referenceType: "project" | "release") { if (this.inspectAction("project_json_write") !== "allow") throw new StorageUnavailableError(); this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, ?, ?)`) diff --git a/apps/api/src/projects.ts b/apps/api/src/projects.ts index 671d34a..a65baf1 100644 --- a/apps/api/src/projects.ts +++ b/apps/api/src/projects.ts @@ -72,6 +72,17 @@ interface ProjectStateRow { state_version: number; } +interface LatestExportRow { + byte_size: number; + created_at: number; + export_id: string; + format: "jpg" | "png"; + pixel_height: number; + pixel_width: number; + sha256: string; + state_version: number; +} + function isProjectRatio(value: string): value is ProjectRatio { return projectRatios.includes(value as ProjectRatio); } @@ -510,6 +521,10 @@ export class ProjectService { const images = this.database.prepare(` SELECT image_id, generation_id, created_at FROM project_images WHERE project_id = ? ORDER BY created_at, rowid `).all(projectId) as ImageRow[]; + const latestExports = this.database.prepare(` + SELECT export_id, format, sha256, byte_size, pixel_width, pixel_height, state_version, created_at + FROM latest_exports WHERE project_id = ? ORDER BY format + `).all(projectId) as LatestExportRow[]; const summary = this.projectSummary(row, images.length, generations.at(-1)?.status ?? null); const projectState = this.readProjectState(projectId); return { @@ -519,6 +534,17 @@ export class ProjectService { draftPrompt: row.draft_prompt, generations: generations.map((generation) => this.generationView(generation)), images: images.map((image) => ({ createdAt: iso(image.created_at), generationId: image.generation_id, imageId: image.image_id })), + latestExports: latestExports.map((item) => ({ + byteSize: item.byte_size, + createdAt: iso(item.created_at), + downloadUrl: `/api/v1/projects/${projectId}/latest-exports/${item.format}`, + exportId: item.export_id, + format: item.format, + pixelHeight: item.pixel_height, + pixelWidth: item.pixel_width, + sha256: item.sha256, + stateVersion: item.state_version, + })), pixelHeight: row.pixel_height, pixelWidth: row.pixel_width, saveStatus: "saved" as const, @@ -748,6 +774,20 @@ export class ProjectService { FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id); + CREATE TABLE IF NOT EXISTS latest_exports ( + project_id TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('jpg', 'png')), + export_id TEXT NOT NULL UNIQUE, + managed_file_id TEXT NOT NULL, + state_version INTEGER NOT NULL CHECK (state_version >= 1), + sha256 TEXT NOT NULL CHECK (length(sha256) = 64), + byte_size INTEGER NOT NULL CHECK (byte_size > 0), + pixel_width INTEGER NOT NULL CHECK (pixel_width > 0), + pixel_height INTEGER NOT NULL CHECK (pixel_height > 0), + created_at INTEGER NOT NULL, + PRIMARY KEY (project_id, format), + FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE + ); CREATE TABLE IF NOT EXISTS project_cleanup_queue ( cleanup_id TEXT PRIMARY KEY, project_id TEXT NOT NULL UNIQUE, diff --git a/apps/api/src/registration.ts b/apps/api/src/registration.ts index 55d1251..74fa9f0 100644 --- a/apps/api/src/registration.ts +++ b/apps/api/src/registration.ts @@ -1210,6 +1210,10 @@ export class RegistrationService { SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'projects' `).get(); if (projectsTable) this.database.prepare("DELETE FROM projects WHERE owner_id = ?").run(session.user_id); + const exportReceiptsTable = this.database.prepare(` + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'latest_export_receipts' + `).get(); + if (exportReceiptsTable) this.database.prepare("DELETE FROM latest_export_receipts WHERE owner_id = ?").run(session.user_id); this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id); this.database.prepare("DELETE FROM email_challenges WHERE email = ?").run(session.normalized_email); this.database.prepare("DELETE FROM auth_rate_limits WHERE rate_key = ?") diff --git a/apps/web/src/export-flow.ts b/apps/web/src/export-flow.ts new file mode 100644 index 0000000..d3f2233 --- /dev/null +++ b/apps/web/src/export-flow.ts @@ -0,0 +1,32 @@ +export type ExportFlowStatus = "composition_failed" | "download_failed" | "downloaded_not_saved" | "downloaded_and_saved"; + +export const exportResultCopy: Record = { + composition_failed: "合成失败,请返回编辑器检查", + download_failed: "浏览器下载失败,可重新下载同一次合成结果", + downloaded_not_saved: "文件已下载,但未保存为最新成品", + downloaded_and_saved: "已下载并保存为最新成品", +}; + +export async function runExportFlow(input: { + compose: () => Promise; + download: (blob: Blob) => Promise; + persist: (blob: Blob) => Promise; +}): Promise<{ blob: Blob | null; status: ExportFlowStatus }> { + let blob: Blob; + try { + blob = await input.compose(); + } catch { + return { blob: null, status: "composition_failed" }; + } + try { + await input.download(blob); + } catch { + return { blob, status: "download_failed" }; + } + try { + await input.persist(blob); + return { blob, status: "downloaded_and_saved" }; + } catch { + return { blob, status: "downloaded_not_saved" }; + } +} diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index bc80267..f8f071f 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; +import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -90,13 +90,28 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op return response.json() as Promise; } -export async function createGeneration(options: ClientOptions = {}): Promise { +export async function createGeneration(body: FormData, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; - const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { method: "POST", headers: options.headers ?? {} }); + const headers = new Headers(options.headers); + const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { body: body, method: "POST", headers }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json() as Promise; } +export async function downloadLatestExport(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.blob() as Promise; +} + +export async function downloadOriginalGeneration(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/private-assets/projects/{projectId}/images/{imageId}`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.blob() as Promise; +} + export async function getAccountSettings(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} }); @@ -240,6 +255,14 @@ export async function restoreProject(options: ClientOptions = {}): Promise; } +export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const headers = new Headers(options.headers); + const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/latest-exports/{format}`, { body: body, method: "PUT", headers }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const headers = new Headers(options.headers); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index ed91086..baffd06 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -311,6 +311,8 @@ export type ErrorEnvelope = { }; }; +export type ExportFormat = "jpg" | "png"; + export type FailedEmptyTrashRequest = { "project_ids": Array; }; @@ -379,6 +381,47 @@ export type GenerationTaskResponse = { export type GenerationTaskStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; +export type LatestExportItem = { + "byte_size": number; + "created_at": string; + "download_url": string; + "export_id": string; + "format": ExportFormat; + "pixel_height": number; + "pixel_width": number; + "sha256": string; + "state_version": number; +}; + +export type LatestExportMultipartBody = { + "byte_size": string; + "export_file": string; + "export_id": string; + "format": ExportFormat; + "pixel_height": string; + "pixel_width": string; + "sha256": string; + "state_version": string; +}; + +export type LatestExportParams = { + "format": ExportFormat; + "projectId": ProjectId; +}; + +export type LatestExportSaveResponse = { + "byte_size": number; + "created_at": string; + "download_url": string; + "export_id": string; + "format": ExportFormat; + "pixel_height": number; + "pixel_width": number; + "sha256": string; + "state_version": number; + "status": "saved"; +}; + export type LoginCompleteRequest = { "registration_id": string; "verification_code": string; @@ -429,6 +472,7 @@ export type ProjectDetailResponse = { "draft_prompt": string; "generations": Array; "images": Array; + "latest_exports": Array; "name": string; "pixel_height": number; "pixel_width": number; @@ -455,6 +499,11 @@ export type ProjectImageItem = { "image_id": ProjectId; }; +export type ProjectImageParams = { + "imageId": ProjectId; + "projectId": ProjectId; +}; + export type ProjectListQuery = { "status"?: "active" | "trashed"; }; diff --git a/apps/web/src/project-pages.css b/apps/web/src/project-pages.css index bf266f7..1f8e744 100644 --- a/apps/web/src/project-pages.css +++ b/apps/web/src/project-pages.css @@ -1049,6 +1049,106 @@ color: #65655f; } +.project-latest-exports { + margin-top: 22px; + border: 1px solid #111111; + background: #ffffff; +} + +.project-latest-exports > header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + border-bottom: 1px solid #111111; +} + +.project-latest-exports h2, +.project-latest-exports p { + margin: 0; +} + +.project-latest-exports p { + font-family: Consolas, monospace; + font-size: 11px; + font-weight: 700; +} + +.project-latest-exports > header > span { + color: #65655f; + font-size: 12px; +} + +.latest-exports-empty { + display: grid; + min-height: 150px; + place-items: center; + gap: 18px; + padding: 28px; + text-align: center; +} + +.latest-exports-empty > strong { + font-size: 18px; +} + +.latest-exports-empty > div { + display: flex; + gap: 8px; +} + +.latest-exports-empty a, +.project-latest-exports li > a { + display: inline-grid; + min-height: 40px; + place-items: center; + padding: 8px 14px; + border: 1px solid #111111; + color: #111111; + background: #ffffff; + font-weight: 800; + text-decoration: none; +} + +.latest-exports-empty a:first-child { + background: #f2f500; +} + +.project-latest-exports ul { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin: 0; + padding: 18px; + list-style: none; +} + +.project-latest-exports li { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px 16px; + padding: 16px; + border: 1px solid #b9b9b1; +} + +.project-latest-exports li > div { + display: flex; + align-items: baseline; + gap: 10px; +} + +.project-latest-exports li > time { + color: #65655f; + font-size: 12px; +} + +.project-latest-exports li > a { + grid-column: 2; + grid-row: 1 / span 2; +} + .project-leave-overlay { position: fixed; z-index: 30; @@ -1330,6 +1430,15 @@ grid-template-columns: 1fr; } + .project-latest-exports ul { + grid-template-columns: 1fr; + } + + .latest-exports-empty > div { + width: 100%; + flex-direction: column; + } + .project-current > .project-placeholder { min-height: 360px; } diff --git a/apps/web/src/project-pages.tsx b/apps/web/src/project-pages.tsx index 6c10840..af4fc19 100644 --- a/apps/web/src/project-pages.tsx +++ b/apps/web/src/project-pages.tsx @@ -114,6 +114,17 @@ interface ProjectDetailPayload extends ProjectSummary { updated_at: string; }>; images: Array<{ created_at: string; generation_id: string; image_id: string }>; + latest_exports: Array<{ + byte_size: number; + created_at: string; + download_url: string; + export_id: string; + format: "jpg" | "png"; + pixel_height: number; + pixel_width: number; + sha256: string; + state_version: number; + }>; pixel_height?: number; pixel_width?: number; save_status: "saved"; @@ -767,6 +778,7 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) { const normalizedProject = { ...nextProject, canvas_state: nextProject.canvas_state ?? initialCanvasState(nextProject), + latest_exports: nextProject.latest_exports ?? [], save_status: nextProject.save_status ?? "saved", }; setProject(normalizedProject); @@ -944,8 +956,8 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
- - + {conflicted || !project.current_image_id ? : 进入编辑器} + {conflicted || !project.current_image_id ? : 下载原始图}
{atHistoryLimit ?

请先删除一张非当前底图的历史图

: null} {project.status === "failed_empty" && !conflicted ? 修改并重试 : null} @@ -959,13 +971,35 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) { {project.images.toReversed().map((image, index) => (
  • -
    生成结果 {project.images.length - index}
    +
    生成结果 {project.images.length - index}下载原始图
  • ))} )} +
    +

    LOCAL LATEST

    最新成品

    同一电脑可重新下载
    + {project.latest_exports.length === 0 ? ( +
    + 暂无导出成品 +
    + 进入编辑并导出 + {project.current_image_id ? 下载原始生成图 : null} +
    +
    + ) : ( +
      + {project.latest_exports.map((item) => ( +
    • +
      {item.format.toUpperCase()}{item.pixel_width} × {item.pixel_height}
      + + 重新下载 +
    • + ))} +
    + )} +
    {pendingNavigation ? (
    diff --git a/openapi/openapi.json b/openapi/openapi.json index 69088c6..98bab34 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -2083,6 +2083,22 @@ ], "type": "object" }, + "ExportFormat": { + "anyOf": [ + { + "enum": [ + "jpg" + ], + "type": "string" + }, + { + "enum": [ + "png" + ], + "type": "string" + } + ] + }, "FailedEmptyTrashRequest": { "additionalProperties": false, "properties": { @@ -2512,6 +2528,180 @@ } ] }, + "LatestExportItem": { + "additionalProperties": false, + "properties": { + "byte_size": { + "minimum": 1, + "type": "integer" + }, + "created_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "download_url": { + "pattern": "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$", + "type": "string" + }, + "export_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/ExportFormat" + }, + "pixel_height": { + "minimum": 1, + "type": "integer" + }, + "pixel_width": { + "minimum": 1, + "type": "integer" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "state_version": { + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "byte_size", + "created_at", + "download_url", + "export_id", + "format", + "pixel_height", + "pixel_width", + "sha256", + "state_version" + ], + "type": "object" + }, + "LatestExportMultipartBody": { + "additionalProperties": false, + "properties": { + "byte_size": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "export_file": { + "format": "binary", + "type": "string" + }, + "export_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/ExportFormat" + }, + "pixel_height": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "pixel_width": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "state_version": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + }, + "required": [ + "byte_size", + "export_file", + "export_id", + "format", + "pixel_height", + "pixel_width", + "sha256", + "state_version" + ], + "type": "object" + }, + "LatestExportParams": { + "additionalProperties": false, + "properties": { + "format": { + "$ref": "#/components/schemas/ExportFormat" + }, + "projectId": { + "$ref": "#/components/schemas/ProjectId" + } + }, + "required": [ + "format", + "projectId" + ], + "type": "object" + }, + "LatestExportSaveResponse": { + "additionalProperties": false, + "properties": { + "byte_size": { + "minimum": 1, + "type": "integer" + }, + "created_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "download_url": { + "pattern": "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$", + "type": "string" + }, + "export_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "format": { + "$ref": "#/components/schemas/ExportFormat" + }, + "pixel_height": { + "minimum": 1, + "type": "integer" + }, + "pixel_width": { + "minimum": 1, + "type": "integer" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "state_version": { + "minimum": 1, + "type": "integer" + }, + "status": { + "enum": [ + "saved" + ], + "type": "string" + } + }, + "required": [ + "byte_size", + "created_at", + "download_url", + "export_id", + "format", + "pixel_height", + "pixel_width", + "sha256", + "state_version", + "status" + ], + "type": "object" + }, "LoginCompleteRequest": { "additionalProperties": false, "properties": { @@ -2737,6 +2927,13 @@ "maxItems": 10, "type": "array" }, + "latest_exports": { + "items": { + "$ref": "#/components/schemas/LatestExportItem" + }, + "maxItems": 2, + "type": "array" + }, "name": { "maxLength": 160, "minLength": 1, @@ -2806,6 +3003,7 @@ "draft_prompt", "generations", "images", + "latest_exports", "pixel_height", "pixel_width", "save_status" @@ -2856,6 +3054,22 @@ ], "type": "object" }, + "ProjectImageParams": { + "additionalProperties": false, + "properties": { + "imageId": { + "$ref": "#/components/schemas/ProjectId" + }, + "projectId": { + "$ref": "#/components/schemas/ProjectId" + } + }, + "required": [ + "imageId", + "projectId" + ], + "type": "object" + }, "ProjectListQuery": { "additionalProperties": false, "properties": { @@ -6143,6 +6357,71 @@ ] } }, + "/api/v1/private-assets/projects/{projectId}/images/{imageId}": { + "get": { + "operationId": "downloadOriginalGeneration", + "parameters": [ + { + "in": "path", + "name": "imageId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + }, + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "503": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + } + }, "/api/v1/projects": { "get": { "operationId": "listProjects", @@ -6350,6 +6629,177 @@ ] } }, + "/api/v1/projects/{projectId}/latest-exports/{format}": { + "get": { + "operationId": "downloadLatestExport", + "parameters": [ + { + "in": "path", + "name": "format", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExportFormat" + } + }, + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "503": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + }, + "put": { + "operationId": "saveLatestExport", + "parameters": [ + { + "in": "path", + "name": "format", + "required": true, + "schema": { + "$ref": "#/components/schemas/ExportFormat" + } + }, + { + "in": "path", + "name": "projectId", + "required": true, + "schema": { + "$ref": "#/components/schemas/ProjectId" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/LatestExportMultipartBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LatestExportSaveResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "409": { + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "507": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Projects" + ] + } + }, "/api/v1/projects/{projectId}/purge": { "post": { "operationId": "purgeProject", diff --git a/package.json b/package.json index bcea71f..8189ce9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -63,7 +63,9 @@ "test:wp2-05": "node scripts/run-wp2-05-validation.mjs", "test:wp2-05:red": "node scripts/run-wp2-05-validation.mjs --phase red", "test:wp2-06": "node scripts/run-wp2-06-validation.mjs", - "test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red" + "test:wp2-06:red": "node scripts/run-wp2-06-validation.mjs --phase red", + "test:wp2-07": "node scripts/run-wp2-07-validation.mjs", + "test:wp2-07:red": "node scripts/run-wp2-07-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/projects.ts b/packages/shared-contracts/src/projects.ts index 66fd5ca..3359e68 100644 --- a/packages/shared-contracts/src/projects.ts +++ b/packages/shared-contracts/src/projects.ts @@ -69,6 +69,21 @@ export const ProjectImageItemSchema = Type.Object( }, { additionalProperties: false, $id: "ProjectImageItem" }, ); +export const ExportFormatSchema = Type.Union([Type.Literal("jpg"), Type.Literal("png")], { $id: "ExportFormat" }); +export const LatestExportItemSchema = Type.Object( + { + byte_size: Type.Integer({ minimum: 1 }), + created_at: Type.String({ pattern: isoTimestampPattern }), + download_url: Type.String({ pattern: "^/api/v1/projects/[0-9a-fA-F-]{36}/latest-exports/(jpg|png)$" }), + export_id: Type.String({ pattern: uuidPattern }), + format: Type.Ref(ExportFormatSchema), + pixel_height: Type.Integer({ minimum: 1 }), + pixel_width: Type.Integer({ minimum: 1 }), + sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }), + state_version: Type.Integer({ minimum: 1 }), + }, + { additionalProperties: false, $id: "LatestExportItem" }, +); export const ProjectDetailResponseSchema = Type.Object( { ...ProjectSummarySchema.properties, @@ -77,12 +92,41 @@ export const ProjectDetailResponseSchema = Type.Object( draft_prompt: Type.String({ maxLength: 4_000, minLength: 1 }), generations: Type.Array(Type.Ref(GenerationProjectItemSchema)), images: Type.Array(Type.Ref(ProjectImageItemSchema), { maxItems: 10 }), + latest_exports: Type.Array(Type.Ref(LatestExportItemSchema), { maxItems: 2 }), pixel_height: Type.Integer({ minimum: 1 }), pixel_width: Type.Integer({ minimum: 1 }), save_status: Type.Literal("saved"), }, { additionalProperties: false, $id: "ProjectDetailResponse" }, ); +export const LatestExportParamsSchema = Type.Object( + { format: Type.Ref(ExportFormatSchema), projectId: Type.Ref(ProjectIdSchema) }, + { additionalProperties: false, $id: "LatestExportParams" }, +); +export const ProjectImageParamsSchema = Type.Object( + { imageId: Type.Ref(ProjectIdSchema), projectId: Type.Ref(ProjectIdSchema) }, + { additionalProperties: false, $id: "ProjectImageParams" }, +); +export const LatestExportMultipartBodySchema = Type.Object( + { + byte_size: Type.String({ pattern: "^[1-9][0-9]*$" }), + export_file: Type.String({ format: "binary" }), + export_id: Type.String({ pattern: uuidPattern }), + format: Type.Ref(ExportFormatSchema), + pixel_height: Type.String({ pattern: "^[1-9][0-9]*$" }), + pixel_width: Type.String({ pattern: "^[1-9][0-9]*$" }), + sha256: Type.String({ pattern: "^[0-9a-f]{64}$" }), + state_version: Type.String({ pattern: "^[1-9][0-9]*$" }), + }, + { additionalProperties: false, $id: "LatestExportMultipartBody" }, +); +export const LatestExportSaveResponseSchema = Type.Object( + { + ...LatestExportItemSchema.properties, + status: Type.Literal("saved"), + }, + { additionalProperties: false, $id: "LatestExportSaveResponse" }, +); export const ProjectRenameRequestSchema = Type.Object( { name: Type.String({ maxLength: 80, minLength: 1 }) }, { additionalProperties: false, $id: "ProjectRenameRequest" }, @@ -134,5 +178,7 @@ export const ProjectPurgeResponseSchema = Type.Object( export type ProjectListQuery = Static; export type ProjectParams = Static; +export type LatestExportParams = Static; +export type ProjectImageParams = Static; export type ProjectRenameRequest = Static; export type FailedEmptyTrashRequest = Static; diff --git a/scripts/lib/generate-client.mjs b/scripts/lib/generate-client.mjs index afaf0b8..274c7e8 100644 --- a/scripts/lib/generate-client.mjs +++ b/scripts/lib/generate-client.mjs @@ -34,15 +34,30 @@ function operationResult(operation) { const response = operation.responses?.["200"]; if (!response) return "unknown"; if (response.content) { - const media = response.content["application/json"] ?? response.content["text/event-stream"]; - if (media?.schema) return schemaType(media.schema); + const media = response.content["application/json"] ?? response.content["text/event-stream"] ?? Object.values(response.content)[0]; + if (media?.schema) return media.schema.format === "binary" ? "Blob" : schemaType(media.schema); } return response.schema ? schemaType(response.schema) : "unknown"; } function operationBodyType(operation) { - const schema = operation.requestBody?.content?.["application/json"]?.schema; - return schema ? schemaType(schema) : undefined; + const content = operation.requestBody?.content; + const jsonSchema = content?.["application/json"]?.schema; + if (jsonSchema) return schemaType(jsonSchema); + if (content?.["multipart/form-data"]?.schema) return "FormData"; + return undefined; +} + +function operationRequestMediaType(operation) { + const content = operation.requestBody?.content; + if (content?.["application/json"]) return "application/json"; + if (content?.["multipart/form-data"]) return "multipart/form-data"; + return undefined; +} + +function operationReturnsBinary(operation) { + return Object.values(operation.responses?.["200"]?.content ?? {}) + .some((media) => media?.schema?.format === "binary"); } function operationMediaType(operation) { @@ -73,10 +88,11 @@ export function generateClient(input, output) { ].join("\n\n"); const operationList = operations(document); + const builtInTypes = new Set(["Blob", "FormData", "boolean", "number", "string", "unknown"]); const importedTypes = [...new Set(operationList.flatMap(({ operation }) => [ operationResult(operation), operationBodyType(operation), - ]).filter((type) => type && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))]; + ]).filter((type) => type && !builtInTypes.has(type) && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(type)))]; const sdk = [ "// Generated from openapi/openapi.json. Do not edit by hand.", importedTypes.length ? `import type { ${importedTypes.join(", ")} } from "./types.gen.js";` : "", @@ -84,13 +100,17 @@ export function generateClient(input, output) { ...operationList.map(({ method, operation, path }) => { const resultType = operationResult(operation); const bodyType = operationBodyType(operation); + const requestMediaType = operationRequestMediaType(operation); if (operationMediaType(operation) === "text/event-stream") { return `export function ${identifier(operation.operationId)}(options: Pick = {}): string {\n return \`${"${options.baseUrl ?? \"\"}"}${path}\`;\n}`; } if (bodyType) { - return `export async function ${identifier(operation.operationId)}(body: ${bodyType}, options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const headers = new Headers(options.headers);\n headers.set("Content-Type", "application/json");\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { body: JSON.stringify(body), method: "${method.toUpperCase()}", headers });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`; + const bodyExpression = requestMediaType === "application/json" ? "JSON.stringify(body)" : "body"; + const contentType = requestMediaType === "application/json" ? '\n headers.set("Content-Type", "application/json");' : ""; + return `export async function ${identifier(operation.operationId)}(body: ${bodyType}, options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const headers = new Headers(options.headers);${contentType}\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { body: ${bodyExpression}, method: "${method.toUpperCase()}", headers });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`; } - return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return response.json() as Promise<${resultType}>;\n}`; + const responseReader = operationReturnsBinary(operation) ? "response.blob()" : "response.json()"; + return `export async function ${identifier(operation.operationId)}(options: ClientOptions = {}): Promise<${resultType}> {\n const request = options.fetch ?? globalThis.fetch;\n const response = await request(\`${"${options.baseUrl ?? \"\"}"}${path}\`, { method: "${method.toUpperCase()}", headers: options.headers ?? {} });\n if (!response.ok) throw new Error(\`HTTP ${"${response.status}"}\`);\n return ${responseReader} as Promise<${resultType}>;\n}`; }), "", ].filter(Boolean).join("\n\n"); diff --git a/scripts/run-wp2-07-validation.mjs b/scripts/run-wp2-07-validation.mjs new file mode 100644 index 0000000..befa2f2 --- /dev/null +++ b/scripts/run-wp2-07-validation.mjs @@ -0,0 +1,81 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp2-07-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const casesDirectory = resolve(runDirectory, "cases"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(casesDirectory, { recursive: true }); + +const cases = [ + { acceptance: ["AC-23", "AC-35"], evidence: ["download.json", "request.json", "response.json", "db-diff.json", "fs-before.json", "fs-after.json"], id: "TDD-WP2-EXP-001-same-blob-success", requirements: ["EXPORT-04"] }, + { acceptance: ["AC-35", "AC-55"], evidence: ["network-timeline.json", "response.json", "db-diff.json", "trace.zip", "screenshots/result.png"], id: "TDD-WP2-EXP-001-result-matrix", requirements: ["EXPORT-05", "EXPORT-06"] }, + { acceptance: ["AC-23", "AC-38"], evidence: ["response.json", "db-diff.json", "trace.zip", "screenshots/no-export.png"], id: "TDD-WP2-EXP-002-empty-latest", requirements: ["EXPORT-04"] }, +]; +for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true }); + +const commands = phase === "red" + ? [ + ["unit", ["exec", "vitest", "run", "tests/unit/wp2-07-export-flow.test.ts"]], + ["api", ["exec", "vitest", "run", "tests/api/wp2-07-latest-exports.test.ts"]], + ["e2e", ["exec", "playwright", "test", "tests/e2e/project-latest-exports.spec.ts", "--config", "playwright.config.ts"]], + ] + : [["unit", ["test:unit"]], ["integration", ["test:integration"]], ["api", ["test:api"]], ["e2e", ["test:e2e"]], ["tdd-trace", ["validate:tdd-trace"]]]; +const environment = { ...process.env, DADA_EVIDENCE_DIR_LATEST_EXPORTS: casesDirectory }; +const commandResults = []; +for (const [name, args] of commands) { + const command = `pnpm ${args.join(" ")}`; + const started_at = new Date().toISOString(); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} +if (phase === "green") { + const traces = []; + const visit = (directory) => { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) visit(path); + else if (entry.name === "trace.zip" && path.includes("project-latest-exports")) traces.push(path); + } + }; + visit(resolve("test-results", "e2e")); + traces.sort((left, right) => statSync(left).mtimeMs - statSync(right).mtimeMs); + const emptyTrace = traces.find((path) => path.includes("two-allowed-actions")); + const matrixTrace = traces.find((path) => path.includes("download-and-persistence")); + if (emptyTrace) copyFileSync(emptyTrace, resolve(casesDirectory, "TDD-WP2-EXP-002-empty-latest", "trace.zip")); + if (matrixTrace) copyFileSync(matrixTrace, resolve(casesDirectory, "TDD-WP2-EXP-001-result-matrix", "trace.zip")); +} +const commandState = phase === "red" ? commandResults.every((result) => result.exit_code !== 0) : commandResults.every((result) => result.exit_code === 0); +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const summaries = []; +for (const item of cases) { + const directory = resolve(casesDirectory, item.id); + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); + if (phase === "red") writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({ expected_failure: "Latest export persistence, private downloads, result matrix, and no-export UI are absent", status: commandState ? "red_confirmed" : "failed" }, null, 2)}\n`); + const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence; + const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file))); + const targetStatus = phase === "red" ? "red_confirmed" : "passed"; + const status = commandState && missingEvidence.length === 0 ? targetStatus : "failed"; + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({ + acceptance_criteria: item.acceptance, automation: ["automated"], commit, evidence_refs: evidenceRefs, + layer: ["DB", "API", "UNIT", "E2E"], manifest, missing_evidence: missingEvidence, phase, + requirements: item.requirements, run_id: runId, status, task_id: "TASK-WP2-07", test_id: item.id, + work_package: "WP-2", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", + }, null, 2)}\n`); + summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id }); +} +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed"; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`); +console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)); +if (status !== targetStatus) process.exit(1); diff --git a/tests/api/wp2-07-latest-exports.test.ts b/tests/api/wp2-07-latest-exports.test.ts new file mode 100644 index 0000000..044c1b8 --- /dev/null +++ b/tests/api/wp2-07-latest-exports.test.ts @@ -0,0 +1,205 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { LatestExportService } from "../../apps/api/src/latest-exports.js"; +import { HARD_LIMIT_BYTES, ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { ProjectService } from "../../apps/api/src/projects.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const now = Date.parse("2026-08-03T08:00:00.000Z"); +const roots: string[] = []; +const closeables: Array<{ close(): void }> = []; +const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; +const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), Buffer.from("dada-export")]); +const jpeg = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.from("dada-export-jpg")]); + +function writeEvidence(caseId: string, file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS; + if (!root) return; + const directory = resolve(root, caseId); + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +async function multipart(fields: Record, bytes: Buffer, type = "image/png") { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) form.append(key, value); + form.append("export_file", new Blob([bytes], { type }), type === "image/png" ? "latest.png" : "latest.jpg"); + const serialized = new Response(form); + return { contentType: serialized.headers.get("content-type")!, payload: Buffer.from(await serialized.arrayBuffer()) }; +} + +afterEach(() => { + for (const value of closeables.splice(0).reverse()) value.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +async function fixture() { + const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-07-api-")); + roots.push(dataRoot); + mkdirSync(join(dataRoot, "db"), { recursive: true }); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x31), clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath, + invitePepper: Buffer.alloc(32, 0x32), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x33), + }); + const projects = new ProjectService({ clock: () => now, databasePath }); + const storage = new ManagedStorage({ dataRoot, databasePath }); + const latestExports = new LatestExportService({ clock: () => now, databasePath, storage }); + closeables.push(latestExports, storage, projects, registration); + const ownerId = randomUUID(); + registration.database.prepare(`INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at + ) VALUES (?, 'exports@example.invalid', 'user', 'active', 1, ?, ?)`).run(ownerId, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Export User', '@export')").run(ownerId); + registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(ownerId, now); + const session = registration.issueAuthenticatedSession(ownerId, "user"); + const csrf = registration.issueUserCsrfToken(session.sessionToken); + const created = projects.createProjectForGeneration({ ownerId, prompt: "导出服务", ratio: "3:4", status: "queued" }); + const original = await storage.commitStream({ + content: (await import("node:stream")).Readable.from(png), expectedMimeType: "image/png", fileKind: "generated", + fileName: "original.png", operationId: randomUUID(), ownerRef: ownerId, projectedWriteBytes: png.byteLength, + }); + projects.linkManagedResource(ownerId, created.project.projectId, original.file_id, "generated"); + projects.recordSuccessfulImage({ generationId: created.generation.generationId, imageId: original.file_id }); + const app = await createApp({ + browserGate: false, latestExports, networkBoundary: { allowTestPort: true }, projects, registration, + }); + const headers = { ...baseHeaders, cookie: `dada_session=${session.sessionToken}`, "x-csrf-token": csrf }; + return { app, created, headers, latestExports, ownerId, projects, registration, storage }; +} + +describe("TDD-WP2-EXP-001 latest export API", () => { + it("saves the uploaded bytes as the only latest export for that format and replays export_id without re-metering", async () => { + const test = await fixture(); + const projectId = test.created.project.projectId; + const stateVersion = test.projects.getProject(test.ownerId, projectId).stateVersion; + const exportId = randomUUID(); + const sha256 = createHash("sha256").update(png).digest("hex"); + const body = await multipart({ byte_size: String(png.byteLength), export_id: exportId, format: "png", pixel_height: "1440", pixel_width: "1080", sha256, state_version: String(stateVersion) }, png); + const request = () => test.app.inject({ headers: { ...test.headers, "content-type": body.contentType }, method: "PUT", payload: body.payload, url: `/api/v1/projects/${projectId}/latest-exports/png` }); + + const saved = await request(); + const metered = test.storage.getState().managed_content_bytes; + const replay = await request(); + expect(saved.statusCode).toBe(200); + expect(saved.json()).toMatchObject({ export_id: exportId, format: "png", sha256, state_version: stateVersion }); + expect(replay.json()).toEqual(saved.json()); + expect(test.storage.getState().managed_content_bytes).toBe(metered); + expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ count: 1 }); + + const downloaded = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}/latest-exports/png` }); + expect(downloaded.statusCode).toBe(200); + expect(downloaded.headers["cache-control"]).toContain("no-store"); + expect(downloaded.rawPayload).toEqual(png); + writeEvidence("TDD-WP2-EXP-001-same-blob-success", "download.json", { byte_size: downloaded.rawPayload.byteLength, sha256: createHash("sha256").update(downloaded.rawPayload).digest("hex") }); + writeEvidence("TDD-WP2-EXP-001-same-blob-success", "request.json", { byte_size: png.byteLength, export_id: exportId, format: "png", sha256, state_version: stateVersion }); + writeEvidence("TDD-WP2-EXP-001-same-blob-success", "response.json", saved.json()); + writeEvidence("TDD-WP2-EXP-001-same-blob-success", "db-diff.json", { latest_rows: 1, replay_metered_delta: 0 }); + writeEvidence("TDD-WP2-EXP-001-same-blob-success", "fs-before.json", { managed_export_files: 0 }); + writeEvidence("TDD-WP2-EXP-001-same-blob-success", "fs-after.json", { managed_export_files: 1, sha256 }); + await test.app.close(); + }); + + it("replaces only the selected format and preserves old latest across conflict, persistence, full, and unavailable failures", async () => { + const test = await fixture(); + const projectId = test.created.project.projectId; + const stateVersion = test.projects.getProject(test.ownerId, projectId).stateVersion; + const save = async (format: "jpg" | "png", bytes: Buffer, overrides: Partial> = {}) => { + const fields = { + byte_size: String(bytes.byteLength), + export_id: overrides.export_id ?? randomUUID(), + format, + pixel_height: "1440", + pixel_width: "1080", + sha256: overrides.sha256 ?? createHash("sha256").update(bytes).digest("hex"), + state_version: overrides.state_version ?? String(stateVersion), + }; + const body = await multipart(fields, bytes, format === "png" ? "image/png" : "image/jpeg"); + return test.app.inject({ + headers: { ...test.headers, "content-type": body.contentType }, method: "PUT", payload: body.payload, + url: `/api/v1/projects/${projectId}/latest-exports/${format}`, + }); + }; + + const oldPng = await save("png", png); + const jpg = await save("jpg", jpeg); + const replacementBytes = Buffer.concat([png, Buffer.from("-replacement")]); + const replacement = await save("png", replacementBytes); + expect([oldPng.statusCode, jpg.statusCode, replacement.statusCode]).toEqual([200, 200, 200]); + const latestRows = test.registration.database.prepare("SELECT format, export_id FROM latest_exports WHERE project_id = ? ORDER BY format").all(projectId) as Array<{ export_id: string; format: string }>; + expect(latestRows).toEqual([ + { export_id: jpg.json().export_id, format: "jpg" }, + { export_id: replacement.json().export_id, format: "png" }, + ]); + expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM managed_files WHERE file_kind = 'export' AND status = 'purged'").get()).toEqual({ count: 1 }); + expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM file_cleanup_queue WHERE reason = 'purge' AND status = 'pending'").get()).toEqual({ count: 1 }); + + const expectedLatestId = replacement.json().export_id as string; + const beforeOldReplay = test.storage.getState().managed_content_bytes; + const oldReplay = await save("png", png, { export_id: oldPng.json().export_id }); + expect(oldReplay.statusCode).toBe(200); + expect(oldReplay.json()).toEqual(oldPng.json()); + expect(test.storage.getState().managed_content_bytes).toBe(beforeOldReplay); + const stale = await save("png", png, { state_version: String(stateVersion + 1) }); + const badHash = await save("png", png, { sha256: "0".repeat(64) }); + expect(stale.statusCode).toBe(409); + expect(badHash.statusCode).toBe(503); + expect(test.registration.database.prepare("SELECT export_id FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ export_id: expectedLatestId }); + test.storage.applyControlledMeasurement(HARD_LIMIT_BYTES); + const full = await save("png", png); + expect(full.statusCode).toBe(507); + expect(full.json()).toMatchObject({ error: { code: "STORAGE_CAPACITY_EXCEEDED", details: { capacity_status: "full" } } }); + test.storage.applyControlledMeasurement(0); + test.storage.setAvailability({ dataRootWritable: false, diskSpaceAvailable: true, sqliteWritable: true }); + const unavailable = await save("png", png); + expect(unavailable.statusCode).toBe(507); + expect(unavailable.json()).toMatchObject({ error: { details: { capacity_status: "unavailable" } } }); + expect(test.registration.database.prepare("SELECT export_id FROM latest_exports WHERE project_id = ? AND format = 'png'").get(projectId)).toEqual({ export_id: expectedLatestId }); + writeEvidence("TDD-WP2-EXP-001-result-matrix", "response.json", { + content_hash_failure: badHash.statusCode, + storage_full: full.statusCode, + storage_unavailable: unavailable.statusCode, + state_conflict: stale.statusCode, + }); + writeEvidence("TDD-WP2-EXP-001-result-matrix", "db-diff.json", { latest_export_id_after_failures: expectedLatestId, old_latest_preserved: true }); + await test.app.close(); + }, 15_000); + + it("returns stable empty latest state without creating a row and serves only owned originals", async () => { + const test = await fixture(); + const projectId = test.created.project.projectId; + const before = test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports").get(); + const missing = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}/latest-exports/jpg` }); + const detail = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/projects/${projectId}` }); + const original = await test.app.inject({ headers: { ...baseHeaders, cookie: test.headers.cookie }, method: "GET", url: `/api/v1/private-assets/projects/${projectId}/images/${test.projects.getProject(test.ownerId, projectId).currentImageId}` }); + const intruderId = randomUUID(); + test.registration.database.prepare(`INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at + ) VALUES (?, 'exports-other@example.invalid', 'user', 'active', 1, ?, ?)`).run(intruderId, randomUUID(), now); + test.registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Other User', '@other_export')").run(intruderId); + test.registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(intruderId, now); + const intruder = test.registration.issueAuthenticatedSession(intruderId, "user"); + const denied = await test.app.inject({ + headers: { ...baseHeaders, cookie: `dada_session=${intruder.sessionToken}` }, method: "GET", + url: `/api/v1/private-assets/projects/${projectId}/images/${test.projects.getProject(test.ownerId, projectId).currentImageId}`, + }); + + expect(missing.statusCode).toBe(404); + expect(detail.json().latest_exports).toEqual([]); + expect(test.registration.database.prepare("SELECT COUNT(*) AS count FROM latest_exports").get()).toEqual(before); + expect(original.statusCode).toBe(200); + expect(original.rawPayload).toEqual(png); + expect(denied.statusCode).toBe(404); + writeEvidence("TDD-WP2-EXP-002-empty-latest", "response.json", { detail_latest_exports: detail.json().latest_exports, intruder_status: denied.statusCode, jpg_status: missing.statusCode, original_status: original.statusCode }); + writeEvidence("TDD-WP2-EXP-002-empty-latest", "db-diff.json", { after: before, before, created_by_read: 0 }); + await test.app.close(); + }); +}); diff --git a/tests/e2e/project-latest-exports.spec.ts b/tests/e2e/project-latest-exports.spec.ts new file mode 100644 index 0000000..2d32093 --- /dev/null +++ b/tests/e2e/project-latest-exports.spec.ts @@ -0,0 +1,102 @@ +import { mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; +import { createServer, type ViteDevServer } from "vite"; + +let vite: ViteDevServer; +let webUrl: string; +const projectId = "00000000-0000-4000-8000-000000000701"; +const imageId = "00000000-0000-4000-8000-000000000702"; + +test.beforeAll(async () => { + vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } }); + await vite.listen(); + const address = vite.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not expose a test port."); + webUrl = `http://127.0.0.1:${address.port}`; +}); + +test.afterAll(async () => vite.close()); + +test("TDD-WP2-EXP-002 shows the no-export state and only its two allowed actions", async ({ page }) => { + await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify({ csrf_token: "csrf-latest-export-fixture-000000000000000000000000000000", user: { creator_name: "Export User" } }), contentType: "application/json", status: 200 })); + await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ + body: JSON.stringify({ + canvas_state: { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: imageId }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1 }, + created_at: "2026-08-03T08:00:00.000Z", current_image_id: imageId, deleted_at: null, draft_prompt: "导出空状态", + generations: [], images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000703", image_id: imageId }], + latest_exports: [], name: "导出空状态", pixel_height: 1440, pixel_width: 1080, project_id: projectId, purge_at: null, + ratio: "3:4", save_status: "saved", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z", + }), contentType: "application/json", status: 200, + })); + + await page.goto(`${webUrl}/app/projects/${projectId}`); + const region = page.getByRole("region", { name: "最新成品" }); + await expect(region.getByText("暂无导出成品", { exact: true })).toBeVisible(); + await expect(region.getByRole("link", { name: "进入编辑并导出" })).toHaveAttribute("href", `/app/projects/${projectId}/editor`); + await expect(region.getByRole("link", { name: "下载原始生成图" })).toHaveAttribute("href", `/api/v1/private-assets/projects/${projectId}/images/${imageId}`); + await expect(region.getByRole("link")).toHaveCount(2); + expect(await region.getByRole("button").count()).toBe(0); + + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS; + if (evidenceRoot) { + const directory = resolve(evidenceRoot, "TDD-WP2-EXP-002-empty-latest", "screenshots"); + mkdirSync(directory, { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(directory, "no-export.png") }); + } +}); + +test("TDD-WP2-EXP-001 exposes the frozen browser result matrix without conflating download and persistence", async ({ page }) => { + await page.goto(webUrl); + const matrix = await page.evaluate(async () => { + const { exportResultCopy, runExportFlow } = await import("/src/export-flow.ts"); + const run = async (downloadFails: boolean, persistFails: boolean) => runExportFlow({ + compose: async () => new Blob(["browser-export"], { type: "image/png" }), + download: async () => { if (downloadFails) throw new Error("download_failed"); }, + persist: async () => { if (persistFails) throw new Error("persist_failed"); }, + }); + const results = [await run(false, false), await run(false, true), await run(true, false)]; + return results.map((result) => ({ copy: exportResultCopy[result.status], status: result.status })); + }); + expect(matrix).toEqual([ + { copy: "已下载并保存为最新成品", status: "downloaded_and_saved" }, + { copy: "文件已下载,但未保存为最新成品", status: "downloaded_not_saved" }, + { copy: "浏览器下载失败,可重新下载同一次合成结果", status: "download_failed" }, + ]); + await page.evaluate((items) => { + document.body.innerHTML = `

    导出结果矩阵

    ${items.map((item) => `

    ${item.copy}

    `).join("")}
    `; + }, matrix); + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS; + if (evidenceRoot) { + const directory = resolve(evidenceRoot, "TDD-WP2-EXP-001-result-matrix", "screenshots"); + mkdirSync(directory, { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(directory, "result.png") }); + } +}); + +test("TDD-WP2-EXP-001 lists only the newest saved artifact for each format", async ({ page }) => { + await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify({ csrf_token: "csrf-latest-list-fixture-00000000000000000000000000000000", user: { creator_name: "Export User" } }), contentType: "application/json", status: 200 })); + await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ + body: JSON.stringify({ + canvas_state: { background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: imageId }, elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1 }, + created_at: "2026-08-03T08:00:00.000Z", current_image_id: imageId, deleted_at: null, draft_prompt: "最新成品", + generations: [], images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000703", image_id: imageId }], + latest_exports: ["jpg", "png"].map((format, index) => ({ + byte_size: 100 + index, created_at: "2026-08-03T08:00:00.000Z", + download_url: `/api/v1/projects/${projectId}/latest-exports/${format}`, + export_id: `00000000-0000-4000-8000-00000000071${index}`, format, pixel_height: 1440, pixel_width: 1080, + sha256: String(index).repeat(64), state_version: 2, + })), + name: "最新成品", pixel_height: 1440, pixel_width: 1080, project_id: projectId, purge_at: null, + ratio: "3:4", save_status: "saved", state_version: 2, status: "active", successful_image_count: 1, updated_at: "2026-08-03T08:00:00.000Z", + }), contentType: "application/json", status: 200, + })); + + await page.goto(`${webUrl}/app/projects/${projectId}`); + const region = page.getByRole("region", { name: "最新成品" }); + await expect(region.getByRole("link", { name: "重新下载" })).toHaveCount(2); + await expect(region.getByText("JPG", { exact: true })).toBeVisible(); + await expect(region.getByText("PNG", { exact: true })).toBeVisible(); + await expect(region.getByText("暂无导出成品", { exact: true })).toHaveCount(0); +}); diff --git a/tests/unit/wp2-07-export-flow.test.ts b/tests/unit/wp2-07-export-flow.test.ts new file mode 100644 index 0000000..62ef940 --- /dev/null +++ b/tests/unit/wp2-07-export-flow.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { exportResultCopy, runExportFlow } from "../../apps/web/src/export-flow.js"; + +function writeEvidence(file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_LATEST_EXPORTS; + if (!root) return; + const directory = resolve(root, "TDD-WP2-EXP-001-result-matrix"); + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), `${JSON.stringify(value, null, 2)}\n`); +} + +describe("TDD-WP2-EXP-001-result-matrix", () => { + it("uses the exact same Blob for download and latest persistence", async () => { + const blob = new Blob(["same-export"], { type: "image/png" }); + const download = vi.fn(async (_value: Blob) => undefined); + const persist = vi.fn(async (_value: Blob) => undefined); + const result = await runExportFlow({ compose: async () => blob, download, persist }); + + expect(result.status).toBe("downloaded_and_saved"); + expect(download).toHaveBeenCalledWith(blob); + expect(persist).toHaveBeenCalledWith(blob); + expect(download.mock.calls[0][0]).toBe(persist.mock.calls[0][0]); + expect(exportResultCopy[result.status]).toBe("已下载并保存为最新成品"); + }); + + it("preserves the composed Blob for retry when browser download fails", async () => { + const blob = new Blob(["retry-export"], { type: "image/jpeg" }); + const persist = vi.fn(); + const result = await runExportFlow({ + compose: async () => blob, + download: async () => { throw new Error("download_failed"); }, + persist, + }); + + expect(result.status).toBe("download_failed"); + expect(result.blob).toBe(blob); + expect(persist).not.toHaveBeenCalled(); + expect(exportResultCopy[result.status]).toBe("浏览器下载失败,可重新下载同一次合成结果"); + }); + + it("does not turn a successful local download into a false double success", async () => { + const result = await runExportFlow({ + compose: async () => new Blob(["local-only"], { type: "image/png" }), + download: async () => undefined, + persist: async () => { throw new Error("latest_failed"); }, + }); + + expect(result.status).toBe("downloaded_not_saved"); + expect(exportResultCopy[result.status]).toBe("文件已下载,但未保存为最新成品"); + }); + + it("does not download or persist after composition failure", async () => { + const download = vi.fn(); + const persist = vi.fn(); + const result = await runExportFlow({ + compose: async () => { throw new Error("composition_failed"); }, + download, + persist, + }); + + expect(result).toEqual({ blob: null, status: "composition_failed" }); + expect(download).not.toHaveBeenCalled(); + expect(persist).not.toHaveBeenCalled(); + expect(exportResultCopy[result.status]).toBe("合成失败,请返回编辑器检查"); + writeEvidence("network-timeline.json", { + composition_failed: ["compose:failed", "download:not_started", "persist:not_started"], + download_failed: ["compose:succeeded", "download:failed", "persist:not_started"], + downloaded_and_saved: ["compose:succeeded", "download:succeeded", "persist:succeeded"], + downloaded_not_saved: ["compose:succeeded", "download:succeeded", "persist:failed"], + }); + writeEvidence("response.json", { copy: exportResultCopy, statuses: Object.keys(exportResultCopy) }); + writeEvidence("db-diff.json", { browser_persistent_blob_writes: 0, latest_updates_after_backend_failure: 0 }); + }); +});