feat: complete TASK-WP2-07 latest exports
This commit is contained in:
+202
-1
@@ -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<LatestExportService["getLatest"]> | Awaited<ReturnType<LatestExportService["saveLatest"]>>) {
|
||||
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<string, string>();
|
||||
let saved: Awaited<ReturnType<LatestExportService["saveLatest"]>> | 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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user