187 lines
7.7 KiB
TypeScript
187 lines
7.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type BetterSqlite3 from "better-sqlite3";
|
|
|
|
import { auditRetentionMilliseconds } from "./audit-policy.js";
|
|
|
|
type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
|
|
|
export class PrivateContentError extends Error {
|
|
constructor(readonly code: "notice_required" | "notice_version_conflict" | "not_found") {
|
|
super(code);
|
|
this.name = "PrivateContentError";
|
|
}
|
|
}
|
|
|
|
function iso(value: number) {
|
|
return new Date(value).toISOString();
|
|
}
|
|
|
|
function isGenerationTablePresent(database: BetterSqlite3.Database) {
|
|
return Boolean(database.prepare(
|
|
"SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'generation_jobs'",
|
|
).get());
|
|
}
|
|
|
|
export class PrivateContentService {
|
|
constructor(
|
|
readonly database: BetterSqlite3.Database,
|
|
readonly currentNoticeVersion: string,
|
|
private readonly clock: () => number = Date.now,
|
|
) {}
|
|
|
|
currentNotice() {
|
|
return {
|
|
version: this.currentNoticeVersion,
|
|
messageKey: "admin.private_content.notice",
|
|
} as const;
|
|
}
|
|
|
|
readAcknowledgement(adminUserId: string) {
|
|
const row = this.database.prepare(`
|
|
SELECT private_content_notice_version, private_content_notice_acknowledged_at
|
|
FROM user_profiles WHERE user_id = ?
|
|
`).get(adminUserId) as { private_content_notice_version: string | null; private_content_notice_acknowledged_at: number | null } | undefined;
|
|
return {
|
|
version: row?.private_content_notice_version ?? null,
|
|
acknowledgedAt: row?.private_content_notice_acknowledged_at === null || row?.private_content_notice_acknowledged_at === undefined
|
|
? null : iso(row.private_content_notice_acknowledged_at),
|
|
};
|
|
}
|
|
|
|
isAcknowledged(adminUserId: string) {
|
|
return this.readAcknowledgement(adminUserId).version === this.currentNoticeVersion;
|
|
}
|
|
|
|
requireAcknowledgement(adminUserId: string) {
|
|
if (!this.isAcknowledged(adminUserId)) throw new PrivateContentError("notice_required");
|
|
}
|
|
|
|
acknowledge(adminUserId: string, expectedNoticeVersion: string) {
|
|
const now = this.clock();
|
|
return this.database.transaction(() => {
|
|
if (expectedNoticeVersion !== this.currentNoticeVersion) {
|
|
throw new PrivateContentError("notice_version_conflict");
|
|
}
|
|
this.database.prepare(`
|
|
INSERT INTO user_profiles (
|
|
user_id, creator_name, social_id, private_content_notice_version,
|
|
private_content_notice_acknowledged_at
|
|
) VALUES (?, '', '', ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
private_content_notice_version = excluded.private_content_notice_version,
|
|
private_content_notice_acknowledged_at =
|
|
CASE WHEN user_profiles.private_content_notice_version = excluded.private_content_notice_version
|
|
THEN user_profiles.private_content_notice_acknowledged_at ELSE excluded.private_content_notice_acknowledged_at END
|
|
`).run(adminUserId, this.currentNoticeVersion, now);
|
|
const acknowledged = this.readAcknowledgement(adminUserId);
|
|
return {
|
|
noticeVersion: this.currentNoticeVersion,
|
|
acknowledgedAt: acknowledged.acknowledgedAt ?? iso(now),
|
|
};
|
|
})();
|
|
}
|
|
|
|
listGenerations() {
|
|
const generatedAt = iso(this.clock());
|
|
if (!isGenerationTablePresent(this.database)) return { generated_at: generatedAt, items: [] };
|
|
const rows = this.database.prepare(`
|
|
SELECT generation_id, owner_id, project_id, model_id, ratio, status,
|
|
confirmed_credit_cost, reserved_credits, final_credit_state,
|
|
error_category, created_at, updated_at
|
|
FROM generation_jobs
|
|
WHERE submission_ready = 1
|
|
ORDER BY created_at DESC, generation_id DESC
|
|
LIMIT 100
|
|
`).all() as Array<{
|
|
generation_id: string;
|
|
owner_id: string;
|
|
project_id: string;
|
|
model_id: string;
|
|
ratio: "3:4" | "1:1" | "4:3" | "9:16";
|
|
status: GenerationStatus;
|
|
confirmed_credit_cost: number;
|
|
reserved_credits: number;
|
|
final_credit_state: "committed" | "released" | null;
|
|
error_category: string | null;
|
|
created_at: number;
|
|
updated_at: number;
|
|
}>;
|
|
return {
|
|
generated_at: generatedAt,
|
|
items: rows.map((row) => {
|
|
const terminal = row.status === "succeeded" || row.status === "failed" || row.status === "rejected";
|
|
return {
|
|
generation_id: row.generation_id,
|
|
owner_ref: row.owner_id,
|
|
project_id: row.project_id,
|
|
model_id: row.model_id,
|
|
ratio: row.ratio,
|
|
status: row.status,
|
|
created_at: iso(row.created_at),
|
|
completed_at: terminal ? iso(row.updated_at) : null,
|
|
duration_ms: terminal ? Math.max(0, row.updated_at - row.created_at) : null,
|
|
confirmed_credit_cost: row.confirmed_credit_cost,
|
|
reserved_credits: row.reserved_credits,
|
|
final_credit_state: row.final_credit_state,
|
|
error_category: row.error_category,
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
private generation(generationId: string) {
|
|
if (!isGenerationTablePresent(this.database)) throw new PrivateContentError("not_found");
|
|
const row = this.database.prepare(`
|
|
SELECT generation_id, owner_id, project_id
|
|
FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1
|
|
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string } | undefined;
|
|
if (!row) throw new PrivateContentError("not_found");
|
|
return row;
|
|
}
|
|
|
|
private recordAccess(input: { adminUserId: string; ownerId: string; generationId: string; contentType: "image" | "prompt" }) {
|
|
const now = this.clock();
|
|
// The insert is committed before the caller reads the private value. A failed
|
|
// constraint therefore cannot accidentally release a private response.
|
|
this.database.transaction(() => {
|
|
this.database.prepare(`
|
|
INSERT INTO private_content_access_logs (
|
|
log_id, actor_ref, subject_ref, target_ref, content_type, occurred_at, expires_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`).run(
|
|
randomUUID(), input.adminUserId, input.ownerId, input.generationId,
|
|
input.contentType, now, now + auditRetentionMilliseconds,
|
|
);
|
|
})();
|
|
}
|
|
|
|
recordPrivateAssetAccess(adminUserId: string, ownerId: string, resourceId: string) {
|
|
this.recordAccess({ adminUserId, ownerId, generationId: resourceId, contentType: "image" });
|
|
}
|
|
|
|
readPrompt(adminUserId: string, generationId: string) {
|
|
this.requireAcknowledgement(adminUserId);
|
|
const row = this.generation(generationId);
|
|
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "prompt" });
|
|
const content = this.database.prepare(
|
|
"SELECT prompt FROM generation_jobs WHERE generation_id = ? AND submission_ready = 1",
|
|
).get(row.generation_id) as { prompt: string } | undefined;
|
|
if (!content) throw new PrivateContentError("not_found");
|
|
return { generationId: row.generation_id, prompt: content.prompt };
|
|
}
|
|
|
|
readImageTarget(adminUserId: string, generationId: string) {
|
|
this.requireAcknowledgement(adminUserId);
|
|
const row = this.database.prepare(`
|
|
SELECT g.generation_id, g.owner_id, g.project_id, pi.image_id
|
|
FROM generation_jobs g
|
|
JOIN project_images pi ON pi.project_id = g.project_id AND pi.generation_id = g.generation_id
|
|
WHERE g.generation_id = ? AND g.status = 'succeeded'
|
|
ORDER BY pi.created_at DESC LIMIT 1
|
|
`).get(generationId) as { generation_id: string; owner_id: string; project_id: string; image_id: string } | undefined;
|
|
if (!row) throw new PrivateContentError("not_found");
|
|
this.recordAccess({ adminUserId, ownerId: row.owner_id, generationId: row.generation_id, contentType: "image" });
|
|
return { projectId: row.project_id, imageId: row.image_id, ownerId: row.owner_id };
|
|
}
|
|
}
|