fix: localize home and split auth setup flow

This commit is contained in:
西街长安 2026-05-07 12:25:16 +08:00
parent fdec8960c2
commit 31d63607ca
11 changed files with 389 additions and 255 deletions

View File

@ -11,7 +11,7 @@
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test",
"check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js"
"check": "node --check src/server.js && node --check src/db/duplicatiRepository.js && node --check src/db/sourceCatalog.js && node --check src/services/activeSourceService.js && node --check src/services/sourceEnhancementService.js && node --check src/services/authService.js && node --check src/services/tlsService.js && node --check src/lib/aesCrypt.js && node --check src/lib/zip.js && node --check src/lib/auth.js && node --check public/app.js && node --check public/source.js && node --check public/file.js && node --check public/login.js && node --check public/setup.js && node --check public/media-sw.js && node --check public/modules/common.js && node --check public/modules/auth-client.js && node --check public/modules/clientSecrets.js && node --check public/modules/media-core.js && node --check public/modules/thumbnail-utils.js && node --check public/vendor/aes-js-esm.js"
},
"dependencies": {
"aes-js": "^3.1.2",

View File

@ -136,13 +136,13 @@ function renderOverviewSummary() {
const mappedCount = state.sources.filter((source) => source.displayNameSource === 'server-db').length;
overviewSummaryElement.innerHTML = renderSummaryRows([
{ label: 'Total sources', value: String(sourceCount) },
{ label: 'Enhanced sources', value: String(readyCount) },
{ label: 'Failed enhancements', value: String(failedCount) },
{ label: 'Mapped task names', value: String(mappedCount) },
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : 'Not uploaded' },
{ label: 'Global WebDAV', value: state.globalDefaults?.configured ? 'Configured' : 'Not configured' },
{ label: 'Active certificate', value: state.tls?.activeSource ?? 'Unavailable' }
{ label: '数据源总数', value: String(sourceCount) },
{ label: '已增强数据源', value: String(readyCount) },
{ label: '增强失败', value: String(failedCount) },
{ label: '已映射任务名', value: String(mappedCount) },
{ label: 'Server DB', value: state.serverDb?.available ? state.serverDb.originalFilename : '未上传' },
{ label: '全局 WebDAV', value: state.globalDefaults?.configured ? '已配置' : '未配置' },
{ label: '当前证书来源', value: state.tls?.activeSource ?? '不可用' }
]);
}
@ -150,15 +150,15 @@ function renderServerDbSummary() {
if (!state.serverDb?.available) {
serverDbSummaryElement.innerHTML = renderSummaryRows([
{ label: 'Status', value: 'No server database uploaded yet' },
{ label: 'Note', value: 'Task names and inferred target URLs stay unavailable until you upload Duplicati-server.sqlite.' }
{ label: '说明', value: '未上传 Duplicati-server.sqlite 之前,任务名和推导目标 URL 不可用。' }
]);
return;
}
serverDbSummaryElement.innerHTML = renderSummaryRows([
{ label: 'Current file', value: state.serverDb.originalFilename },
{ label: 'Uploaded at', value: state.serverDb.uploadedAt },
{ label: 'Backup entries', value: String(state.serverDb.backupCount) }
{ label: '当前文件', value: state.serverDb.originalFilename },
{ label: '上传时间', value: state.serverDb.uploadedAt },
{ label: '任务条目数', value: String(state.serverDb.backupCount) }
]);
}
@ -167,46 +167,46 @@ function renderGlobalDefaultsSummary() {
if (!defaults?.configured) {
writeGlobalDefaultsForm(null);
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
{ label: 'Status', value: 'No global defaults saved' },
{ label: 'Note', value: 'Save shared credentials here if most tasks use the same WebDAV account and passphrase.' }
{ label: '状态', value: '尚未保存全局默认值' },
{ label: '说明', value: '如果大多数任务共用同一套 WebDAV 账号和口令,可以在这里统一保存。' }
]);
return;
}
writeGlobalDefaultsForm(defaults);
globalDefaultsSummaryElement.innerHTML = renderSummaryRows([
{ label: 'Fallback URL', value: defaults.webdavBaseUrl ?? 'Not set' },
{ label: 'Auth mode', value: defaults.authMode ?? 'Auto' },
{ label: '兜底 URL', value: defaults.webdavBaseUrl ?? '未设置' },
{ label: '认证方式', value: defaults.authMode ?? '自动' },
{
label: 'Stored secrets',
label: '已保存凭据',
value: `username=${defaults.hasUsername ? 'yes' : 'no'}, password=${defaults.hasPassword ? 'yes' : 'no'}, passphrase=${defaults.hasPassphrase ? 'yes' : 'no'}`
},
{ label: 'Updated at', value: defaults.updatedAt ?? 'Unknown' }
{ label: '更新时间', value: defaults.updatedAt ?? '未知' }
]);
}
function renderTlsSummary() {
if (!state.tls) {
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: 'Status', value: 'TLS settings not loaded yet' }]);
tlsSummaryElement.innerHTML = renderSummaryRows([{ label: '状态', value: 'TLS 配置尚未加载' }]);
return;
}
writeTlsForm(state.tls);
tlsDeleteCustomButton.disabled = !state.tls.hasCustomCertificate;
tlsSummaryElement.innerHTML = renderSummaryRows([
{ label: 'HTTP endpoint', value: state.tls.access?.httpBaseUrl ?? 'Unknown' },
{ label: 'HTTPS endpoint', value: state.tls.access?.httpsBaseUrl ?? 'Unknown' },
{ label: 'Configured mode', value: state.tls.mode ?? 'self-signed' },
{ label: 'Active certificate', value: state.tls.activeSource ?? 'self-signed' },
{ label: 'Primary domain', value: state.tls.primaryDomain || 'localhost' },
{ label: 'Extra SAN entries', value: (state.tls.subjectAltNames ?? []).join(', ') || 'None' },
{ label: 'HTTP 访问地址', value: state.tls.access?.httpBaseUrl ?? '未知' },
{ label: 'HTTPS 访问地址', value: state.tls.access?.httpsBaseUrl ?? '未知' },
{ label: '配置模式', value: state.tls.mode ?? 'self-signed' },
{ label: '当前生效证书', value: state.tls.activeSource ?? 'self-signed' },
{ label: '主域名', value: state.tls.primaryDomain || 'localhost' },
{ label: '额外 SAN', value: (state.tls.subjectAltNames ?? []).join(', ') || '' },
{
label: 'Validity',
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : 'Unknown'
label: '有效期',
value: state.tls.certificate ? `${state.tls.certificate.validFrom} -> ${state.tls.certificate.validTo}` : '未知'
},
{ label: 'Fingerprint', value: state.tls.certificate?.fingerprint256 ?? 'Unknown' },
{ label: 'Last error', value: state.tls.lastErrorMessage ?? 'None' },
{ label: 'Cookie policy', value: state.tls.cookieNote ?? 'Shared between HTTP and HTTPS' }
{ label: '指纹', value: state.tls.certificate?.fingerprint256 ?? '未知' },
{ label: '最近错误', value: state.tls.lastErrorMessage ?? '' },
{ label: 'Cookie 说明', value: state.tls.cookieNote ?? 'HTTP/HTTPS 共用登录态' }
]);
}
@ -224,16 +224,16 @@ function renderSources() {
sourceListElement.innerHTML = state.sources
.map((source) => {
const targetHint = source.webdav?.effectiveWebdavBaseUrl
? `<div class="summary-chip">Default target URL source: ${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
: '<div class="summary-chip">No effective default target URL yet</div>';
? `<div class="summary-chip">默认目标 URL 来源:${escapeHtml(source.webdav.effectiveWebdavBaseUrlSource ?? 'unknown')}</div>`
: '<div class="summary-chip">暂无可用的默认目标 URL</div>';
return renderSourceSummaryCard(
source,
`
${targetHint}
<div class="card-actions">
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">Open workspace</a>
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">Delete source</button>
<a class="ghost-button button-link" href="${linkToSource(source.id)}#overview">进入工作台</a>
<button class="ghost-button danger-button" type="button" data-delete-source-id="${escapeHtml(source.id)}">删除 source</button>
</div>
`
);
@ -275,13 +275,13 @@ async function handleTaskDbUpload(event) {
const file = databaseInput.files?.[0];
if (!file) {
feedback.set('error', 'Choose a task SQLite file first.');
feedback.set('error', '请先选择一个任务 SQLite 文件。');
return;
}
uploadButton.disabled = true;
refreshButton.disabled = true;
feedback.set('info', 'Uploading task database...');
feedback.set('info', '正在上传任务数据库...');
try {
const formData = new FormData();
@ -302,8 +302,8 @@ async function handleTaskDbUpload(event) {
feedback.set(
payload.reused ? 'info' : 'success',
payload.reused
? 'That task database was already uploaded, so the existing source was reused.'
: 'Task database uploaded successfully.'
? '这个任务数据库之前已经上传过,系统复用了现有 source。'
: '任务数据库上传成功。'
);
} catch (error) {
feedback.set('error', error.message);
@ -319,12 +319,12 @@ async function handleServerDbUpload(event) {
const file = serverDbInput.files?.[0];
if (!file) {
feedback.set('error', 'Choose Duplicati-server.sqlite first.');
feedback.set('error', '请先选择 Duplicati-server.sqlite。');
return;
}
serverDbUploadButton.disabled = true;
feedback.set('info', 'Uploading Server DB and refreshing task-name mappings...');
feedback.set('info', '正在上传 Server DB 并刷新任务名映射...');
try {
const formData = new FormData();
@ -341,7 +341,7 @@ async function handleServerDbUpload(event) {
await Promise.all([loadSources(), loadServerDbSummary()]);
serverDbForm.reset();
feedback.set('success', 'Server DB uploaded successfully.');
feedback.set('success', 'Server DB 上传成功。');
} catch (error) {
feedback.set('error', error.message);
} finally {
@ -353,7 +353,7 @@ async function handleGlobalDefaultsSave(event) {
event.preventDefault();
feedback.clear();
globalDefaultsSaveButton.disabled = true;
feedback.set('info', 'Saving global WebDAV defaults...');
feedback.set('info', '正在保存全局 WebDAV 默认值...');
try {
const payload = await fetchJson('/api/webdav-defaults', {
@ -370,7 +370,7 @@ async function handleGlobalDefaultsSave(event) {
await loadSources();
feedback.set(
payload.defaults.configured ? 'success' : 'info',
payload.defaults.configured ? 'Global defaults saved.' : 'Global defaults cleared.'
payload.defaults.configured ? '全局默认值已保存。' : '全局默认值已清空。'
);
} catch (error) {
feedback.set('error', error.message);
@ -384,7 +384,7 @@ async function handleTlsJsonSave(event) {
feedback.clear();
tlsSaveButton.disabled = true;
tlsUploadButton.disabled = true;
feedback.set('info', 'Saving TLS configuration and applying certificate...');
feedback.set('info', '正在保存 TLS 配置并应用证书...');
try {
const payload = await fetchJson('/api/system/tls', {
@ -397,7 +397,7 @@ async function handleTlsJsonSave(event) {
state.tls = payload.tls;
renderTlsSummary();
renderOverviewSummary();
feedback.set('success', `TLS settings applied. Active certificate source: ${payload.tls.activeSource}.`);
feedback.set('success', `TLS 配置已应用,当前证书来源:${payload.tls.activeSource}`);
} catch (error) {
feedback.set('error', error.message);
} finally {
@ -415,13 +415,13 @@ async function handleTlsUpload(event) {
const chainFile = document.querySelector('#tlsChainInput')?.files?.[0] ?? null;
if (!certificateFile || !privateKeyFile) {
feedback.set('error', 'Upload both the certificate file and the private key file.');
feedback.set('error', '请同时上传证书文件和私钥文件。');
return;
}
tlsSaveButton.disabled = true;
tlsUploadButton.disabled = true;
feedback.set('info', 'Uploading custom certificate...');
feedback.set('info', '正在上传自定义证书...');
try {
const formData = new FormData();
@ -446,7 +446,7 @@ async function handleTlsUpload(event) {
renderTlsSummary();
renderOverviewSummary();
tlsUploadForm.reset();
feedback.set('success', 'Custom certificate uploaded and applied.');
feedback.set('success', '自定义证书已上传并生效。');
} catch (error) {
feedback.set('error', error.message);
} finally {
@ -457,12 +457,12 @@ async function handleTlsUpload(event) {
async function handleDeleteCustomTls() {
feedback.clear();
if (!window.confirm('Delete the stored custom certificate and fall back to a self-signed certificate?')) {
if (!window.confirm('确定要删除当前自定义证书,并回退到自签证书吗?')) {
return;
}
tlsDeleteCustomButton.disabled = true;
feedback.set('info', 'Deleting custom certificate and switching back to self-signed...');
feedback.set('info', '正在删除自定义证书,并切回自签证书...');
try {
const payload = await fetchJson('/api/system/tls/custom-certificate', {
@ -471,7 +471,7 @@ async function handleDeleteCustomTls() {
state.tls = payload.tls;
renderTlsSummary();
renderOverviewSummary();
feedback.set('success', 'Custom certificate removed. The service is back on a self-signed certificate.');
feedback.set('success', '自定义证书已删除,系统已回退到自签证书。');
} catch (error) {
feedback.set('error', error.message);
} finally {
@ -484,20 +484,20 @@ async function deleteSource(sourceId) {
const source = state.sources.find((item) => item.id === sourceId);
const label = source?.displayName || source?.originalFilename || sourceId;
const confirmed = window.confirm(
`Delete this source?\n\n${label}\n\nThis removes only the selected source and its local enhancement data. It does not delete Duplicati-server.sqlite.`
`确定要删除这个 source 吗?\n\n${label}\n\n这只会删除当前 source 及其本地增强数据,不会删除 Duplicati-server.sqlite。`
);
if (!confirmed) {
return;
}
feedback.set('info', `Deleting ${label}...`);
feedback.set('info', `正在删除 ${label}...`);
try {
const payload = await fetchJson(`/api/sources/${encodeURIComponent(sourceId)}`, {
method: 'DELETE'
});
await Promise.all([loadSources(), loadServerDbSummary()]);
feedback.set('success', `Deleted ${payload.deleted.originalFilename}. Server DB was kept untouched.`);
feedback.set('success', `已删除 ${payload.deleted.originalFilename}Server DB 保持不变。`);
} catch (error) {
feedback.set('error', error.message);
}
@ -529,7 +529,7 @@ tlsDeleteCustomButton.addEventListener('click', () => {
refreshButton.addEventListener('click', () => {
feedback.clear();
sourceListElement.textContent = 'Refreshing sources...';
sourceListElement.textContent = '正在刷新数据源列表...';
void Promise.all([loadSources(), loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary()]).catch((error) => {
feedback.set('error', error.message);
});
@ -571,8 +571,8 @@ async function bootstrap() {
const authentication = await loadAuthState();
authStatusElement.textContent = authentication.authenticated
? `Signed in as ${authentication.username}`
: 'Not signed in';
? `已登录:${authentication.username}`
: '未登录';
bindLogoutButton(logoutButton, authStatusElement);
await Promise.all([loadServerDbSummary(), loadGlobalDefaults(), loadTlsSummary(), loadSources()]);
@ -582,7 +582,7 @@ void bootstrap().catch((error) => {
feedback.set('error', error.message);
sourceListElement.innerHTML = `
<div class="empty-state-card">
<p>Could not load the control center.</p>
<p>无法加载控制台</p>
</div>
`;
});

View File

@ -1,9 +1,9 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Duplicati Control Center</title>
<title>数据源控制台</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
@ -11,28 +11,28 @@
<section class="hero hero-compact">
<div>
<p class="eyebrow">Zero-Bandwidth Duplicati Web Client</p>
<h1>Duplicati Control Center</h1>
<h1>数据源控制台</h1>
</div>
<p class="lead">
Manage task databases, Server DB metadata, global WebDAV defaults, and site-wide HTTPS/TLS settings here.
Browsing, enhancement, preview, and download stay in the per-source and per-file workspaces.
这里负责管理任务数据库、Server DB 元数据、全局 WebDAV 默认值,以及站点级 HTTPS/TLS 配置。
目录浏览、增强、预览和下载继续放在单个数据源工作台与文件页里。
</p>
</section>
<div class="auth-toolbar">
<span id="authStatus" class="auth-status">Loading login state...</span>
<button id="logoutButton" class="ghost-button" type="button">Sign out</button>
<span id="authStatus" class="auth-status">正在读取登录状态...</span>
<button id="logoutButton" class="ghost-button" type="button">退出登录</button>
</div>
<div id="feedback" class="feedback" hidden></div>
<section class="workspace-shell">
<aside class="workspace-nav" aria-label="Control Center menu">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">Overview</a>
<a class="workspace-nav-link" href="#sources" data-view-link="sources">Sources</a>
<a class="workspace-nav-link" href="#upload" data-view-link="upload">Upload task DB</a>
<aside class="workspace-nav" aria-label="数据源控制台菜单">
<a class="workspace-nav-link" href="#overview" data-view-link="overview">总览</a>
<a class="workspace-nav-link" href="#sources" data-view-link="sources">数据源列表</a>
<a class="workspace-nav-link" href="#upload" data-view-link="upload">新增任务库</a>
<a class="workspace-nav-link" href="#server-db" data-view-link="server-db">Server DB</a>
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">Global WebDAV</a>
<a class="workspace-nav-link" href="#defaults" data-view-link="defaults">全局 WebDAV</a>
<a class="workspace-nav-link" href="#tls" data-view-link="tls">HTTPS / TLS</a>
</aside>
@ -42,10 +42,10 @@
<div class="panel-header">
<div>
<p class="section-label">Overview</p>
<h2>System summary</h2>
<h2>系统总览</h2>
</div>
</div>
<div id="overviewSummary" class="summary-card">Loading overview...</div>
<div id="overviewSummary" class="summary-card">正在读取系统总览...</div>
</div>
</section>
@ -54,15 +54,14 @@
<div class="panel-header">
<div>
<p class="section-label">Sources</p>
<h2>Uploaded task databases</h2>
<h2>已上传任务数据库</h2>
</div>
<button id="refreshButton" class="ghost-button" type="button">Refresh</button>
<button id="refreshButton" class="ghost-button" type="button">刷新</button>
</div>
<p class="panel-copy">
Each uploaded Duplicati task database becomes one source. Open a source workbench to browse files,
start enhancement, or jump into the file workspace.
每个上传的 Duplicati 任务数据库都会变成一个独立 source。进入工作台后可以继续浏览目录、执行增强或跳转到文件页做预览和下载。
</p>
<div id="sourceList" class="source-list empty-state">Loading sources...</div>
<div id="sourceList" class="source-list empty-state">正在读取数据源列表...</div>
</div>
</section>
@ -71,20 +70,19 @@
<div class="panel-header">
<div>
<p class="section-label">Task DB</p>
<h2>Add a new source</h2>
<h2>上传新的任务库</h2>
</div>
</div>
<p class="panel-copy">
Upload a Duplicati task database such as <code>TMQRJYNADS.sqlite</code>. Re-uploading the same file
reuses the existing source instead of creating a duplicate.
上传随机名任务数据库,例如 <code>TMQRJYNADS.sqlite</code>。重复上传同一个数据库时,系统会复用现有 source而不是重复创建。
</p>
<form id="uploadForm" class="upload-form">
<label class="file-picker">
<span>Select task SQLite file</span>
<span>选择任务 SQLite 文件</span>
<input id="databaseInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
</label>
<div class="form-actions">
<button id="uploadButton" class="primary-button" type="submit">Upload as new source</button>
<button id="uploadButton" class="primary-button" type="submit">上传为新数据源</button>
</div>
</form>
</div>
@ -95,23 +93,22 @@
<div class="panel-header">
<div>
<p class="section-label">Server DB</p>
<h2>Upload Duplicati-server.sqlite</h2>
<h2>上传 Duplicati-server.sqlite</h2>
</div>
</div>
<p class="panel-copy">
The server database lets the UI resolve friendly task names and infer default target URLs from
Duplicati metadata.
上传后,界面可以自动解析任务名称,并尽量从 Duplicati 元数据中推导默认目标 URL。
</p>
<form id="serverDbForm" class="upload-form">
<label class="file-picker">
<span>Select server database</span>
<span>选择服务器数据库</span>
<input id="serverDbInput" name="database" type="file" accept=".sqlite,.db,application/octet-stream">
</label>
<div class="form-actions">
<button id="serverDbUploadButton" class="ghost-button" type="submit">Upload Server DB</button>
<button id="serverDbUploadButton" class="ghost-button" type="submit">上传 Server DB</button>
</div>
</form>
<div id="serverDbSummary" class="summary-card">Loading Server DB status...</div>
<div id="serverDbSummary" class="summary-card">正在读取 Server DB 状态...</div>
</div>
</section>
@ -120,42 +117,42 @@
<div class="panel-header">
<div>
<p class="section-label">Global WebDAV</p>
<h2>Shared defaults</h2>
<h2>共享默认值</h2>
</div>
</div>
<p class="panel-copy">
Save credentials and a fallback base URL once when most tasks share the same WebDAV settings.
如果大多数任务共用同一套 WebDAV 认证和口令,可以在这里统一保存一次。
</p>
<form id="globalDefaultsForm" class="secret-form">
<label>
<span>Fallback WebDAV Base URL</span>
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="Optional shared base URL">
<span>兜底 WebDAV Base URL</span>
<input id="globalWebdavBaseUrl" name="webdavBaseUrl" type="url" placeholder="可选,共享兜底 URL">
</label>
<label>
<span>Authentication mode</span>
<span>认证方式</span>
<select id="globalAuthMode" name="authMode">
<option value="">Auto / inherited</option>
<option value="">自动 / 继承</option>
<option value="basic">Basic</option>
<option value="anonymous">Anonymous</option>
<option value="anonymous">匿名</option>
</select>
</label>
<label>
<span>Username</span>
<input id="globalUsername" name="username" type="text" placeholder="Optional shared username">
<span>用户名</span>
<input id="globalUsername" name="username" type="text" placeholder="可选,共享用户名">
</label>
<label>
<span>Password</span>
<input id="globalPassword" name="password" type="password" placeholder="Optional shared password">
<span>密码</span>
<input id="globalPassword" name="password" type="password" placeholder="可选,共享密码">
</label>
<label>
<span>Backup passphrase</span>
<input id="globalPassphrase" name="passphrase" type="password" placeholder="Optional shared passphrase">
<span>备份口令</span>
<input id="globalPassphrase" name="passphrase" type="password" placeholder="可选,共享备份口令">
</label>
<div class="form-actions">
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">Save defaults</button>
<button id="globalDefaultsSaveButton" class="ghost-button" type="submit">保存默认值</button>
</div>
</form>
<div id="globalDefaultsSummary" class="summary-card">Loading WebDAV defaults...</div>
<div id="globalDefaultsSummary" class="summary-card">正在读取 WebDAV 默认值...</div>
</div>
</section>
@ -164,64 +161,63 @@
<div class="panel-header">
<div>
<p class="section-label">HTTPS / TLS</p>
<h2>Domain and certificate settings</h2>
<h2>域名与证书设置</h2>
</div>
</div>
<p class="panel-copy">
HTTP and HTTPS stay enabled side-by-side. If no custom certificate is configured, the service keeps a
self-signed certificate ready so HTTPS is always available.
HTTP 和 HTTPS 会同时可用。没有自定义证书时,系统会自动维护一张自签证书,保证 HTTPS 始终可访问。
</p>
<div id="tlsSummary" class="summary-card">Loading TLS configuration...</div>
<div id="tlsSummary" class="summary-card">正在读取 TLS 配置...</div>
<form id="tlsJsonForm" class="secret-form">
<label>
<span>Certificate mode</span>
<span>证书模式</span>
<select id="tlsMode" name="mode">
<option value="self-signed">Self-signed</option>
<option value="custom-pem">Custom PEM</option>
<option value="self-signed">自签证书</option>
<option value="custom-pem">自定义 PEM</option>
</select>
</label>
<label>
<span>Primary domain</span>
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="Example: preview.example.com">
<span>主域名</span>
<input id="tlsPrimaryDomain" name="primaryDomain" type="text" placeholder="例如:preview.example.com">
</label>
<label>
<span>Additional SAN entries</span>
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="One hostname or IP per line. Comma-separated values also work."></textarea>
<span>额外 SAN 条目</span>
<textarea id="tlsSubjectAltNames" name="subjectAltNames" rows="3" placeholder="每行一个域名或 IP也支持逗号分隔"></textarea>
</label>
<label>
<span>Certificate PEM</span>
<span>证书 PEM</span>
<textarea id="tlsCertPem" name="certPem" rows="8" placeholder="-----BEGIN CERTIFICATE-----"></textarea>
</label>
<label>
<span>Private key PEM</span>
<span>私钥 PEM</span>
<textarea id="tlsKeyPem" name="keyPem" rows="8" placeholder="-----BEGIN PRIVATE KEY-----"></textarea>
</label>
<label>
<span>Optional chain PEM</span>
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="Optional intermediate certificates"></textarea>
<span>可选链证书 PEM</span>
<textarea id="tlsChainPem" name="chainPem" rows="6" placeholder="可选,中间证书链"></textarea>
</label>
<div class="form-actions">
<button id="tlsSaveButton" class="primary-button" type="submit">Save and apply</button>
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">Delete custom cert and fall back</button>
<button id="tlsSaveButton" class="primary-button" type="submit">保存并应用</button>
<button id="tlsDeleteCustomButton" class="ghost-button danger-button" type="button">删除自定义证书并回退</button>
</div>
</form>
<form id="tlsUploadForm" class="upload-form">
<label class="file-picker">
<span>Upload certificate file</span>
<span>上传证书文件</span>
<input id="tlsCertificateInput" name="certificate" type="file" accept=".pem,.crt,.cer,text/plain">
</label>
<label class="file-picker">
<span>Upload private key file</span>
<span>上传私钥文件</span>
<input id="tlsPrivateKeyInput" name="privateKey" type="file" accept=".pem,.key,text/plain">
</label>
<label class="file-picker">
<span>Upload optional chain file</span>
<span>上传可选链证书文件</span>
<input id="tlsChainInput" name="chain" type="file" accept=".pem,.crt,.cer,text/plain">
</label>
<div class="form-actions">
<button id="tlsUploadButton" class="ghost-button" type="submit">Apply uploaded certificate</button>
<button id="tlsUploadButton" class="ghost-button" type="submit">应用上传的证书</button>
</div>
</form>
</div>

View File

@ -11,10 +11,10 @@
<section class="hero hero-compact">
<div>
<p class="eyebrow">Authentication</p>
<h1 id="authHeading">系统登录</h1>
<h1>系统登录</h1>
</div>
<p id="authLead" class="lead">
登录后才能访问数据源管理、增强任务、视频预览和文件下载。首次启用时,需要先设置管理员账号。
<p class="lead">
登录后才能访问数据源管理、增强任务、视频预览和文件下载。
</p>
</section>
@ -24,14 +24,14 @@
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Account</p>
<h2 id="authFormTitle">正在检查系统状态...</h2>
<p class="section-label">Login</p>
<h2>请输入管理员账号密码</h2>
</div>
</div>
<div id="authSummary" class="summary-card">正在读取认证状态...</div>
<form id="loginForm" class="secret-form" hidden>
<form id="loginForm" class="secret-form">
<label>
<span>用户名</span>
<input id="loginUsername" name="username" type="text" autocomplete="username">
@ -44,24 +44,6 @@
<button id="loginButton" class="primary-button" type="submit">登录</button>
</div>
</form>
<form id="setupForm" class="secret-form" hidden>
<label>
<span>管理员用户名</span>
<input id="setupUsername" name="username" type="text" autocomplete="username">
</label>
<label>
<span>管理员密码</span>
<input id="setupPassword" name="password" type="password" autocomplete="new-password">
</label>
<label>
<span>再次输入密码</span>
<input id="setupPasswordConfirm" name="passwordConfirm" type="password" autocomplete="new-password">
</label>
<div class="form-actions">
<button id="setupButton" class="primary-button" type="submit">完成初始化并登录</button>
</div>
</form>
</div>
</section>
</main>

View File

@ -1,19 +1,10 @@
import { createFeedbackController, escapeHtml } from './modules/common.js';
import { loadAuthState, redirectToNextPath } from './modules/auth-client.js';
import { buildSetupUrl, loadAuthState, redirectToNextPath } from './modules/auth-client.js';
const feedback = createFeedbackController(document.querySelector('#feedback'));
const authHeadingElement = document.querySelector('#authHeading');
const authLeadElement = document.querySelector('#authLead');
const authFormTitleElement = document.querySelector('#authFormTitle');
const authSummaryElement = document.querySelector('#authSummary');
const loginForm = document.querySelector('#loginForm');
const setupForm = document.querySelector('#setupForm');
const loginButton = document.querySelector('#loginButton');
const setupButton = document.querySelector('#setupButton');
const state = {
auth: null
};
function setSummaryRows(rows) {
authSummaryElement.innerHTML = `
@ -32,49 +23,6 @@ function setSummaryRows(rows) {
`;
}
function renderAuthMode() {
const auth = state.auth;
if (!auth) {
return;
}
if (auth.authenticated) {
authHeadingElement.textContent = '登录成功';
authLeadElement.textContent = '当前会话已验证,正在跳转到你的目标页面。';
authFormTitleElement.textContent = '正在跳转...';
setSummaryRows([
{ label: '状态', value: `已登录:${auth.username}` },
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
]);
loginForm.hidden = true;
setupForm.hidden = true;
return;
}
if (auth.setupRequired) {
authHeadingElement.textContent = '初始化管理员账号';
authLeadElement.textContent = '系统第一次运行时,需要先创建管理员用户名和密码。创建完成后会自动登录。';
authFormTitleElement.textContent = '首次初始化';
setSummaryRows([
{ label: '系统状态', value: '尚未配置管理员账号' },
{ label: '下一步', value: '创建管理员并进入系统' }
]);
loginForm.hidden = true;
setupForm.hidden = false;
return;
}
authHeadingElement.textContent = '系统登录';
authLeadElement.textContent = '登录后才能访问数据源管理、增强任务、视频预览和文件下载。';
authFormTitleElement.textContent = '请输入账号密码';
setSummaryRows([
{ label: '系统状态', value: '管理员账号已配置' },
{ label: '当前状态', value: '未登录' }
]);
loginForm.hidden = false;
setupForm.hidden = true;
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: 'POST',
@ -92,11 +40,26 @@ async function postJson(url, payload) {
}
async function refreshAuthState() {
state.auth = await loadAuthState();
renderAuthMode();
if (state.auth.authenticated) {
const auth = await loadAuthState();
if (auth.authenticated) {
setSummaryRows([
{ label: '状态', value: `已登录:${auth.username}` },
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
]);
redirectToNextPath('/');
return;
}
if (auth.setupRequired) {
window.location.assign(buildSetupUrl());
return;
}
setSummaryRows([
{ label: '系统状态', value: '管理员账号已配置' },
{ label: '当前状态', value: '未登录' }
]);
}
async function handleLogin(event) {
@ -117,40 +80,10 @@ async function handleLogin(event) {
}
}
async function handleSetup(event) {
event.preventDefault();
feedback.clear();
const password = document.querySelector('#setupPassword').value;
const passwordConfirm = document.querySelector('#setupPasswordConfirm').value;
if (password !== passwordConfirm) {
feedback.set('error', '两次输入的密码不一致。');
return;
}
setupButton.disabled = true;
try {
await postJson('/api/auth/setup', {
username: document.querySelector('#setupUsername').value.trim(),
password
});
await refreshAuthState();
} catch (error) {
feedback.set('error', error.message);
} finally {
setupButton.disabled = false;
}
}
loginForm.addEventListener('submit', (event) => {
void handleLogin(event);
});
setupForm.addEventListener('submit', (event) => {
void handleSetup(event);
});
void refreshAuthState().catch((error) => {
feedback.set('error', error.message);
setSummaryRows([

View File

@ -11,10 +11,23 @@ export function buildLoginUrl(nextPath = getCurrentRelativeUrl()) {
return `${loginUrl.pathname}${loginUrl.search}`;
}
export function buildSetupUrl(nextPath = getCurrentRelativeUrl()) {
const setupUrl = new URL('/setup.html', window.location.origin);
if (nextPath) {
setupUrl.searchParams.set('next', nextPath);
}
return `${setupUrl.pathname}${setupUrl.search}`;
}
export function redirectToLogin(nextPath = getCurrentRelativeUrl()) {
window.location.assign(buildLoginUrl(nextPath));
}
export function redirectToSetup(nextPath = getCurrentRelativeUrl()) {
window.location.assign(buildSetupUrl(nextPath));
}
export function readNextPath(fallback = '/') {
const nextPath = new URLSearchParams(window.location.search).get('next');
if (!nextPath || !nextPath.startsWith('/') || nextPath.startsWith('//')) {
@ -51,7 +64,7 @@ export async function logoutCurrentSession() {
return payload;
}
export function bindLogoutButton(button, labelElement, fallbackLabel = 'Signed in') {
export function bindLogoutButton(button, labelElement, fallbackLabel = '已登录') {
if (!button) {
return;
}

View File

@ -1,4 +1,4 @@
import { redirectToLogin } from './auth-client.js';
import { redirectToLogin, redirectToSetup } from './auth-client.js';
export function escapeHtml(value) {
return String(value)
@ -37,7 +37,11 @@ export async function fetchJson(url, options = {}) {
if (!response.ok) {
if (response.status === 401 && window.location.pathname !== '/login.html') {
redirectToLogin();
if (payload.error?.code === 'AUTH_SETUP_REQUIRED') {
redirectToSetup();
} else {
redirectToLogin();
}
}
throw new Error(`${payload.error?.code ?? 'REQUEST_FAILED'}: ${payload.error?.message ?? 'Request failed.'}`);
@ -87,15 +91,15 @@ export function linkToFile(sourceId, fileId) {
export function inferEnhancementLabel(source) {
switch (source?.enhancement?.status) {
case 'ready':
return 'Enhanced';
return '已增强';
case 'running':
return 'Running';
return '增强中';
case 'queued':
return 'Queued';
return '排队中';
case 'failed':
return 'Failed';
return '增强失败';
default:
return 'Not ready';
return '未增强';
}
}
@ -116,7 +120,7 @@ export function inferEnhancementClass(source) {
export function renderSourceSummaryCard(source, extraActionsHtml = '') {
const title = escapeHtml(source.displayName || source.originalFilename);
const originalFilename = escapeHtml(source.originalFilename);
const matchedHint = source.matchedBackupName ? 'Task name resolved from Server DB' : 'Task name not mapped yet';
const matchedHint = source.matchedBackupName ? '任务名来自 Server DB' : '尚未匹配任务名';
const progress =
source.enhancement.totalVolumes > 0
? `${source.enhancement.processedVolumes}/${source.enhancement.totalVolumes}`
@ -127,8 +131,8 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
<div class="source-main">
<div>
<p class="source-name">${title}</p>
<p class="source-meta">Database file: ${originalFilename}</p>
<p class="source-meta">Source ID: ${escapeHtml(source.id)}</p>
<p class="source-meta">数据库文件${originalFilename}</p>
<p class="source-meta">Source ID${escapeHtml(source.id)}</p>
</div>
<span class="status-pill ${inferEnhancementClass(source)}">${inferEnhancementLabel(source)}</span>
</div>
@ -137,19 +141,19 @@ export function renderSourceSummaryCard(source, extraActionsHtml = '') {
<div class="card-grid">
<div class="stat">
<span>Uploaded size</span>
<span>上传大小</span>
<strong>${formatBytes(source.fileSize)}</strong>
</div>
<div class="stat">
<span>Latest snapshot</span>
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? 'None')}</strong>
<span>最新快照</span>
<strong>${escapeHtml(source.latestSnapshot?.timestamp ?? '')}</strong>
</div>
<div class="stat">
<span>Browse</span>
<strong>${source.canBrowse ? 'Available' : 'Unavailable'}</strong>
<span>目录浏览</span>
<strong>${source.canBrowse ? '可用' : '不可用'}</strong>
</div>
<div class="stat">
<span>Enhancement</span>
<span>增强进度</span>
<strong>${escapeHtml(progress)}</strong>
</div>
</div>

57
public/setup.html Normal file
View File

@ -0,0 +1,57 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>初始化管理员账号</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="shell shell-auth">
<section class="hero hero-compact">
<div>
<p class="eyebrow">First Run Setup</p>
<h1>初始化管理员账号</h1>
</div>
<p class="lead">
系统首次启用时,需要先创建管理员用户名和密码。完成后会自动登录并进入系统。
</p>
</section>
<div id="feedback" class="feedback" hidden></div>
<section class="auth-layout">
<div class="panel stack-gap">
<div class="panel-header">
<div>
<p class="section-label">Setup</p>
<h2>创建首个管理员</h2>
</div>
</div>
<div id="authSummary" class="summary-card">正在读取认证状态...</div>
<form id="setupForm" class="secret-form">
<label>
<span>管理员用户名</span>
<input id="setupUsername" name="username" type="text" autocomplete="username">
</label>
<label>
<span>管理员密码</span>
<input id="setupPassword" name="password" type="password" autocomplete="new-password">
</label>
<label>
<span>再次输入密码</span>
<input id="setupPasswordConfirm" name="passwordConfirm" type="password" autocomplete="new-password">
</label>
<div class="form-actions">
<button id="setupButton" class="primary-button" type="submit">完成初始化并登录</button>
</div>
</form>
</div>
</section>
</main>
<script type="module" src="/setup.js"></script>
</body>
</html>

101
public/setup.js Normal file
View File

@ -0,0 +1,101 @@
import { createFeedbackController, escapeHtml } from './modules/common.js';
import { buildLoginUrl, loadAuthState, redirectToNextPath } from './modules/auth-client.js';
const feedback = createFeedbackController(document.querySelector('#feedback'));
const authSummaryElement = document.querySelector('#authSummary');
const setupForm = document.querySelector('#setupForm');
const setupButton = document.querySelector('#setupButton');
function setSummaryRows(rows) {
authSummaryElement.innerHTML = `
<div class="summary-stack">
${rows
.map(
(row) => `
<div class="summary-row">
<span>${escapeHtml(row.label)}</span>
<strong>${escapeHtml(row.value)}</strong>
</div>
`
)
.join('')}
</div>
`;
}
async function postJson(url, payload) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${body.error?.code ?? 'REQUEST_FAILED'}: ${body.error?.message ?? 'Request failed.'}`);
}
return body;
}
async function refreshAuthState() {
const auth = await loadAuthState();
if (auth.authenticated) {
setSummaryRows([
{ label: '状态', value: `已登录:${auth.username}` },
{ label: '会话有效期', value: auth.expiresAt ?? '未知' }
]);
redirectToNextPath('/');
return;
}
if (!auth.setupRequired) {
window.location.assign(buildLoginUrl());
return;
}
setSummaryRows([
{ label: '系统状态', value: '尚未配置管理员账号' },
{ label: '下一步', value: '创建管理员账号并进入系统' }
]);
}
async function handleSetup(event) {
event.preventDefault();
feedback.clear();
const password = document.querySelector('#setupPassword').value;
const passwordConfirm = document.querySelector('#setupPasswordConfirm').value;
if (password !== passwordConfirm) {
feedback.set('error', '两次输入的密码不一致。');
return;
}
setupButton.disabled = true;
try {
await postJson('/api/auth/setup', {
username: document.querySelector('#setupUsername').value.trim(),
password
});
await refreshAuthState();
} catch (error) {
feedback.set('error', error.message);
} finally {
setupButton.disabled = false;
}
}
setupForm.addEventListener('submit', (event) => {
void handleSetup(event);
});
void refreshAuthState().catch((error) => {
feedback.set('error', error.message);
setSummaryRows([
{ label: '状态', value: '无法读取认证信息' },
{ label: '错误', value: error.message }
]);
});

View File

@ -54,6 +54,15 @@ function toWebRequest(request) {
});
}
function resolveSafeNextTarget(value, fallback = '/') {
const candidate = String(value ?? '').trim();
if (!candidate || !candidate.startsWith('/') || candidate.startsWith('//')) {
return fallback;
}
return candidate;
}
async function parseJsonBody(request) {
if (!request.is('application/json')) {
throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'Request body must be application/json.');
@ -167,18 +176,44 @@ export async function createServerApp(overrides = {}) {
}
authService.clearSessionCookie(response, request);
response.redirect(302, authService.buildLoginRedirectUrl(request));
response.redirect(302, authService.buildAuthRedirectUrl(request, authentication));
} catch (error) {
next(error);
}
}
app.get('/login', (request, response) => {
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
response.redirect(302, `/login.html${nextTarget}`);
app.get('/login', async (request, response, next) => {
try {
const authentication = await authService.resolveRequestAuth(request);
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
if (authentication.authenticated) {
response.redirect(302, resolveSafeNextTarget(request.query.next, '/'));
return;
}
response.redirect(302, authentication.setupRequired ? `/setup.html${nextTarget}` : `/login.html${nextTarget}`);
} catch (error) {
next(error);
}
});
app.get('/setup', async (request, response, next) => {
try {
const authentication = await authService.resolveRequestAuth(request);
const nextTarget = request.query.next ? `?next=${encodeURIComponent(String(request.query.next))}` : '';
if (authentication.authenticated) {
response.redirect(302, resolveSafeNextTarget(request.query.next, '/'));
return;
}
response.redirect(302, authentication.setupRequired ? `/setup.html${nextTarget}` : `/login.html${nextTarget}`);
} catch (error) {
next(error);
}
});
app.get('/login.html', sendPage('login.html'));
app.get('/setup.html', sendPage('setup.html'));
app.get('/', requirePageAuth, sendPage('index.html'));
app.get('/index.html', requirePageAuth, sendPage('index.html'));
app.get('/source.html', requirePageAuth, sendPage('source.html'));

View File

@ -247,4 +247,17 @@ export class AuthService {
const nextTarget = encodeURIComponent(request.originalUrl || '/');
return `/login.html?next=${nextTarget}`;
}
buildSetupRedirectUrl(request) {
const nextTarget = encodeURIComponent(request.originalUrl || '/');
return `/setup.html?next=${nextTarget}`;
}
buildAuthRedirectUrl(request, resolution = null) {
if (resolution?.setupRequired) {
return this.buildSetupRedirectUrl(request);
}
return this.buildLoginRedirectUrl(request);
}
}