feat: implement TASK-WP1-05 account deletion

This commit is contained in:
suyx
2026-07-28 19:07:11 +08:00
parent 03f1509de7
commit 2358f97e5c
20 changed files with 2565 additions and 13 deletions
+189
View File
@@ -3,6 +3,12 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
AccountDeletionCompleteRequestSchema,
AccountDeletionResponseSchema,
AccountDeletionSendResponseSchema,
AccountProfileUpdateRequestSchema,
AccountProfileUpdateResponseSchema,
AccountSettingsResponseSchema,
AdminAuthenticatedUserSchema,
AdminLoginCompleteRequestSchema,
AdminLoginCompleteResponseSchema,
@@ -12,6 +18,7 @@ import {
CorrelationIdSchema,
AuthenticatedUserSchema,
CreditSummarySchema,
CsrfHeadersSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
GenerationErrorCategorySchema,
@@ -37,6 +44,8 @@ import {
type BootstrapResponse,
type AdminLoginCompleteRequest,
type AdminLoginSendRequest,
type AccountDeletionCompleteRequest,
type AccountProfileUpdateRequest,
type LoginCompleteRequest,
type LoginSendRequest,
type RegistrationCompleteRequest,
@@ -226,6 +235,13 @@ export async function createApp(options: CreateAppOptions = {}) {
AdminLoginCompleteResponseSchema,
AdminSessionResponseSchema,
CreditSummarySchema,
CsrfHeadersSchema,
AccountSettingsResponseSchema,
AccountProfileUpdateRequestSchema,
AccountProfileUpdateResponseSchema,
AccountDeletionSendResponseSchema,
AccountDeletionCompleteRequestSchema,
AccountDeletionResponseSchema,
RegistrationSendRequestSchema,
RegistrationSendResponseSchema,
RegistrationCompleteRequestSchema,
@@ -731,6 +747,179 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.get(
"/api/v1/account/settings",
{
schema: {
operationId: "getAccountSettings",
response: {
200: Type.Ref(AccountSettingsResponseSchema),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Account"],
},
},
async (request, reply) => {
if (!options.registration) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
const settings = options.registration.readAccountSettings(token);
const csrfToken = options.registration.issueUserCsrfToken(token);
return {
account: settings.account,
csrf_token: csrfToken,
local_data: {
backup_enabled: settings.localData.backupEnabled,
capacity_status: settings.localData.capacityStatus,
hard_limit_bytes: settings.localData.hardLimitBytes,
location: settings.localData.location,
managed_content_bytes: settings.localData.managedContentBytes,
migration_supported: settings.localData.migrationSupported,
},
profile: {
creator_name: settings.profile.creatorName,
social_id: settings.profile.socialId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.put(
"/api/v1/account/settings/profile",
{
attachValidation: true,
schema: {
body: Type.Ref(AccountProfileUpdateRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "updateAccountProfile",
response: {
200: Type.Ref(AccountProfileUpdateResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Account"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
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 body = request.body as AccountProfileUpdateRequest;
const saved = options.registration.updateAccountProfile({
creatorName: body.creator_name,
csrfToken,
sessionToken: token,
socialId: body.social_id,
});
return { profile: { creator_name: saved.creatorName, social_id: saved.socialId }, status: saved.status };
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/account/deletion/send",
{
attachValidation: true,
schema: {
headers: Type.Ref(CsrfHeadersSchema),
operationId: "sendAccountDeletionCode",
response: {
200: Type.Ref(AccountDeletionSendResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
429: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Account"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
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 sent = await options.registration.sendAccountDeletionCode({ csrfToken, sessionToken: token });
return {
challenge_expires_at: new Date(sent.challengeExpiresAt).toISOString(),
deletion_id: sent.deletionId,
resend_available_at: new Date(sent.resendAvailableAt).toISOString(),
status: sent.status,
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/account/deletion/complete",
{
attachValidation: true,
schema: {
body: Type.Ref(AccountDeletionCompleteRequestSchema),
headers: Type.Ref(LogoutHeadersSchema),
operationId: "completeAccountDeletion",
response: {
200: Type.Ref(AccountDeletionResponseSchema),
400: Type.Ref(ErrorEnvelopeSchema),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
409: Type.Ref(ErrorEnvelopeSchema),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Account"],
},
},
async (request, reply) => {
if (request.validationError) return registrationValidationFailure(reply, request.id);
if (!options.registration) {
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"]);
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
if (!token || !csrfToken || !idempotencyKey) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
try {
const body = request.body as AccountDeletionCompleteRequest;
const deleted = options.registration.completeAccountDeletion({
code: body.verification_code,
confirmation: body.confirmation,
csrfToken,
deletionId: body.deletion_id,
idempotencyKey,
sessionToken: token,
});
reply.header("Set-Cookie", `${userSessionCookieName}=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict`);
return deleted;
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
app.post(
"/api/v1/support/check",
{
+8 -3
View File
@@ -184,6 +184,7 @@ export class ManagedStorage {
CREATE TABLE IF NOT EXISTS managed_files (
file_id TEXT PRIMARY KEY,
file_kind TEXT NOT NULL CHECK (file_kind IN ('reference', 'generated', 'export', 'derived', 'sticker_original', 'sticker_thumbnail')),
owner_ref TEXT,
relative_path TEXT NOT NULL UNIQUE,
byte_size INTEGER NOT NULL CHECK (byte_size > 0),
mime_type TEXT NOT NULL,
@@ -242,6 +243,10 @@ export class ManagedStorage {
CREATE TRIGGER IF NOT EXISTS admin_operation_logs_no_delete
BEFORE DELETE ON admin_operation_logs BEGIN SELECT RAISE(ABORT, 'admin_operation_logs_immutable'); END;
`);
const managedFileColumns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>;
if (!managedFileColumns.some((column) => column.name === "owner_ref")) {
this.database.exec("ALTER TABLE managed_files ADD COLUMN owner_ref TEXT");
}
this.migrateLegacyAdminOperationLogs();
const initial = classifyCapacity(0, 0);
this.database.prepare(`
@@ -479,9 +484,9 @@ export class ManagedStorage {
const commit = this.database.transaction(() => {
this.database.prepare(`
INSERT INTO managed_files (file_id, file_kind, relative_path, byte_size, mime_type, sha256, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'committed', ?)
`).run(fileId, input.fileKind, destination.relativePath, byteSize, input.expectedMimeType, sha256, now());
INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'committed', ?)
`).run(fileId, input.fileKind, input.ownerRef, destination.relativePath, byteSize, input.expectedMimeType, sha256, now());
this.database.prepare(`
UPDATE local_backend_storage_state SET managed_content_bytes = managed_content_bytes + ? WHERE singleton = 1
`).run(byteSize);
+2
View File
@@ -19,6 +19,7 @@ export type RegistrationErrorReason =
| "resend_too_soon"
| "too_many_attempts"
| "csrf_invalid"
| "deletion_confirmation_invalid"
| "session_invalid";
export type RegistrationErrorCode =
@@ -73,6 +74,7 @@ export function registrationFieldError(reason: RegistrationErrorReason) {
resend_too_soon: { field: "verification_code", message_key: "auth.challenge.resend_too_soon" },
too_many_attempts: { field: "verification_code", message_key: "auth.challenge.too_many_attempts" },
csrf_invalid: { field: "csrf_token", message_key: "auth.csrf.invalid" },
deletion_confirmation_invalid: { field: "confirmation", message_key: "account.deletion.confirmation_invalid" },
session_invalid: { field: "session", message_key: "auth.session.invalid" },
};
return entries[reason];
+316 -2
View File
@@ -36,6 +36,9 @@ export interface RegistrationTransactionEvent {
| "secure_config_apply"
| "session_issue"
| "session_revoke"
| "account_delete_send"
| "account_delete_complete"
| "profile_update"
| "csrf_issue";
outcome: "committed" | "rejected" | "idempotent_replay";
}
@@ -218,6 +221,7 @@ export class RegistrationService {
readonly database: BetterSqlite3.Database;
readonly options: Required<Pick<RegistrationServiceOptions, "clock" | "codeGenerator" | "inviteCodeGenerator">> & RegistrationServiceOptions;
private adminAllowlistHashes = new Set<string>();
private privacyPurgeActive = false;
constructor(options: RegistrationServiceOptions) {
assertSecret("invitePepper", options.invitePepper);
@@ -235,6 +239,8 @@ export class RegistrationService {
this.database.pragma("foreign_keys = ON");
this.database.pragma("synchronous = FULL");
this.database.pragma("busy_timeout = 5000");
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => this.privacyPurgeActive ? 1 : 0);
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => 0);
this.migrate();
}
@@ -985,6 +991,213 @@ export class RegistrationService {
});
}
readAccountSettings(sessionToken: string) {
const now = this.options.clock();
const row = this.database.prepare(`
SELECT u.normalized_email, p.creator_name, p.social_id
FROM sessions s
JOIN users u ON u.user_id = s.user_id
JOIN user_profiles p ON p.user_id = u.user_id
WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL
AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active'
`).get(digest(sessionToken), now) as { creator_name: string; normalized_email: string; social_id: string } | undefined;
if (!row) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
const storageTable = this.database.prepare(`
SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'local_backend_storage_state'
`).get();
const storage = storageTable
? this.database.prepare(`
SELECT hard_limit_bytes, managed_content_bytes, storage_status
FROM local_backend_storage_state WHERE singleton = 1
`).get() as { hard_limit_bytes: number; managed_content_bytes: number; storage_status: "active" | "full" | "unavailable" } | undefined
: undefined;
const capacityStatus = storage?.storage_status === "active"
? (() => {
const bytes = storage.managed_content_bytes;
if (bytes >= 4_831_838_208) return "critical" as const;
if (bytes >= 4_294_967_296) return "warning" as const;
return "normal" as const;
})()
: (storage?.storage_status ?? "unavailable");
return {
account: { email: row.normalized_email, status: "active" as const },
localData: {
backupEnabled: false as const,
capacityStatus,
hardLimitBytes: storage?.hard_limit_bytes ?? 5_368_709_120,
location: "configured_local_data_root" as const,
managedContentBytes: storage?.managed_content_bytes ?? 0,
migrationSupported: false as const,
},
profile: { creatorName: row.creator_name, socialId: row.social_id },
};
}
updateAccountProfile(input: { creatorName: string; csrfToken: string; sessionToken: string; socialId: string }) {
const now = this.options.clock();
const creatorName = normalizeProfileValue(input.creatorName, 80);
const socialId = normalizeSocialId(input.socialId);
if (!creatorName || !socialId) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "profile_invalid");
return this.runImmediate("profile_update", () => {
const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now);
this.database.prepare("UPDATE user_profiles SET creator_name = ?, social_id = ? WHERE user_id = ?")
.run(creatorName, socialId, session.user_id);
return { outcome: "committed", value: { creatorName, socialId, status: "saved" as const } };
});
}
async sendAccountDeletionCode(input: { csrfToken: string; sessionToken: string }) {
const now = this.options.clock();
const deletionId = randomUUID();
const code = this.options.codeGenerator();
if (!/^[0-9]{6}$/.test(code)) throw new Error("Verification code generator must return six digits.");
const result = this.runImmediate("account_delete_send", () => {
const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now);
const previous = this.database.prepare(`
SELECT resend_available_at FROM account_deletion_challenges
WHERE user_id = ? AND consumed_at IS NULL ORDER BY created_at DESC LIMIT 1
`).get(session.user_id) as { resend_available_at: number } | undefined;
if (previous && previous.resend_available_at > now) {
throw new RegistrationError("AUTH_RATE_LIMITED", "resend_too_soon");
}
this.database.prepare("DELETE FROM account_deletion_challenges WHERE user_id = ?").run(session.user_id);
this.database.prepare(`
INSERT INTO account_deletion_challenges (
deletion_id, user_id, email, code_hmac, expires_at, resend_available_at,
failure_count, consumed_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ?)
`).run(
deletionId,
session.user_id,
session.normalized_email,
this.challengeHmac(deletionId, code),
now + challengeLifetimeMilliseconds,
now + resendDelayMilliseconds,
now,
);
return {
outcome: "committed",
value: {
challengeExpiresAt: now + challengeLifetimeMilliseconds,
deletionId,
email: session.normalized_email,
resendAvailableAt: now + resendDelayMilliseconds,
status: "verification_sent" as const,
},
};
});
try {
await this.options.resend.sendVerificationCode({
challengeId: deletionId,
code,
email: result.email,
purpose: "account_delete",
});
} catch {
this.runImmediate("registration_send_compensation", () => {
this.database.prepare("DELETE FROM account_deletion_challenges WHERE deletion_id = ? AND consumed_at IS NULL").run(deletionId);
return { outcome: "committed", value: undefined };
});
throw new Error("AUTH_SERVICE_UNAVAILABLE");
}
return {
challengeExpiresAt: result.challengeExpiresAt,
deletionId: result.deletionId,
resendAvailableAt: result.resendAvailableAt,
status: result.status,
};
}
completeAccountDeletion(input: {
code: string;
confirmation: string;
csrfToken: string;
deletionId: string;
idempotencyKey: string;
sessionToken: string;
}) {
const now = this.options.clock();
const idempotencyDigest = digest(input.idempotencyKey);
const requestHash = this.keyedHmac(
this.options.challengePepper,
JSON.stringify({ confirmation: input.confirmation, deletionId: input.deletionId, verificationCode: input.code }),
);
const previous = this.database.prepare(`
SELECT request_hash FROM account_deletion_receipts WHERE idempotency_key_digest = ?
`).get(idempotencyDigest) as { request_hash: string } | undefined;
if (previous) {
if (!constantTimeTextEqual(previous.request_hash, requestHash)) {
throw new RegistrationError("IDEMPOTENCY_KEY_CONFLICT", "idempotency_conflict");
}
return { status: "deleted" as const };
}
const outcome = this.runImmediate<{ status: "deleted" } | RegistrationError>("account_delete_complete", () => {
const session = this.authenticatedUserMutationSession(input.sessionToken, input.csrfToken, now);
if (input.confirmation !== "注销账号") {
throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "deletion_confirmation_invalid");
}
const challenge = this.database.prepare(`
SELECT code_hmac, expires_at FROM account_deletion_challenges
WHERE deletion_id = ? AND user_id = ? AND consumed_at IS NULL
`).get(input.deletionId, session.user_id) as { code_hmac: string; expires_at: number } | undefined;
if (!challenge) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid");
if (challenge.expires_at <= now) throw new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_expired");
if (!constantTimeTextEqual(challenge.code_hmac, this.challengeHmac(input.deletionId, input.code))) {
this.database.prepare("UPDATE account_deletion_challenges SET failure_count = failure_count + 1 WHERE deletion_id = ?")
.run(input.deletionId);
return {
outcome: "rejected",
value: new RegistrationError("REGISTRATION_REQUEST_INVALID", "challenge_invalid"),
};
}
const anonymousSubjectId = randomUUID();
const anonymousExpiresAt = now + 180 * 24 * 60 * 60 * 1_000;
const ledger = this.database.prepare(`
SELECT entry_type, amount, created_at FROM credit_ledger WHERE user_id = ? ORDER BY created_at, ledger_id
`).all(session.user_id) as Array<{ amount: number; created_at: number; entry_type: string }>;
const insertAnonymous = this.database.prepare(`
INSERT INTO anonymous_retained_events (
event_id, anonymous_subject_id, event_type, model_id, outcome,
error_category, credit_delta, occurred_at, expires_at
) VALUES (?, ?, ?, NULL, 'succeeded', NULL, ?, ?, ?)
`);
for (const entry of ledger) {
insertAnonymous.run(randomUUID(), anonymousSubjectId, entry.entry_type, entry.amount, entry.created_at, anonymousExpiresAt);
}
this.privacyPurgeActive = true;
try {
this.database.prepare("DELETE FROM credit_ledger WHERE user_id = ?").run(session.user_id);
this.database.prepare(`
UPDATE private_content_access_logs SET subject_ref = ?, target_ref = ? WHERE subject_ref = ?
`).run(randomUUID(), randomUUID(), session.user_id);
} finally {
this.privacyPurgeActive = false;
}
this.queueOwnedManagedFiles(session.user_id, now);
this.database.prepare("DELETE FROM registration_attempts WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM login_attempts WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM privacy_consents WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM credit_accounts WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM user_profiles WHERE user_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 = ?")
.run(this.rateKey(session.normalized_email, "registration"));
this.database.prepare("DELETE FROM sessions WHERE user_id = ?").run(session.user_id);
this.database.prepare("DELETE FROM users WHERE user_id = ?").run(session.user_id);
this.database.prepare(`
INSERT INTO account_deletion_receipts (idempotency_key_digest, request_hash, deleted_at)
VALUES (?, ?, ?)
`).run(idempotencyDigest, requestHash, now);
return { outcome: "committed", value: { status: "deleted" as const } };
});
if (outcome instanceof RegistrationError) throw outcome;
return outcome;
}
changeUserStatus(userId: string, status: "suspended" | "deleted") {
const now = this.options.clock();
this.runImmediate("session_revoke", () => {
@@ -1103,8 +1316,11 @@ export class RegistrationService {
);
CREATE TRIGGER IF NOT EXISTS credit_ledger_no_update
BEFORE UPDATE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END;
CREATE TRIGGER IF NOT EXISTS credit_ledger_no_delete
BEFORE DELETE ON credit_ledger BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END;
DROP TRIGGER IF EXISTS credit_ledger_no_delete;
CREATE TRIGGER credit_ledger_no_delete
BEFORE DELETE ON credit_ledger
WHEN dada_allow_privacy_purge() <> 1
BEGIN SELECT RAISE(ABORT, 'credit_ledger_immutable'); END;
CREATE TABLE IF NOT EXISTS privacy_consents (
consent_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id),
@@ -1197,6 +1413,57 @@ export class RegistrationService {
session_id TEXT REFERENCES sessions(session_id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS account_deletion_challenges (
deletion_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id),
email TEXT NOT NULL,
code_hmac TEXT NOT NULL,
expires_at INTEGER NOT NULL,
resend_available_at INTEGER NOT NULL,
failure_count INTEGER NOT NULL DEFAULT 0 CHECK (failure_count >= 0),
consumed_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS account_deletion_receipts (
idempotency_key_digest TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
deleted_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS private_content_access_logs (
log_id TEXT PRIMARY KEY,
actor_ref TEXT NOT NULL,
subject_ref TEXT NOT NULL,
target_ref TEXT NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('image', 'prompt')),
occurred_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TRIGGER IF NOT EXISTS private_content_access_logs_no_update
BEFORE UPDATE ON private_content_access_logs
WHEN dada_allow_privacy_purge() <> 1
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
CREATE TRIGGER IF NOT EXISTS private_content_access_logs_no_delete
BEFORE DELETE ON private_content_access_logs
WHEN dada_allow_retention_purge() <> 1
BEGIN SELECT RAISE(ABORT, 'private_content_access_logs_immutable'); END;
CREATE TABLE IF NOT EXISTS anonymous_retained_events (
event_id TEXT PRIMARY KEY,
anonymous_subject_id TEXT NOT NULL,
event_type TEXT NOT NULL,
model_id TEXT,
outcome TEXT NOT NULL,
error_category TEXT,
credit_delta INTEGER NOT NULL,
occurred_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TRIGGER IF NOT EXISTS anonymous_retained_events_no_update
BEFORE UPDATE ON anonymous_retained_events
BEGIN SELECT RAISE(ABORT, 'anonymous_retained_events_immutable'); END;
CREATE TRIGGER IF NOT EXISTS anonymous_retained_events_no_delete
BEFORE DELETE ON anonymous_retained_events
WHEN dada_allow_retention_purge() <> 1
BEGIN SELECT RAISE(ABORT, 'anonymous_retained_events_immutable'); END;
INSERT OR IGNORE INTO secure_config_apply_state (
singleton, applied_revision, allowlist_count, applied_at
) VALUES (1, 0, 0, 0);
@@ -1273,6 +1540,53 @@ export class RegistrationService {
}
}
private authenticatedUserMutationSession(sessionToken: string, csrfToken: string, now: number) {
const session = this.database.prepare(`
SELECT s.session_id, s.user_id, s.csrf_token_digest, u.normalized_email
FROM sessions s JOIN users u ON u.user_id = s.user_id
WHERE s.token_digest = ? AND s.audience = 'user' AND s.revoked_at IS NULL
AND s.expires_at > ? AND u.role = 'user' AND u.status = 'active'
`).get(digest(sessionToken), now) as {
csrf_token_digest: string | null;
normalized_email: string;
session_id: string;
user_id: string;
} | undefined;
if (!session) throw new RegistrationError("AUTH_SESSION_INVALID", "session_invalid");
if (!session.csrf_token_digest || !constantTimeTextEqual(session.csrf_token_digest, digest(csrfToken))) {
throw new RegistrationError("AUTH_CSRF_INVALID", "csrf_invalid");
}
return session;
}
private queueOwnedManagedFiles(userId: string, now: number) {
const table = this.database.prepare(`
SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'managed_files'
`).get();
if (!table) return 0;
const columns = this.database.prepare("PRAGMA table_info(managed_files)").all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "owner_ref")) return 0;
const files = this.database.prepare(`
SELECT file_id, relative_path, byte_size FROM managed_files
WHERE owner_ref = ? AND status = 'committed'
AND file_kind IN ('reference', 'generated', 'export', 'derived')
`).all(userId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
const purgedAt = new Date(now).toISOString();
for (const file of files) {
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(file.file_id);
this.database.prepare(`
UPDATE managed_files SET status = 'purged', purged_at = ?, owner_ref = ? WHERE file_id = ?
`).run(purgedAt, randomUUID(), file.file_id);
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
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?)
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, purgedAt);
}
return files.length;
}
private keyedHmac(key: Buffer, value: string) {
return createHmac("sha256", key).update(value, "utf8").digest("hex");
}
+1 -1
View File
@@ -2,7 +2,7 @@ export interface RegistrationCodeMessage {
challengeId: string;
code: string;
email: string;
purpose: "register" | "login" | "admin_login";
purpose: "register" | "login" | "admin_login" | "account_delete";
}
export interface ResendAdapter {
+364
View File
@@ -0,0 +1,364 @@
.settings-page,
.settings-loading {
min-height: 100vh;
color: #111111;
background: #f6f6f4;
}
.settings-loading {
display: grid;
place-content: center;
gap: 16px;
text-align: center;
}
.settings-loading button {
min-height: 44px;
border: 1px solid #111111;
background: #ffffff;
}
.settings-header {
display: flex;
min-height: 72px;
align-items: center;
justify-content: space-between;
padding: 0 5vw;
border-bottom: 1px solid #c8c8c3;
background: #ffffff;
}
.settings-brand {
color: #111111;
font-family: Arial Black, "Segoe UI", sans-serif;
font-size: 24px;
font-weight: 900;
text-decoration: none;
}
.settings-header nav {
display: flex;
gap: 28px;
}
.settings-header nav a,
.settings-header nav span {
color: #111111;
text-decoration: none;
}
.settings-header nav span {
font-weight: 700;
}
.settings-title {
padding: 64px max(5vw, calc((100vw - 1120px) / 2)) 38px;
border-bottom: 1px solid #c8c8c3;
}
.settings-title p,
.settings-dialog header p {
margin: 0 0 12px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.settings-title h1 {
margin: 0;
font-size: 40px;
line-height: 1.2;
}
.settings-section,
.settings-danger {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(0, 1.4fr);
gap: 64px;
max-width: 1120px;
margin: 0 auto;
padding: 46px 0;
border-bottom: 1px solid #c8c8c3;
}
.settings-section-heading h2,
.settings-danger h2 {
margin: 0 0 8px;
font-size: 21px;
}
.settings-section-heading p,
.settings-danger p,
.settings-detail {
margin: 0;
color: #585852;
line-height: 1.65;
}
.settings-form {
display: grid;
gap: 10px;
max-width: 560px;
}
.settings-form input,
.settings-dialog input {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid #989891;
border-radius: 2px;
background: #ffffff;
}
.settings-form label,
.settings-dialog label {
margin-top: 10px;
font-size: 13px;
font-weight: 700;
}
.settings-primary,
.settings-code-button,
.settings-delete-confirm,
.settings-danger-button {
min-height: 44px;
border-radius: 2px;
font-weight: 700;
}
.settings-primary {
width: 160px;
margin-top: 12px;
border: 1px solid #111111;
color: #111111;
background: #f2f500;
}
.settings-primary:disabled,
.settings-code-button:disabled,
.settings-delete-confirm:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.settings-definition {
margin: 0;
}
.settings-definition div {
display: grid;
grid-template-columns: 140px 1fr;
gap: 18px;
padding: 15px 0;
border-bottom: 1px solid #deded9;
}
.settings-definition dt {
color: #686862;
}
.settings-definition dd {
margin: 0;
overflow-wrap: anywhere;
font-weight: 600;
}
.settings-local {
align-items: start;
}
.settings-local > :not(.settings-section-heading) {
grid-column: 2;
}
.settings-capacity {
display: grid;
gap: 14px;
}
.settings-capacity > div:first-child {
display: flex;
justify-content: space-between;
gap: 24px;
}
.settings-capacity-track {
height: 12px;
overflow: hidden;
border: 1px solid #111111;
background: #ffffff;
}
.settings-capacity-track span {
display: block;
height: 100%;
background: #111111;
}
.settings-capacity[data-status="warning"] .settings-capacity-track span,
.settings-capacity[data-status="critical"] .settings-capacity-track span {
background: #e2b500;
}
.settings-capacity[data-status="full"] .settings-capacity-track span,
.settings-capacity[data-status="unavailable"] .settings-capacity-track span {
background: #c7352b;
}
.settings-fixed-notice {
margin: 24px 0 0;
padding: 20px;
border-left: 5px solid #111111;
background: #f2f500;
font-weight: 800;
line-height: 1.5;
}
.settings-detail {
margin-top: 16px;
}
.settings-danger {
align-items: center;
border-bottom: 0;
padding-bottom: 80px;
}
.settings-danger-button {
width: 160px;
justify-self: start;
border: 1px solid #a6251d;
color: #ffffff;
background: #b82f26;
}
.settings-error {
margin: 8px 0 0;
color: #a6251d;
font-weight: 700;
}
.settings-saved {
margin: 8px 0 0;
color: #256227;
font-weight: 700;
}
.settings-dialog-backdrop {
position: fixed;
z-index: 20;
inset: 0;
display: grid;
place-items: center;
padding: 20px;
background: rgb(17 17 17 / 62%);
}
.settings-dialog {
width: min(640px, 100%);
max-height: calc(100vh - 40px);
overflow-y: auto;
border: 1px solid #111111;
border-radius: 4px;
background: #ffffff;
box-shadow: 10px 10px 0 #111111;
}
.settings-dialog header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 28px;
border-bottom: 1px solid #c8c8c3;
}
.settings-dialog h2 {
margin: 0;
font-size: 24px;
}
.settings-dialog header button {
width: 40px;
height: 40px;
border: 0;
background: transparent;
font-size: 28px;
}
.settings-dialog-body {
padding: 26px 28px 30px;
line-height: 1.6;
}
.settings-dialog-warning {
padding: 16px;
border-left: 5px solid #b82f26;
background: #fff2f0;
font-weight: 700;
}
.settings-code-button {
width: 180px;
margin: 10px 0;
border: 1px solid #111111;
background: #ffffff;
}
.settings-dialog form {
display: grid;
gap: 8px;
}
.settings-delete-confirm {
width: 180px;
margin-top: 16px;
border: 1px solid #8e2019;
color: #ffffff;
background: #b82f26;
}
@media (max-width: 760px) {
.settings-header {
padding: 0 18px;
}
.settings-header nav {
gap: 14px;
font-size: 13px;
}
.settings-title {
padding: 40px 20px 28px;
}
.settings-title h1 {
font-size: 30px;
}
.settings-section,
.settings-danger {
grid-template-columns: 1fr;
gap: 24px;
margin: 0 20px;
padding: 34px 0;
}
.settings-local > :not(.settings-section-heading) {
grid-column: 1;
}
.settings-definition div {
grid-template-columns: 88px 1fr;
}
.settings-capacity > div:first-child {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.settings-dialog {
box-shadow: 5px 5px 0 #111111;
}
}
+273
View File
@@ -0,0 +1,273 @@
import { type FormEvent, useEffect, useId, useState } from "react";
import "./account-settings.css";
interface SettingsPayload {
account: { email: string; status: "active" };
csrf_token: string;
local_data: {
backup_enabled: false;
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
hard_limit_bytes: number;
location: "configured_local_data_root";
managed_content_bytes: number;
migration_supported: false;
};
profile: { creator_name: string; social_id: string };
}
function formatBytes(bytes: number) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(0, bytes)} B`;
}
function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) {
if (status === "full") return "本机容量已满";
if (status === "unavailable") return "本机数据暂不可写";
if (status === "critical") return "本机容量接近上限";
if (status === "warning") return "本机容量需要关注";
return "本机容量正常";
}
export function AccountSettingsPage() {
const creatorNameId = useId();
const socialId = useId();
const confirmationId = useId();
const deletionCodeId = useId();
const [settings, setSettings] = useState<SettingsPayload>();
const [creatorName, setCreatorName] = useState("");
const [socialHandle, setSocialHandle] = useState("");
const [loadingError, setLoadingError] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(false);
const [saved, setSaved] = useState(false);
const [deletionOpen, setDeletionOpen] = useState(false);
const [deletionId, setDeletionId] = useState<string>();
const [deletionCode, setDeletionCode] = useState("");
const [confirmation, setConfirmation] = useState("");
const [sendingCode, setSendingCode] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deletionError, setDeletionError] = useState(false);
useEffect(() => {
let active = true;
void fetch("/api/v1/account/settings", { credentials: "same-origin" })
.then(async (response) => {
if (!response.ok) throw new Error("settings_load_failed");
return response.json() as Promise<SettingsPayload>;
})
.then((payload) => {
if (!active) return;
setSettings(payload);
setCreatorName(payload.profile.creator_name);
setSocialHandle(payload.profile.social_id);
})
.catch(() => {
if (active) setLoadingError(true);
});
return () => { active = false; };
}, []);
async function saveProfile(event: FormEvent) {
event.preventDefault();
if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return;
setSaving(true);
setSaveError(false);
setSaved(false);
try {
const response = await fetch("/api/v1/account/settings/profile", {
body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token },
method: "PUT",
});
if (!response.ok) throw new Error("profile_save_failed");
setSaved(true);
} catch {
setSaveError(true);
} finally {
setSaving(false);
}
}
async function sendDeletionCode() {
if (!settings || sendingCode || deleting) return;
setSendingCode(true);
setDeletionError(false);
try {
const response = await fetch("/api/v1/account/deletion/send", {
credentials: "same-origin",
headers: { "X-CSRF-Token": settings.csrf_token },
method: "POST",
});
const body = await response.json() as { deletion_id?: string };
if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed");
setDeletionId(body.deletion_id);
} catch {
setDeletionError(true);
} finally {
setSendingCode(false);
}
}
async function completeDeletion(event: FormEvent) {
event.preventDefault();
if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return;
setDeleting(true);
setDeletionError(false);
try {
const response = await fetch("/api/v1/account/deletion/complete", {
body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
"X-CSRF-Token": settings.csrf_token,
},
method: "POST",
});
if (!response.ok) throw new Error("account_deletion_failed");
window.location.assign("/");
} catch {
setDeletionError(true);
setDeleting(false);
}
}
function closeDeletion() {
if (deleting) return;
setDeletionOpen(false);
setDeletionId(undefined);
setDeletionCode("");
setConfirmation("");
setDeletionError(false);
}
if (!settings && !loadingError) {
return <main className="settings-loading" aria-live="polite"></main>;
}
if (!settings) {
return (
<main className="settings-loading">
<p role="alert"></p>
<button onClick={() => window.location.reload()} type="button"></button>
</main>
);
}
const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100));
return (
<main className="settings-page">
<header className="settings-header">
<a className="settings-brand" href="/app">DADA</a>
<nav aria-label="账号导航">
<a href="/app"></a>
<span aria-current="page"></span>
</nav>
</header>
<div className="settings-title">
<p>ACCOUNT / LOCAL DATA</p>
<h1></h1>
</div>
<section className="settings-section" aria-labelledby="profile-title">
<div className="settings-section-heading">
<h2 id="profile-title"></h2>
<p></p>
</div>
<form className="settings-form" onSubmit={saveProfile}>
<label htmlFor={creatorNameId}></label>
<input id={creatorNameId} maxLength={80} onChange={(event) => setCreatorName(event.target.value)} value={creatorName} />
<label htmlFor={socialId}> ID</label>
<input id={socialId} maxLength={80} onChange={(event) => setSocialHandle(event.target.value)} value={socialHandle} />
{saveError ? <p className="settings-error" role="alert"></p> : null}
{saved ? <p className="settings-saved" role="status"></p> : null}
<button className="settings-primary" disabled={saving || !creatorName.trim() || !socialHandle.trim()} type="submit">
{saving ? "保存中" : "保存资料"}
</button>
</form>
</section>
<section className="settings-section" aria-labelledby="account-title">
<div className="settings-section-heading">
<h2 id="account-title"></h2>
<p></p>
</div>
<dl className="settings-definition">
<div><dt></dt><dd>{settings.account.email}</dd></div>
<div><dt></dt><dd></dd></div>
<div><dt></dt><dd></dd></div>
</dl>
</section>
<section className="settings-section settings-local" aria-labelledby="local-title">
<div className="settings-section-heading">
<h2 id="local-title"></h2>
<p>Dada </p>
</div>
<div className="settings-capacity" data-status={settings.local_data.capacity_status}>
<div>
<strong>{capacityLabel(settings.local_data.capacity_status)}</strong>
<span>{formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)}</span>
</div>
<div className="settings-capacity-track" role="progressbar" aria-label="本机数据容量" aria-valuemax={100} aria-valuemin={0} aria-valuenow={capacityPercent}>
<span style={{ width: `${capacityPercent}%` }} />
</div>
</div>
<p className="settings-fixed-notice"></p>
<p className="settings-detail"> Windows Dada </p>
</section>
<section className="settings-danger" aria-labelledby="danger-title">
<div>
<h2 id="danger-title"></h2>
<p>使使</p>
</div>
<button className="settings-danger-button" onClick={() => setDeletionOpen(true)} type="button"></button>
</section>
{deletionOpen ? (
<div className="settings-dialog-backdrop">
<section
aria-labelledby="delete-dialog-title"
aria-modal="true"
className="settings-dialog"
onKeyDown={(event) => {
if (event.key === "Escape" && !deleting) closeDeletion();
}}
role="dialog"
>
<header>
<div>
<p>DANGER ZONE</p>
<h2 id="delete-dialog-title"></h2>
</div>
<button aria-label="关闭注销确认" autoFocus disabled={deleting} onClick={closeDeletion} type="button">×</button>
</header>
<div className="settings-dialog-body">
<p className="settings-dialog-warning">使</p>
<p> 180 </p>
<button className="settings-code-button" disabled={sendingCode || deleting || Boolean(deletionId)} onClick={sendDeletionCode} type="button">
{sendingCode ? "发送中" : deletionId ? "验证码已发送" : "获取注销验证码"}
</button>
<form onSubmit={completeDeletion}>
<label htmlFor={deletionCodeId}></label>
<input disabled={deleting || !deletionId} id={deletionCodeId} inputMode="numeric" maxLength={6} onChange={(event) => setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} />
<label htmlFor={confirmationId}></label>
<input disabled={deleting} id={confirmationId} onChange={(event) => setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} />
{deletionError ? <p className="settings-error" role="alert"></p> : null}
<button className="settings-delete-confirm" disabled={!deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting} type="submit">
{deleting ? "注销处理中" : "永久注销"}
</button>
</form>
</div>
</section>
</div>
) : null}
</main>
);
}
+33 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AdminSessionResponse, UserSessionResponse, LogoutResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest } from "./types.gen.js";
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, UserSessionResponse, LogoutResponse, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -45,6 +45,15 @@ export async function checkBrowserSupport(body: {
}>;
}
export async function completeAccountDeletion(body: AccountDeletionCompleteRequest, options: ClientOptions = {}): Promise<AccountDeletionResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/complete`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AccountDeletionResponse>;
}
export async function completeAdminLogin(body: AdminLoginCompleteRequest, options: ClientOptions = {}): Promise<AdminLoginCompleteResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -72,6 +81,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op
return response.json() as Promise<RegistrationCompleteResponse>;
}
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AccountSettingsResponse>;
}
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
@@ -136,6 +152,13 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function sendAccountDeletionCode(options: ClientOptions = {}): Promise<AccountDeletionSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/deletion/send`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AccountDeletionSendResponse>;
}
export async function sendAdminLoginCode(body: AdminLoginSendRequest, options: ClientOptions = {}): Promise<RegistrationSendResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -162,3 +185,12 @@ export async function sendRegistrationCode(body: RegistrationSendRequest, option
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<RegistrationSendResponse>;
}
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings/profile`, { body: JSON.stringify(body), method: "PUT", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AccountProfileUpdateResponse>;
}
+54
View File
@@ -1,5 +1,55 @@
// Generated from openapi/openapi.json. Do not edit by hand.
export type AccountDeletionCompleteRequest = {
"confirmation": "注销账号";
"deletion_id": string;
"verification_code": string;
};
export type AccountDeletionResponse = {
"status": "deleted";
};
export type AccountDeletionSendResponse = {
"challenge_expires_at": string;
"deletion_id": string;
"resend_available_at": string;
"status": "verification_sent";
};
export type AccountProfileUpdateRequest = {
"creator_name": string;
"social_id": string;
};
export type AccountProfileUpdateResponse = {
"profile": {
"creator_name": string;
"social_id": string;
};
"status": "saved";
};
export type AccountSettingsResponse = {
"account": {
"email": string;
"status": "active";
};
"csrf_token": string;
"local_data": {
"backup_enabled": false;
"capacity_status": "normal" | "warning" | "critical" | "full" | "unavailable";
"hard_limit_bytes": number;
"location": "configured_local_data_root";
"managed_content_bytes": number;
"migration_supported": false;
};
"profile": {
"creator_name": string;
"social_id": string;
};
};
export type AdminAuthenticatedUser = {
"role": "super_admin";
"status": "active";
@@ -93,6 +143,10 @@ export type CreditSummary = {
"reserved_balance": number;
};
export type CsrfHeaders = {
"x-csrf-token": string;
};
export type ErrorDetails = {
"capacity_status"?: "normal" | "warning" | "critical" | "full" | "unavailable";
"current_task_ref"?: string;
+6 -3
View File
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
import { AdminAuthPage } from "./admin-auth.js";
import { UserAuthPage } from "./user-auth.js";
import { AccountSettingsPage } from "./account-settings.js";
const root = document.getElementById("root");
@@ -19,9 +20,11 @@ let authRevision = 0;
function renderAuthenticationEntry() {
authRevision += 1;
const authenticationPage = window.location.pathname.startsWith("/admin")
? <AdminAuthPage key={authRevision} />
: <UserAuthPage key={authRevision} />;
const authenticationPage = window.location.pathname === "/app/settings"
? <AccountSettingsPage key={authRevision} />
: window.location.pathname.startsWith("/admin")
? <AdminAuthPage key={authRevision} />
: <UserAuthPage key={authRevision} />;
appRoot.render(
<StrictMode>
{authenticationPage}
+46
View File
@@ -0,0 +1,46 @@
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
export class RetentionCleanup {
private readonly clock: () => number;
private readonly database: BetterSqlite3.Database;
private retentionPurgeActive = false;
constructor(input: { clock?: () => number; databasePath: string }) {
this.clock = input.clock ?? Date.now;
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
this.database.pragma("foreign_keys = ON");
this.database.pragma("busy_timeout = 5000");
this.database.function("dada_allow_privacy_purge", { deterministic: false }, () => 0);
this.database.function("dada_allow_retention_purge", { deterministic: false }, () => this.retentionPurgeActive ? 1 : 0);
}
purgeExpired() {
const now = this.clock();
this.database.exec("BEGIN IMMEDIATE");
try {
this.retentionPurgeActive = true;
const privateAccess = this.database.prepare("DELETE FROM private_content_access_logs WHERE expires_at <= ?").run(now);
const anonymous = this.database.prepare("DELETE FROM anonymous_retained_events WHERE expires_at <= ?").run(now);
this.retentionPurgeActive = false;
this.database.exec("COMMIT");
return {
anonymous_events: anonymous.changes,
private_access_logs: privateAccess.changes,
};
} catch (error) {
this.retentionPurgeActive = false;
if (this.database.inTransaction) this.database.exec("ROLLBACK");
throw error;
}
}
close() {
this.database.close();
}
}
+19 -1
View File
@@ -3,6 +3,7 @@ import { join } from "node:path";
import { WorkerAiCallGate } from "./ai-call-gate.js";
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
import { RetentionCleanup } from "./retention-cleanup.js";
import { StructuredJsonlLogger } from "./structured-log.js";
import { WorkerStorageStatus } from "./storage-status.js";
import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js";
@@ -26,14 +27,31 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
const keepAlive = setInterval(() => undefined, 30_000);
let storage: WorkerStorageStatus | undefined;
let retention: RetentionCleanup | undefined;
let retentionTimer: ReturnType<typeof setInterval> | undefined;
const control = attachWorkerSupervisorControl(controlPipe, () => {
clearInterval(keepAlive);
if (retentionTimer) clearInterval(retentionTimer);
retention?.close();
storage?.close();
});
let storageStatus: "active" | "unavailable" = "active";
try {
const dataRoot = readConfiguredLocalDataRoot();
storage = new WorkerStorageStatus(join(dataRoot, "db", "dada.sqlite3"));
const databasePath = join(dataRoot, "db", "dada.sqlite3");
storage = new WorkerStorageStatus(databasePath);
retention = new RetentionCleanup({ databasePath });
const runRetentionCleanup = () => {
try {
retention?.purgeExpired();
} catch {
storageStatus = "unavailable";
storage?.markLogUnavailable();
control.reportStatus("storage_unavailable");
}
};
runRetentionCleanup();
retentionTimer = setInterval(runRetentionCleanup, 24 * 60 * 60 * 1_000);
const logger = new StructuredJsonlLogger({
component: "worker",
directory: join(dataRoot, "logs", "worker"),