feat: complete TASK-WP0-04 local data boundary
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
type BrowserUnsupportedReason,
|
||||
} from "./browser-support.js";
|
||||
import { EventHub } from "./event-hub.js";
|
||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
|
||||
const defaultBootstrap: BootstrapResponse = {
|
||||
@@ -55,6 +56,7 @@ export interface CreateAppOptions {
|
||||
browserSupportSecret?: Buffer;
|
||||
eventHub?: EventHub;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
}
|
||||
|
||||
const supportGateDirectory = resolve("apps/web/support-gate");
|
||||
@@ -211,6 +213,21 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
status: "ready",
|
||||
}));
|
||||
|
||||
app.get(
|
||||
"/api/v1/assets/public/:resourceVersion/:assetId",
|
||||
{ schema: { hide: true } },
|
||||
async (request, reply) => {
|
||||
const { assetId, resourceVersion } = request.params as { assetId?: string; resourceVersion?: string };
|
||||
const resource = assetId && resourceVersion
|
||||
? options.publicAssets?.read(resourceVersion, assetId)
|
||||
: undefined;
|
||||
if (!resource) return reply.code(404).send();
|
||||
reply.type(resource.mimeType);
|
||||
reply.header("Content-Disposition", "inline");
|
||||
return resource.bytes;
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/support/check",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import {
|
||||
accessSync,
|
||||
constants,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
|
||||
|
||||
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const fixedDirectories = [
|
||||
"db",
|
||||
"content/references",
|
||||
"content/generated",
|
||||
"content/exports",
|
||||
"managed-assets",
|
||||
"derived-assets",
|
||||
"staging",
|
||||
"logs/api",
|
||||
"logs/worker",
|
||||
"logs/supervisor",
|
||||
] as const;
|
||||
|
||||
export const DATA_TRANSFER_POLICY = {
|
||||
allowed_downloads: ["original_generation", "jpg", "png"],
|
||||
application_backup: false,
|
||||
business_import: false,
|
||||
editable_project_archive: false,
|
||||
p0b_migration: false,
|
||||
recovery: false,
|
||||
} as const;
|
||||
|
||||
export interface LocalDataRootBoundaries {
|
||||
downloadsRoot: string;
|
||||
programRoot: string;
|
||||
projectRoots: string[];
|
||||
readOnlyAssetRoots: string[];
|
||||
repositoryRoot: string;
|
||||
}
|
||||
|
||||
export type LocalDataRootRejectionReason =
|
||||
| "repository_root"
|
||||
| "program_root"
|
||||
| "project_root"
|
||||
| "downloads_root"
|
||||
| "read_only_asset_root"
|
||||
| "symbolic_link";
|
||||
|
||||
export type LocalDataRootValidation =
|
||||
| { ok: true; normalized_path: string }
|
||||
| { ok: false; reason: LocalDataRootRejectionReason };
|
||||
|
||||
interface InstanceConfiguration {
|
||||
data_root: string;
|
||||
initialized: true;
|
||||
instance_id: string;
|
||||
schema_version: 1;
|
||||
}
|
||||
|
||||
export interface ValidatedReadOnlyAssetRoot {
|
||||
absolute_root: string;
|
||||
ok: true;
|
||||
root_ref: string;
|
||||
}
|
||||
|
||||
interface PublicAssetEntry {
|
||||
assetId: string;
|
||||
mimeType: string;
|
||||
relativePath: string;
|
||||
resourceVersion: string;
|
||||
rootRef: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface PublicAssetPayload {
|
||||
assetId: string;
|
||||
bytes: Buffer;
|
||||
mimeType: string;
|
||||
resourceVersion: string;
|
||||
}
|
||||
|
||||
export interface PublicAssetResolver {
|
||||
read(resourceVersion: string, assetId: string): PublicAssetPayload | undefined;
|
||||
}
|
||||
|
||||
export function defaultLocalDataRoot(environment: NodeJS.ProcessEnv = process.env) {
|
||||
const localAppData = environment.LOCALAPPDATA;
|
||||
if (!localAppData || !isAbsolute(localAppData)) throw new Error("local_app_data_unavailable");
|
||||
return join(localAppData, "Dada", "P0A", "data");
|
||||
}
|
||||
|
||||
export function defaultInstanceConfigPath(environment: NodeJS.ProcessEnv = process.env) {
|
||||
const localAppData = environment.LOCALAPPDATA;
|
||||
if (!localAppData || !isAbsolute(localAppData)) throw new Error("local_app_data_unavailable");
|
||||
return join(localAppData, "Dada", "P0A", "config", "instance.json");
|
||||
}
|
||||
|
||||
function comparisonPath(path: string) {
|
||||
const normalized = resolve(path).replace(/[\\/]+$/, "");
|
||||
return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
|
||||
}
|
||||
|
||||
function isSameOrWithin(candidate: string, root: string) {
|
||||
const candidatePath = comparisonPath(candidate);
|
||||
const rootPath = comparisonPath(root);
|
||||
const child = relative(rootPath, candidatePath);
|
||||
return child === "" || (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child));
|
||||
}
|
||||
|
||||
function pathsOverlap(left: string, right: string) {
|
||||
return isSameOrWithin(left, right) || isSameOrWithin(right, left);
|
||||
}
|
||||
|
||||
function containsSymbolicLink(path: string) {
|
||||
const absolute = resolve(path);
|
||||
const parsed = parse(absolute);
|
||||
let current = parsed.root;
|
||||
const segments = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
current = join(current, segment);
|
||||
if (!existsSync(current)) break;
|
||||
if (lstatSync(current).isSymbolicLink()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function canonicalExistingPath(path: string) {
|
||||
const absolute = resolve(path);
|
||||
let existing = absolute;
|
||||
const missing: string[] = [];
|
||||
while (!existsSync(existing)) {
|
||||
const parent = dirname(existing);
|
||||
if (parent === existing) break;
|
||||
missing.unshift(existing.slice(parent.length + 1));
|
||||
existing = parent;
|
||||
}
|
||||
const canonical = existsSync(existing) ? realpathSync.native(existing) : existing;
|
||||
return resolve(canonical, ...missing);
|
||||
}
|
||||
|
||||
export function validateLocalDataRoot(
|
||||
candidate: string,
|
||||
boundaries: LocalDataRootBoundaries,
|
||||
): LocalDataRootValidation {
|
||||
const absolute = resolve(candidate);
|
||||
if (containsSymbolicLink(absolute)) return { ok: false, reason: "symbolic_link" };
|
||||
const canonical = canonicalExistingPath(absolute);
|
||||
const restricted: ReadonlyArray<readonly [LocalDataRootRejectionReason, string]> = [
|
||||
["repository_root", boundaries.repositoryRoot],
|
||||
["program_root", boundaries.programRoot],
|
||||
...boundaries.projectRoots.map((root) => ["project_root", root] as const),
|
||||
["downloads_root", boundaries.downloadsRoot],
|
||||
...boundaries.readOnlyAssetRoots.map((root) => ["read_only_asset_root", root] as const),
|
||||
];
|
||||
for (const [reason, root] of restricted) {
|
||||
if (pathsOverlap(canonical, canonicalExistingPath(root))) return { ok: false, reason };
|
||||
}
|
||||
return { normalized_path: absolute, ok: true };
|
||||
}
|
||||
|
||||
export function resolvePathWithinRoot(root: string, objectKey: string) {
|
||||
if (!objectKey || isAbsolute(objectKey) || objectKey.split(/[\\/]/).includes("..")) {
|
||||
throw new Error("path_escape");
|
||||
}
|
||||
if (containsSymbolicLink(root)) throw new Error("symbolic_link");
|
||||
const target = resolve(root, objectKey);
|
||||
if (!isSameOrWithin(target, root) || containsSymbolicLink(target)) throw new Error("path_escape");
|
||||
const canonicalRoot = canonicalExistingPath(root);
|
||||
const canonicalTarget = canonicalExistingPath(target);
|
||||
if (!isSameOrWithin(canonicalTarget, canonicalRoot)) throw new Error("path_escape");
|
||||
return target;
|
||||
}
|
||||
|
||||
function openInstanceDatabase(databasePath: string) {
|
||||
const database = new Database(databasePath);
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.exec(`
|
||||
CREATE TABLE instance_metadata (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
schema_version INTEGER NOT NULL CHECK (schema_version = 1)
|
||||
);
|
||||
INSERT INTO instance_metadata (singleton, schema_version) VALUES (1, 1);
|
||||
`);
|
||||
database.close();
|
||||
}
|
||||
|
||||
export function initializeLocalDataRoot(input: {
|
||||
boundaries: LocalDataRootBoundaries;
|
||||
configFile: string;
|
||||
dataRoot: string;
|
||||
}) {
|
||||
const validation = validateLocalDataRoot(input.dataRoot, input.boundaries);
|
||||
if (!validation.ok) throw new Error(validation.reason);
|
||||
if (existsSync(input.configFile)) throw new Error("already_initialized");
|
||||
if (existsSync(validation.normalized_path) && readdirSync(validation.normalized_path).length > 0) {
|
||||
throw new Error("data_root_not_empty");
|
||||
}
|
||||
|
||||
const createdRoot = !existsSync(validation.normalized_path);
|
||||
try {
|
||||
for (const directory of fixedDirectories) {
|
||||
mkdirSync(join(validation.normalized_path, directory), { recursive: true });
|
||||
}
|
||||
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
|
||||
const configuration: InstanceConfiguration = {
|
||||
data_root: validation.normalized_path,
|
||||
initialized: true,
|
||||
instance_id: randomUUID(),
|
||||
schema_version: 1,
|
||||
};
|
||||
mkdirSync(dirname(input.configFile), { recursive: true });
|
||||
const temporaryConfig = `${input.configFile}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporaryConfig, `${JSON.stringify(configuration, null, 2)}\n`, { flag: "wx" });
|
||||
renameSync(temporaryConfig, input.configFile);
|
||||
return {
|
||||
database: "db/dada.sqlite3",
|
||||
directories: [...fixedDirectories],
|
||||
status: "ready" as const,
|
||||
};
|
||||
} catch (error) {
|
||||
if (createdRoot && existsSync(validation.normalized_path)) {
|
||||
rmSync(validation.normalized_path, { force: true, recursive: true });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectInitializedLocalDataRoot(input: {
|
||||
boundaries: LocalDataRootBoundaries;
|
||||
configFile: string;
|
||||
}) {
|
||||
const configuration = JSON.parse(readFileSync(input.configFile, "utf8")) as InstanceConfiguration;
|
||||
const validation = validateLocalDataRoot(configuration.data_root, input.boundaries);
|
||||
if (!validation.ok) throw new Error(validation.reason);
|
||||
const rootExists = existsSync(validation.normalized_path) && statSync(validation.normalized_path).isDirectory();
|
||||
const databasePath = join(validation.normalized_path, "db", "dada.sqlite3");
|
||||
const databaseExists = rootExists && existsSync(databasePath) && statSync(databasePath).isFile();
|
||||
if (!rootExists || !databaseExists) {
|
||||
return {
|
||||
database_exists: databaseExists,
|
||||
root_exists: rootExists,
|
||||
status: "data_missing" as const,
|
||||
};
|
||||
}
|
||||
let writable = true;
|
||||
try {
|
||||
accessSync(validation.normalized_path, constants.R_OK | constants.W_OK);
|
||||
} catch {
|
||||
writable = false;
|
||||
}
|
||||
return {
|
||||
database_exists: true,
|
||||
root_exists: true,
|
||||
status: "ready" as const,
|
||||
writable,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateReadOnlyAssetRoot(input: {
|
||||
dataRoot: string;
|
||||
expectedSha256: string;
|
||||
manifestRelativePath: string;
|
||||
root: string;
|
||||
rootRef: string;
|
||||
}): ValidatedReadOnlyAssetRoot | { ok: false; reason: string } {
|
||||
const absoluteRoot = resolve(input.root);
|
||||
if (!existsSync(absoluteRoot) || !statSync(absoluteRoot).isDirectory()) {
|
||||
return { ok: false, reason: "asset_root_missing" };
|
||||
}
|
||||
if (containsSymbolicLink(absoluteRoot)) return { ok: false, reason: "symbolic_link" };
|
||||
if (pathsOverlap(absoluteRoot, input.dataRoot)) return { ok: false, reason: "data_root_overlap" };
|
||||
let manifestPath: string;
|
||||
try {
|
||||
manifestPath = resolvePathWithinRoot(absoluteRoot, input.manifestRelativePath);
|
||||
} catch {
|
||||
return { ok: false, reason: "manifest_path_invalid" };
|
||||
}
|
||||
if (!existsSync(manifestPath) || !statSync(manifestPath).isFile()) {
|
||||
return { ok: false, reason: "manifest_missing" };
|
||||
}
|
||||
const actualSha256 = createHash("sha256").update(readFileSync(manifestPath)).digest("hex");
|
||||
if (actualSha256.toLowerCase() !== input.expectedSha256.toLowerCase()) {
|
||||
return { ok: false, reason: "manifest_hash_invalid" };
|
||||
}
|
||||
return { absolute_root: absoluteRoot, ok: true, root_ref: input.rootRef };
|
||||
}
|
||||
|
||||
export function createPublicAssetResolver(input: {
|
||||
entries: PublicAssetEntry[];
|
||||
roots: ValidatedReadOnlyAssetRoot[];
|
||||
}): PublicAssetResolver {
|
||||
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
|
||||
const entries = new Map<string, PublicAssetEntry>();
|
||||
for (const entry of input.entries) {
|
||||
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
|
||||
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
|
||||
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
|
||||
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
|
||||
entries.set(entry.assetId, { ...entry });
|
||||
}
|
||||
|
||||
return {
|
||||
read(resourceVersion, assetId) {
|
||||
if (!assetIdPattern.test(assetId)) return undefined;
|
||||
const entry = entries.get(assetId);
|
||||
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
|
||||
const root = roots.get(entry.rootRef);
|
||||
if (!root) return undefined;
|
||||
let path: string;
|
||||
try {
|
||||
path = resolvePathWithinRoot(root, entry.relativePath);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!existsSync(path) || !statSync(path).isFile()) return undefined;
|
||||
const bytes = readFileSync(path);
|
||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||
if (sha256.toLowerCase() !== entry.sha256.toLowerCase()) return undefined;
|
||||
return {
|
||||
assetId: entry.assetId,
|
||||
bytes,
|
||||
mimeType: entry.mimeType,
|
||||
resourceVersion: entry.resourceVersion,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
:root {
|
||||
color: #111111;
|
||||
background: #ffffff;
|
||||
font-family: "Segoe UI", Arial, sans-serif;
|
||||
font-synthesis: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.local-data-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: 8px 1fr 44px;
|
||||
}
|
||||
|
||||
.local-data-rule {
|
||||
background: #eaff00;
|
||||
}
|
||||
|
||||
.local-data-main {
|
||||
width: min(920px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 64px 28px 48px;
|
||||
}
|
||||
|
||||
.local-data-kicker {
|
||||
margin: 0 0 14px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.local-data-title {
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.local-data-intro {
|
||||
max-width: 680px;
|
||||
margin: 14px 0 0;
|
||||
color: #555555;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.local-data-notice {
|
||||
margin-top: 30px;
|
||||
border-block: 2px solid #111111;
|
||||
padding: 22px 0;
|
||||
}
|
||||
|
||||
.local-data-notice strong {
|
||||
display: block;
|
||||
max-width: 760px;
|
||||
font-size: 24px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.local-data-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 36px;
|
||||
margin: 30px 0 0;
|
||||
}
|
||||
|
||||
.local-data-fact {
|
||||
min-width: 0;
|
||||
border-top: 1px solid #c7c7c7;
|
||||
padding: 16px 0 18px;
|
||||
}
|
||||
|
||||
.local-data-fact dt {
|
||||
color: #666666;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-data-fact dd {
|
||||
margin: 7px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.local-data-download {
|
||||
margin: 26px 0 0;
|
||||
padding: 14px 16px;
|
||||
border-left: 6px solid #eaff00;
|
||||
background: #f2f2f2;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.local-data-footer {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #eaff00;
|
||||
background: #111111;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.local-data-main {
|
||||
padding: 36px 18px 40px;
|
||||
}
|
||||
|
||||
.local-data-title {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.local-data-notice strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.local-data-facts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import "./local-data-boundary.css";
|
||||
|
||||
export const localDataRiskNotice = "测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。";
|
||||
|
||||
export function LocalDataBoundary() {
|
||||
return (
|
||||
<div className="local-data-page">
|
||||
<div className="local-data-rule" aria-hidden="true" />
|
||||
<main className="local-data-main">
|
||||
<p className="local-data-kicker">SETTINGS / LOCAL DATA</p>
|
||||
<h1 className="local-data-title">本机数据</h1>
|
||||
<p className="local-data-intro">
|
||||
Dada 的 P0-A 测试数据由本地后端管理。浏览器只通过资源 ID 访问文件,不会显示或直接读取本机目录路径。
|
||||
</p>
|
||||
|
||||
<section className="local-data-notice" aria-labelledby="local-data-warning">
|
||||
<strong id="local-data-warning">{localDataRiskNotice}</strong>
|
||||
</section>
|
||||
|
||||
<dl className="local-data-facts">
|
||||
<div className="local-data-fact">
|
||||
<dt>逻辑位置</dt>
|
||||
<dd>当前 Windows 用户的 Dada 本机数据目录</dd>
|
||||
</div>
|
||||
<div className="local-data-fact">
|
||||
<dt>保护方式</dt>
|
||||
<dd>依赖当前 Windows 用户登录和文件系统权限</dd>
|
||||
</div>
|
||||
<div className="local-data-fact">
|
||||
<dt>备份与加密</dt>
|
||||
<dd>不提供 Dada 应用层加密或云备份</dd>
|
||||
</div>
|
||||
<div className="local-data-fact">
|
||||
<dt>恢复边界</dt>
|
||||
<dd>机器损坏、重装或删除本机数据目录后不可恢复</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="local-data-download">请主动下载需要保留的原始生成图或 JPG/PNG 成品。</p>
|
||||
</main>
|
||||
<footer className="local-data-footer">P0-A · LOCAL FILESYSTEM · NO BACKUP</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function mountLocalDataBoundary(element: Element) {
|
||||
createRoot(element).render(
|
||||
<StrictMode>
|
||||
<LocalDataBoundary />
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user