feat: implement TASK-WP6-02 private content access
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 10m0s
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 10m0s
This commit is contained in:
+195
-3
@@ -10,6 +10,8 @@ import {
|
||||
AccountProfileUpdateResponseSchema,
|
||||
AccountSettingsResponseSchema,
|
||||
AdminAuthenticatedUserSchema,
|
||||
AdminGenerationRecordSchema,
|
||||
AdminGenerationListResponseSchema,
|
||||
AdminOverviewResponseSchema,
|
||||
AdminCreditParamsSchema,
|
||||
AdminLoginCompleteRequestSchema,
|
||||
@@ -61,6 +63,10 @@ import {
|
||||
ModelConfigUpdateRequestSchema,
|
||||
ModelParamsSchema,
|
||||
ModelConfigUpdateHeadersSchema,
|
||||
PrivateContentGenerationParamsSchema,
|
||||
PrivateContentNoticeAckRequestSchema,
|
||||
PrivateContentNoticeAckResponseSchema,
|
||||
PrivateContentPromptResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ExportFormatSchema,
|
||||
@@ -180,6 +186,7 @@ import type { RecentAssetService } from "./recent-assets.js";
|
||||
import type { AmapAdapter } from "./amap-adapter.js";
|
||||
import { ModelConfigurationError } from "./model-configuration.js";
|
||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||
import { PrivateContentError, PrivateContentService } from "./private-content.js";
|
||||
|
||||
const defaultBootstrap: BootstrapResponse = {
|
||||
app_version: "0.0.0",
|
||||
@@ -221,6 +228,7 @@ export interface CreateAppOptions {
|
||||
releaseVersion: string;
|
||||
resourceId: string;
|
||||
}) => boolean | Promise<boolean>;
|
||||
privateContent?: PrivateContentService;
|
||||
registration?: RegistrationService;
|
||||
}
|
||||
|
||||
@@ -640,6 +648,13 @@ function sendBrowserUnsupported(
|
||||
export async function createApp(options: CreateAppOptions = {}) {
|
||||
const eventHub = options.eventHub ?? new EventHub();
|
||||
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
|
||||
const privateContent = options.privateContent ?? (options.registration
|
||||
? new PrivateContentService(
|
||||
options.registration.database,
|
||||
options.registration.options.currentPrivacyNoticeVersion,
|
||||
options.registration.options.clock,
|
||||
)
|
||||
: undefined);
|
||||
const browserGate = options.browserGate ?? true;
|
||||
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
|
||||
const browserSupportRelease = options.browserSupportRelease;
|
||||
@@ -691,6 +706,12 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
AdminLoginCompleteRequestSchema,
|
||||
AdminLoginCompleteResponseSchema,
|
||||
AdminSessionResponseSchema,
|
||||
AdminGenerationRecordSchema,
|
||||
AdminGenerationListResponseSchema,
|
||||
PrivateContentNoticeAckRequestSchema,
|
||||
PrivateContentNoticeAckResponseSchema,
|
||||
PrivateContentPromptResponseSchema,
|
||||
PrivateContentGenerationParamsSchema,
|
||||
AdminOverviewResponseSchema,
|
||||
CreditSummarySchema,
|
||||
CreditEntryTypeSchema,
|
||||
@@ -833,6 +854,167 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
status: "ready",
|
||||
}));
|
||||
|
||||
const readAdminRequestSession = (request: { headers: Record<string, string | string[] | undefined> }) => {
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
return token && options.registration ? options.registration.readAdminSession(token) : undefined;
|
||||
};
|
||||
const privateContentNoticeRequired = (reply: FastifyReply, correlationId: string) => {
|
||||
const notice = privateContent?.currentNotice();
|
||||
return reply.code(428).send(createErrorEnvelope({
|
||||
code: "PRIVATE_CONTENT_NOTICE_ACK_REQUIRED",
|
||||
correlationId,
|
||||
details: { latest_version: notice?.version ?? "" },
|
||||
}));
|
||||
};
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin/private-content-notice/ack",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(PrivateContentNoticeAckRequestSchema),
|
||||
headers: Type.Intersect([Type.Ref(CsrfHeadersSchema), Type.Ref(RegistrationCompleteHeadersSchema)]),
|
||||
operationId: "ackPrivateContentNotice",
|
||||
response: {
|
||||
200: Type.Ref(PrivateContentNoticeAckResponseSchema),
|
||||
400: Type.Ref(ErrorEnvelopeSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
428: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Private Content"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError || !headerValue(request.headers["idempotency-key"])) {
|
||||
return reply.code(400).send(createErrorEnvelope({
|
||||
code: "REGISTRATION_REQUEST_INVALID",
|
||||
correlationId: request.id,
|
||||
details: { field_errors: [{ field: "headers", message_key: "request.headers.invalid" }] },
|
||||
}));
|
||||
}
|
||||
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName);
|
||||
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const admin = options.registration.authorizeAdminMutation({
|
||||
csrfToken: headerValue(request.headers["x-csrf-token"]) ?? "",
|
||||
sessionToken: token,
|
||||
});
|
||||
const result = privateContent.acknowledge(admin.userId, (request.body as { expected_notice_version: string }).expected_notice_version);
|
||||
return { acknowledged_at: result.acknowledgedAt, notice_version: result.noticeVersion, status: "acknowledged" as const };
|
||||
} catch (error) {
|
||||
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
|
||||
if (error instanceof PrivateContentError && error.code === "notice_version_conflict") return privateContentNoticeRequired(reply, request.id);
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/generations",
|
||||
{
|
||||
schema: {
|
||||
operationId: "listAdminGenerations",
|
||||
response: {
|
||||
200: Type.Ref(AdminGenerationListResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
428: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Private Content"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const session = readAdminRequestSession(request);
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
privateContent.requireAcknowledgement(session.user_id);
|
||||
return privateContent.listGenerations();
|
||||
} catch (error) {
|
||||
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/private-content/generations/:generationId/prompt",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
params: Type.Ref(PrivateContentGenerationParamsSchema),
|
||||
operationId: "openAdminGenerationPrompt",
|
||||
response: {
|
||||
200: Type.Ref(PrivateContentPromptResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
428: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Private Content"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(404).send(null);
|
||||
if (!privateContent || !options.registration) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const session = readAdminRequestSession(request);
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const generationId = (request.params as { generationId: string }).generationId;
|
||||
const value = privateContent.readPrompt(session.user_id, generationId);
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
return { content_type: "prompt" as const, generation_id: value.generationId, prompt: value.prompt };
|
||||
} catch (error) {
|
||||
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
|
||||
if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null);
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/admin/private-content/generations/:generationId/image",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
params: Type.Ref(PrivateContentGenerationParamsSchema),
|
||||
operationId: "openAdminGenerationImage",
|
||||
produces: ["application/octet-stream"],
|
||||
response: {
|
||||
200: Type.String({ format: "binary" }),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
428: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Admin Private Content"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(404).send(null);
|
||||
if (!privateContent || !options.registration || !options.latestExports) return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
const session = readAdminRequestSession(request);
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
const generationId = (request.params as { generationId: string }).generationId;
|
||||
const target = privateContent.readImageTarget(session.user_id, generationId);
|
||||
const item = options.latestExports.getOriginal(target.ownerId, target.projectId, target.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", `inline; filename="dada-generation.${extension}"`);
|
||||
reply.type(item.mime_type);
|
||||
return reply.send(createReadStream(item.path));
|
||||
} catch (error) {
|
||||
if (error instanceof PrivateContentError && error.code === "notice_required") return privateContentNoticeRequired(reply, request.id);
|
||||
if (error instanceof PrivateContentError && error.code === "not_found") return reply.code(404).send(null);
|
||||
return latestExportFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/assets/public/:resourceVersion/manifest",
|
||||
{ schema: { hide: true } },
|
||||
@@ -961,6 +1143,13 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
})
|
||||
: false;
|
||||
if (userSession?.userId !== resource.ownerId && !controlledAdmin) return reply.code(404).send();
|
||||
if (adminSession && controlledAdmin && privateContent) {
|
||||
try {
|
||||
privateContent.recordPrivateAssetAccess(adminSession.user_id, resource.ownerId, resource.resourceId);
|
||||
} catch {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
}
|
||||
reply.type(resource.mimeType);
|
||||
reply.header("Cache-Control", "private, no-store");
|
||||
reply.header("Content-Disposition", "inline");
|
||||
@@ -1194,15 +1383,18 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
if (!session) {
|
||||
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
}
|
||||
const acknowledgement = privateContent?.readAcknowledgement(session.user_id);
|
||||
const notice = privateContent?.currentNotice();
|
||||
return {
|
||||
acknowledged_private_content_notice_version: null,
|
||||
acknowledged_private_content_notice_version: acknowledgement?.version ?? null,
|
||||
admin: { role: "super_admin" as const, status: "active" as const, user_id: session.user_id },
|
||||
audience: "admin" as const,
|
||||
authenticated: true as const,
|
||||
csrf_token: options.registration.issueAdminCsrfToken(token!),
|
||||
current_private_content_notice_version: null,
|
||||
...(notice ? { current_private_content_notice_message_key: notice.messageKey } : {}),
|
||||
current_private_content_notice_version: notice?.version ?? null,
|
||||
expires_at: new Date(session.expires_at).toISOString(),
|
||||
notice_acknowledged: false,
|
||||
notice_acknowledged: notice ? acknowledgement?.version === notice.version : false,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user