mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-21 13:20:13 +00:00
Compare commits
266 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4e95ef66 | |||
| 2a7879efaa | |||
| bd8e26d2ab | |||
| 783fcbbe4b | |||
| 9e892e85a2 | |||
| 3e5a80e498 | |||
| 89308fc8a6 | |||
| fe0bd80f43 | |||
| 0062fd6c48 | |||
| 7373eeb501 | |||
| 8b07cd4409 | |||
| 0fc7bd7985 | |||
| 58c029beba | |||
| ac79cbd8bd | |||
| 96fc3ae485 | |||
| cb4632cd04 | |||
| f7b5534cd0 | |||
| b50673f7d9 | |||
| 98e94e766f | |||
| a17ed646a0 | |||
| c2b920532d | |||
| fba2aa9746 | |||
| cbf1e86881 | |||
| 3d38424d77 | |||
| 5ff322d809 | |||
| facd0ea5f7 | |||
| 8bc43b8f0c | |||
| bb3fe41330 | |||
| 3204eeb9ab | |||
| 9280f6916e | |||
| 3f7ca52983 | |||
| 011fe15aae | |||
| 98a653efb6 | |||
| b5d58f1aa8 | |||
| 010cda972c | |||
| 911cec337e | |||
| 40fe9223ac | |||
| 3791f89a5c | |||
| 0ba85229a9 | |||
| b5f8ef28cc | |||
| c16f9881d3 | |||
| 99f5bc735e | |||
| 623ad1acda | |||
| 43ec591414 | |||
| 2ebd0b60f7 | |||
| 4de8643360 | |||
| 2f448964f2 | |||
| 9fcd700dc4 | |||
| 3cb2ef1015 | |||
| 557f4bfbbd | |||
| c42a52f889 | |||
| 3d33f78a0c | |||
| 4b8cad6d00 | |||
| fc2667501c | |||
| 9820c2ed44 | |||
| a4b45c1b59 | |||
| 171f3c5d71 | |||
| 588408ff96 | |||
| 722d3db0e9 | |||
| ca74e55979 | |||
| f0ace28bf2 | |||
| 1cef45e373 | |||
| 1fcfeb91d1 | |||
| f749bbf7fd | |||
| 5faf1bdee1 | |||
| 8755b64f56 | |||
| b1c6ec50da | |||
| 05f1b2f9a8 | |||
| 51d0e60cf1 | |||
| 33323439cd | |||
| cc522ec40f | |||
| 96b076b113 | |||
| 246a743822 | |||
| 73e90f7860 | |||
| 37cbb2f2c7 | |||
| b10e6032d4 | |||
| 0bb1baf768 | |||
| a994214e4a | |||
| 3eb517a92f | |||
| f51468b7b9 | |||
| ad764a9c5b | |||
| 94cb6177f2 | |||
| 9b26feb310 | |||
| 80d6315148 | |||
| f4d2e7932a | |||
| 7c64453c1a | |||
| 810edfe8a6 | |||
| d1aee25905 | |||
| 3b0ccf2a77 | |||
| cf815805e9 | |||
| bc5efbf2fd | |||
| 616d6273bb | |||
| 1285f6296e | |||
| cb137fe0c7 | |||
| 899f1004a3 | |||
| f0c57a7f9c | |||
| 54cf1ff718 | |||
| e0d53b4683 | |||
| c34c44ce5b | |||
| d48e6b6ce5 | |||
| 1062725b46 | |||
| 61dac98a12 | |||
| c8194a04c7 | |||
| 219f569969 | |||
| a372b99fc9 | |||
| f556782c86 | |||
| 68583821fe | |||
| ed678a070e | |||
| 0e1152a0b9 | |||
| 5fee320eee | |||
| eeb477b84c | |||
| 01f01e5903 | |||
| 206b0be566 | |||
| 5c2c6cfb6c | |||
| eec27f3a40 | |||
| ec57897a5f | |||
| d828f145db | |||
| 3f7af954c7 | |||
| e7d2c85de9 | |||
| 1b242b8404 | |||
| 49c71039a4 | |||
| 4cec39cfe2 | |||
| ca194da822 | |||
| e931307c8f | |||
| 23c78b3408 | |||
| 0fcdc61843 | |||
| 1aa29dda11 | |||
| be572746a3 | |||
| bf066fc68b | |||
| 40a3105b82 | |||
| 03b793b14a | |||
| 5f386c80c5 | |||
| 54466160af | |||
| 257928a317 | |||
| fdf266111b | |||
| 39ec5da861 | |||
| 5d636e4977 | |||
| 57aa7457ae | |||
| 773453b7cc | |||
| c54740517c | |||
| d054d76afe | |||
| dc7d80ddfc | |||
| dab0961a63 | |||
| 1e34a96c57 | |||
| e12ab2b334 | |||
| 380cd34474 | |||
| 7b5f6163cf | |||
| 56235cb94d | |||
| 55c5573544 | |||
| 49af3e7099 | |||
| 9db92d13ab | |||
| c39654ab3c | |||
| 12024203be | |||
| f5684145f9 | |||
| a2654dcde3 | |||
| 8c35d89519 | |||
| cb662b7d70 | |||
| 4d5f207ce7 | |||
| 1ac063909f | |||
| 3f62a03181 | |||
| 35dc239c25 | |||
| 7ace10e7cc | |||
| c99a558b5e | |||
| 8df3221078 | |||
| 819734ce5c | |||
| 36f398b728 | |||
| 7b4733d4c4 | |||
| 6ca1fa739f | |||
| af56236dba | |||
| 7193df7f11 | |||
| 3622c58680 | |||
| 0d36aa9139 | |||
| b5284e669a | |||
| d63755f67d | |||
| 4da5525a1a | |||
| 6dcc18e2e9 | |||
| 16a7bcace9 | |||
| f230e5c8c2 | |||
| f59e81de3a | |||
| 8ac2ab0699 | |||
| 227d43194d | |||
| f9030d5dbb | |||
| 3341a9ef74 | |||
| 41221998c9 | |||
| d0c97ee573 | |||
| fab6d9da67 | |||
| 01154947ef | |||
| 82131bd892 | |||
| ddf5901730 | |||
| 65b57b00e2 | |||
| 15eb72a4b3 | |||
| 30884d7184 | |||
| 1ab8e1baa7 | |||
| d3d4755505 | |||
| a0b9f970c1 | |||
| f20a71e8a8 | |||
| 7d5681665f | |||
| 1a94f8dd44 | |||
| 66f995d981 | |||
| 234e3a5e96 | |||
| d3b515fd99 | |||
| 68f66cf4e6 | |||
| 9061ab52b6 | |||
| 1d170baaaf | |||
| bacf27b936 | |||
| 1810e0aa7a | |||
| 3a650740a1 | |||
| 9b490016aa | |||
| 0db5f957c8 | |||
| 8481e2756e | |||
| b7dfd1b3ad | |||
| 9c1c5e2c26 | |||
| 15e0a29bb1 | |||
| 205ccdad8b | |||
| 389872d491 | |||
| d7c41edad4 | |||
| 5509492563 | |||
| 7c7d32de30 | |||
| 4831a0915c | |||
| 930f4f86cc | |||
| ceb4bef9e4 | |||
| c4c25efc50 | |||
| bda0cba1c6 | |||
| b10ce83ca0 | |||
| ee784d18db | |||
| ec9be40d6c | |||
| b21b031120 | |||
| 90da97c945 | |||
| 39fbdc7e0e | |||
| 9359ce2a2c | |||
| 026aea03dc | |||
| 6621738b02 | |||
| 431cc0d5d7 | |||
| 2226bdd9ef | |||
| f7a5966104 | |||
| 747cad35f5 | |||
| c44436a5fd | |||
| a3f074f38a | |||
| 8106364650 | |||
| 2934ebd36d | |||
| 177d34ba54 | |||
| 622a4ec506 | |||
| 3f8a6d78d5 | |||
| 269055867b | |||
| 363a029618 | |||
| 2b6852fb7f | |||
| e452dde3dc | |||
| 6b8ee28e54 | |||
| 2f7dbc78d3 | |||
| 1a22b108ca | |||
| 40549147bd | |||
| c0a390baa5 | |||
| 7cdccde684 | |||
| 9edaa647c4 | |||
| ba9710cdf0 | |||
| 69f4fde5a2 | |||
| 2a747c996d | |||
| e1f1c6f865 | |||
| 73db6c518b | |||
| 1d1cbd2c8e | |||
| 72ec21415b | |||
| 649f54f923 | |||
| beefe2227e | |||
| 326e13adf0 | |||
| 6e1a8e7b5c | |||
| c5d3052080 |
@@ -0,0 +1,467 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security Report Generator (Node.js)
|
||||||
|
* Better, faster, and more maintainable than Bash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class SecurityReport {
|
||||||
|
constructor() {
|
||||||
|
this.results = {
|
||||||
|
codeql: { status: 'PASS', findings: [], alertCount: 0, rulesCount: 0 },
|
||||||
|
snyk: { status: 'PASS', findings: [], vulnCount: 0 },
|
||||||
|
gitleaks: { status: 'PASS', findings: [], leaksCount: 0 },
|
||||||
|
trivy: { status: 'PASS', findings: [], misconfigCount: 0 },
|
||||||
|
coverage: { actions: 0, js: 0, ts: 0 },
|
||||||
|
artifactUris: []
|
||||||
|
};
|
||||||
|
this.auditTime = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
|
||||||
|
this.runId = process.env.GITHUB_RUN_ID || '0';
|
||||||
|
this.repository = process.env.GITHUB_REPOSITORY || 'unknown/repo';
|
||||||
|
this.runUrl = `https://github.com/${this.repository}/actions/runs/${this.runId}`;
|
||||||
|
|
||||||
|
this.locales = {
|
||||||
|
zh: {
|
||||||
|
filename: 'security-report-cn.md',
|
||||||
|
switcher: '[English](security-report.md) | 中文',
|
||||||
|
title: '🛡️ 安全审计与透明度报告',
|
||||||
|
grade: '安全评级',
|
||||||
|
important: '> [!IMPORTANT]\n> 本报告由 **GitHub Actions** 自动生成。为确保数据主权的绝对透明度,所有核心模块的安全扫描结果均实时公开。',
|
||||||
|
auditTime: '📅 审计时间',
|
||||||
|
runId: '📝 运行 ID',
|
||||||
|
env: '🛠️ 环境',
|
||||||
|
dashboard: '📉 实时安全仪表盘',
|
||||||
|
tool: '工具',
|
||||||
|
status: '状态',
|
||||||
|
findings: '发现项',
|
||||||
|
leaks: '泄露',
|
||||||
|
vulns: '漏洞',
|
||||||
|
alerts: '告警',
|
||||||
|
coverageTitle: '🔍 扫描覆盖范围',
|
||||||
|
module: '模块',
|
||||||
|
auditedFiles: '已审计文件',
|
||||||
|
coverage: '覆盖率',
|
||||||
|
detailedFindings: '🔍 详细发现项',
|
||||||
|
gitleaksTitle: '🔑 凭据泄露检查 (Gitleaks)',
|
||||||
|
gitleaksDesc: '`检测代码历史记录中硬编码的 API 密钥、密码或其他敏感令牌。`',
|
||||||
|
gitleaksSafe: '✅ **安全**:未发现硬编码的敏感凭据。',
|
||||||
|
gitleaksScope: '`扫描范围:所有代码更改和 Git 历史记录 (Gitleaks 全量扫描)`',
|
||||||
|
snykTitle: '📦 第三方依赖',
|
||||||
|
snykSafe: '✅ **安全**:在依赖项中未发现已知漏洞。',
|
||||||
|
package: '软件包',
|
||||||
|
severity: '严重程度',
|
||||||
|
description: '描述',
|
||||||
|
fixPlan: '修复方案',
|
||||||
|
codeqlTitle: '💻 代码质量与安全 (CodeQL)',
|
||||||
|
codeqlSummary: '#### 摘要',
|
||||||
|
rulesChecked: '已检查规则',
|
||||||
|
totalAlerts: '告警总数',
|
||||||
|
codeqlSafe: '✅ **安全**:CodeQL 扫描清洁,未检测到问题。',
|
||||||
|
ruleId: '规则 ID',
|
||||||
|
level: '级别',
|
||||||
|
location: '位置',
|
||||||
|
auditedList: '📂 已审计文件列表',
|
||||||
|
guideTitle: '⚠️ 操作指南',
|
||||||
|
guideDesc: '如果您看到 **FAIL** 状态或严重的代码问题:',
|
||||||
|
guideStep1: '1. **开发人员**:使用上方表格中的 **位置** 列找到确切的文件和行号。',
|
||||||
|
guideStep2: '2. **纠正**:遵循为每个规则提供的文档链接以提交修复。',
|
||||||
|
guideStep3: '3. **可追溯性**:完整的原始 `.sarif` 数据已附加到此分支。下载并将其导入您的 IDE(例如 VS Code SARIF 查看器)进行本地分析。',
|
||||||
|
footer: '💡 *由 Antigravity AI 安全引擎生成。透明度是我们的承诺。*',
|
||||||
|
auditedIcon: '✅ **已审计**',
|
||||||
|
noFiles: '未检索到文件。',
|
||||||
|
trivyTitle: '🛡️ 容器配置安全 (Trivy)',
|
||||||
|
trivyDesc: '`检测 Dockerfile 和容器配置中的安全风险与最佳实践。`',
|
||||||
|
trivySafe: '✅ **安全**:未发现容器配置缺陷。'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
filename: 'security-report.md',
|
||||||
|
switcher: 'English | [中文](security-report-cn.md)',
|
||||||
|
title: '🛡️ Security Audit & Transparency Report',
|
||||||
|
grade: 'Security Grade',
|
||||||
|
important: '> [!IMPORTANT]\n> This report is automatically generated by **GitHub Actions**. To ensure absolute transparency of data sovereignty, all core module security scan results are made public in real-time.',
|
||||||
|
auditTime: '📅 Audit Time',
|
||||||
|
runId: '📝 Run ID',
|
||||||
|
env: '🛠️ Environment',
|
||||||
|
dashboard: '📉 Real-time Security Dashboard',
|
||||||
|
tool: 'Tool',
|
||||||
|
status: 'Status',
|
||||||
|
findings: 'Findings',
|
||||||
|
leaks: 'Leaks',
|
||||||
|
vulns: 'Vulns',
|
||||||
|
alerts: 'Alerts',
|
||||||
|
coverageTitle: '🔍 Scan Coverage',
|
||||||
|
module: 'Module',
|
||||||
|
auditedFiles: 'Audited Files',
|
||||||
|
coverage: 'Coverage',
|
||||||
|
detailedFindings: '🔍 Detailed Findings',
|
||||||
|
gitleaksTitle: '🔑 Credential Leak Check (Gitleaks)',
|
||||||
|
gitleaksDesc: '`This section detects hardcoded API Keys, passwords, or other sensitive tokens in the code history.`',
|
||||||
|
gitleaksSafe: '✅ **SAFE**: No hardcoded sensitive credentials found.',
|
||||||
|
gitleaksScope: '`Scan Scope: All code changes and Git history (Gitleaks Full Scan)`',
|
||||||
|
snykTitle: '📦 Third-party Dependencies',
|
||||||
|
snykSafe: '✅ **SAFE**: No known vulnerabilities found in dependencies.',
|
||||||
|
package: 'Package',
|
||||||
|
severity: 'Severity',
|
||||||
|
description: 'Description',
|
||||||
|
fixPlan: 'Fix Plan',
|
||||||
|
codeqlTitle: '💻 Code Quality & Safety (CodeQL)',
|
||||||
|
codeqlSummary: '#### Summary',
|
||||||
|
rulesChecked: 'Rules Checked',
|
||||||
|
totalAlerts: 'Total Alerts',
|
||||||
|
codeqlSafe: '✅ **SAFE**: CodeQL clean. No issues detected.',
|
||||||
|
ruleId: 'Rule ID',
|
||||||
|
level: 'Level',
|
||||||
|
location: 'Location',
|
||||||
|
auditedList: '📂 Audited File List',
|
||||||
|
guideTitle: '⚠️ Action Guide',
|
||||||
|
guideDesc: 'If you see a **FAIL** status or serious code issues:',
|
||||||
|
guideStep1: '1. **Developers**: Use the **Location** column in the tables above to find the exact file and line number.',
|
||||||
|
guideStep2: '2. **Remediate**: Follow the documentation links provided for each rule to submit a fix.',
|
||||||
|
guideStep3: '3. **Traceability**: Full raw `.sarif` data is attached to this branch. Download and import it into your IDE (e.g., VS Code SARIF Viewer) for local analysis.',
|
||||||
|
footer: '💡 *Generated by Antigravity AI Security Engine. Transparency is our commitment.*',
|
||||||
|
auditedIcon: '✅ **Audited**',
|
||||||
|
noFiles: 'No files found.',
|
||||||
|
trivyTitle: '🛡️ Container Config Security (Trivy)',
|
||||||
|
trivyDesc: '`This section detects security risks and best practices in Dockerfile and container configurations.`',
|
||||||
|
trivySafe: '✅ **SAFE**: No container configuration defects found.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data Parsers ---
|
||||||
|
|
||||||
|
async parseCodeQL() {
|
||||||
|
const sarifPath = 'sarif-results';
|
||||||
|
if (!fs.existsSync(sarifPath)) return;
|
||||||
|
|
||||||
|
const files = this.globFiles(sarifPath, '.sarif');
|
||||||
|
let totalAlerts = 0;
|
||||||
|
let rulesSet = new Set();
|
||||||
|
let findings = [];
|
||||||
|
let artifactUris = new Set();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
for (const run of data.runs || []) {
|
||||||
|
// Collect Rules
|
||||||
|
(run.tool.driver.rules || []).forEach(r => rulesSet.add(r.id));
|
||||||
|
(run.tool.extensions || []).forEach(ext => {
|
||||||
|
(ext.rules || []).forEach(r => rulesSet.add(r.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect Results
|
||||||
|
for (const res of run.results || []) {
|
||||||
|
totalAlerts++;
|
||||||
|
const loc = (res.locations && res.locations[0]?.physicalLocation) || {};
|
||||||
|
findings.push({
|
||||||
|
id: res.ruleId,
|
||||||
|
level: res.level || 'warning',
|
||||||
|
path: loc.artifactLocation?.uri || 'Global',
|
||||||
|
line: loc.region?.startLine || '-',
|
||||||
|
message: res.message?.text || 'No description'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track Coverage (Deduplicated)
|
||||||
|
(run.artifacts || []).forEach(art => {
|
||||||
|
const uri = art.location?.uri || '';
|
||||||
|
if (uri) artifactUris.add(uri);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.artifactUris = Array.from(artifactUris).sort();
|
||||||
|
this.results.coverage.actions = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/')).length;
|
||||||
|
this.results.coverage.js = this.results.artifactUris.filter(u => u.endsWith('.js')).length;
|
||||||
|
this.results.coverage.ts = this.results.artifactUris.filter(u => u.endsWith('.ts')).length;
|
||||||
|
|
||||||
|
this.results.codeql.alertCount = totalAlerts;
|
||||||
|
this.results.codeql.rulesCount = rulesSet.size;
|
||||||
|
this.results.codeql.findings = findings;
|
||||||
|
if (totalAlerts > 0) this.results.codeql.status = 'INFO';
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseSnyk() {
|
||||||
|
const jsonPath = 'snyk_result.json';
|
||||||
|
if (!fs.existsSync(jsonPath)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
const projects = Array.isArray(data) ? data : [data];
|
||||||
|
let vulnTotal = 0;
|
||||||
|
let findings = [];
|
||||||
|
|
||||||
|
for (const proj of projects) {
|
||||||
|
const vulns = proj.vulnerabilities || [];
|
||||||
|
vulnTotal += vulns.length;
|
||||||
|
vulns.forEach(v => {
|
||||||
|
findings.push({
|
||||||
|
pkg: `${v.packageName}@${v.version}`,
|
||||||
|
severity: v.severity,
|
||||||
|
title: v.title,
|
||||||
|
url: v.url,
|
||||||
|
fixedIn: Array.isArray(v.fixedIn) ? v.fixedIn.join(', ') : (v.fixedIn || 'N/A')
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.snyk.vulnCount = vulnTotal;
|
||||||
|
this.results.snyk.findings = findings;
|
||||||
|
if (vulnTotal > 0) this.results.snyk.status = 'WARN';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing Snyk JSON:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseGitleaks() {
|
||||||
|
const files = this.globFiles('.', 'results.sarif');
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(files[0], 'utf8'));
|
||||||
|
let leaks = 0;
|
||||||
|
let findings = [];
|
||||||
|
for (const run of data.runs || []) {
|
||||||
|
for (const res of run.results || []) {
|
||||||
|
leaks++;
|
||||||
|
findings.push({
|
||||||
|
id: res.ruleId,
|
||||||
|
message: res.message.text,
|
||||||
|
path: res.locations[0]?.physicalLocation?.artifactLocation?.uri || 'Unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.results.gitleaks.leaksCount = leaks;
|
||||||
|
this.results.gitleaks.findings = findings;
|
||||||
|
if (leaks > 0) this.results.gitleaks.status = 'FAIL';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing Gitleaks SARIF:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async parseTrivy() {
|
||||||
|
const jsonPath = 'trivy_result.json';
|
||||||
|
if (!fs.existsSync(jsonPath)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
let misconfigs = 0;
|
||||||
|
let findings = [];
|
||||||
|
|
||||||
|
(data.Results || []).forEach(res => {
|
||||||
|
(res.Misconfigurations || []).forEach(m => {
|
||||||
|
misconfigs++;
|
||||||
|
findings.push({
|
||||||
|
id: m.ID,
|
||||||
|
severity: m.Severity,
|
||||||
|
title: m.Title,
|
||||||
|
message: m.Message,
|
||||||
|
status: m.Status,
|
||||||
|
target: res.Target
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.results.trivy.misconfigCount = misconfigs;
|
||||||
|
this.results.trivy.findings = findings;
|
||||||
|
if (misconfigs > 0) this.results.trivy.status = 'WARN';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing Trivy JSON:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateTable(type, t) {
|
||||||
|
let files = [];
|
||||||
|
if (type === 'actions') files = this.results.artifactUris.filter(u => u.startsWith('.github/workflows/'));
|
||||||
|
else if (type === 'js') files = this.results.artifactUris.filter(u => u.endsWith('.js'));
|
||||||
|
else if (type === 'ts') files = this.results.artifactUris.filter(u => u.endsWith('.ts'));
|
||||||
|
|
||||||
|
if (files.length === 0) return `> ${t.noFiles}\n`;
|
||||||
|
|
||||||
|
let table = `| ${t.module} | ${t.location} | ${t.status} |\n| :--- | :--- | :--- |\n`;
|
||||||
|
files.forEach(f => {
|
||||||
|
const filename = path.basename(f);
|
||||||
|
table += `| \`${filename}\` | \`${f}\` | ${t.auditedIcon} |\n`;
|
||||||
|
});
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Renderers ---
|
||||||
|
|
||||||
|
generateMarkdown(localeKey) {
|
||||||
|
const { codeql, snyk, gitleaks, coverage } = this.results;
|
||||||
|
const t = this.locales[localeKey];
|
||||||
|
|
||||||
|
// Calculate Grade
|
||||||
|
let grade = 'A+';
|
||||||
|
let gradeColor = 'success';
|
||||||
|
if (gitleaks.status === 'FAIL') { grade = 'D'; gradeColor = 'red'; }
|
||||||
|
else if (snyk.vulnCount > 10 || this.results.trivy.misconfigCount > 5) { grade = 'C'; gradeColor = 'orange'; }
|
||||||
|
else if (snyk.vulnCount > 0 || codeql.alertCount > 0 || this.results.trivy.misconfigCount > 0) { grade = 'B'; gradeColor = 'blue'; }
|
||||||
|
|
||||||
|
const badge = (label, value, color) => `}-${value}-${color}?style=for-the-badge)`;
|
||||||
|
|
||||||
|
let md = `# ${t.title}\n\n`;
|
||||||
|
md += `${t.switcher}\n\n`;
|
||||||
|
md += `${badge(t.grade.replace(/ /g, '_'), grade, gradeColor)}\n\n`;
|
||||||
|
md += `${t.important}\n\n`;
|
||||||
|
|
||||||
|
md += `| ${t.auditTime} | ${t.runId} | ${t.env} |\n`;
|
||||||
|
md += `| :--- | :--- | :--- |\n`;
|
||||||
|
md += `| \`${this.auditTime}\` | [#${this.runId}](${this.runUrl}) | \`GitHub CI/CD\` |\n\n`;
|
||||||
|
|
||||||
|
md += `---\n\n## ${t.dashboard}\n\n`;
|
||||||
|
md += `| ${t.tool} | ${t.status} | ${t.findings} |\n`;
|
||||||
|
md += `| :--- | :--- | :--- |\n`;
|
||||||
|
md += `| **Credential Leak (Gitleaks)** | ${this.getBadge(gitleaks.status)} | \`${gitleaks.leaksCount}\` ${t.leaks} |\n`;
|
||||||
|
md += `| **Dependency Scan (Snyk)** | ${this.getBadge(snyk.status)} | \`${snyk.vulnCount}\` ${t.vulns} |\n`;
|
||||||
|
md += `| **Static Analysis (CodeQL)** | ${this.getBadge(codeql.status)} | \`${codeql.alertCount}\` ${t.alerts} |\n`;
|
||||||
|
md += `| **Container Scan (Trivy)** | ${this.getBadge(this.results.trivy.status)} | \`${this.results.trivy.misconfigCount}\` ${t.findings} |\n\n`;
|
||||||
|
|
||||||
|
md += `---\n\n## ${t.coverageTitle}\n\n`;
|
||||||
|
md += `| ${t.module} | ${t.auditedFiles} | ${t.coverage} |\n`;
|
||||||
|
md += `| :--- | :---: | :---: |\n`;
|
||||||
|
md += `| **GitHub Actions** | \`${coverage.actions}\` | ✨ **100%** |\n`;
|
||||||
|
md += `| **JavaScript (Frontend)** | \`${coverage.js}\` | ✨ **100%** |\n`;
|
||||||
|
md += `| **TypeScript (Backend)** | \`${coverage.ts}\` | ✨ **100%** |\n\n`;
|
||||||
|
|
||||||
|
md += `---\n\n## ${t.detailedFindings}\n\n`;
|
||||||
|
|
||||||
|
// Gitleaks Section
|
||||||
|
md += `### ${t.gitleaksTitle}\n`;
|
||||||
|
md += `${t.gitleaksDesc} ${t.gitleaksScope}\n\n`;
|
||||||
|
if (gitleaks.findings.length > 0) {
|
||||||
|
md += `| ${t.ruleId} | ${t.location} | ${t.description} |\n`;
|
||||||
|
md += `| :--- | :--- | :--- |\n`;
|
||||||
|
gitleaks.findings.forEach(f => {
|
||||||
|
md += `| \`${f.id}\` | \`${f.path}\` | ${f.message} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.gitleaksSafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trivy Section
|
||||||
|
md += `\n### ${t.trivyTitle}\n`;
|
||||||
|
md += `${t.trivyDesc}\n\n`;
|
||||||
|
if (this.results.trivy.findings.length > 0) {
|
||||||
|
md += `| ${t.ruleId} | ${t.severity} | ${t.location} | ${t.description} |\n`;
|
||||||
|
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||||
|
this.results.trivy.findings.forEach(f => {
|
||||||
|
const icon = f.severity === 'CRITICAL' ? '🔴' : (f.severity === 'HIGH' ? '🟠' : '🟡');
|
||||||
|
md += `| \`${f.id}\` | ${icon} ${f.severity} | \`${f.target}\` | ${f.title}: ${f.message} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.trivySafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snyk Section
|
||||||
|
md += `\n### ${t.snykTitle}\n`;
|
||||||
|
if (snyk.findings.length > 0) {
|
||||||
|
md += `| ${t.package} | ${t.severity} | ${t.description} | ${t.fixPlan} |\n`;
|
||||||
|
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||||
|
snyk.findings.forEach(f => {
|
||||||
|
const icon = f.severity === 'critical' ? '🔴' : (f.severity === 'high' ? '🟠' : '🟡');
|
||||||
|
md += `| \`${f.pkg}\` | ${icon} ${f.severity} | [${f.title}](${f.url}) | ${f.fixedIn === 'N/A' ? 'No fix' : `Upgrade to \`${f.fixedIn}\``} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.snykSafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeQL Section
|
||||||
|
md += `\n### ${t.codeqlTitle}\n`;
|
||||||
|
if (codeql.findings.length > 0) {
|
||||||
|
md += `${t.codeqlSummary}\n- **${t.rulesChecked}**: \`${codeql.rulesCount}\`\n- **${t.totalAlerts}**: \`${codeql.alertCount}\`\n\n`;
|
||||||
|
md += `| ${t.ruleId} | ${t.level} | ${t.location} | ${t.description} |\n`;
|
||||||
|
md += `| :--- | :---: | :--- | :--- |\n`;
|
||||||
|
codeql.findings.forEach(f => {
|
||||||
|
const icon = f.level === 'error' ? '🔴' : (f.level === 'warning' ? '🟠' : '🔵');
|
||||||
|
const prefix = f.id.split('/')[0];
|
||||||
|
const langMap = {
|
||||||
|
'js': 'javascript',
|
||||||
|
'actions': 'github-actions',
|
||||||
|
'cpp': 'cpp',
|
||||||
|
'cs': 'csharp',
|
||||||
|
'go': 'go',
|
||||||
|
'java': 'java',
|
||||||
|
'py': 'python',
|
||||||
|
'rb': 'ruby',
|
||||||
|
'swift': 'swift'
|
||||||
|
};
|
||||||
|
const langPath = langMap[prefix] || 'javascript';
|
||||||
|
md += `| [${f.id}](https://codeql.github.com/codeql-query-help/${langPath}/${f.id.replace(/\//g, '-')}/) | ${icon} ${f.level} | \`${f.path}:${f.line}\` | ${f.message} |\n`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += `${t.codeqlSafe}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audited Files List
|
||||||
|
md += `\n### ${t.auditedList}\n`;
|
||||||
|
md += `<details>\n<summary><b>GitHub Actions (${this.results.coverage.actions})</b></summary>\n\n`;
|
||||||
|
md += this.generateTable('actions', t);
|
||||||
|
md += `\n</details>\n\n`;
|
||||||
|
|
||||||
|
md += `<details>\n<summary><b>JavaScript (${this.results.coverage.js})</b></summary>\n\n`;
|
||||||
|
md += this.generateTable('js', t);
|
||||||
|
md += `\n</details>\n\n`;
|
||||||
|
|
||||||
|
md += `<details>\n<summary><b>TypeScript (${this.results.coverage.ts})</b></summary>\n\n`;
|
||||||
|
md += this.generateTable('ts', t);
|
||||||
|
md += `\n</details>\n\n`;
|
||||||
|
|
||||||
|
// Action Guide
|
||||||
|
md += `--- \n\n## ${t.guideTitle}\n\n`;
|
||||||
|
md += `${t.guideDesc}\n`;
|
||||||
|
md += `${t.guideStep1}\n`;
|
||||||
|
md += `${t.guideStep2}\n`;
|
||||||
|
md += `${t.guideStep3}\n\n`;
|
||||||
|
|
||||||
|
md += `--- \n\n${t.footer}`;
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
getBadge(status) {
|
||||||
|
if (status === 'PASS') return '';
|
||||||
|
if (status === 'WARN' || status === 'INFO') return '';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
globFiles(dir, ext) {
|
||||||
|
let results = [];
|
||||||
|
const list = fs.readdirSync(dir);
|
||||||
|
for (const file of list) {
|
||||||
|
const fullPath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat && stat.isDirectory()) {
|
||||||
|
results = results.concat(this.globFiles(fullPath, ext));
|
||||||
|
} else if (file.endsWith(ext)) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
console.log('--- Security Report Generation Started ---');
|
||||||
|
await this.parseCodeQL();
|
||||||
|
await this.parseSnyk();
|
||||||
|
await this.parseGitleaks();
|
||||||
|
await this.parseTrivy();
|
||||||
|
|
||||||
|
for (const localeKey of Object.keys(this.locales)) {
|
||||||
|
const locale = this.locales[localeKey];
|
||||||
|
const markdown = this.generateMarkdown(localeKey);
|
||||||
|
fs.writeFileSync(locale.filename, markdown);
|
||||||
|
console.log(`Report generated successfully at ${locale.filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new SecurityReport().run().catch(err => {
|
||||||
|
console.error('Report generation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
name: Security Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
scan:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
actions: read
|
||||||
|
env:
|
||||||
|
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: github/codeql-action/init@v4
|
||||||
|
with:
|
||||||
|
languages: javascript-typescript, actions
|
||||||
|
build-mode: none
|
||||||
|
queries: security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
uses: github/codeql-action/analyze@v4
|
||||||
|
with:
|
||||||
|
upload: true
|
||||||
|
output: sarif-results
|
||||||
|
|
||||||
|
- name: Install Gitleaks
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
GITLEAKS_VERSION="8.28.0"
|
||||||
|
curl -sSL -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
|
||||||
|
tar -xzf gitleaks.tar.gz gitleaks
|
||||||
|
chmod +x gitleaks
|
||||||
|
sudo mv gitleaks /usr/local/bin/gitleaks
|
||||||
|
|
||||||
|
- name: Secret Detection
|
||||||
|
if: env.ACT != 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
gitleaks git . --report-format sarif --report-path results.sarif --no-banner || true
|
||||||
|
|
||||||
|
- name: Install Project Dependencies
|
||||||
|
if: env.SECURITY_SNYK_TOKEN != ''
|
||||||
|
env:
|
||||||
|
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
|
||||||
|
run: |
|
||||||
|
echo "Preparing dependency lock files for security scanning..."
|
||||||
|
if [ -z "$SECURITY_PACKAGE" ]; then
|
||||||
|
echo "SECURITY_PACKAGE is empty, installing in root..."
|
||||||
|
npm install --package-lock-only
|
||||||
|
else
|
||||||
|
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
|
||||||
|
# Split by comma and install
|
||||||
|
IFS=',' read -ra PACKAGES <<< "$SECURITY_PACKAGE"
|
||||||
|
for pkg in "${PACKAGES[@]}"; do
|
||||||
|
if [ -d "$pkg" ]; then
|
||||||
|
echo "Installing in "$pkg"..."
|
||||||
|
npm install --prefix "$pkg" --package-lock-only
|
||||||
|
else
|
||||||
|
echo "Warning: Directory $pkg not found, skipping."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Dependency Scan
|
||||||
|
id: snyk
|
||||||
|
if: env.SECURITY_SNYK_TOKEN != ''
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
npm install -g snyk
|
||||||
|
snyk auth ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||||
|
snyk test --all-projects --json-file-output=snyk_result.json > snyk_result.txt || true
|
||||||
|
env:
|
||||||
|
SECURITY_SNYK_TOKEN: ${{ secrets.SECURITY_SNYK_TOKEN }}
|
||||||
|
|
||||||
|
- name: Check for Dockerfile
|
||||||
|
id: check_docker
|
||||||
|
run: |
|
||||||
|
if [ -f "Dockerfile" ]; then
|
||||||
|
echo "exists=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "exists=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Container Security Scan (Trivy)
|
||||||
|
if: steps.check_docker.outputs.exists == 'true'
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
VERSION="0.56.1"
|
||||||
|
echo "Installing Trivy $VERSION..."
|
||||||
|
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin "v$VERSION"
|
||||||
|
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
|
||||||
|
|
||||||
|
- name: Generate Security Report
|
||||||
|
run: |
|
||||||
|
# Gitleaks typically produces results.sarif if configured or by default in some versions
|
||||||
|
# We'll ensure it exists for our reporter
|
||||||
|
node .github/scripts/security.cjs
|
||||||
|
|
||||||
|
# Also append to step summary for immediate visibility in GHA UI
|
||||||
|
cat security-report.md >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo -e "\n---\n" >> $GITHUB_STEP_SUMMARY
|
||||||
|
cat security-report-cn.md >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Upload Gitleaks Results to GitHub Security
|
||||||
|
uses: github/codeql-action/upload-sarif@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
|
category: gitleaks
|
||||||
|
|
||||||
|
- name: Upload Security Report Artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v6
|
||||||
|
with:
|
||||||
|
name: security-report
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: |
|
||||||
|
security-report.md
|
||||||
|
security-report-cn.md
|
||||||
|
snyk_result.txt
|
||||||
|
snyk_result.json
|
||||||
|
trivy_result.json
|
||||||
|
results.sarif
|
||||||
|
sarif-results/*.sarif
|
||||||
@@ -13,16 +13,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- run: |
|
- name: Configure git
|
||||||
git remote add upstream https://github.com/shuaiplus/nodewarden.git || true
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Sync main from upstream
|
||||||
|
run: |
|
||||||
|
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
|
||||||
git fetch upstream
|
git fetch upstream
|
||||||
|
git checkout main
|
||||||
|
git merge upstream/main
|
||||||
|
|
||||||
# 强制让当前分支完全等于 upstream
|
- name: Push synced main
|
||||||
git reset --hard upstream/main
|
run: |
|
||||||
|
git push origin main
|
||||||
# 强制推送
|
|
||||||
git push origin main --force
|
|
||||||
|
|||||||
+2
-1
@@ -38,4 +38,5 @@ npm-debug.log*
|
|||||||
# Package lock (optional - remove if you want to commit it)
|
# Package lock (optional - remove if you want to commit it)
|
||||||
# package-lock.json
|
# package-lock.json
|
||||||
|
|
||||||
tmp/
|
tmp/
|
||||||
|
.tmp/
|
||||||
|
|||||||
@@ -3,120 +3,128 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
运行在 Cloudflare Workers 的 Bitwarden 第三方服务端,兼容官方客户
|
运行在 Cloudflare Workers 上的第三方 Bitwarden 兼容服务端。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
[](https://workers.cloudflare.com/)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
|
|
||||||
[更新日志](./RELEASE_NOTES.md) • [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) • [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[更新日志](./RELEASE_NOTES.md) | [提交问题](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [最新发布](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
|
||||||
English:[`README_EN.md`](./README_EN.md)
|
|
||||||
|
|
||||||
|
English: [`README_EN.md`](./README_EN.md)
|
||||||
|
|
||||||
> **免责声明**
|
> **免责声明**
|
||||||
> 本项目仅供学习交流使用。我们不对任何数据丢失负责,强烈建议定期备份您的密码库。
|
> 本项目仅供学习与交流使用,请定期备份你的密码库。
|
||||||
> 本项目与 Bitwarden 官方无关,请勿向 Bitwarden 官方反馈问题。
|
> 本项目与 Bitwarden 官方无关,请不要向 Bitwarden 官方反馈 NodeWarden 的问题。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与 Bitwarden 官方服务端能力对比
|
## 与 Bitwarden 官方服务端能力对比
|
||||||
|
|
||||||
| 能力项 | Bitwarden | NodeWarden | 说明 |
|
| 能力 | Bitwarden | NodeWarden | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault(登录/笔记/卡片/身份) | ✅ | ✅ | 网页端密码库管理页面 |
|
| 网页密码库 | ✅ | ✅ | **原创Web Vault界面** |
|
||||||
| 文件夹 / 收藏 | ✅ | ✅ | 常用管理能力可用 |
|
| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 |
|
||||||
| 全量同步 `/api/sync` | ✅ | ✅ | 已做兼容与性能优化 |
|
| 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV |
|
||||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
| Send | ✅ | ✅ | 支持文本与文件 Send |
|
||||||
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
| 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** |
|
||||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
| **云端备份中心** | ❌ | ✅ | **支持 WebDAV / E3 定时备份** |
|
||||||
| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
| 密码提示(网页端) | ⚠️ 有限 | ✅ | **无需发送邮件** |
|
||||||
| Send | ✅ | ✅ | 已支持文本 Send 与文件 Send |
|
| TOTP / Steam TOTP | ✅ | ✅ | 含 `steam://` 支持 |
|
||||||
| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 |
|
| 多用户 | ✅ | ✅ | 支持邀请码注册 |
|
||||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
| 组织 / 集合 / 成员权限 | ✅ | ❌ | 未实现 |
|
||||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) |
|
| 登录 2FA | ✅ | ⚠️ 部分支持 | 当前仅支持用户级 TOTP |
|
||||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
| SSO / SCIM / 企业目录 | ✅ | ❌ | 未实现 |
|
||||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
|
||||||
| 管理后台 / 计费订阅 | ✅ | ❌ | 纯免费 |
|
|
||||||
| 推送通知完整链路 | ✅ | ❌ | 没必要实现 |
|
|
||||||
|
|
||||||
## 测试情况:
|
|
||||||
|
|
||||||
- ✅ Windows 客户端(v2026.1.0)
|
|
||||||
- ✅ 手机 App(v2026.1.0)
|
|
||||||
- ✅ 浏览器扩展(v2026.1.0)
|
|
||||||
- ✅ Linux 客户端(v2026.1.0)
|
|
||||||
- ⬜ macOS 客户端(未测试)
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 快速开始
|
## 已测试客户端
|
||||||
|
|
||||||
### 一键部署
|
- ✅ Windows 桌面端
|
||||||
|
- ✅ 手机 App
|
||||||
|
- ✅ 浏览器扩展
|
||||||
|
- ✅ Linux 桌面端
|
||||||
|
- ⚠️ macOS 桌面端尚未完整验证
|
||||||
|
|
||||||
**部署步骤:**
|
---
|
||||||
|
|
||||||
1. 首先Fork本仓库,命名为**NodeWarden**
|
## 网页部署
|
||||||
2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串
|
|
||||||
3. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接
|
|
||||||
5. 同一位置,**Git存储库**链接至第一步Fork的仓库
|
|
||||||
|
|
||||||
**同步上游(更新):**
|
|
||||||
- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。
|
|
||||||
- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游
|
|
||||||
|
|
||||||
### CLI 部署
|
1. Fork 本仓库。若本项目对你有帮助,欢迎点个 Star。
|
||||||
|
2. 打开 [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) ➜ `Continue with GitHub` ➜ 选择你 Fork 后的仓库(`NodeWarden`)➜ 下一步 ➜ (默认使用 R2 存储;若未开通,可用 KV 来代替,将**部署命令**改为 `npm run deploy:kv`)➜ 部署 ➜ 打开生成的链接
|
||||||
|
|
||||||
|
| 储存 | 是否需绑卡 | 单个附件/Send文件上限 | 免费额度 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R2 | 需要 | 100 MB(软限制可更改) | 10 GB |
|
||||||
|
| KV | 不需要 | 25 MiB(Cloudflare限制) | 1 GB |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 同步方法(更新仓库):
|
||||||
|
>- 手动:打开你 Fork 的 GitHub 仓库,看到顶部同步提示后,点击 `Sync fork` ➜ `Update branch`
|
||||||
|
>- 自动:进入你的 Fork 仓库 ➜ `Actions` ➜ `Sync upstream` ➜ `Enable workflow`,会在每天凌晨 3 点自动同步上游。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## CLI 部署
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 先把仓库拉到本地
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Cloudflare CLI 登录
|
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# 创建云资源(D1 + R2)
|
# 默认:R2 模式
|
||||||
npx wrangler d1 create nodewarden-db
|
npm run deploy
|
||||||
npx wrangler r2 bucket create nodewarden-attachments
|
|
||||||
|
|
||||||
# 部署
|
# 可选:KV 模式
|
||||||
npm run deploy
|
npm run deploy:kv
|
||||||
|
|
||||||
# 需更新时重新拉取仓库,重新部署即可,无需创建云资源
|
# 本地开发
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 本地开发
|
|
||||||
|
|
||||||
这是一个 Cloudflare Workers 的 TypeScript 项目(Wrangler)。
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
---
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**Q: 如何备份数据?**
|
|
||||||
A: 在客户端中选择「导出密码库」,保存 JSON 文件。
|
|
||||||
|
|
||||||
**Q: 忘记主密码怎么办?**
|
|
||||||
A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。
|
|
||||||
|
|
||||||
**Q: 可以多人使用吗?**
|
|
||||||
A: 支持。第一个注册的用户自动成为管理员,管理员可在管理页面生成邀请码,其他用户凭邀请码注册。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 云端备份说明
|
||||||
|
|
||||||
|
- 远程备份支持 **WebDAV** 与 **E3**
|
||||||
|
- 勾选“包含附件”后:
|
||||||
|
- ZIP 内仍只包含 `db.json` 与 `manifest.json`
|
||||||
|
- 真实附件单独存放在 `attachments/`
|
||||||
|
- 后续备份会按稳定 blob 名复用已有附件,不会每次全量重传
|
||||||
|
- 远程还原时:
|
||||||
|
- 会从 `attachments/` 目录按需读取附件
|
||||||
|
- 缺失的附件会被安全跳过
|
||||||
|
- 被跳过的附件不会在恢复后的数据库中留下脏记录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 导入 / 导出
|
||||||
|
|
||||||
|
当前支持的导入来源包括:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden CSV
|
||||||
|
- Bitwarden 密码库 + 附件 ZIP
|
||||||
|
- NodeWarden JSON
|
||||||
|
- 网页导入器里可见的多种浏览器 / 密码管理器格式
|
||||||
|
|
||||||
|
当前支持的导出方式包括:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden 加密 JSON
|
||||||
|
- 带附件的 ZIP 导出
|
||||||
|
- NodeWarden JSON 系列
|
||||||
|
- 备份中心中的实例级完整手动导出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
## 开源协议
|
## 开源协议
|
||||||
|
|
||||||
LGPL-3.0 License
|
LGPL-3.0 License
|
||||||
@@ -125,10 +133,12 @@ LGPL-3.0 License
|
|||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
- [Bitwarden](https://bitwarden.com/) - 原始设计和客户端
|
- [Bitwarden](https://bitwarden.com/) - 原始设计与客户端
|
||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务器实现参考
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - 服务端实现参考
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - 无服务器平台
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
|
|||||||
+77
-75
@@ -3,119 +3,122 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
A third-party Bitwarden server running on Cloudflare Workers, fully compatible with official clients.
|
A third-party Bitwarden-compatible server running on Cloudflare Workers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://workers.cloudflare.com/)
|
[](https://workers.cloudflare.com/)
|
||||||
[](./LICENSE)
|
[](./LICENSE)
|
||||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/NodeWarden)
|
|
||||||
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
[](https://github.com/shuaiplus/NodeWarden/actions/workflows/sync-upstream.yml)
|
||||||
|
|
||||||
[Release Notes](./RELEASE_NOTES.md) • [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) • [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
[Release Notes](./RELEASE_NOTES.md) | [Report an Issue](https://github.com/shuaiplus/NodeWarden/issues/new/choose) | [Latest Release](https://github.com/shuaiplus/NodeWarden/releases/latest)
|
||||||
|
|
||||||
中文文档:[`README.md`](./README.md)
|
English: [`README.md`](./README.md)
|
||||||
|
|
||||||
> **Disclaimer**
|
> **Disclaimer**
|
||||||
> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended.
|
> This project is for learning and communication purposes only. Please back up your vault regularly.
|
||||||
> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team.
|
> This project is not affiliated with Bitwarden. Please do not report NodeWarden issues to the official Bitwarden team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Comparison Table (vs Official Bitwarden Server)
|
## Feature Comparison with Official Bitwarden Server
|
||||||
|
|
||||||
| Capability | Bitwarden | NodeWarden | Notes |
|
| Capability | Bitwarden | NodeWarden | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Web Vault (logins/notes/cards/identities) | ✅ | ✅ | Web-based vault management UI |
|
| Web Vault | ✅ | ✅ | **Original Web Vault interface** |
|
||||||
| Folders / Favorites | ✅ | ✅ | Common vault organization supported |
|
| Full sync `/api/sync` | ✅ | ✅ | Optimized for official clients |
|
||||||
| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized |
|
| Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV |
|
||||||
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
| Send | ✅ | ✅ | Supports both text and file Sends |
|
||||||
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
| Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** |
|
||||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
| **Cloud Backup Center** | ❌ | ✅ | **Supports scheduled backups with WebDAV / E3** |
|
||||||
| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
| Password hint (web) | ⚠️ Limited | ✅ | **No email required** |
|
||||||
| Multi-user | ✅ | ✅ | Full user management with invitation mechanism |
|
| TOTP / Steam TOTP | ✅ | ✅ | Includes `steam://` support |
|
||||||
| Send | ✅ | ✅ | Text Send and File Send are supported |
|
| Multi-user | ✅ | ✅ | Invite-based registration |
|
||||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
| Organizations / Collections / Member roles | ✅ | ❌ | Not implemented |
|
||||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
|
| Login 2FA | ✅ | ⚠️ Partial | Currently only user-level TOTP |
|
||||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not implemented |
|
||||||
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
|
||||||
| Admin console / Billing & subscription | ✅ | ❌ | Free only |
|
|
||||||
| Full push notification pipeline | ✅ | ❌ | Not necessary to implement |
|
|
||||||
|
|
||||||
|
|
||||||
## Tested clients / platforms
|
|
||||||
|
|
||||||
- ✅ Windows desktop client (v2026.1.0)
|
|
||||||
- ✅ Mobile app (v2026.1.0)
|
|
||||||
- ✅ Browser extension (v2026.1.0)
|
|
||||||
- ✅ Linux desktop client (v2026.1.0)
|
|
||||||
- ⬜ macOS desktop client (not tested)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Quick start
|
## Tested Clients
|
||||||
|
|
||||||
### One-click deploy
|
- ✅ Windows desktop client
|
||||||
|
- ✅ Mobile app
|
||||||
|
- ✅ Browser extension
|
||||||
|
- ✅ Linux desktop client
|
||||||
|
- ⚠️ macOS desktop client not fully verified
|
||||||
|
|
||||||
**Deploy steps:**
|
---
|
||||||
|
|
||||||
1. Fork this repository and name it **NodeWarden**.
|
## Web Deploy
|
||||||
2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string.
|
|
||||||
3. [](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden)
|
|
||||||
4. After deployment, open the Workers settings on the same page and disconnect the **Git repository**.
|
|
||||||
5. From the same location, reconnect the **Git repository** to the fork you created in step 1.
|
|
||||||
|
|
||||||
**Sync upstream (update):**
|
1. Fork this repository. If this project helps you, please consider giving it a Star.
|
||||||
- Manual: Open your forked repository on GitHub and click **Sync fork** when the sync prompt appears at the top.
|
2. Open [Workers](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) -> `Continue with GitHub` -> select your forked repository (`NodeWarden`) -> `Next` -> deploy.
|
||||||
- Automatic: Go to your fork → Actions, click "I understand my workflows, go ahead and enable them". The repository will auto-sync with upstream every day at 3 AM.
|
R2 is used by default. If R2 is unavailable for your account, you can use KV instead by changing the **deploy command** to `npm run deploy:kv`.
|
||||||
|
|
||||||
### CLI deploy
|
| Storage | Card required | Single attachment / Send file limit | Free tier |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R2 | Yes | 100 MB (soft limit, can be adjusted) | 10 GB |
|
||||||
|
| KV | No | 25 MiB (Cloudflare limit) | 1 GB |
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> How to keep your fork updated:
|
||||||
|
> - Manual: open your fork on GitHub, click `Sync fork`, then `Update branch`
|
||||||
|
> - Automatic: go to your fork -> `Actions` -> `Sync upstream` -> `Enable workflow`; it will sync upstream automatically every day at 3 AM
|
||||||
|
|
||||||
|
## CLI Deploy
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
git clone https://github.com/shuaiplus/NodeWarden.git
|
||||||
cd NodeWarden
|
cd NodeWarden
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Cloudflare CLI login
|
|
||||||
npx wrangler login
|
npx wrangler login
|
||||||
|
|
||||||
# Create cloud resources (D1 + R2)
|
# Default: R2 mode
|
||||||
npx wrangler d1 create nodewarden-db
|
npm run deploy
|
||||||
npx wrangler r2 bucket create nodewarden-attachments
|
|
||||||
|
|
||||||
# Deploy
|
# Optional: KV mode
|
||||||
npm run deploy
|
npm run deploy:kv
|
||||||
|
|
||||||
# To update later: re-clone and re-deploy — no need to recreate cloud resources
|
# Local development
|
||||||
git clone https://github.com/shuaiplus/NodeWarden.git
|
|
||||||
cd NodeWarden
|
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
## Local development
|
|
||||||
|
|
||||||
This repo is a Cloudflare Workers TypeScript project (Wrangler).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
npm run dev:kv
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ
|
## Cloud Backup Notes
|
||||||
|
|
||||||
**Q: How do I back up my data?**
|
- Remote backup supports **WebDAV** and **E3**
|
||||||
A: Use **Export vault** in your client and save the JSON file.
|
- When `Include attachments` is enabled:
|
||||||
|
- the ZIP still contains only `db.json` and `manifest.json`
|
||||||
|
- real attachment files are stored separately under `attachments/`
|
||||||
|
- later backups reuse existing attachments by stable blob name instead of uploading everything again
|
||||||
|
- During remote restore:
|
||||||
|
- required attachment files are loaded from `attachments/`
|
||||||
|
- missing attachments are skipped safely
|
||||||
|
- skipped attachments do not leave broken rows in the restored database
|
||||||
|
|
||||||
**Q: What if I forget the master password?**
|
---
|
||||||
A: It can’t be recovered (end-to-end encryption). Keep it safe.
|
|
||||||
|
|
||||||
**Q: Can multiple people use it?**
|
## Import / Export
|
||||||
A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes.
|
|
||||||
|
Current supported import sources include:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden CSV
|
||||||
|
- Bitwarden vault + attachments ZIP
|
||||||
|
- NodeWarden JSON
|
||||||
|
- Multiple browser / password-manager formats visible in the web import selector
|
||||||
|
|
||||||
|
Current supported export formats include:
|
||||||
|
|
||||||
|
- Bitwarden JSON
|
||||||
|
- Bitwarden encrypted JSON
|
||||||
|
- ZIP export with attachments
|
||||||
|
- NodeWarden JSON variants
|
||||||
|
- Full manual instance export from the backup center
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -131,9 +134,8 @@ LGPL-3.0 License
|
|||||||
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
- [Vaultwarden](https://github.com/dani-garcia/vaultwarden) - server implementation reference
|
||||||
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
- [Cloudflare Workers](https://workers.cloudflare.com/) - serverless platform
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
[](https://www.star-history.com/#shuaiplus/NodeWarden&type=timeline&legend=top-left)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
name TEXT,
|
name TEXT,
|
||||||
|
master_password_hint TEXT,
|
||||||
master_password_hash TEXT NOT NULL,
|
master_password_hash TEXT NOT NULL,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
private_key TEXT,
|
private_key TEXT,
|
||||||
@@ -24,6 +25,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
security_stamp TEXT NOT NULL,
|
security_stamp TEXT NOT NULL,
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
verify_devices INTEGER NOT NULL DEFAULT 1,
|
||||||
totp_secret TEXT,
|
totp_secret TEXT,
|
||||||
totp_recovery_code TEXT,
|
totp_recovery_code TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -50,10 +52,12 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
|||||||
key TEXT,
|
key TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
|
archived_at TEXT,
|
||||||
deleted_at TEXT,
|
deleted_at TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS folders (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
@@ -143,6 +147,10 @@ CREATE TABLE IF NOT EXISTS devices (
|
|||||||
device_identifier TEXT NOT NULL,
|
device_identifier TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
type INTEGER NOT NULL,
|
type INTEGER NOT NULL,
|
||||||
|
session_stamp TEXT,
|
||||||
|
encrypted_user_key TEXT,
|
||||||
|
encrypted_public_key TEXT,
|
||||||
|
encrypted_private_key TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id, device_identifier),
|
PRIMARY KEY (user_id, device_identifier),
|
||||||
|
|||||||
Generated
+151
-44
@@ -1,15 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.1.0",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.1.0",
|
"version": "1.4.2",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"lucide-preact": "^0.575.0",
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
@@ -22,7 +28,7 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"wrangler": "^4.69.0"
|
"wrangler": "^4.71.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -383,14 +389,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/unenv-preset": {
|
"node_modules/@cloudflare/unenv-preset": {
|
||||||
"version": "2.14.0",
|
"version": "2.15.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.14.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@cloudflare/unenv-preset/-/unenv-preset-2.15.0.tgz",
|
||||||
"integrity": "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg==",
|
"integrity": "sha512-EGYmJaGZKWl+X8tXxcnx4v2bOZSjQeNI5dWFeXivgX9+YCT69AkzHHwlNbVpqtEUTbew8eQurpyOpeN8fg00nw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"workerd": "^1.20260218.0"
|
"workerd": "1.20260301.1 || ~1.20260302.1 || ~1.20260303.1 || ~1.20260304.1 || >1.20260305.0 <2.0.0-0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"workerd": {
|
"workerd": {
|
||||||
@@ -399,9 +405,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-darwin-64": {
|
"node_modules/@cloudflare/workerd-darwin-64": {
|
||||||
"version": "1.20260305.0",
|
"version": "1.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260301.1.tgz",
|
||||||
"integrity": "sha512-chhKOpymo0Eh9J3nymrauMqKGboCc4uz/j0gA1G4gioMnKsN2ZDKJ+qjRZDnCoVGy8u2C4pxlmyIfsXCAfIzhQ==",
|
"integrity": "sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -416,9 +422,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
"node_modules/@cloudflare/workerd-darwin-arm64": {
|
||||||
"version": "1.20260305.0",
|
"version": "1.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260301.1.tgz",
|
||||||
"integrity": "sha512-K9aG2OQk5bBfOP+fyGPqLcqZ9OR3ra6uwnxJ8f2mveq2A2LsCI7ZeGxQiAj75Ti80ytH/gJffZIx4Np2JtU3aQ==",
|
"integrity": "sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -433,9 +439,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-linux-64": {
|
"node_modules/@cloudflare/workerd-linux-64": {
|
||||||
"version": "1.20260305.0",
|
"version": "1.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260301.1.tgz",
|
||||||
"integrity": "sha512-tt7XUoIw/cYFeGbkPkcZ6XX1aZm26Aju/4ih+DXxOosbBeGshFSrNJDBfAKKOvkjsAZymJ+WWVDBU+hmNaGfwA==",
|
"integrity": "sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -450,9 +456,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-linux-arm64": {
|
"node_modules/@cloudflare/workerd-linux-arm64": {
|
||||||
"version": "1.20260305.0",
|
"version": "1.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260301.1.tgz",
|
||||||
"integrity": "sha512-72QTkY5EzylmvCZ8ZTrnJ9DctmQsfSof1OKyOWqu/pv/B2yACfuPMikq8RpPxvVu7hhS0ztGP6ZvXz72Htq4Zg==",
|
"integrity": "sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -467,9 +473,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@cloudflare/workerd-windows-64": {
|
"node_modules/@cloudflare/workerd-windows-64": {
|
||||||
"version": "1.20260305.0",
|
"version": "1.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260301.1.tgz",
|
||||||
"integrity": "sha512-BA0uaQPOaI2F6mJtBDqplGnQQhpXCzwEMI33p/TnDxtSk9u8CGIfBFuI6uqo8mJ6ijIaPjeBLGOn2CiRMET4qg==",
|
"integrity": "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -504,6 +510,60 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
|
||||||
@@ -1519,6 +1579,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@poppinss/colors": {
|
"node_modules/@poppinss/colors": {
|
||||||
"version": "4.1.6",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@poppinss/colors/-/colors-4.1.6.tgz",
|
||||||
@@ -2075,6 +2147,17 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zip.js/zip.js": {
|
||||||
|
"version": "2.8.22",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@zip.js/zip.js/-/zip.js-2.8.22.tgz",
|
||||||
|
"integrity": "sha512-0KlzbVR6r8irIX2o3zvUlosBDef62VDl47oUfa1U/qgEs67h4/eGBrX/6HWa1RQbt+J6sAeVmtyFKbTHNdF8qQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=0.7.0",
|
||||||
|
"deno": ">=1.0.0",
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-transform-hook-names": {
|
"node_modules/babel-plugin-transform-hook-names": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
|
||||||
@@ -2413,6 +2496,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2541,16 +2630,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/miniflare": {
|
"node_modules/miniflare": {
|
||||||
"version": "4.20260305.0",
|
"version": "4.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/miniflare/-/miniflare-4.20260301.1.tgz",
|
||||||
"integrity": "sha512-jVhtKJtiwaZa3rI+WgoLvSJmEazDsoUmAPYRUmEe2VO6VSbvkhbnDRm+dsPbYRatgNIExwrpqG1rv96jHiSb0w==",
|
"integrity": "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "0.8.1",
|
"@cspotcode/source-map-support": "0.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"undici": "7.18.2",
|
"undici": "7.18.2",
|
||||||
"workerd": "1.20260305.0",
|
"workerd": "1.20260301.1",
|
||||||
"ws": "8.18.0",
|
"ws": "8.18.0",
|
||||||
"youch": "4.1.0-beta.10"
|
"youch": "4.1.0-beta.10"
|
||||||
},
|
},
|
||||||
@@ -2714,6 +2803,19 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-dom": {
|
||||||
|
"version": "19.2.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"scheduler": "^0.27.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/regexparam": {
|
"node_modules/regexparam": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/regexparam/-/regexparam-3.0.0.tgz",
|
||||||
@@ -2779,6 +2881,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/scheduler": {
|
||||||
|
"version": "0.27.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -2911,9 +3019,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
@@ -3113,12 +3219,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workerd": {
|
"node_modules/workerd": {
|
||||||
"version": "1.20260305.0",
|
"version": "1.20260301.1",
|
||||||
"resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260305.0.tgz",
|
"resolved": "https://registry.npmmirror.com/workerd/-/workerd-1.20260301.1.tgz",
|
||||||
"integrity": "sha512-JkhfCLU+w+KbQmZ9k49IcDYc78GBo7eG8Mir8E2+KVjR7otQAmpcLlsous09YLh8WQ3Bt3Mi6/WMStvMAPukeA==",
|
"integrity": "sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"workerd": "bin/workerd"
|
"workerd": "bin/workerd"
|
||||||
},
|
},
|
||||||
@@ -3126,11 +3233,11 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@cloudflare/workerd-darwin-64": "1.20260305.0",
|
"@cloudflare/workerd-darwin-64": "1.20260301.1",
|
||||||
"@cloudflare/workerd-darwin-arm64": "1.20260305.0",
|
"@cloudflare/workerd-darwin-arm64": "1.20260301.1",
|
||||||
"@cloudflare/workerd-linux-64": "1.20260305.0",
|
"@cloudflare/workerd-linux-64": "1.20260301.1",
|
||||||
"@cloudflare/workerd-linux-arm64": "1.20260305.0",
|
"@cloudflare/workerd-linux-arm64": "1.20260301.1",
|
||||||
"@cloudflare/workerd-windows-64": "1.20260305.0"
|
"@cloudflare/workerd-windows-64": "1.20260301.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wouter": {
|
"node_modules/wouter": {
|
||||||
@@ -3148,20 +3255,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/wrangler": {
|
"node_modules/wrangler": {
|
||||||
"version": "4.69.0",
|
"version": "4.71.0",
|
||||||
"resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.69.0.tgz",
|
"resolved": "https://registry.npmmirror.com/wrangler/-/wrangler-4.71.0.tgz",
|
||||||
"integrity": "sha512-EmVfIM65I5b4ITHe3Y9R7zQyf4NUBQ1leStakMlWiVR9n6VlDwuEltyQI2l3i0JciDnWyR3uqe+T6C08ivniTQ==",
|
"integrity": "sha512-j6pSGAncOLNQDRzqtp0EqzYj52CldDP7uz/C9cxVrIgqa5p+cc0b4pIwnapZZAGv9E1Loa3tmPD0aXonH7KTkw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/kv-asset-handler": "0.4.2",
|
"@cloudflare/kv-asset-handler": "0.4.2",
|
||||||
"@cloudflare/unenv-preset": "2.14.0",
|
"@cloudflare/unenv-preset": "2.15.0",
|
||||||
"blake3-wasm": "2.1.5",
|
"blake3-wasm": "2.1.5",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.27.3",
|
||||||
"miniflare": "4.20260305.0",
|
"miniflare": "4.20260301.1",
|
||||||
"path-to-regexp": "6.3.0",
|
"path-to-regexp": "6.3.0",
|
||||||
"unenv": "2.0.0-rc.24",
|
"unenv": "2.0.0-rc.24",
|
||||||
"workerd": "1.20260305.0"
|
"workerd": "1.20260301.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"wrangler": "bin/wrangler.js",
|
"wrangler": "bin/wrangler.js",
|
||||||
@@ -3174,7 +3281,7 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@cloudflare/workers-types": "^4.20260305.0"
|
"@cloudflare/workers-types": "^4.20260226.1"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@cloudflare/workers-types": {
|
"@cloudflare/workers-types": {
|
||||||
|
|||||||
+15
-8
@@ -1,19 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.1.0",
|
"version": "1.4.2",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run web:build && wrangler dev -c wrangler.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"dev:worker": "wrangler dev -c wrangler.toml",
|
"dev:kv": "wrangler dev -c wrangler.kv.toml",
|
||||||
"web:dev": "vite --config webapp/vite.config.ts",
|
|
||||||
"web:build": "vite build --config webapp/vite.config.ts",
|
|
||||||
"web:typecheck": "tsc -p webapp/tsconfig.json --noEmit",
|
|
||||||
"build": "vite build --config webapp/vite.config.ts",
|
"build": "vite build --config webapp/vite.config.ts",
|
||||||
"deploy": "npm run build && wrangler deploy"
|
"deploy": "wrangler deploy",
|
||||||
|
"deploy:kv": "wrangler deploy -c wrangler.kv.toml"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
@@ -32,6 +30,9 @@
|
|||||||
},
|
},
|
||||||
"ATTACHMENTS": {
|
"ATTACHMENTS": {
|
||||||
"description": "R2 bucket for storing file attachments"
|
"description": "R2 bucket for storing file attachments"
|
||||||
|
},
|
||||||
|
"ATTACHMENTS_KV": {
|
||||||
|
"description": "Optional KV namespace fallback for attachment/send-file storage"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -42,10 +43,16 @@
|
|||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"wrangler": "^4.69.0"
|
"wrangler": "^4.71.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@noble/hashes": "^2.0.1",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@zip.js/zip.js": "^2.8.22",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
"lucide-preact": "^0.575.0",
|
"lucide-preact": "^0.575.0",
|
||||||
"preact": "^10.28.4",
|
"preact": "^10.28.4",
|
||||||
"qrcode-generator": "^2.0.4",
|
"qrcode-generator": "^2.0.4",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const APP_VERSION = '1.4.2';
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
|
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||||
|
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||||
|
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||||
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
|
export type BackupDestinationType = 'e3' | 'webdav';
|
||||||
|
|
||||||
|
export interface E3BackupDestination {
|
||||||
|
endpoint: string;
|
||||||
|
bucket: string;
|
||||||
|
region: string;
|
||||||
|
accessKeyId: string;
|
||||||
|
secretAccessKey: string;
|
||||||
|
rootPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebDavBackupDestination {
|
||||||
|
baseUrl: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
remotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupDestinationConfig =
|
||||||
|
| E3BackupDestination
|
||||||
|
| WebDavBackupDestination;
|
||||||
|
|
||||||
|
export interface BackupRuntimeState {
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
lastAttemptLocalDate: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastErrorAt: string | null;
|
||||||
|
lastErrorMessage: string | null;
|
||||||
|
lastUploadedFileName: string | null;
|
||||||
|
lastUploadedSizeBytes: number | null;
|
||||||
|
lastUploadedDestination: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupScheduleConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
intervalHours: number;
|
||||||
|
startTime: string;
|
||||||
|
timezone: string;
|
||||||
|
retentionCount: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupDestinationRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: BackupDestinationType;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
destination: BackupDestinationConfig;
|
||||||
|
schedule: BackupScheduleConfig;
|
||||||
|
runtime: BackupRuntimeState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettings {
|
||||||
|
destinations: BackupDestinationRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupRandomId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `backup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupRuntimeState(): BackupRuntimeState {
|
||||||
|
return {
|
||||||
|
lastAttemptAt: null,
|
||||||
|
lastAttemptLocalDate: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastErrorAt: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
lastUploadedFileName: null,
|
||||||
|
lastUploadedSizeBytes: null,
|
||||||
|
lastUploadedDestination: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
intervalHours: BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
|
timezone,
|
||||||
|
retentionCount: BACKUP_DEFAULT_RETENTION_COUNT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig {
|
||||||
|
if (type === 'e3') {
|
||||||
|
return {
|
||||||
|
endpoint: '',
|
||||||
|
bucket: '',
|
||||||
|
region: BACKUP_DEFAULT_E3_REGION,
|
||||||
|
accessKeyId: '',
|
||||||
|
secretAccessKey: '',
|
||||||
|
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
|
if (type === 'e3') return `E3 ${index}`;
|
||||||
|
return `WebDAV ${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateBackupDestinationRecordOptions {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBackupDestinationRecord(
|
||||||
|
type: BackupDestinationType,
|
||||||
|
index: number,
|
||||||
|
options: CreateBackupDestinationRecordOptions = {}
|
||||||
|
): BackupDestinationRecord {
|
||||||
|
return {
|
||||||
|
id: options.id || createBackupRandomId(),
|
||||||
|
name: options.name || createDefaultBackupDestinationName(type, index),
|
||||||
|
type,
|
||||||
|
includeAttachments: false,
|
||||||
|
destination: createDefaultBackupDestinationConfig(type),
|
||||||
|
schedule: createDefaultBackupScheduleConfig(options.timezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
runtime: createDefaultBackupRuntimeState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultBackupSettings(
|
||||||
|
timezone: string = BACKUP_DEFAULT_TIMEZONE,
|
||||||
|
options: { destinationName?: string } = {}
|
||||||
|
): BackupSettings {
|
||||||
|
return {
|
||||||
|
destinations: [
|
||||||
|
createBackupDestinationRecord('webdav', 1, {
|
||||||
|
timezone,
|
||||||
|
name: options.destinationName,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+25
-1
@@ -38,6 +38,24 @@
|
|||||||
// Public (unauthenticated) request budget per IP per minute.
|
// Public (unauthenticated) request budget per IP per minute.
|
||||||
// 公开(未认证)接口每 IP 每分钟请求配额。
|
// 公开(未认证)接口每 IP 每分钟请求配额。
|
||||||
publicRequestsPerMinute: 60,
|
publicRequestsPerMinute: 60,
|
||||||
|
// Public read-only request budget per IP per minute.
|
||||||
|
// 公开只读接口每 IP 每分钟请求配额。
|
||||||
|
publicReadRequestsPerMinute: 120,
|
||||||
|
// Sensitive public/auth request budget per IP per minute.
|
||||||
|
// 敏感公开/认证接口每 IP 每分钟请求配额。
|
||||||
|
sensitivePublicRequestsPerMinute: 30,
|
||||||
|
// Password hint lookup budget per IP per minute.
|
||||||
|
// 密码提示查询接口每 IP 每分钟请求配额。
|
||||||
|
passwordHintRequestsPerMinute: 1,
|
||||||
|
// Password hint lookup budget per IP per hour.
|
||||||
|
// 密码提示查询接口每 IP 每小时请求配额。
|
||||||
|
passwordHintRequestsPerHour: 3,
|
||||||
|
// Register endpoint budget per IP per minute.
|
||||||
|
// 注册接口每 IP 每分钟请求配额。
|
||||||
|
registerRequestsPerMinute: 5,
|
||||||
|
// Refresh-token grant budget per IP per minute.
|
||||||
|
// refresh_token 授权每 IP 每分钟请求配额。
|
||||||
|
refreshTokenRequestsPerMinute: 30,
|
||||||
// Fixed window size for API rate limiting in seconds.
|
// Fixed window size for API rate limiting in seconds.
|
||||||
// API 限流固定窗口大小(秒)。
|
// API 限流固定窗口大小(秒)。
|
||||||
apiWindowSeconds: 60,
|
apiWindowSeconds: 60,
|
||||||
@@ -70,7 +88,7 @@
|
|||||||
send: {
|
send: {
|
||||||
// Max file size allowed for Send file uploads.
|
// Max file size allowed for Send file uploads.
|
||||||
// Send 文件上传大小上限。
|
// Send 文件上传大小上限。
|
||||||
maxFileSizeBytes: 550_502_400,
|
maxFileSizeBytes: 100 * 1024 * 1024,
|
||||||
// Max days allowed between now and deletion date.
|
// Max days allowed between now and deletion date.
|
||||||
// 允许的最远删除日期(距当前天数)。
|
// 允许的最远删除日期(距当前天数)。
|
||||||
maxDeletionDays: 31,
|
maxDeletionDays: 31,
|
||||||
@@ -95,6 +113,12 @@
|
|||||||
// In-memory /api/sync response cache TTL (milliseconds).
|
// In-memory /api/sync response cache TTL (milliseconds).
|
||||||
// /api/sync 内存缓存有效期(毫秒)。
|
// /api/sync 内存缓存有效期(毫秒)。
|
||||||
syncResponseTtlMs: 30 * 1000,
|
syncResponseTtlMs: 30 * 1000,
|
||||||
|
// Max size of a single cached /api/sync body in bytes.
|
||||||
|
// 单个 /api/sync 缓存响应允许的最大字节数。
|
||||||
|
syncResponseMaxBodyBytes: 512 * 1024,
|
||||||
|
// Max total in-memory bytes used by /api/sync cache per isolate.
|
||||||
|
// 每个 isolate 中 /api/sync 缓存允许占用的最大总字节数。
|
||||||
|
syncResponseMaxTotalBytes: 2 * 1024 * 1024,
|
||||||
// Max in-memory /api/sync cache entries per isolate.
|
// Max in-memory /api/sync cache entries per isolate.
|
||||||
// 每个 isolate 的 /api/sync 最大缓存条目数。
|
// 每个 isolate 的 /api/sync 最大缓存条目数。
|
||||||
syncResponseMaxEntries: 64,
|
syncResponseMaxEntries: 64,
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
|
||||||
|
const SIGNALR_RECORD_SEPARATOR = 0x1e;
|
||||||
|
const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]);
|
||||||
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
|
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
|
|
||||||
|
interface ConnectionState {
|
||||||
|
handshakeComplete: boolean;
|
||||||
|
protocol: HubProtocol;
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
||||||
|
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const out = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
out.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeUtf8(value: string): Uint8Array {
|
||||||
|
return new TextEncoder().encode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackInteger(value: number): Uint8Array {
|
||||||
|
const normalized = Math.trunc(value);
|
||||||
|
if (normalized >= 0 && normalized <= 0x7f) {
|
||||||
|
return new Uint8Array([normalized]);
|
||||||
|
}
|
||||||
|
if (normalized >= 0 && normalized <= 0xff) {
|
||||||
|
return new Uint8Array([0xcc, normalized]);
|
||||||
|
}
|
||||||
|
if (normalized >= 0 && normalized <= 0xffff) {
|
||||||
|
return new Uint8Array([0xcd, normalized >> 8, normalized & 0xff]);
|
||||||
|
}
|
||||||
|
const safe = normalized >>> 0;
|
||||||
|
return new Uint8Array([
|
||||||
|
0xce,
|
||||||
|
(safe >>> 24) & 0xff,
|
||||||
|
(safe >>> 16) & 0xff,
|
||||||
|
(safe >>> 8) & 0xff,
|
||||||
|
safe & 0xff,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackString(value: string): Uint8Array {
|
||||||
|
const bytes = encodeUtf8(value);
|
||||||
|
const len = bytes.length;
|
||||||
|
if (len < 32) {
|
||||||
|
return concatBytes([new Uint8Array([0xa0 | len]), bytes]);
|
||||||
|
}
|
||||||
|
if (len <= 0xff) {
|
||||||
|
return concatBytes([new Uint8Array([0xd9, len]), bytes]);
|
||||||
|
}
|
||||||
|
return concatBytes([new Uint8Array([0xda, (len >> 8) & 0xff, len & 0xff]), bytes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackTimestamp(date: Date): Uint8Array {
|
||||||
|
const seconds = BigInt(Math.floor(date.getTime() / 1000));
|
||||||
|
const nanos = BigInt(date.getMilliseconds()) * 1000000n;
|
||||||
|
const timestamp = (nanos << 34n) | seconds;
|
||||||
|
const payload = new Uint8Array(8);
|
||||||
|
for (let i = 7; i >= 0; i--) {
|
||||||
|
payload[i] = Number((timestamp >> BigInt((7 - i) * 8)) & 0xffn);
|
||||||
|
}
|
||||||
|
return concatBytes([new Uint8Array([0xc7, 0x08, 0xff]), payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackArray(values: unknown[]): Uint8Array {
|
||||||
|
const items = values.map(encodeMsgPack);
|
||||||
|
const len = items.length;
|
||||||
|
const header =
|
||||||
|
len < 16
|
||||||
|
? new Uint8Array([0x90 | len])
|
||||||
|
: new Uint8Array([0xdc, (len >> 8) & 0xff, len & 0xff]);
|
||||||
|
return concatBytes([header, ...items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPackMap(value: Record<string, unknown>): Uint8Array {
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
const len = entries.length;
|
||||||
|
const header =
|
||||||
|
len < 16
|
||||||
|
? new Uint8Array([0x80 | len])
|
||||||
|
: new Uint8Array([0xde, (len >> 8) & 0xff, len & 0xff]);
|
||||||
|
const chunks: Uint8Array[] = [header];
|
||||||
|
for (const [key, entryValue] of entries) {
|
||||||
|
chunks.push(encodeMsgPackString(key), encodeMsgPack(entryValue));
|
||||||
|
}
|
||||||
|
return concatBytes(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeMsgPack(value: unknown): Uint8Array {
|
||||||
|
if (value === null || value === undefined) return new Uint8Array([0xc0]);
|
||||||
|
if (value instanceof Date) return encodeMsgPackTimestamp(value);
|
||||||
|
if (typeof value === 'string') return encodeMsgPackString(value);
|
||||||
|
if (typeof value === 'number') return encodeMsgPackInteger(value);
|
||||||
|
if (typeof value === 'boolean') return new Uint8Array([value ? 0xc3 : 0xc2]);
|
||||||
|
if (Array.isArray(value)) return encodeMsgPackArray(value);
|
||||||
|
if (value instanceof Uint8Array) {
|
||||||
|
const len = value.length;
|
||||||
|
if (len <= 0xff) return concatBytes([new Uint8Array([0xc4, len]), value]);
|
||||||
|
return concatBytes([new Uint8Array([0xc5, (len >> 8) & 0xff, len & 0xff]), value]);
|
||||||
|
}
|
||||||
|
return encodeMsgPackMap(value as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
||||||
|
const len = payload.length;
|
||||||
|
const prefix: number[] = [];
|
||||||
|
let value = len;
|
||||||
|
do {
|
||||||
|
let current = value & 0x7f;
|
||||||
|
value >>>= 7;
|
||||||
|
if (value > 0) current |= 0x80;
|
||||||
|
prefix.push(current);
|
||||||
|
} while (value > 0);
|
||||||
|
return concatBytes([new Uint8Array(prefix), payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalRJsonInvocation(
|
||||||
|
updateType: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
contextId: string | null
|
||||||
|
): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 1,
|
||||||
|
target: 'ReceiveMessage',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
ContextId: contextId,
|
||||||
|
Type: updateType,
|
||||||
|
Payload: payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalRJsonPing(): string {
|
||||||
|
return JSON.stringify({ type: 6 }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalRMessagePackInvocation(
|
||||||
|
updateType: number,
|
||||||
|
messagePayload: Record<string, unknown>,
|
||||||
|
contextId: string | null
|
||||||
|
): Uint8Array {
|
||||||
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
|
// [type, headers, invocationId, target, arguments]
|
||||||
|
const encodedPayload = encodeMsgPack([
|
||||||
|
1,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
'ReceiveMessage',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ContextId: contextId,
|
||||||
|
Type: updateType,
|
||||||
|
Payload: messagePayload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
return frameSignalRBinary(encodedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignalRMessagePackPing(): Uint8Array {
|
||||||
|
return frameSignalRBinary(encodeMsgPack([6]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeIncomingMessage(data: string | ArrayBuffer | ArrayBufferView): string {
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
||||||
|
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotificationsHub {
|
||||||
|
private readonly connections = new Map<WebSocket, ConnectionState>();
|
||||||
|
private userId = '';
|
||||||
|
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly state: DurableObjectState, private readonly env: Env) {
|
||||||
|
void this.state;
|
||||||
|
void this.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/notify' && request.method === 'POST') {
|
||||||
|
const body = (await request.json().catch(() => null)) as {
|
||||||
|
revisionDate?: string;
|
||||||
|
userId?: string;
|
||||||
|
contextId?: string | null;
|
||||||
|
updateType?: number;
|
||||||
|
targetDeviceIdentifier?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
|
} | null;
|
||||||
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
|
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
||||||
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
|
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
|
const payload = body?.payload && typeof body.payload === 'object'
|
||||||
|
? body.payload
|
||||||
|
: {
|
||||||
|
UserId: this.userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
};
|
||||||
|
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/internal/online' && request.method === 'GET') {
|
||||||
|
return new Response(JSON.stringify({ deviceIdentifiers: this.getOnlineDeviceIdentifiers() }), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname !== '/notifications/hub') {
|
||||||
|
return new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||||
|
return new Response('Expected websocket', { status: 426 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUserId = String(url.searchParams.get('nw_uid') || '').trim();
|
||||||
|
const requestDeviceIdentifier = String(url.searchParams.get('nw_did') || '').trim() || null;
|
||||||
|
if (requestUserId) {
|
||||||
|
this.userId = requestUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.userId) {
|
||||||
|
return new Response('Unauthorized', { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
const client = pair[0];
|
||||||
|
const server = pair[1];
|
||||||
|
server.accept();
|
||||||
|
|
||||||
|
this.connections.set(server, {
|
||||||
|
handshakeComplete: false,
|
||||||
|
protocol: 'messagepack',
|
||||||
|
deviceIdentifier: requestDeviceIdentifier,
|
||||||
|
});
|
||||||
|
this.ensurePingLoop();
|
||||||
|
|
||||||
|
server.addEventListener('message', (event) => {
|
||||||
|
void this.handleSocketMessage(server, event.data);
|
||||||
|
});
|
||||||
|
server.addEventListener('close', () => {
|
||||||
|
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
||||||
|
this.connections.delete(server);
|
||||||
|
this.stopPingLoopIfIdle();
|
||||||
|
if (shouldBroadcast) this.broadcastDeviceStatus();
|
||||||
|
});
|
||||||
|
server.addEventListener('error', () => {
|
||||||
|
const shouldBroadcast = !!this.connections.get(server)?.handshakeComplete;
|
||||||
|
this.connections.delete(server);
|
||||||
|
this.stopPingLoopIfIdle();
|
||||||
|
if (shouldBroadcast) this.broadcastDeviceStatus();
|
||||||
|
try {
|
||||||
|
server.close(1011, 'Socket error');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 101,
|
||||||
|
webSocket: client,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleSocketMessage(socket: WebSocket, rawData: string | ArrayBuffer | ArrayBufferView): Promise<void> {
|
||||||
|
const connection = this.connections.get(socket);
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
if (!connection.handshakeComplete) {
|
||||||
|
const text = decodeIncomingMessage(rawData);
|
||||||
|
const frames = text.split(String.fromCharCode(SIGNALR_RECORD_SEPARATOR)).filter(Boolean);
|
||||||
|
for (const frame of frames) {
|
||||||
|
try {
|
||||||
|
const handshake = JSON.parse(frame) as { protocol?: string };
|
||||||
|
const protocol = handshake.protocol === 'json' ? 'json' : 'messagepack';
|
||||||
|
connection.protocol = protocol;
|
||||||
|
connection.handshakeComplete = true;
|
||||||
|
socket.send(SIGNALR_HANDSHAKE_ACK);
|
||||||
|
this.broadcastDeviceStatus();
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed pre-handshake payloads.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensurePingLoop(): void {
|
||||||
|
if (this.pingTimer !== null) return;
|
||||||
|
this.pingTimer = setInterval(() => {
|
||||||
|
this.broadcastPing();
|
||||||
|
}, SIGNALR_PING_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPingLoopIfIdle(): void {
|
||||||
|
if (this.connections.size > 0 || this.pingTimer === null) return;
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastPing(): void {
|
||||||
|
if (this.connections.size === 0) {
|
||||||
|
this.stopPingLoopIfIdle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [socket, connection] of this.connections) {
|
||||||
|
if (!connection.handshakeComplete) continue;
|
||||||
|
try {
|
||||||
|
if (connection.protocol === 'json') {
|
||||||
|
socket.send(buildSignalRJsonPing());
|
||||||
|
} else {
|
||||||
|
socket.send(buildSignalRMessagePackPing());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.connections.delete(socket);
|
||||||
|
try {
|
||||||
|
socket.close(1011, 'Ping send failed');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopPingLoopIfIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOnlineDeviceIdentifiers(): string[] {
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const connection of this.connections.values()) {
|
||||||
|
if (!connection.handshakeComplete || !connection.deviceIdentifier) continue;
|
||||||
|
out.add(connection.deviceIdentifier);
|
||||||
|
}
|
||||||
|
return Array.from(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastMessage(
|
||||||
|
updateType: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
contextId: string | null,
|
||||||
|
targetDeviceIdentifier: string | null
|
||||||
|
): void {
|
||||||
|
if (!this.userId || this.connections.size === 0) return;
|
||||||
|
|
||||||
|
for (const [socket, connection] of this.connections) {
|
||||||
|
if (!connection.handshakeComplete) continue;
|
||||||
|
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
||||||
|
try {
|
||||||
|
if (connection.protocol === 'json') {
|
||||||
|
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
|
} else {
|
||||||
|
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.connections.delete(socket);
|
||||||
|
try {
|
||||||
|
socket.close(1011, 'Notification send failed');
|
||||||
|
} catch {
|
||||||
|
// ignore close races
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopPingLoopIfIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastDeviceStatus(): void {
|
||||||
|
this.broadcastMessage(
|
||||||
|
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||||
|
{
|
||||||
|
UserId: this.userId,
|
||||||
|
Date: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserVaultSync(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string,
|
||||||
|
contextId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserLogout(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_LOG_OUT, new Date().toISOString(), null, targetDeviceIdentifier ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOnlineUserDevices(env: Env, userId: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
const response = await stub.fetch('https://notifications/internal/online');
|
||||||
|
if (!response.ok) return [];
|
||||||
|
const body = (await response.json().catch(() => null)) as { deviceIdentifiers?: string[] } | null;
|
||||||
|
return Array.isArray(body?.deviceIdentifiers) ? body.deviceIdentifiers.filter((value) => !!String(value || '').trim()) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyUserUpdate(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
updateType: number,
|
||||||
|
revisionDate: string,
|
||||||
|
contextId: string | null,
|
||||||
|
targetDeviceIdentifier: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
await stub.fetch('https://notifications/internal/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-NodeWarden-UserId': userId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
revisionDate,
|
||||||
|
contextId: contextId || null,
|
||||||
|
updateType,
|
||||||
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast realtime notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserBackupProgress(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
progress: {
|
||||||
|
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
|
||||||
|
source?: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
timestamp?: string;
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
const revisionDate = progress.timestamp || new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
await stub.fetch('https://notifications/internal/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-NodeWarden-UserId': userId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
revisionDate,
|
||||||
|
contextId: null,
|
||||||
|
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
|
||||||
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
...progress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast backup progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserBackupRestoreProgress(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
progress: {
|
||||||
|
operation: 'backup-restore';
|
||||||
|
source: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
timestamp?: string;
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
|
||||||
|
}
|
||||||
+162
-24
@@ -7,6 +7,7 @@ import { generateUUID } from '../utils/uuid';
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
import { buildAccountKeys } from '../utils/user-decryption';
|
||||||
|
|
||||||
function looksLikeEncString(value: string): boolean {
|
function looksLikeEncString(value: string): boolean {
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
@@ -45,13 +46,27 @@ function validateKdfParams(kdfType: number | undefined, kdfIterations: number |
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTotpSecret(input: string): string {
|
function normalizeTotpSecret(input: string): string {
|
||||||
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
const raw = String(input || '').toUpperCase();
|
||||||
|
let out = '';
|
||||||
|
for (const char of raw) {
|
||||||
|
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
|
||||||
|
out += char;
|
||||||
|
}
|
||||||
|
while (out.endsWith('=')) {
|
||||||
|
out = out.slice(0, -1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRecoveryCodeInput(input: string): string {
|
function normalizeRecoveryCodeInput(input: string): string {
|
||||||
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMasterPasswordHint(input: string | null | undefined): string | null {
|
||||||
|
const normalized = String(input || '').trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
if (!secret) return 'missing';
|
if (!secret) return 'missing';
|
||||||
@@ -60,7 +75,18 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyUserSecret(
|
||||||
|
auth: AuthService,
|
||||||
|
user: User,
|
||||||
|
secret: string | null | undefined
|
||||||
|
): Promise<boolean> {
|
||||||
|
const normalized = String(secret || '').trim();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return auth.verifyPassword(normalized, user.masterPasswordHash, user.email);
|
||||||
|
}
|
||||||
|
|
||||||
function toProfile(user: User, env: Env): ProfileResponse {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
|
void env;
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -69,12 +95,12 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
premium: true,
|
premium: true,
|
||||||
premiumFromOrganization: false,
|
premiumFromOrganization: false,
|
||||||
usesKeyConnector: false,
|
usesKeyConnector: false,
|
||||||
masterPasswordHint: null,
|
masterPasswordHint: user.masterPasswordHint,
|
||||||
culture: 'en-US',
|
culture: 'en-US',
|
||||||
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET),
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: buildAccountKeys(user),
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -82,6 +108,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
|
verifyDevices: user.verifyDevices,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
status: user.status,
|
status: user.status,
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
@@ -114,6 +141,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
kdfMemory?: number;
|
kdfMemory?: number;
|
||||||
kdfParallelism?: number;
|
kdfParallelism?: number;
|
||||||
inviteCode?: string;
|
inviteCode?: string;
|
||||||
|
masterPasswordHint?: string;
|
||||||
keys?: {
|
keys?: {
|
||||||
publicKey?: string;
|
publicKey?: string;
|
||||||
encryptedPrivateKey?: string;
|
encryptedPrivateKey?: string;
|
||||||
@@ -133,6 +161,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
const privateKey = body.keys?.encryptedPrivateKey;
|
const privateKey = body.keys?.encryptedPrivateKey;
|
||||||
const publicKey = body.keys?.publicKey;
|
const publicKey = body.keys?.publicKey;
|
||||||
const inviteCode = (body.inviteCode || '').trim();
|
const inviteCode = (body.inviteCode || '').trim();
|
||||||
|
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||||
|
|
||||||
if (!email || !masterPasswordHash || !key) {
|
if (!email || !masterPasswordHash || !key) {
|
||||||
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
return errorResponse('Email, masterPasswordHash, and key are required', 400);
|
||||||
@@ -149,6 +178,9 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
if (!looksLikeEncString(privateKey)) {
|
if (!looksLikeEncString(privateKey)) {
|
||||||
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
}
|
}
|
||||||
|
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
||||||
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||||
|
}
|
||||||
|
|
||||||
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism);
|
||||||
if (kdfErr) return errorResponse(kdfErr, 400);
|
if (kdfErr) return errorResponse(kdfErr, 400);
|
||||||
@@ -161,6 +193,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
email,
|
email,
|
||||||
name: name || email,
|
name: name || email,
|
||||||
|
masterPasswordHint,
|
||||||
masterPasswordHash: serverHash,
|
masterPasswordHash: serverHash,
|
||||||
key,
|
key,
|
||||||
privateKey,
|
privateKey,
|
||||||
@@ -172,6 +205,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
securityStamp: generateUUID(),
|
securityStamp: generateUUID(),
|
||||||
role: 'user',
|
role: 'user',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
verifyDevices: true,
|
||||||
totpSecret: null,
|
totpSecret: null,
|
||||||
totpRecoveryCode: null,
|
totpRecoveryCode: null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -231,6 +265,80 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
|||||||
return jsonResponse({ success: true, role: user.role }, 200);
|
return jsonResponse({ success: true, role: user.role }, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /api/accounts/password-hint
|
||||||
|
export async function handleGetPasswordHint(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { email?: string };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(body.email || '').trim().toLowerCase();
|
||||||
|
if (!email) {
|
||||||
|
return errorResponse('Email is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
|
const minuteBudget = await rateLimit.consumeBudgetWithWindow(
|
||||||
|
`${clientIdentifier}:password-hint`,
|
||||||
|
LIMITS.rateLimit.passwordHintRequestsPerMinute,
|
||||||
|
60
|
||||||
|
);
|
||||||
|
if (!minuteBudget.allowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(minuteBudget.retryAfterSeconds || 60),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hourlyBudget = await rateLimit.consumeBudgetWithWindow(
|
||||||
|
`${clientIdentifier}:password-hint-hour`,
|
||||||
|
LIMITS.rateLimit.passwordHintRequestsPerHour,
|
||||||
|
60 * 60
|
||||||
|
);
|
||||||
|
if (!hourlyBudget.allowed) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Too many requests',
|
||||||
|
error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await storage.getUser(email);
|
||||||
|
const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null;
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'passwordHint',
|
||||||
|
hasHint: !!hint,
|
||||||
|
masterPasswordHint: hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/accounts/profile
|
// GET /api/accounts/profile
|
||||||
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetProfile(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
@@ -246,34 +354,59 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
|
|||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) return errorResponse('User not found', 404);
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
let body: { name?: string; email?: string };
|
let body: {
|
||||||
|
masterPasswordHint?: string | null;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
body = await request.json();
|
body = await request.json();
|
||||||
} catch {
|
} catch {
|
||||||
return errorResponse('Invalid JSON', 400);
|
return errorResponse('Invalid JSON', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof body.name === 'string') {
|
const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint);
|
||||||
user.name = body.name.trim() || user.name;
|
if (masterPasswordHint && masterPasswordHint.length > 120) {
|
||||||
}
|
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
|
||||||
if (typeof body.email === 'string') {
|
|
||||||
const normalized = body.email.trim().toLowerCase();
|
|
||||||
if (!normalized) return errorResponse('Email is required', 400);
|
|
||||||
user.email = normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.masterPasswordHint = masterPasswordHint;
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
|
||||||
|
return jsonResponse(toProfile(user, env));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/accounts/verify-devices
|
||||||
|
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
secret?: string;
|
||||||
|
masterPasswordHash?: string;
|
||||||
|
verifyDevices?: boolean;
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await storage.saveUser(user);
|
body = await request.json();
|
||||||
} catch (error) {
|
} catch {
|
||||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
return errorResponse('Invalid JSON', 400);
|
||||||
if (msg.includes('unique') || msg.includes('constraint')) {
|
|
||||||
return errorResponse('Email already registered', 409);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleGetProfile(request, env, userId);
|
if (typeof body.verifyDevices !== 'boolean') {
|
||||||
|
return errorResponse('verifyDevices must be true or false', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
|
||||||
|
if (!verified) {
|
||||||
|
return errorResponse('User verification failed.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.verifyDevices = body.verifyDevices;
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/accounts/keys
|
// POST /api/accounts/keys
|
||||||
@@ -308,15 +441,16 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
|||||||
return errorResponse('Invalid password', 400);
|
return errorResponse('Invalid password', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.key) user.key = body.key;
|
|
||||||
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
|
||||||
if (body.publicKey) user.publicKey = body.publicKey;
|
|
||||||
if (body.key && !looksLikeEncString(body.key)) {
|
if (body.key && !looksLikeEncString(body.key)) {
|
||||||
return errorResponse('key is not a valid encrypted string', 400);
|
return errorResponse('key is not a valid encrypted string', 400);
|
||||||
}
|
}
|
||||||
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
|
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
|
||||||
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.key) user.key = body.key;
|
||||||
|
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
||||||
|
if (body.publicKey) user.publicKey = body.publicKey;
|
||||||
user.updatedAt = new Date().toISOString();
|
user.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
@@ -526,7 +660,11 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis
|
|||||||
const email = String(body.email || body.username || '').trim().toLowerCase();
|
const email = String(body.email || body.username || '').trim().toLowerCase();
|
||||||
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
|
||||||
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
|
||||||
const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`;
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
const recoverLimitKey = `${clientIdentifier}:recover-2fa:${email || 'unknown'}`;
|
||||||
|
|
||||||
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey);
|
||||||
if (!recoverAttemptCheck.allowed) {
|
if (!recoverAttemptCheck.allowed) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Env, User, Invite } from '../types';
|
|||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { deleteBlobObject, getAttachmentObjectKey, getSendFileObjectKey } from '../services/blob-store';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
@@ -260,7 +261,7 @@ export async function handleAdminDeleteUser(
|
|||||||
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
|
const attachmentMap = await storage.getAttachmentsByUserId(target.id);
|
||||||
for (const [cipherId, attachments] of attachmentMap) {
|
for (const [cipherId, attachments] of attachmentMap) {
|
||||||
for (const att of attachments) {
|
for (const att of attachments) {
|
||||||
await env.ATTACHMENTS.delete(`${cipherId}/${att.id}`);
|
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, att.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 2. Send files (keyed by sends/sendId/fileId)
|
// 2. Send files (keyed by sends/sendId/fileId)
|
||||||
@@ -271,7 +272,7 @@ export async function handleAdminDeleteUser(
|
|||||||
const parsed = JSON.parse(send.data) as Record<string, unknown>;
|
const parsed = JSON.parse(send.data) as Record<string, unknown>;
|
||||||
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
|
const fileId = typeof parsed.id === 'string' ? parsed.id : null;
|
||||||
if (fileId) {
|
if (fileId) {
|
||||||
await env.ATTACHMENTS.delete(`sends/${send.id}/${fileId}`);
|
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||||
}
|
}
|
||||||
} catch { /* non-file send or bad data, skip */ }
|
} catch { /* non-file send or bad data, skip */ }
|
||||||
}
|
}
|
||||||
|
|||||||
+130
-61
@@ -1,10 +1,34 @@
|
|||||||
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
import {
|
||||||
import { cipherToResponse } from './ciphers';
|
createAttachmentUploadToken,
|
||||||
|
createFileDownloadToken,
|
||||||
|
verifyAttachmentUploadToken,
|
||||||
|
verifyFileDownloadToken,
|
||||||
|
} from '../utils/jwt';
|
||||||
|
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import {
|
||||||
|
deleteBlobObject,
|
||||||
|
getAttachmentObjectKey,
|
||||||
|
getBlobObject,
|
||||||
|
getBlobStorageMaxBytes,
|
||||||
|
putBlobObject,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
|
||||||
|
async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
// Format file size to human readable
|
// Format file size to human readable
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -14,9 +38,53 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get R2 object path for attachment
|
async function processAttachmentUpload(
|
||||||
function getAttachmentPath(cipherId: string, attachmentId: string): string {
|
request: Request,
|
||||||
return `${cipherId}/${attachmentId}`;
|
env: Env,
|
||||||
|
attachment: Attachment,
|
||||||
|
cipherId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.attachment.maxFileSizeBytes);
|
||||||
|
const upload = await parseDirectUploadPayload(request, {
|
||||||
|
expectedSize: Number(attachment.size) || 0,
|
||||||
|
maxFileSize,
|
||||||
|
tooLargeMessage: `File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`,
|
||||||
|
});
|
||||||
|
if (upload instanceof Response) {
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, path, upload.body, {
|
||||||
|
size: upload.size,
|
||||||
|
contentType: upload.contentType,
|
||||||
|
customMetadata: {
|
||||||
|
cipherId,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('KV object too large')) {
|
||||||
|
return errorResponse(`File too large. Maximum size is ${Math.floor(maxFileSize / (1024 * 1024))}MB`, 413);
|
||||||
|
}
|
||||||
|
return errorResponse('Attachment storage is not configured', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upload.size !== attachment.size) {
|
||||||
|
attachment.size = upload.size;
|
||||||
|
attachment.sizeName = formatSize(upload.size);
|
||||||
|
await storage.saveAttachment(attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/ciphers/{cipherId}/attachment/v2
|
// POST /api/ciphers/{cipherId}/attachment/v2
|
||||||
@@ -71,24 +139,31 @@ export async function handleCreateAttachment(
|
|||||||
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
await storage.addAttachmentToCipher(cipherId, attachmentId);
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
const updatedCipher = await storage.getCipher(cipherId);
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
const uploadToken = await createAttachmentUploadToken(userId, cipherId, attachmentId, jwtSecret);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'attachment-fileUpload',
|
object: 'attachment-fileUpload',
|
||||||
attachmentId: attachmentId,
|
attachmentId: attachmentId,
|
||||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
url: buildDirectUploadUrl(request, `/api/ciphers/${cipherId}/attachment/${attachmentId}`, uploadToken),
|
||||||
fileUploadType: 0, // Direct upload
|
fileUploadType: 1,
|
||||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maximum file size: 100MB
|
|
||||||
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
|
|
||||||
|
|
||||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
// Upload attachment file content
|
// Upload attachment file content
|
||||||
export async function handleUploadAttachment(
|
export async function handleUploadAttachment(
|
||||||
@@ -112,54 +187,45 @@ export async function handleUploadAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check content-length header for size limit
|
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||||
const contentLength = request.headers.get('content-length');
|
}
|
||||||
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
|
||||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
export async function handlePublicUploadAttachment(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the file from multipart form data
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
const contentType = request.headers.get('content-type') || '';
|
if (!token) {
|
||||||
if (!contentType.includes('multipart/form-data')) {
|
return errorResponse('Token required', 401);
|
||||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const claims = await verifyAttachmentUploadToken(token, jwtSecret);
|
||||||
const file = formData.get('data') as File | null;
|
if (!claims) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
if (!file) {
|
}
|
||||||
return errorResponse('No file uploaded', 400);
|
if (claims.cipherId !== cipherId || claims.attachmentId !== attachmentId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check actual file size
|
const storage = new StorageService(env.DB);
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
const cipher = await storage.getCipher(cipherId);
|
||||||
return errorResponse('File too large. Maximum size is 100MB', 413);
|
if (!cipher || cipher.userId !== claims.userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store file in R2
|
const attachment = await storage.getAttachment(attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
if (!attachment || attachment.cipherId !== cipherId) {
|
||||||
await env.ATTACHMENTS.put(path, file.stream(), {
|
return errorResponse('Attachment not found', 404);
|
||||||
httpMetadata: {
|
|
||||||
contentType: 'application/octet-stream',
|
|
||||||
},
|
|
||||||
customMetadata: {
|
|
||||||
cipherId: cipherId,
|
|
||||||
attachmentId: attachmentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update attachment size if different
|
|
||||||
const actualSize = file.size;
|
|
||||||
if (actualSize !== attachment.size) {
|
|
||||||
attachment.size = actualSize;
|
|
||||||
attachment.sizeName = formatSize(actualSize);
|
|
||||||
await storage.saveAttachment(attachment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update cipher revision date
|
return processAttachmentUpload(request, env, attachment, cipherId);
|
||||||
await storage.updateCipherRevisionDate(cipherId);
|
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
// GET /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||||
@@ -242,9 +308,8 @@ export async function handlePublicDownloadAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get file from R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
const object = await getBlobObject(env, path);
|
||||||
const object = await env.ATTACHMENTS.get(path);
|
|
||||||
|
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Attachment file not found', 404);
|
return errorResponse('Attachment file not found', 404);
|
||||||
@@ -257,7 +322,7 @@ export async function handlePublicDownloadAttachment(
|
|||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
'Content-Length': String(object.size),
|
'Content-Length': String(object.size),
|
||||||
'Cache-Control': 'private, no-cache',
|
'Cache-Control': 'private, no-cache',
|
||||||
},
|
},
|
||||||
@@ -287,9 +352,8 @@ export async function handleDeleteAttachment(
|
|||||||
return errorResponse('Attachment not found', 404);
|
return errorResponse('Attachment not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete file from R2
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const path = getAttachmentPath(cipherId, attachmentId);
|
await deleteBlobObject(env, path);
|
||||||
await env.ATTACHMENTS.delete(path);
|
|
||||||
|
|
||||||
// Delete attachment metadata
|
// Delete attachment metadata
|
||||||
await storage.deleteAttachment(attachmentId);
|
await storage.deleteAttachment(attachmentId);
|
||||||
@@ -298,14 +362,19 @@ export async function handleDeleteAttachment(
|
|||||||
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
await storage.removeAttachmentFromCipher(cipherId, attachmentId);
|
||||||
|
|
||||||
// Update cipher revision date
|
// Update cipher revision date
|
||||||
await storage.updateCipherRevisionDate(cipherId);
|
const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
|
||||||
|
if (revisionInfo) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, revisionInfo.userId, revisionInfo.revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
// Get updated cipher for response
|
// Get updated cipher for response
|
||||||
const updatedCipher = await storage.getCipher(cipherId);
|
const updatedCipher = await storage.getCipher(cipherId);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
cipher: cipherToResponse(updatedCipher!, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,8 +387,8 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
for (const attachment of attachments) {
|
||||||
const path = getAttachmentPath(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await env.ATTACHMENTS.delete(path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
await storage.deleteAttachment(attachment.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,865 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import {
|
||||||
|
type BackupArchiveBundle,
|
||||||
|
buildBackupArchive,
|
||||||
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
verifyBackupArchiveFileNameChecksum,
|
||||||
|
} from '../services/backup-archive';
|
||||||
|
import {
|
||||||
|
type BackupDestinationRecord,
|
||||||
|
type BackupSettingsInput,
|
||||||
|
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
||||||
|
getBackupLocalDateKey,
|
||||||
|
getDefaultBackupSettings,
|
||||||
|
getBackupSettingsRepairState,
|
||||||
|
isBackupDueNow,
|
||||||
|
loadBackupSettings,
|
||||||
|
normalizeBackupSettingsInput,
|
||||||
|
normalizeImportedBackupSettings,
|
||||||
|
repairBackupSettings,
|
||||||
|
requireBackupDestination,
|
||||||
|
saveBackupSettings,
|
||||||
|
} from '../services/backup-config';
|
||||||
|
import {
|
||||||
|
type BackupImportExecutionResult,
|
||||||
|
type BackupRestoreProgressReporter,
|
||||||
|
importBackupArchiveBytes,
|
||||||
|
importRemoteBackupArchiveBytes,
|
||||||
|
} from '../services/backup-import';
|
||||||
|
import {
|
||||||
|
type RemoteBackupTransferSession,
|
||||||
|
createRemoteBackupTransferSession,
|
||||||
|
deleteRemoteBackupFile,
|
||||||
|
downloadRemoteBackupFile,
|
||||||
|
ensureRemoteRestoreCandidate,
|
||||||
|
listRemoteBackupEntries,
|
||||||
|
pruneRemoteBackupArchives,
|
||||||
|
uploadBackupArchive,
|
||||||
|
} from '../services/backup-uploader';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { getBlobObject } from '../services/blob-store';
|
||||||
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
|
||||||
|
function isAdmin(user: User): boolean {
|
||||||
|
return user.role === 'admin' && user.status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAuditLog(
|
||||||
|
storage: StorageService,
|
||||||
|
actorUserId: string | null,
|
||||||
|
action: string,
|
||||||
|
targetType: string | null,
|
||||||
|
targetId: string | null,
|
||||||
|
metadata: Record<string, unknown> | null
|
||||||
|
): Promise<void> {
|
||||||
|
await storage.createAuditLog({
|
||||||
|
id: generateUUID(),
|
||||||
|
actorUserId,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupDestinationSummary(destination: BackupDestinationRecord | null): Record<string, unknown> {
|
||||||
|
if (!destination) {
|
||||||
|
return {
|
||||||
|
destinationId: null,
|
||||||
|
destinationName: null,
|
||||||
|
destinationType: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
destinationId: destination.id,
|
||||||
|
destinationName: destination.name,
|
||||||
|
destinationType: destination.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBackupBlobName(value: string): string {
|
||||||
|
const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('Backup attachment blob is required');
|
||||||
|
}
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
|
||||||
|
throw new Error('Backup attachment blob is invalid');
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
||||||
|
|
||||||
|
interface RemoteAttachmentIndexPayload {
|
||||||
|
version: 1;
|
||||||
|
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
||||||
|
try {
|
||||||
|
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
|
||||||
|
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
Object.entries(payload.blobs)
|
||||||
|
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
|
||||||
|
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('404') || message.includes('Please select a backup file')) {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemoteAttachmentIndex(
|
||||||
|
session: RemoteBackupTransferSession,
|
||||||
|
index: Map<string, number>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: RemoteAttachmentIndexPayload = {
|
||||||
|
version: 1,
|
||||||
|
blobs: Object.fromEntries(
|
||||||
|
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
|
||||||
|
blobName,
|
||||||
|
{
|
||||||
|
sizeBytes,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeConfiguredBackup(
|
||||||
|
env: Env,
|
||||||
|
storage: StorageService,
|
||||||
|
actorUserId: string | null,
|
||||||
|
trigger: 'manual' | 'scheduled',
|
||||||
|
destinationId?: string | null,
|
||||||
|
progress?: ((event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => Promise<void>) | null
|
||||||
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||||
|
const maxArchiveUploadAttempts = 3;
|
||||||
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
destination.runtime.lastAttemptAt = now.toISOString();
|
||||||
|
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
|
destination.runtime.lastErrorAt = null;
|
||||||
|
destination.runtime.lastErrorMessage = null;
|
||||||
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_prepare',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
||||||
|
});
|
||||||
|
const archive = await buildBackupArchive(env, now, {
|
||||||
|
includeAttachments: destination.includeAttachments,
|
||||||
|
progress: progress
|
||||||
|
? async (event) => {
|
||||||
|
if (event.step === 'archive_ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await progress({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: `remote_run_${event.step}`,
|
||||||
|
fileName: event.fileName || '',
|
||||||
|
stageTitle: event.stageTitle,
|
||||||
|
stageDetail: event.stageDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_sync_attachments',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
|
||||||
|
stageDetail: destination.includeAttachments
|
||||||
|
? 'txt_backup_remote_run_progress_sync_attachments_detail'
|
||||||
|
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||||
|
});
|
||||||
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||||
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
|
let attachmentIndexChanged = false;
|
||||||
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||||
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const remotePath = `attachments/${attachment.blobName}`;
|
||||||
|
const object = await getBlobObject(env, attachment.blobName);
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||||
|
await remoteSession.putFile(remotePath, bytes, {
|
||||||
|
contentType: object.contentType,
|
||||||
|
});
|
||||||
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||||
|
attachmentIndexChanged = true;
|
||||||
|
}
|
||||||
|
if (attachmentIndexChanged) {
|
||||||
|
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||||
|
}
|
||||||
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_upload_archive',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_upload_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
|
||||||
|
});
|
||||||
|
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_verify_archive',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||||
|
});
|
||||||
|
const remoteFile = await remoteSession.download(archive.fileName);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||||
|
if (!checksumOk) {
|
||||||
|
throw new Error('Remote backup ZIP checksum verification failed');
|
||||||
|
}
|
||||||
|
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||||
|
throw new Error('Remote backup ZIP size verification failed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||||
|
if (attempt === maxArchiveUploadAttempts) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
|
||||||
|
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!upload) {
|
||||||
|
throw new Error('Backup archive upload failed');
|
||||||
|
}
|
||||||
|
let prunedFileCount = 0;
|
||||||
|
let pruneErrorMessage: string | null = null;
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_cleanup',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
|
||||||
|
});
|
||||||
|
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
||||||
|
} catch (error) {
|
||||||
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
destination.runtime.lastSuccessAt = new Date().toISOString();
|
||||||
|
destination.runtime.lastErrorAt = null;
|
||||||
|
destination.runtime.lastErrorMessage = null;
|
||||||
|
destination.runtime.lastUploadedFileName = archive.fileName;
|
||||||
|
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
||||||
|
destination.runtime.lastUploadedDestination = upload.remotePath;
|
||||||
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
provider: upload.provider,
|
||||||
|
remotePath: upload.remotePath,
|
||||||
|
fileName: archive.fileName,
|
||||||
|
fileBytes: archive.bytes.byteLength,
|
||||||
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
|
prunedFileCount,
|
||||||
|
pruneError: pruneErrorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_complete',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_complete_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: archive.fileName,
|
||||||
|
fileSize: archive.bytes.byteLength,
|
||||||
|
remotePath: upload.remotePath,
|
||||||
|
provider: upload.provider,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
destination.runtime.lastErrorAt = new Date().toISOString();
|
||||||
|
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
error: destination.runtime.lastErrorMessage,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_failed',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_failed_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: destination.runtime.lastErrorMessage,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toImportStatusCode(message: string): number {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
||||||
|
if (lower.includes('fresh instance')) return 409;
|
||||||
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
||||||
|
return 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImportAndAudit(
|
||||||
|
env: Env,
|
||||||
|
request: Request,
|
||||||
|
actorUser: User,
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
fileName: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await progress({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_upload_received',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_upload_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
|
||||||
|
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
|
users: imported.result.imported.users,
|
||||||
|
ciphers: imported.result.imported.ciphers,
|
||||||
|
attachments: imported.result.imported.attachmentFiles,
|
||||||
|
skippedAttachments: imported.result.skipped.attachments,
|
||||||
|
skippedReason: imported.result.skipped.reason,
|
||||||
|
replaceExisting,
|
||||||
|
...metadata,
|
||||||
|
});
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const now = new Date();
|
||||||
|
for (const destination of settings.destinations) {
|
||||||
|
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
|
||||||
|
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
return jsonResponse(settings);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: BackupSettingsInput;
|
||||||
|
try {
|
||||||
|
body = await request.json<BackupSettingsInput>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup settings payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let previous;
|
||||||
|
try {
|
||||||
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
} catch {
|
||||||
|
previous = getDefaultBackupSettings('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
let next;
|
||||||
|
try {
|
||||||
|
next = normalizeBackupSettingsInput(body, previous);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings are invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveBackupSettings(storage, env, next);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
||||||
|
destinationCount: next.destinations.length,
|
||||||
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
|
});
|
||||||
|
return jsonResponse(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const state = await getBackupSettingsRepairState(storage, env, 'UTC');
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-settings-repair',
|
||||||
|
needsRepair: state.needsRepair,
|
||||||
|
portable: state.portable,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair state could not be loaded', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: BackupSettingsInput;
|
||||||
|
try {
|
||||||
|
body = await request.json<BackupSettingsInput>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup settings repair payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
let previous;
|
||||||
|
try {
|
||||||
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
} catch {
|
||||||
|
previous = getDefaultBackupSettings('UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
let next;
|
||||||
|
try {
|
||||||
|
next = normalizeBackupSettingsInput(body, previous);
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await repairBackupSettings(storage, env, next);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
||||||
|
destinationCount: next.destinations.length,
|
||||||
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
||||||
|
});
|
||||||
|
return jsonResponse(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
let body: { destinationId?: string } | null = null;
|
||||||
|
try {
|
||||||
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
|
body = await request.json<{ destinationId?: string }>();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup run payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const progress = async (event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||||
|
};
|
||||||
|
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-run',
|
||||||
|
result: {
|
||||||
|
fileName: result.fileName,
|
||||||
|
fileSize: result.fileSize,
|
||||||
|
provider: result.provider,
|
||||||
|
remotePath: result.remotePath,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleListAdminRemoteBackups(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
const listing = await listRemoteBackupEntries(destination, url.searchParams.get('path') || '');
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-remote-browser',
|
||||||
|
destinationId: destination.id,
|
||||||
|
destinationName: destination.name,
|
||||||
|
...listing,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup listing failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||||
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
return new Response(remoteFile.bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': remoteFile.contentType || 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup download failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||||
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-remote-integrity',
|
||||||
|
destinationId: destination.id,
|
||||||
|
path,
|
||||||
|
fileName: remoteFile.fileName || path.split('/').pop() || path,
|
||||||
|
integrity,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||||
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
await deleteRemoteBackupFile(destination, path);
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
remotePath: path,
|
||||||
|
});
|
||||||
|
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
||||||
|
try {
|
||||||
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Remote restore payload is invalid', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_fetch_archive',
|
||||||
|
fileName: restoreFileNameFromPath,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||||
|
replaceExisting: !!body.replaceExisting,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||||
|
return errorResponse('Remote backup file checksum does not match its filename', 400);
|
||||||
|
}
|
||||||
|
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
||||||
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const imported = await (async () => {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const result = await importRemoteBackupArchiveBytes(
|
||||||
|
remoteFile.bytes,
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
!!body.replaceExisting,
|
||||||
|
{
|
||||||
|
loadAttachment: async (blobName) => {
|
||||||
|
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||||
|
return file?.bytes || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
restoreFileName
|
||||||
|
);
|
||||||
|
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
|
users: result.result.imported.users,
|
||||||
|
ciphers: result.result.imported.ciphers,
|
||||||
|
attachments: result.result.imported.attachmentFiles,
|
||||||
|
skippedAttachments: result.result.skipped.attachments,
|
||||||
|
skippedReason: result.result.skipped.reason,
|
||||||
|
replaceExisting: !!body.replaceExisting,
|
||||||
|
...getBackupDestinationSummary(destination),
|
||||||
|
remotePath: path,
|
||||||
|
bytes: remoteFile.bytes.byteLength,
|
||||||
|
trigger: 'remote',
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
return jsonResponse(imported.result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
||||||
|
return errorResponse(message, toImportStatusCode(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAdminExportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
let body: { includeAttachments?: boolean } | null = null;
|
||||||
|
try {
|
||||||
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
|
body = await request.json<{ includeAttachments?: boolean }>();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup export payload is invalid', 400);
|
||||||
|
}
|
||||||
|
let archive: BackupArchiveBundle;
|
||||||
|
try {
|
||||||
|
const progress = async (event: {
|
||||||
|
step: string;
|
||||||
|
fileName?: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: `export_${event.step}`,
|
||||||
|
fileName: event.fileName || '',
|
||||||
|
stageTitle: event.stageTitle,
|
||||||
|
stageDetail: event.stageDetail,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
archive = await buildBackupArchive(env, new Date(), {
|
||||||
|
includeAttachments: !!body?.includeAttachments,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Backup export failed';
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_failed',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_export_progress_failed_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_failed_detail',
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
|
||||||
|
users: archive.manifest.tableCounts.users,
|
||||||
|
ciphers: archive.manifest.tableCounts.ciphers,
|
||||||
|
attachments: archive.manifest.tableCounts.attachments,
|
||||||
|
compressedBytes: archive.bytes.byteLength,
|
||||||
|
includesAttachments: archive.manifest.includes.attachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(archive.bytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownloadAdminBackupAttachment(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
|
||||||
|
const object = await getBlobObject(env, blobName);
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Backup attachment blob not found', 404);
|
||||||
|
}
|
||||||
|
return new Response(object.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': String(object.size),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Backup attachment download failed', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await request.formData();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get('file');
|
||||||
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||||
|
return errorResponse('Backup file is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||||
|
let archiveBytes: Uint8Array;
|
||||||
|
try {
|
||||||
|
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Unable to read backup file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileName = 'name' in file ? String((file as File).name || '') : '';
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
|
||||||
|
if (!checksumOk && !allowChecksumMismatch) {
|
||||||
|
return errorResponse('Backup file checksum does not match its filename', 400);
|
||||||
|
}
|
||||||
|
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
|
||||||
|
trigger: 'local',
|
||||||
|
bytes: archiveBytes.byteLength,
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
|
});
|
||||||
|
return jsonResponse(imported.result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Backup import failed';
|
||||||
|
return errorResponse(message, toImportStatusCode(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedDefaultBackupSettings(env: Env): Promise<void> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const current = await storage.getConfigValue('backup.settings.v1');
|
||||||
|
if (current) {
|
||||||
|
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveBackupSettings(storage, env, getDefaultBackupSettings('UTC'));
|
||||||
|
}
|
||||||
+416
-44
@@ -1,9 +1,26 @@
|
|||||||
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
import { Env, Cipher, CipherResponse, Attachment } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { deleteAllAttachmentsForCipher } from './attachments';
|
import { deleteAllAttachmentsForCipher } from './attachments';
|
||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } {
|
||||||
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||||
@@ -15,28 +32,130 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
|||||||
return { present: false, value: undefined };
|
return { present: false, value: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android 2026.2.0 expects fido2Credentials[].counter to be a string.
|
function normalizeCipherTimestamp(value: unknown): string | null {
|
||||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
if (value == null || value === '') return null;
|
||||||
if (!login || typeof login !== 'object') return login ?? null;
|
const parsed = new Date(String(value));
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
const fido2 = Array.isArray(login.fido2Credentials)
|
function readCipherArchivedAt(source: any, fallback: string | null = null): string | null {
|
||||||
? login.fido2Credentials.map((cred: any) => {
|
const archived = getAliasedProp(source, ['archivedAt', 'ArchivedAt', 'archivedDate', 'ArchivedDate']);
|
||||||
if (!cred || typeof cred !== 'object') return cred;
|
return archived.present ? normalizeCipherTimestamp(archived.value) : fallback;
|
||||||
const rawCounter = cred.counter;
|
}
|
||||||
const counter =
|
|
||||||
rawCounter === null || rawCounter === undefined
|
function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
||||||
? '0'
|
cipher.archivedDate = cipher.archivedAt ?? null;
|
||||||
: String(rawCounter);
|
cipher.deletedDate = cipher.deletedAt ?? null;
|
||||||
return {
|
return cipher;
|
||||||
...cred,
|
}
|
||||||
counter,
|
|
||||||
};
|
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||||
})
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
: login.fido2Credentials;
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
|
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||||
|
cipher.archivedAt = hasArchivedAt
|
||||||
|
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||||
|
: normalizeCipherTimestamp(cipher.archivedDate) ?? null;
|
||||||
|
return syncCipherComputedAliases(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeCipherString(value: unknown): boolean {
|
||||||
|
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
||||||
|
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
||||||
|
if (!userAgent) return false;
|
||||||
|
|
||||||
|
// Temporary compatibility fallback:
|
||||||
|
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
||||||
|
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
||||||
|
// for mobile clients so newly-saved credentials can flow through unchanged.
|
||||||
|
return (
|
||||||
|
userAgent.includes('android') ||
|
||||||
|
userAgent.includes('iphone') ||
|
||||||
|
userAgent.includes('ipad') ||
|
||||||
|
userAgent.includes('ios')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...login,
|
...login,
|
||||||
fido2Credentials: fido2,
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCipherLoginForCompatibility(
|
||||||
|
login: any,
|
||||||
|
options?: { omitFido2Credentials?: boolean }
|
||||||
|
): any {
|
||||||
|
const normalized = normalizeCipherLoginForStorage(login);
|
||||||
|
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||||
|
if (!options?.omitFido2Credentials) return normalized;
|
||||||
|
|
||||||
|
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
||||||
|
if (!credentials?.length) return normalized;
|
||||||
|
|
||||||
|
const hasMalformedCredential = credentials.some((credential: any) => {
|
||||||
|
if (!credential || typeof credential !== 'object') return true;
|
||||||
|
const requiredEncryptedFields = [
|
||||||
|
credential.credentialId,
|
||||||
|
credential.keyType,
|
||||||
|
credential.keyAlgorithm,
|
||||||
|
credential.keyCurve,
|
||||||
|
credential.keyValue,
|
||||||
|
credential.rpId,
|
||||||
|
credential.counter,
|
||||||
|
credential.discoverable,
|
||||||
|
];
|
||||||
|
const optionalEncryptedFields = [
|
||||||
|
credential.userHandle,
|
||||||
|
credential.userName,
|
||||||
|
credential.rpName,
|
||||||
|
credential.userDisplayName,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasMalformedCredential
|
||||||
|
? {
|
||||||
|
...normalized,
|
||||||
|
fido2Credentials: null,
|
||||||
|
}
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||||
|
// Keep legacy alias "fingerprint" in parallel for older web payloads.
|
||||||
|
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||||
|
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
|
||||||
|
|
||||||
|
const candidate =
|
||||||
|
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
|
||||||
|
? sshKey.keyFingerprint
|
||||||
|
: sshKey.fingerprint;
|
||||||
|
|
||||||
|
const normalizedFingerprint =
|
||||||
|
candidate === undefined || candidate === null
|
||||||
|
? ''
|
||||||
|
: String(candidate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sshKey,
|
||||||
|
keyFingerprint: normalizedFingerprint,
|
||||||
|
fingerprint: normalizedFingerprint,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,22 +178,28 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
|||||||
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
||||||
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
||||||
// survive a round-trip without code changes.
|
// survive a round-trip without code changes.
|
||||||
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
export function cipherToResponse(
|
||||||
|
cipher: Cipher,
|
||||||
|
attachments: Attachment[] = [],
|
||||||
|
options?: { omitFido2Credentials?: boolean }
|
||||||
|
): CipherResponse {
|
||||||
// Strip internal-only fields that must not appear in the API response
|
// Strip internal-only fields that must not appear in the API response
|
||||||
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
|
const { userId, createdAt, updatedAt, archivedAt, deletedAt, ...passthrough } = cipher;
|
||||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
||||||
|
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
...passthrough,
|
...passthrough,
|
||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
|
folderId: normalizeOptionalId(cipher.folderId),
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: false,
|
||||||
creationDate: createdAt,
|
creationDate: createdAt,
|
||||||
revisionDate: updatedAt,
|
revisionDate: updatedAt,
|
||||||
deletedDate: deletedAt,
|
deletedDate: deletedAt,
|
||||||
archivedDate: null,
|
archivedDate: archivedAt ?? null,
|
||||||
edit: true,
|
edit: true,
|
||||||
viewPassword: true,
|
viewPassword: true,
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -85,6 +210,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
|
|||||||
collectionIds: [],
|
collectionIds: [],
|
||||||
attachments: formatAttachments(attachments),
|
attachments: formatAttachments(attachments),
|
||||||
login: normalizedLogin,
|
login: normalizedLogin,
|
||||||
|
sshKey: normalizedSshKey,
|
||||||
encryptedFor: null,
|
encryptedFor: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -95,6 +221,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||||
const pagination = parsePagination(url);
|
const pagination = parsePagination(url);
|
||||||
|
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||||
|
|
||||||
let filteredCiphers: Cipher[];
|
let filteredCiphers: Cipher[];
|
||||||
let continuationToken: string | null = null;
|
let continuationToken: string | null = null;
|
||||||
@@ -121,7 +248,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
const cipherResponses = [];
|
const cipherResponses = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -141,7 +268,11 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
return jsonResponse(cipherToResponse(cipher, attachments));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||||
@@ -178,11 +309,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
reprompt: cipherData.reprompt || 0,
|
reprompt: cipherData.reprompt || 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
archivedAt: readCipherArchivedAt(cipherData, null),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
|
||||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
|
||||||
// Prevent referencing a folder owned by another user.
|
// Prevent referencing a folder owned by another user.
|
||||||
if (cipher.folderId) {
|
if (cipher.folderId) {
|
||||||
@@ -191,9 +323,15 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher), 200);
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
}),
|
||||||
|
200
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/ciphers/:id
|
// PUT /api/ciphers/:id
|
||||||
@@ -229,9 +367,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
reprompt: cipherData.reprompt ?? existingCipher.reprompt,
|
||||||
createdAt: existingCipher.createdAt,
|
createdAt: existingCipher.createdAt,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
archivedAt: readCipherArchivedAt(cipherData, existingCipher.archivedAt ?? null),
|
||||||
deletedAt: existingCipher.deletedAt,
|
deletedAt: existingCipher.deletedAt,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
|
||||||
|
|
||||||
// Custom fields deletion compatibility:
|
// Custom fields deletion compatibility:
|
||||||
// - Accept both camelCase "fields" and PascalCase "Fields".
|
// - Accept both camelCase "fields" and PascalCase "Fields".
|
||||||
@@ -243,6 +381,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
} else if (request.method === 'PUT' || request.method === 'POST') {
|
} else if (request.method === 'PUT' || request.method === 'POST') {
|
||||||
cipher.fields = null;
|
cipher.fields = null;
|
||||||
}
|
}
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
|
||||||
// Prevent referencing a folder owned by another user.
|
// Prevent referencing a folder owned by another user.
|
||||||
if (cipher.folderId) {
|
if (cipher.folderId) {
|
||||||
@@ -251,9 +390,14 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/ciphers/:id
|
// DELETE /api/ciphers/:id
|
||||||
@@ -268,10 +412,16 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
|||||||
// Soft delete
|
// Soft delete
|
||||||
cipher.deletedAt = new Date().toISOString();
|
cipher.deletedAt = new Date().toISOString();
|
||||||
cipher.updatedAt = cipher.deletedAt;
|
cipher.updatedAt = cipher.deletedAt;
|
||||||
|
syncCipherComputedAliases(cipher);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/ciphers/:id (compat mode)
|
// DELETE /api/ciphers/:id (compat mode)
|
||||||
@@ -290,7 +440,8 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
|
|||||||
if (cipher.deletedAt) {
|
if (cipher.deletedAt) {
|
||||||
await deleteAllAttachmentsForCipher(env, id);
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +461,8 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
|
|||||||
await deleteAllAttachmentsForCipher(env, id);
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
|
|
||||||
await storage.deleteCipher(id, userId);
|
await storage.deleteCipher(id, userId);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
@@ -326,10 +478,16 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
cipher.deletedAt = null;
|
cipher.deletedAt = null;
|
||||||
cipher.updatedAt = new Date().toISOString();
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
syncCipherComputedAliases(cipher);
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||||
@@ -349,21 +507,28 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId !== undefined) {
|
if (body.folderId !== undefined) {
|
||||||
if (body.folderId) {
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
cipher.folderId = body.folderId;
|
cipher.folderId = folderId;
|
||||||
}
|
}
|
||||||
if (body.favorite !== undefined) {
|
if (body.favorite !== undefined) {
|
||||||
cipher.favorite = body.favorite;
|
cipher.favorite = body.favorite;
|
||||||
}
|
}
|
||||||
cipher.updatedAt = new Date().toISOString();
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
syncCipherComputedAliases(cipher);
|
||||||
|
|
||||||
await storage.saveCipher(cipher);
|
await storage.saveCipher(cipher);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(cipherToResponse(cipher));
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, [], {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||||
@@ -381,12 +546,219 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
|||||||
return errorResponse('ids array is required', 400);
|
return errorResponse('ids array is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId) {
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
|
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCipherListResponse(
|
||||||
|
request: Request,
|
||||||
|
storage: StorageService,
|
||||||
|
userId: string,
|
||||||
|
ids: string[]
|
||||||
|
): Promise<Response> {
|
||||||
|
const ciphers = await storage.getCiphersByIds(ids, userId);
|
||||||
|
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map((cipher) => cipher.id));
|
||||||
|
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: ciphers.map((cipher) =>
|
||||||
|
cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], {
|
||||||
|
omitFido2Credentials,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCipherIdList(body: { ids?: unknown }): string[] | null {
|
||||||
|
if (!Array.isArray(body.ids)) return null;
|
||||||
|
return Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/:id/archive
|
||||||
|
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
if (cipher.deletedAt) {
|
||||||
|
return errorResponse('Cannot archive a deleted cipher', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher.archivedAt = new Date().toISOString();
|
||||||
|
cipher.updatedAt = cipher.archivedAt;
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/:id/unarchive
|
||||||
|
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const cipher = await storage.getCipher(id);
|
||||||
|
|
||||||
|
if (!cipher || cipher.userId !== userId) {
|
||||||
|
return errorResponse('Cipher not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher.archivedAt = null;
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
normalizeCipherForStorage(cipher);
|
||||||
|
await storage.saveCipher(cipher);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||||
|
return jsonResponse(
|
||||||
|
cipherToResponse(cipher, attachments, {
|
||||||
|
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/archive
|
||||||
|
export async function handleBulkArchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = parseCipherIdList(body);
|
||||||
|
if (!ids) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkArchiveCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/ciphers/unarchive
|
||||||
|
export async function handleBulkUnarchiveCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: unknown };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = parseCipherIdList(body);
|
||||||
|
if (!ids) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildCipherListResponse(request, storage, userId, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/delete - Bulk soft delete
|
||||||
|
export async function handleBulkDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/restore - Bulk restore
|
||||||
|
export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/ciphers/delete-permanent - Bulk permanent delete
|
||||||
|
export async function handleBulkPermanentDeleteCiphers(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!ids.length) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
await deleteAllAttachmentsForCipher(env, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteCiphers(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
+332
-21
@@ -1,7 +1,105 @@
|
|||||||
|
import type { Device, DevicePendingAuthRequest, DeviceResponse, ProtectedDeviceResponse as ProtectedDeviceWireResponse } from '../types';
|
||||||
import { Env } from '../types';
|
import { Env } from '../types';
|
||||||
|
import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { readKnownDeviceProbe } from '../utils/device';
|
import { readKnownDeviceProbe } from '../utils/device';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
function normalizeIdentifier(value: string | null | undefined): string {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDevicePendingAuthRequest(value?: { id?: string | null; creationDate?: string | null } | null): DevicePendingAuthRequest | null {
|
||||||
|
if (!value?.id || !value.creationDate) return null;
|
||||||
|
return {
|
||||||
|
id: String(value.id),
|
||||||
|
creationDate: String(value.creationDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrustedDevice(device: Pick<Device, 'encryptedUserKey' | 'encryptedPublicKey'>): boolean {
|
||||||
|
return !!(device.encryptedUserKey && device.encryptedPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeviceResponse(device: Device): DeviceResponse {
|
||||||
|
const response = {
|
||||||
|
Id: device.deviceIdentifier,
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
UserId: device.userId,
|
||||||
|
userId: device.userId,
|
||||||
|
Name: device.name,
|
||||||
|
name: device.name,
|
||||||
|
Identifier: device.deviceIdentifier,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
Type: device.type,
|
||||||
|
type: device.type,
|
||||||
|
CreationDate: device.createdAt,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
RevisionDate: device.updatedAt,
|
||||||
|
revisionDate: device.updatedAt,
|
||||||
|
IsTrusted: isTrustedDevice(device),
|
||||||
|
isTrusted: isTrustedDevice(device),
|
||||||
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
|
encryptedUserKey: device.encryptedUserKey,
|
||||||
|
EncryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
encryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
DevicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||||
|
devicePendingAuthRequest: buildDevicePendingAuthRequest(device.devicePendingAuthRequest),
|
||||||
|
object: 'device',
|
||||||
|
};
|
||||||
|
return response as DeviceResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProtectedDeviceResponse(device: Device): ProtectedDeviceWireResponse {
|
||||||
|
const response = {
|
||||||
|
Id: device.deviceIdentifier,
|
||||||
|
id: device.deviceIdentifier,
|
||||||
|
Name: device.name,
|
||||||
|
name: device.name,
|
||||||
|
Identifier: device.deviceIdentifier,
|
||||||
|
identifier: device.deviceIdentifier,
|
||||||
|
Type: device.type,
|
||||||
|
type: device.type,
|
||||||
|
CreationDate: device.createdAt,
|
||||||
|
creationDate: device.createdAt,
|
||||||
|
EncryptedUserKey: device.encryptedUserKey,
|
||||||
|
encryptedUserKey: device.encryptedUserKey,
|
||||||
|
EncryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
encryptedPublicKey: device.encryptedPublicKey,
|
||||||
|
object: 'protectedDevice',
|
||||||
|
};
|
||||||
|
return response as ProtectedDeviceWireResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseKeysBody(body: any, fallback?: Device): {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
encryptedUserKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedUserKey')
|
||||||
|
? body?.encryptedUserKey ?? null
|
||||||
|
: fallback?.encryptedUserKey ?? null,
|
||||||
|
encryptedPublicKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPublicKey')
|
||||||
|
? body?.encryptedPublicKey ?? null
|
||||||
|
: fallback?.encryptedPublicKey ?? null,
|
||||||
|
encryptedPrivateKey:
|
||||||
|
Object.prototype.hasOwnProperty.call(body || {}, 'encryptedPrivateKey')
|
||||||
|
? body?.encryptedPrivateKey ?? null
|
||||||
|
: fallback?.encryptedPrivateKey ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonBody(request: Request): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await request.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/devices/knowndevice
|
// GET /api/devices/knowndevice
|
||||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||||
@@ -26,29 +124,53 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
|
|||||||
const devices = await storage.getDevicesByUserId(userId);
|
const devices = await storage.getDevicesByUserId(userId);
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: devices.map(device => ({
|
data: devices.map((device) => buildDeviceResponse(device)),
|
||||||
id: device.deviceIdentifier,
|
|
||||||
name: device.name,
|
|
||||||
identifier: device.deviceIdentifier,
|
|
||||||
type: device.type,
|
|
||||||
creationDate: device.createdAt,
|
|
||||||
revisionDate: device.updatedAt,
|
|
||||||
object: 'device',
|
|
||||||
})),
|
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken: null,
|
continuationToken: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/identifier/:deviceIdentifier
|
||||||
|
export async function handleGetDeviceByIdentifier(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(buildDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/devices/:deviceIdentifier
|
||||||
|
export async function handleGetDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/devices/authorized
|
// GET /api/devices/authorized
|
||||||
// Returns known devices together with active 2FA remember-token expiry.
|
// Returns known devices together with active 2FA remember-token expiry.
|
||||||
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
void request;
|
void request;
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const [devices, trusted] = await Promise.all([
|
const [devices, trusted, onlineDeviceIdentifiers] = await Promise.all([
|
||||||
storage.getDevicesByUserId(userId),
|
storage.getDevicesByUserId(userId),
|
||||||
storage.getTrustedDeviceTokenSummariesByUserId(userId),
|
storage.getTrustedDeviceTokenSummariesByUserId(userId),
|
||||||
|
getOnlineUserDevices(env, userId),
|
||||||
]);
|
]);
|
||||||
|
const onlineSet = new Set(onlineDeviceIdentifiers);
|
||||||
|
|
||||||
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
|
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
|
||||||
for (const row of trusted) {
|
for (const row of trusted) {
|
||||||
@@ -60,12 +182,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
knownIdentifiers.add(device.deviceIdentifier);
|
knownIdentifiers.add(device.deviceIdentifier);
|
||||||
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
|
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
|
||||||
return {
|
return {
|
||||||
id: device.deviceIdentifier,
|
...buildDeviceResponse(device),
|
||||||
name: device.name,
|
online: onlineSet.has(device.deviceIdentifier),
|
||||||
identifier: device.deviceIdentifier,
|
|
||||||
type: device.type,
|
|
||||||
creationDate: device.createdAt,
|
|
||||||
revisionDate: device.updatedAt,
|
|
||||||
trusted: !!trustedInfo,
|
trusted: !!trustedInfo,
|
||||||
trustedTokenCount: trustedInfo?.tokenCount || 0,
|
trustedTokenCount: trustedInfo?.tokenCount || 0,
|
||||||
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
|
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
|
||||||
@@ -75,13 +193,23 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use
|
|||||||
|
|
||||||
for (const row of trusted) {
|
for (const row of trusted) {
|
||||||
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
|
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
|
||||||
data.push({
|
const placeholderDevice: Device = {
|
||||||
id: row.deviceIdentifier,
|
userId,
|
||||||
|
deviceIdentifier: row.deviceIdentifier,
|
||||||
name: 'Unknown device',
|
name: 'Unknown device',
|
||||||
identifier: row.deviceIdentifier,
|
|
||||||
type: 14,
|
type: 14,
|
||||||
creationDate: '',
|
sessionStamp: '',
|
||||||
revisionDate: '',
|
encryptedUserKey: null,
|
||||||
|
encryptedPublicKey: null,
|
||||||
|
encryptedPrivateKey: null,
|
||||||
|
devicePendingAuthRequest: null,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
};
|
||||||
|
data.push({
|
||||||
|
...buildDeviceResponse(placeholderDevice),
|
||||||
|
isTrusted: true,
|
||||||
|
online: onlineSet.has(row.deviceIdentifier),
|
||||||
trusted: true,
|
trusted: true,
|
||||||
trustedTokenCount: row.tokenCount,
|
trustedTokenCount: row.tokenCount,
|
||||||
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
|
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
|
||||||
@@ -133,7 +261,162 @@ export async function handleDeleteDevice(
|
|||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
const deleted = await storage.deleteDevice(userId, normalized);
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
|
if (deleted) {
|
||||||
|
await notifyUserLogout(env, userId, normalized);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/devices
|
||||||
|
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const user = await storage.getUserById(userId);
|
||||||
|
if (!user) return errorResponse('User not found', 404);
|
||||||
|
|
||||||
|
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
|
||||||
|
storage.deleteTrustedTwoFactorTokensByUserId(userId),
|
||||||
|
storage.deleteRefreshTokensByUserId(userId),
|
||||||
|
storage.deleteDevicesByUserId(userId),
|
||||||
|
]);
|
||||||
|
user.securityStamp = generateUUID();
|
||||||
|
user.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveUser(user);
|
||||||
|
await notifyUserLogout(env, userId, null);
|
||||||
|
return jsonResponse({ success: true, removedTrusted, removedSessions: removedSessions ?? 0, removedDevices });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/identifier/:deviceIdentifier/keys
|
||||||
|
export async function handleUpdateDeviceKeys(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.updateDeviceKeys(userId, normalized, parseKeysBody(body, device));
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDevice = await storage.getDevice(userId, normalized);
|
||||||
|
return jsonResponse(buildDeviceResponse(nextDevice || device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/update-trust
|
||||||
|
export async function handleUpdateDeviceTrust(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const currentDeviceIdentifier =
|
||||||
|
normalizeIdentifier(request.headers.get('Device-Identifier')) ||
|
||||||
|
normalizeIdentifier(request.headers.get('X-Device-Identifier'));
|
||||||
|
|
||||||
|
const updates: Array<{
|
||||||
|
deviceIdentifier: string;
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
};
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (currentDeviceIdentifier && body?.currentDevice) {
|
||||||
|
updates.push({
|
||||||
|
deviceIdentifier: currentDeviceIdentifier,
|
||||||
|
keys: parseKeysBody(body.currentDevice, await storage.getDevice(userId, currentDeviceIdentifier) || undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(body?.otherDevices)) {
|
||||||
|
for (const item of body.otherDevices) {
|
||||||
|
const deviceIdentifier = normalizeIdentifier(item?.deviceId);
|
||||||
|
if (!deviceIdentifier) continue;
|
||||||
|
updates.push({
|
||||||
|
deviceIdentifier,
|
||||||
|
keys: parseKeysBody(item, await storage.getDevice(userId, deviceIdentifier) || undefined),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (const update of updates) {
|
||||||
|
const ok = await storage.updateDeviceKeys(userId, update.deviceIdentifier, update.keys);
|
||||||
|
if (ok) updatedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({ success: true, updated: updatedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/untrust
|
||||||
|
export async function handleUntrustDevices(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const body = await readJsonBody(request);
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const devices = Array.isArray(body?.devices) ? body.devices.map((id: unknown) => normalizeIdentifier(String(id))) : [];
|
||||||
|
const removed = await storage.clearDeviceKeys(userId, devices);
|
||||||
|
for (const deviceIdentifier of devices) {
|
||||||
|
if (!deviceIdentifier) continue;
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: true, removed });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/:deviceIdentifier/retrieve-keys
|
||||||
|
export async function handleRetrieveDeviceKeys(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const device = await storage.getDevice(userId, normalized);
|
||||||
|
if (!device) {
|
||||||
|
return errorResponse('Device not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(buildProtectedDeviceResponse(device));
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/devices/:id/deactivate
|
||||||
|
export async function handleDeactivateDevice(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const normalized = normalizeIdentifier(deviceIdentifier);
|
||||||
|
if (!normalized) return errorResponse('Invalid device identifier', 400);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
|
||||||
|
await storage.deleteRefreshTokensByDevice(userId, normalized);
|
||||||
|
const deleted = await storage.deleteDevice(userId, normalized);
|
||||||
|
if (deleted) {
|
||||||
|
await notifyUserLogout(env, userId, normalized);
|
||||||
|
}
|
||||||
return jsonResponse({ success: deleted });
|
return jsonResponse({ success: deleted });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,3 +436,31 @@ export async function handleUpdateDeviceToken(
|
|||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/:deviceIdentifier/web-push-auth
|
||||||
|
export async function handleUpdateDeviceWebPushAuth(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
void env;
|
||||||
|
void userId;
|
||||||
|
void deviceIdentifier;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT/POST /api/devices/:deviceIdentifier/clear-token
|
||||||
|
export async function handleClearDeviceToken(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
void env;
|
||||||
|
void userId;
|
||||||
|
void deviceIdentifier;
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-3
@@ -1,9 +1,20 @@
|
|||||||
import { Env, Folder, FolderResponse } from '../types';
|
import { Env, Folder, FolderResponse } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
|
||||||
|
async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
// Convert internal folder to API response format
|
// Convert internal folder to API response format
|
||||||
function folderToResponse(folder: Folder): FolderResponse {
|
function folderToResponse(folder: Folder): FolderResponse {
|
||||||
return {
|
return {
|
||||||
@@ -75,7 +86,8 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
|
|||||||
};
|
};
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder), 200);
|
return jsonResponse(folderToResponse(folder), 200);
|
||||||
}
|
}
|
||||||
@@ -102,7 +114,8 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
|
|||||||
folder.updatedAt = new Date().toISOString();
|
folder.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await storage.saveFolder(folder);
|
await storage.saveFolder(folder);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
return jsonResponse(folderToResponse(folder));
|
return jsonResponse(folderToResponse(folder));
|
||||||
}
|
}
|
||||||
@@ -118,7 +131,32 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str
|
|||||||
|
|
||||||
await storage.clearFolderFromCiphers(userId, id);
|
await storage.clearFolderFromCiphers(userId, id);
|
||||||
await storage.deleteFolder(id, userId);
|
await storage.deleteFolder(id, userId);
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/folders/delete
|
||||||
|
export async function handleBulkDeleteFolders(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.isArray(body.ids) ? body.ids.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||||||
|
if (!ids.length) {
|
||||||
|
return errorResponse('Folder ids are required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteFolders(ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-68
@@ -8,43 +8,80 @@ import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
|||||||
import { createRefreshToken } from '../utils/jwt';
|
import { createRefreshToken } from '../utils/jwt';
|
||||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { issueSendAccessToken } from './sends';
|
import { issueSendAccessToken } from './sends';
|
||||||
|
import {
|
||||||
|
buildAccountKeys,
|
||||||
|
buildUserDecryptionOptions,
|
||||||
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||||
|
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY = 8;
|
||||||
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST = 100;
|
||||||
|
|
||||||
function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null {
|
function resolveTotpSecret(userSecret: string | null): string | null {
|
||||||
if (userSecret && isTotpEnabled(userSecret)) {
|
if (userSecret && isTotpEnabled(userSecret)) {
|
||||||
return userSecret;
|
return userSecret;
|
||||||
}
|
}
|
||||||
if (isTotpEnabled(envSecret)) {
|
|
||||||
return envSecret!;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPreloginResponse(
|
||||||
|
email: string,
|
||||||
|
kdfType: number,
|
||||||
|
kdfIterations: number,
|
||||||
|
kdfMemory: number | null,
|
||||||
|
kdfParallelism: number | null
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
kdf: kdfType,
|
||||||
|
kdfIterations,
|
||||||
|
kdfMemory,
|
||||||
|
kdfParallelism,
|
||||||
|
KdfSettings: {
|
||||||
|
KdfType: kdfType,
|
||||||
|
Iterations: kdfIterations,
|
||||||
|
Memory: kdfMemory,
|
||||||
|
Parallelism: kdfParallelism,
|
||||||
|
},
|
||||||
|
Salt: email.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
|
||||||
const providers = includeRecoveryCode
|
const providers = includeRecoveryCode
|
||||||
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), String(TWO_FACTOR_PROVIDER_RECOVERY_CODE)]
|
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE]
|
||||||
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
|
||||||
const providers2: Record<string, null> = {};
|
const providers2: Record<string, null> = {};
|
||||||
for (const provider of providers) providers2[provider] = null;
|
for (const provider of providers) providers2[provider] = null;
|
||||||
|
const customResponse = {
|
||||||
|
TwoFactorProviders: providers,
|
||||||
|
TwoFactorProviders2: providers2,
|
||||||
|
SsoEmail2faSessionToken: null,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
error: 'invalid_grant',
|
error: 'invalid_grant',
|
||||||
error_description: message,
|
error_description: message,
|
||||||
TwoFactorProviders: providers,
|
Error: 'invalid_grant',
|
||||||
TwoFactorProviders2: providers2,
|
ErrorDescription: message,
|
||||||
|
ErrorMessage: message,
|
||||||
|
TwoFactorProviders: customResponse.TwoFactorProviders,
|
||||||
|
TwoFactorProviders2: customResponse.TwoFactorProviders2,
|
||||||
// Required by current Android parser (nullable value is acceptable).
|
// Required by current Android parser (nullable value is acceptable).
|
||||||
SsoEmail2faSessionToken: null,
|
SsoEmail2faSessionToken: customResponse.SsoEmail2faSessionToken,
|
||||||
// Keep payload shape close to upstream implementations.
|
MasterPasswordPolicy: customResponse.MasterPasswordPolicy,
|
||||||
MasterPasswordPolicy: {
|
CustomResponse: customResponse,
|
||||||
Object: 'masterPasswordPolicy',
|
|
||||||
},
|
|
||||||
ErrorModel: {
|
ErrorModel: {
|
||||||
Message: message,
|
Message: message,
|
||||||
Object: 'error',
|
Object: 'error',
|
||||||
@@ -106,6 +143,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const grantType = body.grant_type;
|
const grantType = body.grant_type;
|
||||||
const clientIdentifier = getClientIdentifier(request);
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return identityErrorResponse('Client IP is required', 'invalid_request', 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (grantType === 'password') {
|
if (grantType === 'password') {
|
||||||
// Login with password
|
// Login with password
|
||||||
@@ -151,9 +191,9 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional 2FA: enabled per-user secret first, then falls back to global env secret for compatibility.
|
// Optional 2FA: enabled only by per-user secret.
|
||||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET);
|
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret);
|
||||||
if (effectiveTotpSecret) {
|
if (effectiveTotpSecret) {
|
||||||
const canUseRecoveryCode = !!user.totpRecoveryCode;
|
const canUseRecoveryCode = !!user.totpRecoveryCode;
|
||||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
|
||||||
@@ -168,13 +208,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10);
|
|
||||||
if (!Number.isFinite(parsedProvider)) {
|
|
||||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
let passedByRememberToken = false;
|
let passedByRememberToken = false;
|
||||||
if (parsedProvider === TWO_FACTOR_PROVIDER_REMEMBER) {
|
if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_REMEMBER)) {
|
||||||
if (deviceInfo.deviceIdentifier) {
|
if (deviceInfo.deviceIdentifier) {
|
||||||
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||||
normalizedTwoFactorToken,
|
normalizedTwoFactorToken,
|
||||||
@@ -187,12 +222,16 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
if (!passedByRememberToken) {
|
if (!passedByRememberToken) {
|
||||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||||
}
|
}
|
||||||
} else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
|
||||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||||
if (!totpOk) {
|
if (!totpOk) {
|
||||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
} else if (parsedProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE) {
|
} else if (
|
||||||
|
normalizedTwoFactorProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE ||
|
||||||
|
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_LEGACY) ||
|
||||||
|
normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_RECOVERY_CODE_ANDROID_REQUEST)
|
||||||
|
) {
|
||||||
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
|
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
|
||||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||||
}
|
}
|
||||||
@@ -220,15 +259,25 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Persist device only after successful password + (optional) 2FA verification.
|
// Persist device only after successful password + (optional) 2FA verification.
|
||||||
if (deviceInfo.deviceIdentifier) {
|
const deviceSession =
|
||||||
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
deviceInfo.deviceIdentifier
|
||||||
|
? { identifier: deviceInfo.deviceIdentifier, sessionStamp: generateUUID() }
|
||||||
|
: null;
|
||||||
|
if (deviceSession) {
|
||||||
|
await storage.upsertDevice(
|
||||||
|
user.id,
|
||||||
|
deviceSession.identifier,
|
||||||
|
deviceInfo.deviceName,
|
||||||
|
deviceInfo.deviceType,
|
||||||
|
deviceSession.sessionStamp
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login - clear failed attempts
|
// Successful login - clear failed attempts
|
||||||
await rateLimit.clearLoginAttempts(loginIdentifier);
|
await rateLimit.clearLoginAttempts(loginIdentifier);
|
||||||
|
|
||||||
const accessToken = await auth.generateAccessToken(user);
|
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -238,30 +287,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
|
AccountKeys: buildAccountKeys(user),
|
||||||
|
accountKeys: buildAccountKeys(user),
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
HasMasterPassword: true,
|
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: email, // email is already lowercased above
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
@@ -297,7 +338,14 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
).trim() || null;
|
).trim() || null;
|
||||||
const password = String(body.password || '').trim() || null;
|
const password = String(body.password || '').trim() || null;
|
||||||
|
|
||||||
const result = await issueSendAccessToken(env, sendId, passwordHashB64, password);
|
const result = await issueSendAccessToken(
|
||||||
|
env,
|
||||||
|
sendId,
|
||||||
|
passwordHashB64,
|
||||||
|
password,
|
||||||
|
rateLimit,
|
||||||
|
`${clientIdentifier}:send-password`
|
||||||
|
);
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
return result.error;
|
return result.error;
|
||||||
}
|
}
|
||||||
@@ -310,6 +358,18 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
});
|
});
|
||||||
} else if (grantType === 'refresh_token') {
|
} else if (grantType === 'refresh_token') {
|
||||||
|
const refreshLimit = await rateLimit.consumeBudget(
|
||||||
|
`${clientIdentifier}:identity-refresh`,
|
||||||
|
LIMITS.rateLimit.refreshTokenRequestsPerMinute
|
||||||
|
);
|
||||||
|
if (!refreshLimit.allowed) {
|
||||||
|
return identityErrorResponse(
|
||||||
|
`Rate limit exceeded. Try again in ${refreshLimit.retryAfterSeconds} seconds.`,
|
||||||
|
'TooManyRequests',
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh token
|
// Refresh token
|
||||||
const refreshToken = body.refresh_token;
|
const refreshToken = body.refresh_token;
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
@@ -328,8 +388,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
Date.now() + LIMITS.auth.refreshTokenOverlapGraceMs
|
||||||
);
|
);
|
||||||
|
|
||||||
const { accessToken, user } = result;
|
const { accessToken, user, device } = result;
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id);
|
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -338,30 +398,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
refresh_token: newRefreshToken,
|
refresh_token: newRefreshToken,
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
|
AccountKeys: buildAccountKeys(user),
|
||||||
|
accountKeys: buildAccountKeys(user),
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
KdfParallelism: user.kdfParallelism,
|
KdfParallelism: user.kdfParallelism,
|
||||||
ForcePasswordReset: false,
|
ForcePasswordReset: false,
|
||||||
ResetMasterPassword: false,
|
ResetMasterPassword: false,
|
||||||
|
MasterPasswordPolicy: {
|
||||||
|
Object: 'masterPasswordPolicy',
|
||||||
|
},
|
||||||
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
HasMasterPassword: true,
|
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: user.email.toLowerCase(),
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
return jsonResponse(response);
|
||||||
@@ -396,12 +448,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
|
|||||||
const kdfMemory = user?.kdfMemory ?? null;
|
const kdfMemory = user?.kdfMemory ?? null;
|
||||||
const kdfParallelism = user?.kdfParallelism ?? null;
|
const kdfParallelism = user?.kdfParallelism ?? null;
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse(buildPreloginResponse(email, kdfType, kdfIterations, kdfMemory, kdfParallelism));
|
||||||
kdf: kdfType,
|
|
||||||
kdfIterations: kdfIterations,
|
|
||||||
kdfMemory: kdfMemory,
|
|
||||||
kdfParallelism: kdfParallelism,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /identity/connect/revocation
|
// POST /identity/connect/revocation
|
||||||
|
|||||||
+27
-8
@@ -1,13 +1,16 @@
|
|||||||
import { Env, Cipher, Folder, CipherType } from '../types';
|
import { Env, Cipher, Folder, CipherType } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { errorResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { normalizeCipherLoginForCompatibility } from './ciphers';
|
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||||
|
|
||||||
// Bitwarden client import request format
|
// Bitwarden client import request format
|
||||||
interface CiphersImportRequest {
|
interface CiphersImportRequest {
|
||||||
ciphers: Array<{
|
ciphers: Array<{
|
||||||
|
id?: string | null;
|
||||||
type: number;
|
type: number;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
@@ -90,6 +93,8 @@ async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[
|
|||||||
// POST /api/ciphers/import - Bitwarden client import endpoint
|
// POST /api/ciphers/import - Bitwarden client import endpoint
|
||||||
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
export async function handleCiphersImport(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const returnCipherMap = url.searchParams.get('returnCipherMap') === '1';
|
||||||
|
|
||||||
let importData: CiphersImportRequest;
|
let importData: CiphersImportRequest;
|
||||||
try {
|
try {
|
||||||
@@ -151,9 +156,12 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
|
|
||||||
// Create ciphers
|
// Create ciphers
|
||||||
const cipherRows: Cipher[] = [];
|
const cipherRows: Cipher[] = [];
|
||||||
|
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
|
||||||
for (let i = 0; i < ciphers.length; i++) {
|
for (let i = 0; i < ciphers.length; i++) {
|
||||||
const c = ciphers[i];
|
const c = ciphers[i];
|
||||||
const folderId = cipherFolderMap.get(i) || null;
|
const folderId = cipherFolderMap.get(i) || null;
|
||||||
|
const sourceIdRaw = String(c?.id ?? '').trim();
|
||||||
|
const sourceId = sourceIdRaw || null;
|
||||||
|
|
||||||
const cipher: Cipher = {
|
const cipher: Cipher = {
|
||||||
...c,
|
...c,
|
||||||
@@ -220,15 +228,17 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
})) || null,
|
})) || null,
|
||||||
passwordHistory: c.passwordHistory ?? null,
|
passwordHistory: c.passwordHistory ?? null,
|
||||||
reprompt: c.reprompt ?? 0,
|
reprompt: c.reprompt ?? 0,
|
||||||
sshKey: (c as any).sshKey ?? null,
|
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
|
||||||
key: (c as any).key ?? null,
|
key: (c as any).key ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
archivedAt: null,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
};
|
};
|
||||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
|
|
||||||
cipherRows.push(cipher);
|
cipherRows.push(cipher);
|
||||||
|
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cipherRows.length > 0) {
|
if (cipherRows.length > 0) {
|
||||||
@@ -236,10 +246,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
const data = JSON.stringify(cipher);
|
const data = JSON.stringify(cipher);
|
||||||
return env.DB
|
return env.DB
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' +
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
'ON CONFLICT(id) DO UPDATE SET ' +
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at'
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||||
)
|
)
|
||||||
.bind(
|
.bind(
|
||||||
cipher.id,
|
cipher.id,
|
||||||
@@ -254,6 +264,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
bindNull(cipher.key),
|
bindNull(cipher.key),
|
||||||
cipher.createdAt,
|
cipher.createdAt,
|
||||||
cipher.updatedAt,
|
cipher.updatedAt,
|
||||||
|
bindNull(cipher.archivedAt),
|
||||||
bindNull(cipher.deletedAt)
|
bindNull(cipher.deletedAt)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -261,7 +272,15 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update revision date
|
// Update revision date
|
||||||
await storage.updateRevisionDate(userId);
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
|
||||||
|
if (returnCipherMap) {
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'import-result',
|
||||||
|
cipherMap: cipherMapRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { AuthService } from '../services/auth';
|
||||||
|
import type { Env, JWTPayload } from '../types';
|
||||||
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
function extractAccessToken(request: Request): string | null {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const queryToken = String(url.searchParams.get('access_token') || '').trim();
|
||||||
|
if (queryToken) return queryToken;
|
||||||
|
|
||||||
|
const authHeader = String(request.headers.get('Authorization') || '').trim();
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
return match?.[1]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticateNotificationsRequest(request: Request, env: Env): Promise<JWTPayload | null> {
|
||||||
|
const accessToken = extractAccessToken(request);
|
||||||
|
if (!accessToken) return null;
|
||||||
|
|
||||||
|
const auth = new AuthService(env);
|
||||||
|
return auth.verifyAccessToken(`Bearer ${accessToken}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleNotificationsNegotiate(request: Request, env: Env): Promise<Response> {
|
||||||
|
const payload = await authenticateNotificationsRequest(request, env);
|
||||||
|
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||||
|
|
||||||
|
const connectionId = generateUUID();
|
||||||
|
return jsonResponse({
|
||||||
|
connectionId,
|
||||||
|
connectionToken: connectionId,
|
||||||
|
negotiateVersion: 1,
|
||||||
|
availableTransports: [
|
||||||
|
{
|
||||||
|
transport: 'WebSockets',
|
||||||
|
transferFormats: ['Text', 'Binary'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleNotificationsHub(request: Request, env: Env): Promise<Response> {
|
||||||
|
const payload = await authenticateNotificationsRequest(request, env);
|
||||||
|
if (!payload?.sub) return errorResponse('Unauthorized', 401);
|
||||||
|
if (request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
||||||
|
return errorResponse('Expected websocket', 426);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = payload.sub;
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
const forwardedUrl = new URL(request.url);
|
||||||
|
forwardedUrl.searchParams.set('nw_uid', userId);
|
||||||
|
if (payload.did) {
|
||||||
|
forwardedUrl.searchParams.set('nw_did', payload.did);
|
||||||
|
}
|
||||||
|
return stub.fetch(new Request(forwardedUrl.toString(), request));
|
||||||
|
}
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
import { Env, Send, SendAuthType, SendType } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
|
||||||
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
getBlobStorageMaxBytes,
|
||||||
|
getSendFileObjectKey,
|
||||||
|
putBlobObject,
|
||||||
|
deleteBlobObject,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
import { createSendFileUploadToken, verifySendFileUploadToken } from '../utils/jwt';
|
||||||
|
import {
|
||||||
|
formatSize,
|
||||||
|
getAliasedProp,
|
||||||
|
normalizeEmails,
|
||||||
|
notifyVaultSyncForRequest,
|
||||||
|
parseDate,
|
||||||
|
parseFileLength,
|
||||||
|
parseInteger,
|
||||||
|
parseMaxAccessCount,
|
||||||
|
parseSendAuthType,
|
||||||
|
parseSendType,
|
||||||
|
parseStoredSendData,
|
||||||
|
sanitizeSendData,
|
||||||
|
sendToResponse,
|
||||||
|
setSendPassword,
|
||||||
|
validateDeletionDate,
|
||||||
|
} from './sends-shared';
|
||||||
|
|
||||||
|
async function processSendFileUpload(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
send: Send,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||||
|
const sendData = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse('Send file does not match send data.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null;
|
||||||
|
const expectedSize = parseInteger(sendData.size);
|
||||||
|
const upload = await parseDirectUploadPayload(request, {
|
||||||
|
expectedSize,
|
||||||
|
expectedFileName,
|
||||||
|
maxFileSize,
|
||||||
|
tooLargeMessage: 'Send storage limit exceeded with this file',
|
||||||
|
sizeMismatchMessage: 'Send file size does not match.',
|
||||||
|
fileNameMismatchMessage: 'Send file name does not match.',
|
||||||
|
});
|
||||||
|
if (upload instanceof Response) {
|
||||||
|
return upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, {
|
||||||
|
size: upload.size,
|
||||||
|
contentType: upload.contentType,
|
||||||
|
customMetadata: {
|
||||||
|
sendId: send.id,
|
||||||
|
fileId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('KV object too large')) {
|
||||||
|
return errorResponse('Send storage limit exceeded with this file', 413);
|
||||||
|
}
|
||||||
|
return errorResponse('Attachment storage is not configured', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
|
return new Response(null, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pagination = parsePagination(url);
|
||||||
|
|
||||||
|
let sends: Send[];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
if (pagination) {
|
||||||
|
const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset);
|
||||||
|
const hasNext = pageRows.length > pagination.limit;
|
||||||
|
sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows;
|
||||||
|
continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null;
|
||||||
|
} else {
|
||||||
|
sends = await storage.getAllSends(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
data: sends.map(sendToResponse),
|
||||||
|
object: 'list',
|
||||||
|
continuationToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateSend(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||||
|
const sendType = parseSendType(typeRaw.value);
|
||||||
|
if (sendType === null) {
|
||||||
|
return errorResponse('Invalid Send type', 400);
|
||||||
|
}
|
||||||
|
if (sendType === SendType.File) {
|
||||||
|
return errorResponse('File sends should use /api/sends/file/v2', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||||
|
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||||
|
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||||
|
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||||
|
|
||||||
|
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||||
|
return errorResponse('Key is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionDate = parseDate(deletionDateRaw.value);
|
||||||
|
if (!deletionDate) {
|
||||||
|
return errorResponse('Invalid deletionDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionValidation = validateDeletionDate(deletionDate);
|
||||||
|
if (deletionValidation) return deletionValidation;
|
||||||
|
|
||||||
|
const sendData = sanitizeSendData(textRaw.value);
|
||||||
|
if (!sendData) {
|
||||||
|
return errorResponse('Send data not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||||
|
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||||
|
if (!maxAccess.ok) return maxAccess.response;
|
||||||
|
|
||||||
|
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||||
|
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||||
|
? null
|
||||||
|
: parseDate(expirationRaw.value);
|
||||||
|
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||||
|
return errorResponse('Invalid expirationDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||||
|
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||||
|
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||||
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
|
|
||||||
|
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||||
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
|
return errorResponse('Invalid authType', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
|
return errorResponse('Invalid emails', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const send: Send = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
type: sendType,
|
||||||
|
name: nameRaw.value.trim(),
|
||||||
|
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||||
|
data: JSON.stringify(sendData),
|
||||||
|
key: keyRaw.value,
|
||||||
|
passwordHash: null,
|
||||||
|
passwordSalt: null,
|
||||||
|
passwordIterations: null,
|
||||||
|
authType: requestedAuthType ?? SendAuthType.None,
|
||||||
|
emails: normalizedEmails,
|
||||||
|
maxAccessCount: maxAccess.value,
|
||||||
|
accessCount: 0,
|
||||||
|
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||||
|
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||||
|
deletionDate: deletionDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||||
|
await setSendPassword(send, passwordRaw.value);
|
||||||
|
} else if (send.authType === SendAuthType.Password) {
|
||||||
|
return errorResponse('Password is required for password auth', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.authType !== SendAuthType.Email) {
|
||||||
|
send.emails = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes);
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||||
|
const sendType = parseSendType(typeRaw.value);
|
||||||
|
if (sendType !== SendType.File) {
|
||||||
|
return errorResponse('Send content is not a file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']);
|
||||||
|
const fileLengthParsed = parseFileLength(fileLengthRaw.value);
|
||||||
|
if (!fileLengthParsed.ok) return fileLengthParsed.response;
|
||||||
|
if (fileLengthParsed.value > maxFileSize) {
|
||||||
|
return errorResponse('Send storage limit exceeded with this file', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||||
|
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||||
|
const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||||
|
const fileRaw = getAliasedProp(body, ['file', 'File']);
|
||||||
|
|
||||||
|
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||||
|
return errorResponse('Key is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionDate = parseDate(deletionDateRaw.value);
|
||||||
|
if (!deletionDate) {
|
||||||
|
return errorResponse('Invalid deletionDate', 400);
|
||||||
|
}
|
||||||
|
const deletionValidation = validateDeletionDate(deletionDate);
|
||||||
|
if (deletionValidation) return deletionValidation;
|
||||||
|
|
||||||
|
const fileData = sanitizeSendData(fileRaw.value);
|
||||||
|
if (!fileData) {
|
||||||
|
return errorResponse('Send data not provided', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = generateUUID();
|
||||||
|
fileData.id = fileId;
|
||||||
|
fileData.size = fileLengthParsed.value;
|
||||||
|
fileData.sizeName = formatSize(fileLengthParsed.value);
|
||||||
|
|
||||||
|
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||||
|
const maxAccess = parseMaxAccessCount(maxAccessRaw.value);
|
||||||
|
if (!maxAccess.ok) return maxAccess.response;
|
||||||
|
|
||||||
|
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||||
|
const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined
|
||||||
|
? null
|
||||||
|
: parseDate(expirationRaw.value);
|
||||||
|
if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) {
|
||||||
|
return errorResponse('Invalid expirationDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||||
|
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||||
|
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||||
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
|
|
||||||
|
const requestedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||||
|
if (authTypeRaw.present && requestedAuthType === null) {
|
||||||
|
return errorResponse('Invalid authType', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
|
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
|
return errorResponse('Invalid emails', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const send: Send = {
|
||||||
|
id: generateUUID(),
|
||||||
|
userId,
|
||||||
|
type: sendType,
|
||||||
|
name: nameRaw.value.trim(),
|
||||||
|
notes: typeof notesRaw.value === 'string' ? notesRaw.value : null,
|
||||||
|
data: JSON.stringify(fileData),
|
||||||
|
key: keyRaw.value,
|
||||||
|
passwordHash: null,
|
||||||
|
passwordSalt: null,
|
||||||
|
passwordIterations: null,
|
||||||
|
authType: requestedAuthType ?? SendAuthType.None,
|
||||||
|
emails: normalizedEmails,
|
||||||
|
maxAccessCount: maxAccess.value,
|
||||||
|
accessCount: 0,
|
||||||
|
disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false,
|
||||||
|
hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
expirationDate: expirationDate ? expirationDate.toISOString() : null,
|
||||||
|
deletionDate: deletionDate.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) {
|
||||||
|
await setSendPassword(send, passwordRaw.value);
|
||||||
|
} else if (send.authType === SendAuthType.Password) {
|
||||||
|
return errorResponse('Password is required for password auth', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.authType !== SendAuthType.Email) {
|
||||||
|
send.emails = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
fileUploadType: 1,
|
||||||
|
object: 'send-fileUpload',
|
||||||
|
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
|
||||||
|
sendResponse: sendToResponse(send),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGetSendFileUpload(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
if (send.type !== SendType.File) {
|
||||||
|
return errorResponse('Send is not a file type send.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendData = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse('Send file does not match send data.', 400);
|
||||||
|
}
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
const uploadToken = await createSendFileUploadToken(userId, send.id, fileId, jwtSecret);
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
fileUploadType: 1,
|
||||||
|
object: 'send-fileUpload',
|
||||||
|
url: buildDirectUploadUrl(request, `/api/sends/${send.id}/file/${fileId}`, uploadToken),
|
||||||
|
sendResponse: sendToResponse(send),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUploadSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||||
|
}
|
||||||
|
if (send.type !== SendType.File) {
|
||||||
|
return errorResponse('Send is not a file type send.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processSendFileUpload(request, env, send, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePublicUploadSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const jwtSecret = getSafeJwtSecret(env);
|
||||||
|
if (!jwtSecret) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = new URL(request.url).searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendFileUploadToken(token, jwtSecret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
if (claims.sendId !== sendId || claims.fileId !== fileId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== claims.userId) {
|
||||||
|
return errorResponse('Send not found. Unable to save the file.', 404);
|
||||||
|
}
|
||||||
|
if (send.type !== SendType.File) {
|
||||||
|
return errorResponse('Send is not a file type send.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return processSendFileUpload(request, env, send, fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeRaw = getAliasedProp(body, ['type', 'Type']);
|
||||||
|
if (typeRaw.present) {
|
||||||
|
const incomingType = parseSendType(typeRaw.value);
|
||||||
|
if (incomingType === null) {
|
||||||
|
return errorResponse('Invalid Send type', 400);
|
||||||
|
}
|
||||||
|
if (incomingType !== send.type) {
|
||||||
|
return errorResponse("Sends can't change type", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']);
|
||||||
|
if (deletionRaw.present) {
|
||||||
|
const deletionDate = parseDate(deletionRaw.value);
|
||||||
|
if (!deletionDate) return errorResponse('Invalid deletionDate', 400);
|
||||||
|
const deletionValidation = validateDeletionDate(deletionDate);
|
||||||
|
if (deletionValidation) return deletionValidation;
|
||||||
|
send.deletionDate = deletionDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']);
|
||||||
|
if (expirationRaw.present) {
|
||||||
|
if (expirationRaw.value === null || expirationRaw.value === '') {
|
||||||
|
send.expirationDate = null;
|
||||||
|
} else {
|
||||||
|
const expiration = parseDate(expirationRaw.value);
|
||||||
|
if (!expiration) return errorResponse('Invalid expirationDate', 400);
|
||||||
|
send.expirationDate = expiration.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameRaw = getAliasedProp(body, ['name', 'Name']);
|
||||||
|
if (nameRaw.present) {
|
||||||
|
if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) {
|
||||||
|
return errorResponse('Name is required', 400);
|
||||||
|
}
|
||||||
|
send.name = nameRaw.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyRaw = getAliasedProp(body, ['key', 'Key']);
|
||||||
|
if (keyRaw.present) {
|
||||||
|
if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) {
|
||||||
|
return errorResponse('Key is required', 400);
|
||||||
|
}
|
||||||
|
send.key = keyRaw.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notesRaw = getAliasedProp(body, ['notes', 'Notes']);
|
||||||
|
if (notesRaw.present) {
|
||||||
|
send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']);
|
||||||
|
if (disabledRaw.present) {
|
||||||
|
if (typeof disabledRaw.value !== 'boolean') {
|
||||||
|
return errorResponse('Invalid disabled', 400);
|
||||||
|
}
|
||||||
|
send.disabled = disabledRaw.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']);
|
||||||
|
if (hideEmailRaw.present) {
|
||||||
|
if (hideEmailRaw.value === null) {
|
||||||
|
send.hideEmail = null;
|
||||||
|
} else if (typeof hideEmailRaw.value === 'boolean') {
|
||||||
|
send.hideEmail = hideEmailRaw.value;
|
||||||
|
} else {
|
||||||
|
return errorResponse('Invalid hideEmail', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']);
|
||||||
|
if (maxAccessRaw.present) {
|
||||||
|
const parsedMax = parseMaxAccessCount(maxAccessRaw.value);
|
||||||
|
if (!parsedMax.ok) return parsedMax.response;
|
||||||
|
send.maxAccessCount = parsedMax.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.Text) {
|
||||||
|
const textRaw = getAliasedProp(body, ['text', 'Text']);
|
||||||
|
if (textRaw.present) {
|
||||||
|
const textData = sanitizeSendData(textRaw.value);
|
||||||
|
if (!textData) {
|
||||||
|
return errorResponse('Send data not provided', 400);
|
||||||
|
}
|
||||||
|
send.data = JSON.stringify(textData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']);
|
||||||
|
if (authTypeRaw.present) {
|
||||||
|
const parsedAuthType = parseSendAuthType(authTypeRaw.value);
|
||||||
|
if (parsedAuthType === null) {
|
||||||
|
return errorResponse('Invalid authType', 400);
|
||||||
|
}
|
||||||
|
send.authType = parsedAuthType;
|
||||||
|
if (parsedAuthType !== SendAuthType.Email) {
|
||||||
|
send.emails = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
|
||||||
|
if (emailsRaw.present) {
|
||||||
|
const normalizedEmails = normalizeEmails(emailsRaw.value);
|
||||||
|
if (emailsRaw.value !== null && normalizedEmails === null) {
|
||||||
|
return errorResponse('Invalid emails', 400);
|
||||||
|
}
|
||||||
|
send.emails = normalizedEmails;
|
||||||
|
if (send.emails) {
|
||||||
|
send.authType = SendAuthType.Email;
|
||||||
|
} else if (send.authType === SendAuthType.Email) {
|
||||||
|
send.authType = SendAuthType.None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
if (passwordRaw.present && typeof passwordRaw.value === 'string') {
|
||||||
|
await setSendPassword(send, passwordRaw.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.authType === SendAuthType.Password && !send.passwordHash) {
|
||||||
|
return errorResponse('Password is required for password auth', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
send.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.File) {
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (fileId) {
|
||||||
|
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.deleteSend(sendId, userId);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
|
||||||
|
let body: { ids?: string[] };
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Invalid JSON', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.ids || !Array.isArray(body.ids)) {
|
||||||
|
return errorResponse('ids array is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sends = await storage.getSendsByIds(body.ids, userId);
|
||||||
|
for (const send of sends) {
|
||||||
|
if (send.type !== SendType.File) continue;
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const fileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (fileId) {
|
||||||
|
await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionDate = await storage.bulkDeleteSends(body.ids, userId);
|
||||||
|
if (revisionDate) {
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSendPassword(send, null);
|
||||||
|
send.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
|
||||||
|
void request;
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || send.userId !== userId) {
|
||||||
|
return errorResponse('Send not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
send.authType = SendAuthType.None;
|
||||||
|
send.emails = null;
|
||||||
|
send.updatedAt = new Date().toISOString();
|
||||||
|
await storage.saveSend(send);
|
||||||
|
const revisionDate = await storage.updateRevisionDate(userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
|
|
||||||
|
return jsonResponse(sendToResponse(send));
|
||||||
|
}
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { Env, SendType } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import {
|
||||||
|
createSendAccessToken,
|
||||||
|
createSendFileDownloadToken,
|
||||||
|
verifySendAccessToken,
|
||||||
|
verifySendFileDownloadToken,
|
||||||
|
} from '../utils/jwt';
|
||||||
|
import {
|
||||||
|
getBlobObject,
|
||||||
|
getSendFileObjectKey,
|
||||||
|
} from '../services/blob-store';
|
||||||
|
import {
|
||||||
|
SEND_INACCESSIBLE_MSG,
|
||||||
|
extractBearerToken,
|
||||||
|
fromAccessId,
|
||||||
|
getCreatorIdentifier,
|
||||||
|
getSafeJwtSecret,
|
||||||
|
hasEmailAuth,
|
||||||
|
isSendAvailable,
|
||||||
|
notifyVaultSyncForRequest,
|
||||||
|
parseStoredSendData,
|
||||||
|
resolveSendFromIdOrAccessId,
|
||||||
|
sendPasswordLimitKey,
|
||||||
|
sendPasswordLockedErrorResponse,
|
||||||
|
sendPasswordLockedOAuthResponse,
|
||||||
|
sendToAccessResponse,
|
||||||
|
validatePublicSendAccess,
|
||||||
|
verifySendPassword,
|
||||||
|
verifySendPasswordHashB64,
|
||||||
|
} from './sends-shared';
|
||||||
|
|
||||||
|
export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const sendId = fromAccessId(accessId);
|
||||||
|
if (!sendId) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = await storage.getSend(sendId);
|
||||||
|
if (!send || !isSendAvailable(send)) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendPasswordLimitIpKey: string | null = null;
|
||||||
|
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||||
|
if (send.passwordHash) {
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||||
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
if (!sendPasswordCheck.allowed) {
|
||||||
|
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await validatePublicSendAccess(send, body);
|
||||||
|
if (!validation.ok) {
|
||||||
|
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||||
|
if (failed.locked) {
|
||||||
|
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validation.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.Text) {
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
|
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAccessSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
idOrAccessId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
|
||||||
|
return errorResponse('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
|
||||||
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown = {};
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
body = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sendPasswordLimitIpKey: string | null = null;
|
||||||
|
let sendPasswordRateLimit: RateLimitService | null = null;
|
||||||
|
if (send.passwordHash) {
|
||||||
|
const clientIdentifier = getClientIdentifier(request);
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return errorResponse('Client IP is required', 403);
|
||||||
|
}
|
||||||
|
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier);
|
||||||
|
sendPasswordRateLimit = new RateLimitService(env.DB);
|
||||||
|
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
if (!sendPasswordCheck.allowed) {
|
||||||
|
return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = await validatePublicSendAccess(send, body);
|
||||||
|
if (!validation.ok) {
|
||||||
|
if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||||
|
if (failed.locked) {
|
||||||
|
return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validation.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) {
|
||||||
|
await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
|
const token = await createSendFileDownloadToken(send.id, fileId, secret);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'send-fileDownload',
|
||||||
|
id: fileId,
|
||||||
|
url: downloadUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAccessSendV2(request: Request, env: Env): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
|
const token = extractBearerToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(claims.sub);
|
||||||
|
if (!send || !isSendAvailable(send)) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.type === SendType.Text) {
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorIdentifier = await getCreatorIdentifier(storage, send);
|
||||||
|
return jsonResponse(sendToAccessResponse(send, creatorIdentifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
|
const token = extractBearerToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendAccessToken(token, jwt.secret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await storage.getSend(claims.sub);
|
||||||
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseStoredSendData(send);
|
||||||
|
const expectedFileId = typeof data.id === 'string' ? data.id : null;
|
||||||
|
if (!expectedFileId || expectedFileId !== fileId) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await storage.incrementSendAccessCount(send.id);
|
||||||
|
if (!updated) {
|
||||||
|
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
|
||||||
|
}
|
||||||
|
send.accessCount += 1;
|
||||||
|
const revisionDate = await storage.updateRevisionDate(send.userId);
|
||||||
|
await notifyVaultSyncForRequest(request, env, send.userId, revisionDate);
|
||||||
|
|
||||||
|
const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret);
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`;
|
||||||
|
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'send-fileDownload',
|
||||||
|
id: fileId,
|
||||||
|
url: downloadUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleDownloadSendFile(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) return jwt.response;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const token = url.searchParams.get('t') || url.searchParams.get('token');
|
||||||
|
if (!token) {
|
||||||
|
return errorResponse('Token required', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await verifySendFileDownloadToken(token, jwt.secret);
|
||||||
|
if (!claims) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
if (claims.sendId !== sendId || claims.fileId !== fileId) {
|
||||||
|
return errorResponse('Token mismatch', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Send file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
|
if (!firstUse) {
|
||||||
|
return errorResponse('Invalid or expired token', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(object.body, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': object.contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': String(object.size),
|
||||||
|
'Cache-Control': 'private, no-cache',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueSendAccessToken(
|
||||||
|
env: Env,
|
||||||
|
sendIdOrAccessId: string,
|
||||||
|
passwordHashB64?: string | null,
|
||||||
|
password?: string | null,
|
||||||
|
rateLimit?: RateLimitService,
|
||||||
|
sendPasswordLimitIpKey?: string
|
||||||
|
): Promise<{ token: string } | { error: Response }> {
|
||||||
|
const jwt = getSafeJwtSecret(env);
|
||||||
|
if (!jwt.ok) {
|
||||||
|
return { error: jwt.response };
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId);
|
||||||
|
|
||||||
|
if (!send || !isSendAvailable(send)) {
|
||||||
|
return {
|
||||||
|
error: jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: SEND_INACCESSIBLE_MSG,
|
||||||
|
send_access_error_type: 'send_not_available',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: SEND_INACCESSIBLE_MSG,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEmailAuth(send)) {
|
||||||
|
const message = 'Email verification for this Send is not supported by this server.';
|
||||||
|
return {
|
||||||
|
error: jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
send_access_error_type: 'email_verification_not_supported',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.passwordHash) {
|
||||||
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
|
||||||
|
if (!sendPasswordCheck.allowed) {
|
||||||
|
return {
|
||||||
|
error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
if (passwordHashB64) {
|
||||||
|
ok = verifySendPasswordHashB64(send, passwordHashB64);
|
||||||
|
} else if (password) {
|
||||||
|
ok = await verifySendPassword(send, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
|
const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey);
|
||||||
|
if (failed.locked) {
|
||||||
|
return {
|
||||||
|
error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
error: jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'Invalid password.',
|
||||||
|
send_access_error_type: 'invalid_password',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: 'Invalid password.',
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimit && sendPasswordLimitIpKey) {
|
||||||
|
await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createSendAccessToken(send.id, jwt.secret);
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types';
|
||||||
|
import { notifyUserVaultSync } from '../durable/notifications-hub';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
|
export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available';
|
||||||
|
const SEND_PASSWORD_ITERATIONS = 100_000;
|
||||||
|
export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password';
|
||||||
|
|
||||||
|
export async function notifyVaultSyncForRequest(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
revisionDate: string
|
||||||
|
): Promise<void> {
|
||||||
|
await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } {
|
||||||
|
if (!source || typeof source !== 'object') return { present: false, value: undefined };
|
||||||
|
for (const key of aliases) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||||
|
const value = (source as Record<string, unknown>)[key];
|
||||||
|
return { present: true, value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { present: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64UrlEncode(data: Uint8Array): string {
|
||||||
|
const base64 = btoa(String.fromCharCode(...data));
|
||||||
|
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64UrlDecode(input: string): Uint8Array | null {
|
||||||
|
try {
|
||||||
|
let normalized = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
while (normalized.length % 4) normalized += '=';
|
||||||
|
const raw = atob(normalized);
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidToBytes(uuid: string): Uint8Array | null {
|
||||||
|
const hex = uuid.replace(/-/g, '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{32}$/.test(hex)) return null;
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToUuid(bytes: Uint8Array): string | null {
|
||||||
|
if (bytes.length !== 16) return null;
|
||||||
|
const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return [
|
||||||
|
hex.slice(0, 8),
|
||||||
|
hex.slice(8, 12),
|
||||||
|
hex.slice(12, 16),
|
||||||
|
hex.slice(16, 20),
|
||||||
|
hex.slice(20, 32),
|
||||||
|
].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAccessId(sendId: string): string {
|
||||||
|
const bytes = uuidToBytes(sendId);
|
||||||
|
if (!bytes) return '';
|
||||||
|
return base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromAccessId(accessId: string): string | null {
|
||||||
|
const bytes = base64UrlDecode(accessId);
|
||||||
|
if (!bytes || bytes.length !== 16) return null;
|
||||||
|
return bytesToUuid(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyUuid(value: string): boolean {
|
||||||
|
return /^[a-f0-9-]{36}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise<Send | null> {
|
||||||
|
if (isLikelyUuid(idOrAccessId)) {
|
||||||
|
const send = await storage.getSend(idOrAccessId);
|
||||||
|
if (send) return send;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendId = fromAccessId(idOrAccessId);
|
||||||
|
if (!sendId) return null;
|
||||||
|
return storage.getSend(sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} Bytes`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(raw: unknown): Date | null {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseInteger(raw: unknown): number | null {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return null;
|
||||||
|
const value = typeof raw === 'string' ? Number(raw) : raw;
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeSendData(raw: unknown): Record<string, unknown> | null {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||||
|
const data = { ...(raw as Record<string, unknown>) };
|
||||||
|
delete data.response;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStoredSendData(send: Send): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(send.data) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return { ...(parsed as Record<string, unknown>) };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSendDataSizeField(data: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const normalized = { ...data };
|
||||||
|
if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) {
|
||||||
|
normalized.size = String(Math.trunc(normalized.size));
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSendAvailable(send: Send): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.expirationDate) {
|
||||||
|
const expirationMs = new Date(send.expirationDate).getTime();
|
||||||
|
if (!Number.isNaN(expirationMs) && now >= expirationMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionMs = new Date(send.deletionDate).getTime();
|
||||||
|
if (!Number.isNaN(deletionMs) && now >= deletionMs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (send.disabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise<Uint8Array> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
return new Uint8Array(bits);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
diff |= a[i] ^ b[i];
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyHashB64(value: string): boolean {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return false;
|
||||||
|
if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false;
|
||||||
|
const decoded = base64UrlDecode(raw);
|
||||||
|
return !!decoded && decoded.length === 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSendPassword(send: Send, password: string | null): Promise<void> {
|
||||||
|
if (!password) {
|
||||||
|
send.passwordHash = null;
|
||||||
|
send.passwordSalt = null;
|
||||||
|
send.passwordIterations = null;
|
||||||
|
if (send.authType === SendAuthType.Password) {
|
||||||
|
send.authType = SendAuthType.None;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLikelyHashB64(password)) {
|
||||||
|
send.passwordHash = password.trim();
|
||||||
|
send.passwordSalt = null;
|
||||||
|
send.passwordIterations = null;
|
||||||
|
send.authType = SendAuthType.Password;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS);
|
||||||
|
|
||||||
|
send.passwordSalt = base64UrlEncode(salt);
|
||||||
|
send.passwordHash = base64UrlEncode(hash);
|
||||||
|
send.passwordIterations = SEND_PASSWORD_ITERATIONS;
|
||||||
|
send.authType = SendAuthType.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySendPassword(send: Send, password: string): Promise<boolean> {
|
||||||
|
if (!send.passwordHash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!send.passwordSalt || !send.passwordIterations) {
|
||||||
|
return verifySendPasswordHashB64(send, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const salt = base64UrlDecode(send.passwordSalt);
|
||||||
|
const expected = base64UrlDecode(send.passwordHash);
|
||||||
|
if (!salt || !expected) return false;
|
||||||
|
|
||||||
|
const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations);
|
||||||
|
return constantTimeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean {
|
||||||
|
if (!send.passwordHash || !passwordHashB64) return false;
|
||||||
|
const expected = base64UrlDecode(send.passwordHash);
|
||||||
|
const provided = base64UrlDecode(passwordHashB64);
|
||||||
|
if (!expected || !provided) return false;
|
||||||
|
return constantTimeEqual(expected, provided);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDeletionDate(date: Date): Response | null {
|
||||||
|
const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000;
|
||||||
|
if (date.getTime() > maxMs) {
|
||||||
|
return errorResponse(
|
||||||
|
'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } {
|
||||||
|
const parsed = parseInteger(value);
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return { ok: true, value: null };
|
||||||
|
}
|
||||||
|
if (parsed === null || parsed < 0) {
|
||||||
|
return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } {
|
||||||
|
const parsed = parseInteger(value);
|
||||||
|
if (parsed === null) {
|
||||||
|
return { ok: false, response: errorResponse('Invalid send length', 400) };
|
||||||
|
}
|
||||||
|
if (parsed < 0) {
|
||||||
|
return { ok: false, response: errorResponse("Send size can't be negative", 400) };
|
||||||
|
}
|
||||||
|
return { ok: true, value: parsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSendType(value: unknown): SendType | null {
|
||||||
|
const type = parseInteger(value);
|
||||||
|
if (type === SendType.Text || type === SendType.File) return type;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSendAuthType(value: unknown): SendAuthType | null {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
const parsed = parseInteger(value);
|
||||||
|
if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEmails(value: unknown): string | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const strings = value.filter((v) => typeof v === 'string').map((v) => String(v));
|
||||||
|
if (strings.length === 0) return null;
|
||||||
|
return strings.join(',');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasEmailAuth(send: Send): boolean {
|
||||||
|
return send.authType === SendAuthType.Email;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return { ok: false, response: errorResponse('Server configuration error', 500) };
|
||||||
|
}
|
||||||
|
return { ok: true, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBearerToken(request: Request): string | null {
|
||||||
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
if (!authHeader) return null;
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
return match ? match[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendToResponse(send: Send): SendResponse {
|
||||||
|
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||||
|
return {
|
||||||
|
id: send.id,
|
||||||
|
accessId: toAccessId(send.id),
|
||||||
|
type: Number(send.type) || 0,
|
||||||
|
name: send.name,
|
||||||
|
notes: send.notes,
|
||||||
|
text: send.type === SendType.Text ? data : null,
|
||||||
|
file: send.type === SendType.File ? data : null,
|
||||||
|
key: send.key,
|
||||||
|
maxAccessCount: send.maxAccessCount,
|
||||||
|
accessCount: send.accessCount,
|
||||||
|
password: send.passwordHash,
|
||||||
|
emails: send.emails,
|
||||||
|
authType: send.authType,
|
||||||
|
disabled: send.disabled,
|
||||||
|
hideEmail: send.hideEmail,
|
||||||
|
revisionDate: send.updatedAt,
|
||||||
|
expirationDate: send.expirationDate,
|
||||||
|
deletionDate: send.deletionDate,
|
||||||
|
object: 'send',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record<string, unknown> {
|
||||||
|
const data = normalizeSendDataSizeField(parseStoredSendData(send));
|
||||||
|
return {
|
||||||
|
id: send.id,
|
||||||
|
type: Number(send.type) || 0,
|
||||||
|
name: send.name,
|
||||||
|
text: send.type === SendType.Text ? data : null,
|
||||||
|
file: send.type === SendType.File ? data : null,
|
||||||
|
expirationDate: send.expirationDate,
|
||||||
|
deletionDate: send.deletionDate,
|
||||||
|
creatorIdentifier,
|
||||||
|
object: 'send-access',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise<string | null> {
|
||||||
|
if (send.hideEmail) return null;
|
||||||
|
const owner = await storage.getUserById(send.userId);
|
||||||
|
return owner?.email ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicSendAccessValidationResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
|
||||||
|
|
||||||
|
export function sendPasswordLimitKey(clientIdentifier: string): string {
|
||||||
|
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendPasswordLockMessage(retryAfterSeconds: number): string {
|
||||||
|
return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response {
|
||||||
|
return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response {
|
||||||
|
const message = sendPasswordLockMessage(retryAfterSeconds);
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: message,
|
||||||
|
send_access_error_type: 'too_many_password_attempts',
|
||||||
|
ErrorModel: {
|
||||||
|
Message: message,
|
||||||
|
Object: 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
429
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
|
||||||
|
if (hasEmailAuth(send)) {
|
||||||
|
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!send.passwordHash) return { ok: true };
|
||||||
|
|
||||||
|
const passwordRaw = getAliasedProp(body, ['password', 'Password']);
|
||||||
|
const passwordHashB64Raw = getAliasedProp(body, [
|
||||||
|
'password_hash_b64',
|
||||||
|
'passwordHashB64',
|
||||||
|
'passwordHash',
|
||||||
|
'password_hash',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let validPassword = false;
|
||||||
|
if (send.passwordSalt && send.passwordIterations) {
|
||||||
|
if (typeof passwordRaw.value !== 'string') {
|
||||||
|
return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||||
|
}
|
||||||
|
validPassword = await verifySendPassword(send, passwordRaw.value);
|
||||||
|
} else {
|
||||||
|
const candidate =
|
||||||
|
typeof passwordHashB64Raw.value === 'string'
|
||||||
|
? passwordHashB64Raw.value
|
||||||
|
: typeof passwordRaw.value === 'string'
|
||||||
|
? passwordRaw.value
|
||||||
|
: '';
|
||||||
|
if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' };
|
||||||
|
validPassword = verifySendPasswordHashB64(send, candidate);
|
||||||
|
}
|
||||||
|
if (!validPassword) {
|
||||||
|
return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
+3
-1290
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
|||||||
import { Env } from '../types';
|
|
||||||
import { StorageService } from '../services/storage';
|
|
||||||
import { jsonResponse } from '../utils/response';
|
|
||||||
|
|
||||||
// GET /setup/status
|
|
||||||
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
|
|
||||||
void request;
|
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const registered = (await storage.isRegistered()) || (await storage.getUserCount()) > 0;
|
|
||||||
return jsonResponse({ registered });
|
|
||||||
}
|
|
||||||
+82
-41
@@ -4,14 +4,23 @@ import { errorResponse } from '../utils/response';
|
|||||||
import { cipherToResponse } from './ciphers';
|
import { cipherToResponse } from './ciphers';
|
||||||
import { sendToResponse } from './sends';
|
import { sendToResponse } from './sends';
|
||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
import { isTotpEnabled } from '../utils/totp';
|
import {
|
||||||
|
buildAccountKeys,
|
||||||
|
buildUserDecryptionCompat,
|
||||||
|
buildUserDecryptionOptions,
|
||||||
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
interface SyncCacheEntry {
|
||||||
|
userId: string;
|
||||||
|
revisionDate: string;
|
||||||
body: string;
|
body: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
bytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||||
|
let syncResponseCacheTotalBytes = 0;
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string {
|
||||||
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`;
|
||||||
@@ -21,21 +30,67 @@ function readSyncCache(key: string): string | null {
|
|||||||
const hit = syncResponseCache.get(key);
|
const hit = syncResponseCache.get(key);
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (hit.expiresAt <= Date.now()) {
|
if (hit.expiresAt <= Date.now()) {
|
||||||
syncResponseCache.delete(key);
|
deleteSyncCacheEntry(key, hit);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return hit.body;
|
return hit.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeSyncCache(key: string, body: string): void {
|
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
||||||
if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) {
|
const existing = entry ?? syncResponseCache.get(key);
|
||||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
if (!existing) return;
|
||||||
if (oldestKey) syncResponseCache.delete(oldestKey);
|
syncResponseCache.delete(key);
|
||||||
|
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
||||||
|
for (const [key, entry] of syncResponseCache.entries()) {
|
||||||
|
if (entry.expiresAt <= nowMs) {
|
||||||
|
deleteSyncCacheEntry(key, entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
||||||
|
for (const [key, entry] of syncResponseCache.entries()) {
|
||||||
|
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
||||||
|
deleteSyncCacheEntry(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
pruneExpiredSyncCache(nowMs);
|
||||||
|
pruneStaleUserSyncCache(userId, revisionDate);
|
||||||
|
|
||||||
|
const bodyBytes = textEncoder.encode(body).byteLength;
|
||||||
|
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = syncResponseCache.get(key);
|
||||||
|
if (existing) {
|
||||||
|
deleteSyncCacheEntry(key, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (
|
||||||
|
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
||||||
|
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
||||||
|
) {
|
||||||
|
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||||
|
if (!oldestKey) break;
|
||||||
|
deleteSyncCacheEntry(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
syncResponseCache.set(key, {
|
syncResponseCache.set(key, {
|
||||||
|
userId,
|
||||||
|
revisionDate,
|
||||||
body,
|
body,
|
||||||
expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs,
|
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
||||||
|
bytes: bodyBytes,
|
||||||
});
|
});
|
||||||
|
syncResponseCacheTotalBytes += bodyBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// GET /api/sync
|
||||||
@@ -44,6 +99,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
||||||
|
const omitFido2Credentials =
|
||||||
|
userAgent.includes('android') ||
|
||||||
|
userAgent.includes('iphone') ||
|
||||||
|
userAgent.includes('ipad') ||
|
||||||
|
userAgent.includes('ios');
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -74,12 +135,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
premium: true,
|
premium: true,
|
||||||
premiumFromOrganization: false,
|
premiumFromOrganization: false,
|
||||||
usesKeyConnector: false,
|
usesKeyConnector: false,
|
||||||
masterPasswordHint: null,
|
masterPasswordHint: user.masterPasswordHint,
|
||||||
culture: 'en-US',
|
culture: 'en-US',
|
||||||
twoFactorEnabled: !!user.totpSecret || isTotpEnabled(env.TOTP_SECRET),
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: null,
|
accountKeys: buildAccountKeys(user),
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -87,6 +148,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
forcePasswordReset: false,
|
forcePasswordReset: false,
|
||||||
avatarColor: null,
|
avatarColor: null,
|
||||||
creationDate: user.createdAt,
|
creationDate: user.createdAt,
|
||||||
|
verifyDevices: user.verifyDevices,
|
||||||
object: 'profile',
|
object: 'profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +156,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
// Build folder responses
|
||||||
@@ -119,42 +181,21 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sends.map(sendToResponse),
|
||||||
|
UserDecryption: {
|
||||||
|
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
||||||
|
TrustedDeviceOption: null,
|
||||||
|
KeyConnectorOption: null,
|
||||||
|
Object: 'userDecryption',
|
||||||
|
},
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
UserDecryptionOptions: {
|
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
HasMasterPassword: true,
|
|
||||||
Object: 'userDecryptionOptions',
|
|
||||||
MasterPasswordUnlock: {
|
|
||||||
Kdf: {
|
|
||||||
KdfType: user.kdfType,
|
|
||||||
Iterations: user.kdfIterations,
|
|
||||||
Memory: user.kdfMemory || null,
|
|
||||||
Parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
MasterKeyEncryptedUserKey: user.key,
|
|
||||||
MasterKeyWrappedUserKey: user.key,
|
|
||||||
Salt: user.email.toLowerCase(),
|
|
||||||
Object: 'masterPasswordUnlock',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
userDecryption: {
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
masterPasswordUnlock: {
|
|
||||||
kdf: {
|
|
||||||
kdfType: user.kdfType,
|
|
||||||
iterations: user.kdfIterations,
|
|
||||||
memory: user.kdfMemory || null,
|
|
||||||
parallelism: user.kdfParallelism || null,
|
|
||||||
},
|
|
||||||
masterKeyWrappedUserKey: user.key,
|
|
||||||
masterKeyEncryptedUserKey: user.key,
|
|
||||||
salt: user.email.toLowerCase(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = JSON.stringify(syncResponse);
|
const body = JSON.stringify(syncResponse);
|
||||||
writeSyncCache(cacheKey, body);
|
writeSyncCache(userId, revisionDate, cacheKey, body);
|
||||||
|
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
import { Env } from './types';
|
import { Env } from './types';
|
||||||
|
import { NotificationsHub } from './durable/notifications-hub';
|
||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import { applyCors, jsonResponse } from './utils/response';
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
|
import { runScheduledBackupIfDue } from './handlers/backup';
|
||||||
|
|
||||||
let dbInitialized = false;
|
let dbInitialized = false;
|
||||||
let dbInitError: string | null = null;
|
let dbInitError: string | null = null;
|
||||||
let dbInitPromise: Promise<void> | null = null;
|
let dbInitPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function isWorkerHandledPath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path.startsWith('/api/') ||
|
||||||
|
path.startsWith('/identity/') ||
|
||||||
|
path.startsWith('/icons/') ||
|
||||||
|
path.startsWith('/notifications/') ||
|
||||||
|
path.startsWith('/.well-known/') ||
|
||||||
|
path === '/config' ||
|
||||||
|
path === '/api/config' ||
|
||||||
|
path === '/api/version'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
|
||||||
|
if (!env.ASSETS) return null;
|
||||||
|
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (isWorkerHandledPath(url.pathname)) return null;
|
||||||
|
|
||||||
|
return env.ASSETS.fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||||
if (dbInitialized) return;
|
if (dbInitialized) return;
|
||||||
|
|
||||||
@@ -32,6 +56,11 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
|||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
void ctx;
|
void ctx;
|
||||||
|
const assetResponse = await maybeServeAsset(request, env);
|
||||||
|
if (assetResponse) {
|
||||||
|
return applyCors(request, assetResponse);
|
||||||
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
if (dbInitError) {
|
if (dbInitError) {
|
||||||
// Log full error server-side, return generic message to client.
|
// Log full error server-side, return generic message to client.
|
||||||
@@ -53,4 +82,18 @@ export default {
|
|||||||
const resp = await handleRequest(request, env);
|
const resp = await handleRequest(request, env);
|
||||||
return applyCors(request, resp);
|
return applyCors(request, resp);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
void controller;
|
||||||
|
await ensureDatabaseInitialized(env);
|
||||||
|
if (dbInitError) {
|
||||||
|
console.error('Skipping scheduled backup because DB init failed:', dbInitError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => {
|
||||||
|
console.error('Scheduled backup failed:', error);
|
||||||
|
}));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export { NotificationsHub };
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Env, User } from './types';
|
||||||
|
import {
|
||||||
|
handleAdminExportBackup,
|
||||||
|
handleDownloadAdminRemoteBackup,
|
||||||
|
handleDeleteAdminRemoteBackup,
|
||||||
|
handleDownloadAdminBackupAttachment,
|
||||||
|
handleGetAdminBackupSettings,
|
||||||
|
handleGetAdminBackupSettingsRepairState,
|
||||||
|
handleInspectAdminRemoteBackup,
|
||||||
|
handleAdminImportBackup,
|
||||||
|
handleListAdminRemoteBackups,
|
||||||
|
handleRepairAdminBackupSettings,
|
||||||
|
handleRestoreAdminRemoteBackup,
|
||||||
|
handleRunAdminConfiguredBackup,
|
||||||
|
handleUpdateAdminBackupSettings,
|
||||||
|
} from './handlers/backup';
|
||||||
|
|
||||||
|
export async function handleAdminBackupRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/api/admin/backup/export' && method === 'POST') {
|
||||||
|
return handleAdminExportBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/blob' && method === 'GET') {
|
||||||
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/settings') {
|
||||||
|
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
|
||||||
|
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/settings/repair') {
|
||||||
|
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, actorUser);
|
||||||
|
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/run' && method === 'POST') {
|
||||||
|
return handleRunAdminConfiguredBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote' && method === 'GET') {
|
||||||
|
return handleListAdminRemoteBackups(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||||
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
||||||
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||||
|
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
|
||||||
|
return handleRestoreAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||||
|
return handleAdminImportBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Env, User } from './types';
|
||||||
|
import {
|
||||||
|
handleAdminListUsers,
|
||||||
|
handleAdminCreateInvite,
|
||||||
|
handleAdminListInvites,
|
||||||
|
handleAdminDeleteAllInvites,
|
||||||
|
handleAdminRevokeInvite,
|
||||||
|
handleAdminSetUserStatus,
|
||||||
|
handleAdminDeleteUser,
|
||||||
|
} from './handlers/admin';
|
||||||
|
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||||
|
|
||||||
|
export async function handleAdminRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
actorUser: User,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/api/admin/users' && method === 'GET') {
|
||||||
|
return handleAdminListUsers(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||||
|
if (adminBackupResponse) return adminBackupResponse;
|
||||||
|
|
||||||
|
if (path === '/api/admin/invites') {
|
||||||
|
if (method === 'GET') return handleAdminListInvites(request, env, actorUser);
|
||||||
|
if (method === 'POST') return handleAdminCreateInvite(request, env, actorUser);
|
||||||
|
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, actorUser);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||||
|
if (adminInviteMatch && method === 'DELETE') {
|
||||||
|
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
||||||
|
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||||
|
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleAdminSetUserStatus(request, env, actorUser, adminUserStatusMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
|
||||||
|
if (adminUserDeleteMatch && method === 'DELETE') {
|
||||||
|
return handleAdminDeleteUser(request, env, actorUser, adminUserDeleteMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import type { Env, User } from './types';
|
||||||
|
import { errorResponse, jsonResponse } from './utils/response';
|
||||||
|
import {
|
||||||
|
handleGetProfile,
|
||||||
|
handleUpdateProfile,
|
||||||
|
handleSetKeys,
|
||||||
|
handleGetRevisionDate,
|
||||||
|
handleVerifyPassword,
|
||||||
|
handleChangePassword,
|
||||||
|
handleSetVerifyDevices,
|
||||||
|
handleGetTotpStatus,
|
||||||
|
handleSetTotpStatus,
|
||||||
|
handleGetTotpRecoveryCode,
|
||||||
|
} from './handlers/accounts';
|
||||||
|
import {
|
||||||
|
handleGetCiphers,
|
||||||
|
handleGetCipher,
|
||||||
|
handleCreateCipher,
|
||||||
|
handleUpdateCipher,
|
||||||
|
handleDeleteCipher,
|
||||||
|
handleDeleteCipherCompat,
|
||||||
|
handlePermanentDeleteCipher,
|
||||||
|
handleRestoreCipher,
|
||||||
|
handleBulkArchiveCiphers,
|
||||||
|
handlePartialUpdateCipher,
|
||||||
|
handleBulkUnarchiveCiphers,
|
||||||
|
handleBulkMoveCiphers,
|
||||||
|
handleBulkDeleteCiphers,
|
||||||
|
handleBulkPermanentDeleteCiphers,
|
||||||
|
handleBulkRestoreCiphers,
|
||||||
|
handleArchiveCipher,
|
||||||
|
handleUnarchiveCipher,
|
||||||
|
} from './handlers/ciphers';
|
||||||
|
import {
|
||||||
|
handleGetFolders,
|
||||||
|
handleGetFolder,
|
||||||
|
handleCreateFolder,
|
||||||
|
handleUpdateFolder,
|
||||||
|
handleDeleteFolder,
|
||||||
|
handleBulkDeleteFolders,
|
||||||
|
} from './handlers/folders';
|
||||||
|
import {
|
||||||
|
handleGetSends,
|
||||||
|
handleGetSend,
|
||||||
|
handleCreateSend,
|
||||||
|
handleCreateFileSendV2,
|
||||||
|
handleGetSendFileUpload,
|
||||||
|
handleUploadSendFile,
|
||||||
|
handleUpdateSend,
|
||||||
|
handleDeleteSend,
|
||||||
|
handleBulkDeleteSends,
|
||||||
|
handleRemoveSendPassword,
|
||||||
|
handleRemoveSendAuth,
|
||||||
|
} from './handlers/sends';
|
||||||
|
import { handleSync } from './handlers/sync';
|
||||||
|
import { handleCiphersImport } from './handlers/import';
|
||||||
|
import {
|
||||||
|
handleCreateAttachment,
|
||||||
|
handleUploadAttachment,
|
||||||
|
handleGetAttachment,
|
||||||
|
handleDeleteAttachment,
|
||||||
|
} from './handlers/attachments';
|
||||||
|
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||||
|
import { handleAdminRoute } from './router-admin';
|
||||||
|
|
||||||
|
export async function handleAuthenticatedRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
currentUser: User,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
||||||
|
const blockedAccountPaths = new Set([
|
||||||
|
'/api/accounts/set-password',
|
||||||
|
'/api/accounts/delete',
|
||||||
|
'/api/accounts/delete-account',
|
||||||
|
'/api/accounts/delete-vault',
|
||||||
|
]);
|
||||||
|
if (blockedAccountPaths.has(path)) {
|
||||||
|
return errorResponse('Not implemented', 501);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/profile') {
|
||||||
|
if (method === 'GET') return handleGetProfile(request, env, userId);
|
||||||
|
if (method === 'PUT') return handleUpdateProfile(request, env, userId);
|
||||||
|
return errorResponse('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return handleChangePassword(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/keys' && method === 'POST') {
|
||||||
|
return handleSetKeys(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/totp') {
|
||||||
|
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||||
|
return handleGetTotpRecoveryCode(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||||
|
return handleGetRevisionDate(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
||||||
|
return handleVerifyPassword(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/verify-devices' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleSetVerifyDevices(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sync' && method === 'GET') {
|
||||||
|
return handleSync(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/notifications/')) {
|
||||||
|
return errorResponse('Not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
||||||
|
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/import' && method === 'POST') {
|
||||||
|
return handleCiphersImport(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/delete' && method === 'POST') {
|
||||||
|
return handleBulkDeleteCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/delete-permanent' && method === 'POST') {
|
||||||
|
return handleBulkPermanentDeleteCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/restore' && method === 'POST') {
|
||||||
|
return handleBulkRestoreCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/archive' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleBulkArchiveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/unarchive' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleBulkUnarchiveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) {
|
||||||
|
return handleBulkMoveCiphers(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
||||||
|
if (cipherMatch) {
|
||||||
|
const cipherId = cipherMatch[1];
|
||||||
|
const subPath = cipherMatch[2] || '';
|
||||||
|
|
||||||
|
if (subPath === '' || subPath === '/') {
|
||||||
|
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
||||||
|
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/archive' && (method === 'PUT' || method === 'POST')) return handleArchiveCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/unarchive' && (method === 'PUT' || method === 'POST')) return handleUnarchiveCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||||
|
if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId);
|
||||||
|
|
||||||
|
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
||||||
|
if (attachmentMatch) {
|
||||||
|
const attachmentId = attachmentMatch[1];
|
||||||
|
if (method === 'POST' || method === 'PUT') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
||||||
|
if (attachmentDeleteMatch && method === 'POST') {
|
||||||
|
return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/folders') {
|
||||||
|
if (method === 'GET') return handleGetFolders(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/folders/delete' && method === 'POST') {
|
||||||
|
return handleBulkDeleteFolders(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
||||||
|
if (folderMatch) {
|
||||||
|
const folderId = folderMatch[1];
|
||||||
|
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
||||||
|
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
||||||
|
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('/api/auth-requests')) {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends') {
|
||||||
|
if (method === 'GET') return handleGetSends(request, env, userId);
|
||||||
|
if (method === 'POST') return handleCreateSend(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/file/v2' && method === 'POST') {
|
||||||
|
return handleCreateFileSendV2(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/delete' && method === 'POST') {
|
||||||
|
return handleBulkDeleteSends(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
||||||
|
if (sendMatch) {
|
||||||
|
const sendId = sendMatch[1];
|
||||||
|
const subPath = sendMatch[2] || '';
|
||||||
|
|
||||||
|
if (subPath === '' || subPath === '/') {
|
||||||
|
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
||||||
|
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
||||||
|
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleRemoveSendPassword(request, env, userId, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return handleRemoveSendAuth(request, env, userId, sendId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
||||||
|
if (sendFileUploadMatch) {
|
||||||
|
const fileId = sendFileUploadMatch[1];
|
||||||
|
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
||||||
|
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
||||||
|
if (method === 'GET') {
|
||||||
|
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/settings/domains') {
|
||||||
|
if (method === 'GET' || method === 'PUT' || method === 'POST') {
|
||||||
|
return jsonResponse({
|
||||||
|
equivalentDomains: [],
|
||||||
|
globalEquivalentDomains: [],
|
||||||
|
object: 'domains',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||||
|
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||||
|
|
||||||
|
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||||
|
if (adminResponse) return adminResponse;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { Env } from './types';
|
||||||
|
import {
|
||||||
|
handleGetAuthorizedDevices,
|
||||||
|
handleGetDevice,
|
||||||
|
handleGetDevices,
|
||||||
|
handleGetDeviceByIdentifier,
|
||||||
|
handleUpdateDeviceKeys,
|
||||||
|
handleUpdateDeviceTrust,
|
||||||
|
handleUntrustDevices,
|
||||||
|
handleRetrieveDeviceKeys,
|
||||||
|
handleDeactivateDevice,
|
||||||
|
handleRevokeAllTrustedDevices,
|
||||||
|
handleRevokeTrustedDevice,
|
||||||
|
handleDeleteAllDevices,
|
||||||
|
handleDeleteDevice,
|
||||||
|
handleUpdateDeviceToken,
|
||||||
|
handleUpdateDeviceWebPushAuth,
|
||||||
|
handleClearDeviceToken,
|
||||||
|
} from './handlers/devices';
|
||||||
|
|
||||||
|
export async function handleAuthenticatedDeviceRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
path: string,
|
||||||
|
method: string
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/api/devices') {
|
||||||
|
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/authorized') {
|
||||||
|
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||||
|
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
||||||
|
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||||
|
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||||
|
if (deleteDeviceMatch && method === 'GET') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
|
return handleGetDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
if (deleteDeviceMatch && method === 'DELETE') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||||
|
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i);
|
||||||
|
if (identifierMatch && method === 'GET') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierMatch[1]);
|
||||||
|
return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i);
|
||||||
|
if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]);
|
||||||
|
return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||||
|
if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]);
|
||||||
|
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i);
|
||||||
|
if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]);
|
||||||
|
return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||||
|
if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]);
|
||||||
|
return handleClearDeviceToken(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i);
|
||||||
|
if (identifierRetrieveKeysMatch && method === 'POST') {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]);
|
||||||
|
return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i);
|
||||||
|
if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) {
|
||||||
|
const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]);
|
||||||
|
return handleDeactivateDevice(request, env, userId, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/update-trust' && method === 'POST') {
|
||||||
|
return handleUpdateDeviceTrust(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/untrust' && method === 'POST') {
|
||||||
|
return handleUntrustDevices(request, env, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { LIMITS } from './config/limits';
|
||||||
|
import { DEFAULT_DEV_SECRET } from './types';
|
||||||
|
import {
|
||||||
|
handleAccessSend,
|
||||||
|
handleAccessSendFile,
|
||||||
|
handleAccessSendV2,
|
||||||
|
handleAccessSendFileV2,
|
||||||
|
handleDownloadSendFile,
|
||||||
|
} from './handlers/sends';
|
||||||
|
import { handleKnownDevice } from './handlers/devices';
|
||||||
|
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
||||||
|
import {
|
||||||
|
handleRegister,
|
||||||
|
handleGetPasswordHint,
|
||||||
|
handleRecoverTwoFactor,
|
||||||
|
} from './handlers/accounts';
|
||||||
|
import { handlePublicDownloadAttachment } from './handlers/attachments';
|
||||||
|
import { handlePublicUploadAttachment } from './handlers/attachments';
|
||||||
|
import {
|
||||||
|
handleNotificationsHub,
|
||||||
|
handleNotificationsNegotiate,
|
||||||
|
} from './handlers/notifications';
|
||||||
|
import { handlePublicUploadSendFile } from './handlers/sends';
|
||||||
|
import { jsonResponse } from './utils/response';
|
||||||
|
import type { Env } from './types';
|
||||||
|
|
||||||
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
|
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null;
|
||||||
|
|
||||||
|
export interface WebBootstrapResponse {
|
||||||
|
defaultKdfIterations: number;
|
||||||
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
|
jwtSecretMinLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
|
const targetOrigin = new URL(request.url).origin;
|
||||||
|
const origin = request.headers.get('Origin');
|
||||||
|
if (origin) {
|
||||||
|
return origin === targetOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = request.headers.get('Referer');
|
||||||
|
if (referer) {
|
||||||
|
try {
|
||||||
|
return new URL(referer).origin === targetOrigin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNwIconSvg(): string {
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNwFavicon(): Response {
|
||||||
|
return new Response(getNwIconSvg(), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'image/svg+xml; charset=utf-8',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceBase(origin: string): string {
|
||||||
|
return `${origin}/icons`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceTemplate(origin: string): string {
|
||||||
|
return `${buildIconServiceBase(origin)}/{}/icon.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIconServiceCsp(origin: string): string {
|
||||||
|
return `img-src 'self' data: ${origin}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigResponse(origin: string) {
|
||||||
|
return {
|
||||||
|
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||||
|
gitHash: 'nodewarden',
|
||||||
|
server: null,
|
||||||
|
environment: {
|
||||||
|
cloudRegion: 'self-hosted',
|
||||||
|
vault: origin,
|
||||||
|
api: origin + '/api',
|
||||||
|
identity: origin + '/identity',
|
||||||
|
notifications: origin + '/notifications',
|
||||||
|
icons: origin,
|
||||||
|
sso: '',
|
||||||
|
fillAssistRules: null,
|
||||||
|
},
|
||||||
|
push: {
|
||||||
|
pushTechnology: 0,
|
||||||
|
vapidPublicKey: null,
|
||||||
|
},
|
||||||
|
communication: null,
|
||||||
|
settings: {
|
||||||
|
disableUserRegistration: false,
|
||||||
|
},
|
||||||
|
_icon_service_url: buildIconServiceTemplate(origin),
|
||||||
|
_icon_service_csp: buildIconServiceCsp(origin),
|
||||||
|
featureStates: {
|
||||||
|
'duo-redirect': true,
|
||||||
|
'email-verification': true,
|
||||||
|
'pm-19051-send-email-verification': false,
|
||||||
|
'pm-19148-innovation-archive': true,
|
||||||
|
'unauth-ui-refresh': true,
|
||||||
|
'web-push': false,
|
||||||
|
},
|
||||||
|
object: 'config',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIconHost(rawHost: string): string | null {
|
||||||
|
const decoded = decodeURIComponent(String(rawHost || '').trim()).toLowerCase().replace(/\.+$/, '');
|
||||||
|
if (!decoded || decoded.includes('/') || decoded.includes('\\')) return null;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(`https://${decoded}`);
|
||||||
|
return parsed.hostname === decoded ? decoded : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWebsiteIcon(host: string): Promise<Response> {
|
||||||
|
const normalizedHost = normalizeIconHost(host);
|
||||||
|
if (!normalizedHost) return handleNwFavicon();
|
||||||
|
|
||||||
|
const encodedHost = encodeURIComponent(normalizedHost);
|
||||||
|
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
|
||||||
|
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [
|
||||||
|
{
|
||||||
|
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `https://favicon.im/${encodedHost}`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const source of upstreamSources) {
|
||||||
|
const resp = await fetch(source.url, {
|
||||||
|
headers: source.headers,
|
||||||
|
redirect: 'follow',
|
||||||
|
cf: {
|
||||||
|
cacheEverything: true,
|
||||||
|
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
||||||
|
},
|
||||||
|
} as RequestInit & { cf: { cacheEverything: boolean; cacheTtl: number } });
|
||||||
|
|
||||||
|
if (!resp.ok) continue;
|
||||||
|
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
|
||||||
|
if (!contentType.startsWith('image/')) continue;
|
||||||
|
|
||||||
|
return new Response(resp.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
||||||
|
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleNwFavicon();
|
||||||
|
} catch {
|
||||||
|
return handleNwFavicon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
const jwtUnsafeReason =
|
||||||
|
!secret
|
||||||
|
? 'missing'
|
||||||
|
: secret === DEFAULT_DEV_SECRET
|
||||||
|
? 'default'
|
||||||
|
: secret.length < LIMITS.auth.jwtSecretMinLength
|
||||||
|
? 'too_short'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
||||||
|
jwtUnsafeReason,
|
||||||
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handlePublicRoute(
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
path: string,
|
||||||
|
method: string,
|
||||||
|
enforcePublicRateLimit: PublicRateLimiter
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
||||||
|
return new Response('{}', {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/api/web-bootstrap' || path === '/web-bootstrap') && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return jsonResponse(buildWebBootstrapResponse(env));
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
||||||
|
if (iconMatch && method === 'GET') {
|
||||||
|
return handleWebsiteIcon(iconMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
|
if (publicAttachmentMatch && method === 'GET') {
|
||||||
|
return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAttachmentUploadMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)\/attachment\/([a-f0-9-]+)$/i);
|
||||||
|
if (publicAttachmentUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
|
||||||
|
return handlePublicUploadAttachment(request, env, publicAttachmentUploadMatch[1], publicAttachmentUploadMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicSendUploadMatch = path.match(/^\/api\/sends\/([^/]+)\/file\/([^/]+)\/?$/i);
|
||||||
|
if (publicSendUploadMatch && (method === 'POST' || method === 'PUT') && new URL(request.url).searchParams.has('token')) {
|
||||||
|
return handlePublicUploadSendFile(request, env, publicSendUploadMatch[1], publicSendUploadMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
||||||
|
if (sendAccessMatch && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSend(request, env, sendAccessMatch[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/sends/access' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSendV2(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
||||||
|
if (sendAccessFileV2Match && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
||||||
|
if (sendAccessFileMatch && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
||||||
|
if (sendDownloadMatch && method === 'GET') {
|
||||||
|
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/connect/token' && method === 'POST') {
|
||||||
|
return handleToken(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit();
|
||||||
|
if (blocked) return jsonResponse(false);
|
||||||
|
return handleKnownDevice(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearDeviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i);
|
||||||
|
if (clearDeviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||||
|
return new Response(null, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handleRevocation(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handlePrelogin(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/identity/accounts/prelogin/password' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return handlePrelogin(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
||||||
|
return handleRecoverTwoFactor(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/password-hint' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
if (!isSameOriginWriteRequest(request)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return handleGetPasswordHint(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((path === '/config' || path === '/api/config') && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
return jsonResponse(buildConfigResponse(origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/version' && method === 'GET') {
|
||||||
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/accounts/register' && method === 'POST') {
|
||||||
|
const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute);
|
||||||
|
if (blocked) return blocked;
|
||||||
|
if (!isSameOriginWriteRequest(request)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Forbidden origin' }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return handleRegister(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/notifications/hub/negotiate' && method === 'POST') {
|
||||||
|
return handleNotificationsNegotiate(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/notifications/hub' && method === 'GET') {
|
||||||
|
return handleNotificationsHub(request, env);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+82
-707
@@ -1,124 +1,10 @@
|
|||||||
import { Env, DEFAULT_DEV_SECRET } from './types';
|
import { DEFAULT_DEV_SECRET, Env } from './types';
|
||||||
import { AuthService } from './services/auth';
|
import { AuthService } from './services/auth';
|
||||||
import { StorageService } from './services/storage';
|
|
||||||
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
import { RateLimitService, getClientIdentifier } from './services/ratelimit';
|
||||||
import { handleCors, errorResponse, jsonResponse } from './utils/response';
|
import { handleCors, errorResponse } from './utils/response';
|
||||||
import { LIMITS } from './config/limits';
|
import { LIMITS } from './config/limits';
|
||||||
|
import { handleAuthenticatedRoute } from './router-authenticated';
|
||||||
// Identity handlers
|
import { handlePublicRoute } from './router-public';
|
||||||
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
|
|
||||||
|
|
||||||
// Account handlers
|
|
||||||
import {
|
|
||||||
handleRegister,
|
|
||||||
handleGetProfile,
|
|
||||||
handleSetKeys,
|
|
||||||
handleGetRevisionDate,
|
|
||||||
handleVerifyPassword,
|
|
||||||
handleChangePassword,
|
|
||||||
handleGetTotpStatus,
|
|
||||||
handleSetTotpStatus,
|
|
||||||
handleGetTotpRecoveryCode,
|
|
||||||
handleRecoverTwoFactor,
|
|
||||||
} from './handlers/accounts';
|
|
||||||
|
|
||||||
// Cipher handlers
|
|
||||||
import {
|
|
||||||
handleGetCiphers,
|
|
||||||
handleGetCipher,
|
|
||||||
handleCreateCipher,
|
|
||||||
handleUpdateCipher,
|
|
||||||
handleDeleteCipher,
|
|
||||||
handleDeleteCipherCompat,
|
|
||||||
handlePermanentDeleteCipher,
|
|
||||||
handleRestoreCipher,
|
|
||||||
handlePartialUpdateCipher,
|
|
||||||
handleBulkMoveCiphers,
|
|
||||||
} from './handlers/ciphers';
|
|
||||||
|
|
||||||
// Folder handlers
|
|
||||||
import {
|
|
||||||
handleGetFolders,
|
|
||||||
handleGetFolder,
|
|
||||||
handleCreateFolder,
|
|
||||||
handleUpdateFolder,
|
|
||||||
handleDeleteFolder
|
|
||||||
} from './handlers/folders';
|
|
||||||
|
|
||||||
// Send handlers
|
|
||||||
import {
|
|
||||||
handleGetSends,
|
|
||||||
handleGetSend,
|
|
||||||
handleCreateSend,
|
|
||||||
handleCreateFileSendV2,
|
|
||||||
handleGetSendFileUpload,
|
|
||||||
handleUploadSendFile,
|
|
||||||
handleUpdateSend,
|
|
||||||
handleDeleteSend,
|
|
||||||
handleRemoveSendPassword,
|
|
||||||
handleRemoveSendAuth,
|
|
||||||
handleAccessSend,
|
|
||||||
handleAccessSendFile,
|
|
||||||
handleAccessSendV2,
|
|
||||||
handleAccessSendFileV2,
|
|
||||||
handleDownloadSendFile,
|
|
||||||
} from './handlers/sends';
|
|
||||||
|
|
||||||
// Sync handler
|
|
||||||
import { handleSync } from './handlers/sync';
|
|
||||||
|
|
||||||
// Setup handlers
|
|
||||||
import { handleSetupStatus } from './handlers/setup';
|
|
||||||
import {
|
|
||||||
handleKnownDevice,
|
|
||||||
handleGetAuthorizedDevices,
|
|
||||||
handleGetDevices,
|
|
||||||
handleRevokeAllTrustedDevices,
|
|
||||||
handleRevokeTrustedDevice,
|
|
||||||
handleDeleteDevice,
|
|
||||||
handleUpdateDeviceToken
|
|
||||||
} from './handlers/devices';
|
|
||||||
|
|
||||||
// Import handler
|
|
||||||
import { handleCiphersImport } from './handlers/import';
|
|
||||||
|
|
||||||
// Attachment handlers
|
|
||||||
import {
|
|
||||||
handleCreateAttachment,
|
|
||||||
handleUploadAttachment,
|
|
||||||
handleGetAttachment,
|
|
||||||
handleDeleteAttachment,
|
|
||||||
handlePublicDownloadAttachment,
|
|
||||||
} from './handlers/attachments';
|
|
||||||
import {
|
|
||||||
handleAdminListUsers,
|
|
||||||
handleAdminCreateInvite,
|
|
||||||
handleAdminListInvites,
|
|
||||||
handleAdminDeleteAllInvites,
|
|
||||||
handleAdminRevokeInvite,
|
|
||||||
handleAdminSetUserStatus,
|
|
||||||
handleAdminDeleteUser,
|
|
||||||
} from './handlers/admin';
|
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
|
||||||
const targetOrigin = new URL(request.url).origin;
|
|
||||||
const origin = request.headers.get('Origin');
|
|
||||||
if (origin) {
|
|
||||||
return origin === targetOrigin;
|
|
||||||
}
|
|
||||||
|
|
||||||
const referer = request.headers.get('Referer');
|
|
||||||
if (referer) {
|
|
||||||
try {
|
|
||||||
return new URL(referer).origin === targetOrigin;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require browser-origin evidence for setup/register write operations.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||||
const secret = (env.JWT_SECRET || '').trim();
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
@@ -128,85 +14,16 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNwIconSvg(): string {
|
function isImportBypassRequest(request: Request, path: string, method: string): boolean {
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96" role="img" aria-label="NW icon"><rect x="4" y="4" width="88" height="88" rx="20" fill="#111418"/><text x="48" y="60" text-anchor="middle" font-size="36" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-weight="800" letter-spacing="0.5" fill="#FFFFFF">NW</text></svg>`;
|
if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
|
||||||
}
|
|
||||||
|
|
||||||
function handleNwFavicon(): Response {
|
if (method === 'POST') {
|
||||||
return new Response(getNwIconSvg(), {
|
if (path === '/api/ciphers/import') return true;
|
||||||
status: 200,
|
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/v2$/i.test(path)) return true;
|
||||||
headers: {
|
if (/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path)) return true;
|
||||||
'Content-Type': 'image/svg+xml; charset=utf-8',
|
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidIconHostname(hostname: string): boolean {
|
|
||||||
if (!hostname) return false;
|
|
||||||
if (hostname.length > 253) return false;
|
|
||||||
|
|
||||||
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
||||||
// Slightly relaxed domain validation:
|
|
||||||
// - keep strict label boundaries (no leading/trailing hyphen)
|
|
||||||
// - allow punycode TLD (e.g. xn--...)
|
|
||||||
const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/;
|
|
||||||
const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
|
||||||
|
|
||||||
if (domainPattern.test(normalized)) return true;
|
|
||||||
if (!ipv4Pattern.test(normalized)) return false;
|
|
||||||
|
|
||||||
const parts = normalized.split('.');
|
|
||||||
return parts.every(p => {
|
|
||||||
const n = Number(p);
|
|
||||||
return Number.isInteger(n) && n >= 0 && n <= 255;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icons handler - proxy to Bitwarden's official icon service
|
|
||||||
async function handleGetIcon(request: Request, env: Env, hostname: string): Promise<Response> {
|
|
||||||
try {
|
|
||||||
void env;
|
|
||||||
const normalizedHostname = hostname.toLowerCase();
|
|
||||||
if (!isValidIconHostname(normalizedHostname)) {
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = caches.default;
|
|
||||||
const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' });
|
|
||||||
const cached = await cache.match(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Bitwarden's official icon service
|
|
||||||
const iconUrl = `https://icons.bitwarden.net/${normalizedHostname}/icon.png`;
|
|
||||||
const resp = await fetch(iconUrl, {
|
|
||||||
headers: { 'User-Agent': 'NodeWarden/1.0' },
|
|
||||||
redirect: 'follow',
|
|
||||||
cf: {
|
|
||||||
cacheEverything: true,
|
|
||||||
cacheTtl: LIMITS.cache.iconTtlSeconds,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resp.ok) {
|
|
||||||
const body = await resp.arrayBuffer();
|
|
||||||
const iconResponse = new Response(body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
|
|
||||||
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await cache.put(cacheKey, iconResponse.clone());
|
|
||||||
return iconResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
} catch {
|
|
||||||
return new Response(null, { status: 204 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
export async function handleRequest(request: Request, env: Env): Promise<Response> {
|
||||||
@@ -215,554 +32,112 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
const method = request.method;
|
const method = request.method;
|
||||||
const clientId = getClientIdentifier(request);
|
const clientId = getClientIdentifier(request);
|
||||||
|
|
||||||
async function enforcePublicRateLimit(): Promise<Response | null> {
|
async function enforcePublicRateLimit(
|
||||||
|
category: string = 'public',
|
||||||
|
maxRequests: number = LIMITS.rateLimit.publicRequestsPerMinute
|
||||||
|
): Promise<Response | null> {
|
||||||
|
if (!clientId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Forbidden',
|
||||||
|
error_description: 'Client IP is required',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const check = await rateLimit.consumeBudget(`${clientId}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
|
||||||
if (check.allowed) return null;
|
if (check.allowed) return null;
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Too many requests',
|
return new Response(
|
||||||
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
JSON.stringify({
|
||||||
}), {
|
error: 'Too many requests',
|
||||||
status: 429,
|
error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`,
|
||||||
headers: {
|
}),
|
||||||
'Content-Type': 'application/json',
|
{
|
||||||
'Retry-After': String(check.retryAfterSeconds || 60),
|
status: 429,
|
||||||
'X-RateLimit-Remaining': '0',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
});
|
'Retry-After': String(check.retryAfterSeconds || 60),
|
||||||
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
return handleCors(request);
|
return handleCors(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route matching
|
|
||||||
try {
|
try {
|
||||||
|
const isLargeUploadPath =
|
||||||
// Reject oversized bodies before any path-specific parsing.
|
|
||||||
// File upload paths enforce their own limits and are exempt here.
|
|
||||||
const isFileUploadPath =
|
|
||||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path);
|
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||||
if (!isFileUploadPath) {
|
path === '/api/admin/backup/import';
|
||||||
|
if (!isLargeUploadPath) {
|
||||||
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
||||||
if (contentLength > LIMITS.request.maxBodyBytes) {
|
if (contentLength > LIMITS.request.maxBodyBytes) {
|
||||||
return errorResponse('Request body too large', 413);
|
return errorResponse('Request body too large', 413);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup status
|
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
|
||||||
if (path === '/setup/status' && method === 'GET') {
|
if (publicResponse) return publicResponse;
|
||||||
return handleSetupStatus(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Web runtime config for static client bootstrap
|
const secretIssue = jwtSecretUnsafeReason(env);
|
||||||
if (path === '/api/web/config' && method === 'GET') {
|
if (secretIssue) {
|
||||||
const jwtUnsafeReason = jwtSecretUnsafeReason(env);
|
|
||||||
return jsonResponse({
|
|
||||||
defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
|
|
||||||
jwtUnsafeReason,
|
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browser/devtools probe endpoint
|
|
||||||
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
|
|
||||||
return new Response('{}', {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
'Cache-Control': 'no-store',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favicon
|
|
||||||
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
|
|
||||||
return handleNwFavicon();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Icon endpoint - proxy to Bitwarden's icon service (no auth required)
|
|
||||||
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
|
|
||||||
if (iconMatch) {
|
|
||||||
const hostname = iconMatch[1];
|
|
||||||
return handleGetIcon(request, env, hostname);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public attachment download (no auth header, uses token in query string)
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
|
||||||
if (publicAttachmentMatch && method === 'GET') {
|
|
||||||
const cipherId = publicAttachmentMatch[1];
|
|
||||||
const attachmentId = publicAttachmentMatch[2];
|
|
||||||
return handlePublicDownloadAttachment(request, env, cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public Send access endpoints
|
|
||||||
const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i);
|
|
||||||
if (sendAccessMatch && method === 'POST') {
|
|
||||||
const blocked = await enforcePublicRateLimit();
|
|
||||||
if (blocked) return blocked;
|
|
||||||
const accessId = sendAccessMatch[1];
|
|
||||||
return handleAccessSend(request, env, accessId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendAccessV2Match = path === '/api/sends/access';
|
|
||||||
if (sendAccessV2Match && method === 'POST') {
|
|
||||||
const blocked = await enforcePublicRateLimit();
|
|
||||||
if (blocked) return blocked;
|
|
||||||
return handleAccessSendV2(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i);
|
|
||||||
if (sendAccessFileV2Match && method === 'POST') {
|
|
||||||
const blocked = await enforcePublicRateLimit();
|
|
||||||
if (blocked) return blocked;
|
|
||||||
const fileId = sendAccessFileV2Match[1];
|
|
||||||
return handleAccessSendFileV2(request, env, fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i);
|
|
||||||
if (sendAccessFileMatch && method === 'POST') {
|
|
||||||
const blocked = await enforcePublicRateLimit();
|
|
||||||
if (blocked) return blocked;
|
|
||||||
const idOrAccessId = sendAccessFileMatch[1];
|
|
||||||
const fileId = sendAccessFileMatch[2];
|
|
||||||
return handleAccessSendFile(request, env, idOrAccessId, fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i);
|
|
||||||
if (sendDownloadMatch && method === 'GET') {
|
|
||||||
const sendId = sendDownloadMatch[1];
|
|
||||||
const fileId = sendDownloadMatch[2];
|
|
||||||
return handleDownloadSendFile(request, env, sendId, fileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notifications hub (stub - no auth required, return 200 for connection)
|
|
||||||
if (path.startsWith('/notifications/')) {
|
|
||||||
const blocked = await enforcePublicRateLimit();
|
|
||||||
if (blocked) return blocked;
|
|
||||||
return new Response(null, { status: 200 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known device check (no auth required)
|
|
||||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
|
||||||
const blocked = await enforcePublicRateLimit();
|
|
||||||
if (blocked) return jsonResponse(false);
|
|
||||||
return handleKnownDevice(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Identity endpoints (no auth required)
|
|
||||||
if (path === '/identity/connect/token' && method === 'POST') {
|
|
||||||
return handleToken(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') {
|
|
||||||
return handleRevocation(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/identity/accounts/prelogin' && method === 'POST') {
|
|
||||||
return handlePrelogin(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
|
|
||||||
return handleRecoverTwoFactor(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config endpoint (no auth required for basic config)
|
|
||||||
// Bitwarden clients call GET "/config" (relative to the API base URL).
|
|
||||||
// They also tolerate different casing, but their response models use PascalCase.
|
|
||||||
const isConfigRequest = (path === '/config' || path === '/api/config') && method === 'GET';
|
|
||||||
if (isConfigRequest) {
|
|
||||||
const origin = url.origin;
|
|
||||||
return jsonResponse({
|
|
||||||
// ── Version Strategy (Plan E) ──────────────────────────────────────
|
|
||||||
// Bitwarden clients use this version for backwards-compatibility feature gating.
|
|
||||||
// Confirmed version-gated features (from client source code):
|
|
||||||
// - Individual cipher key encryption: >= 2024.2.0
|
|
||||||
// (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER)
|
|
||||||
// (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION)
|
|
||||||
// - MasterPasswordUnlockData (mobile): >= 2025.8.0
|
|
||||||
// (documented in Vaultwarden source comments)
|
|
||||||
// There is NO global minimum version that blocks all client functionality.
|
|
||||||
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
|
|
||||||
// When Vaultwarden bumps their version, update this value accordingly.
|
|
||||||
// Vaultwarden source: src/api/core/mod.rs → fn config()
|
|
||||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
|
||||||
gitHash: 'nodewarden',
|
|
||||||
server: null,
|
|
||||||
environment: {
|
|
||||||
vault: origin,
|
|
||||||
api: origin + '/api',
|
|
||||||
identity: origin + '/identity',
|
|
||||||
notifications: origin + '/notifications',
|
|
||||||
sso: '',
|
|
||||||
},
|
|
||||||
// Feature flags control client behavior. Clients use server-provided values;
|
|
||||||
// flags not listed here fall back to DefaultFeatureFlagValue (all false).
|
|
||||||
// Only enable flags for features we actually support.
|
|
||||||
// Reference: clients/libs/common/src/enums/feature-flag.enum.ts
|
|
||||||
featureStates: {
|
|
||||||
'duo-redirect': true,
|
|
||||||
'email-verification': true,
|
|
||||||
'pm-19051-send-email-verification': false,
|
|
||||||
'unauth-ui-refresh': true,
|
|
||||||
},
|
|
||||||
object: 'config',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version endpoint (some clients probe this to validate the server)
|
|
||||||
if (path === '/api/version' && method === 'GET') {
|
|
||||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registration endpoint (no auth required):
|
|
||||||
// - first user can self-register and becomes admin
|
|
||||||
// - later registrations require inviteCode in request body
|
|
||||||
if (path === '/api/accounts/register' && method === 'POST') {
|
|
||||||
if (!isSameOriginWriteRequest(request)) {
|
|
||||||
return errorResponse('Forbidden origin', 403);
|
|
||||||
}
|
|
||||||
return handleRegister(request, env);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If JWT_SECRET is not safely configured, block any other endpoints.
|
|
||||||
const secret = jwtSecretUnsafeReason(env);
|
|
||||||
if (secret) {
|
|
||||||
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All other API endpoints require authentication
|
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
const payload = await auth.verifyAccessToken(authHeader);
|
const verified = await auth.verifyAccessTokenWithUser(authHeader);
|
||||||
|
if (!verified) {
|
||||||
if (!payload) {
|
|
||||||
return errorResponse('Unauthorized', 401);
|
return errorResponse('Unauthorized', 401);
|
||||||
}
|
}
|
||||||
|
const { payload, user: currentUser } = verified;
|
||||||
|
|
||||||
|
const actingDeviceId = String(payload.did || '').trim();
|
||||||
|
if (actingDeviceId) {
|
||||||
|
const nextHeaders = new Headers(request.headers);
|
||||||
|
nextHeaders.set('X-NodeWarden-Acting-Device-Id', actingDeviceId);
|
||||||
|
request = new Request(request, { headers: nextHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
const userId = payload.sub;
|
const userId = payload.sub;
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const currentUser = await storage.getUserById(userId);
|
|
||||||
if (!currentUser) {
|
|
||||||
return errorResponse('Unauthorized', 401);
|
|
||||||
}
|
|
||||||
if (currentUser.status !== 'active') {
|
if (currentUser.status !== 'active') {
|
||||||
return errorResponse('Account is disabled', 403);
|
return errorResponse('Account is disabled', 403);
|
||||||
}
|
}
|
||||||
// Unified rate limiting for all authenticated API requests.
|
|
||||||
{
|
if (!isImportBypassRequest(request, path, method)) {
|
||||||
const rateLimit = new RateLimitService(env.DB);
|
const rateLimit = new RateLimitService(env.DB);
|
||||||
const rateLimitCheck = await rateLimit.consumeBudget(
|
const rateLimitCheck = await rateLimit.consumeBudget(`${userId}:api`, LIMITS.rateLimit.apiRequestsPerMinute);
|
||||||
userId + ':api',
|
|
||||||
LIMITS.rateLimit.apiRequestsPerMinute
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rateLimitCheck.allowed) {
|
if (!rateLimitCheck.allowed) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Too many requests',
|
JSON.stringify({
|
||||||
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
error: 'Too many requests',
|
||||||
}), {
|
error_description: `Rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||||
status: 429,
|
}),
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/json',
|
status: 429,
|
||||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
headers: {
|
||||||
'X-RateLimit-Remaining': '0',
|
'Content-Type': 'application/json',
|
||||||
},
|
'Retry-After': String(rateLimitCheck.retryAfterSeconds || 60),
|
||||||
});
|
'X-RateLimit-Remaining': '0',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block account operations we do not support yet.
|
const authenticatedResponse = await handleAuthenticatedRoute(request, env, userId, currentUser, path, method);
|
||||||
if (method === 'POST' || method === 'PUT' || method === 'DELETE') {
|
if (authenticatedResponse) return authenticatedResponse;
|
||||||
const blockedAccountPaths = new Set([
|
|
||||||
'/api/accounts/set-password',
|
|
||||||
'/api/accounts/delete',
|
|
||||||
'/api/accounts/delete-account',
|
|
||||||
'/api/accounts/delete-vault',
|
|
||||||
]);
|
|
||||||
if (blockedAccountPaths.has(path)) {
|
|
||||||
return errorResponse('Not implemented', 501);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account endpoints
|
|
||||||
if (path === '/api/accounts/profile') {
|
|
||||||
if (method === 'GET') return handleGetProfile(request, env, userId);
|
|
||||||
return errorResponse('Method not allowed', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) {
|
|
||||||
return handleChangePassword(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/accounts/keys' && method === 'POST') {
|
|
||||||
return handleSetKeys(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/accounts/totp') {
|
|
||||||
if (method === 'GET') return handleGetTotpStatus(request, env, userId);
|
|
||||||
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
|
||||||
return handleGetTotpRecoveryCode(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Revision date endpoint
|
|
||||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
|
||||||
return handleGetRevisionDate(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify password endpoint
|
|
||||||
if (path === '/api/accounts/verify-password' && method === 'POST') {
|
|
||||||
return handleVerifyPassword(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync endpoint
|
|
||||||
if (path === '/api/sync' && method === 'GET') {
|
|
||||||
return handleSync(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cipher endpoints
|
|
||||||
if (path === '/api/ciphers' || path === '/api/ciphers/create') {
|
|
||||||
if (method === 'GET') return handleGetCiphers(request, env, userId);
|
|
||||||
if (method === 'POST') return handleCreateCipher(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ciphers import endpoint (Bitwarden client format)
|
|
||||||
if (path === '/api/ciphers/import' && method === 'POST') {
|
|
||||||
return handleCiphersImport(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk cipher operations (only move is allowed)
|
|
||||||
if (path === '/api/ciphers/move') {
|
|
||||||
if (method === 'POST' || method === 'PUT') {
|
|
||||||
return handleBulkMoveCiphers(request, env, userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match /api/ciphers/:id patterns
|
|
||||||
const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i);
|
|
||||||
if (cipherMatch) {
|
|
||||||
const cipherId = cipherMatch[1];
|
|
||||||
const subPath = cipherMatch[2] || '';
|
|
||||||
|
|
||||||
if (subPath === '' || subPath === '/') {
|
|
||||||
if (method === 'GET') return handleGetCipher(request, env, userId, cipherId);
|
|
||||||
if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId);
|
|
||||||
if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/delete' && method === 'PUT') {
|
|
||||||
return handleDeleteCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/delete' && method === 'DELETE') {
|
|
||||||
return handlePermanentDeleteCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/restore' && method === 'PUT') {
|
|
||||||
return handleRestoreCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) {
|
|
||||||
return handlePartialUpdateCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share endpoint - just return the cipher (single user mode)
|
|
||||||
if (subPath === '/share' && method === 'POST') {
|
|
||||||
return handleGetCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/details' && method === 'GET') {
|
|
||||||
return handleGetCipher(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachment endpoints
|
|
||||||
// POST /api/ciphers/{id}/attachment/v2 - Create attachment metadata
|
|
||||||
if (subPath === '/attachment/v2' && method === 'POST') {
|
|
||||||
return handleCreateAttachment(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy attachment endpoint - also goes to v2 flow
|
|
||||||
if (subPath === '/attachment' && method === 'POST') {
|
|
||||||
return handleCreateAttachment(request, env, userId, cipherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match /api/ciphers/{id}/attachment/{attachmentId}
|
|
||||||
const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i);
|
|
||||||
if (attachmentMatch) {
|
|
||||||
const attachmentId = attachmentMatch[1];
|
|
||||||
if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE via POST (legacy)
|
|
||||||
const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i);
|
|
||||||
if (attachmentDeleteMatch && method === 'POST') {
|
|
||||||
const attachmentId = attachmentDeleteMatch[1];
|
|
||||||
return handleDeleteAttachment(request, env, userId, cipherId, attachmentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Folder endpoints
|
|
||||||
if (path === '/api/folders') {
|
|
||||||
if (method === 'GET') return handleGetFolders(request, env, userId);
|
|
||||||
if (method === 'POST') return handleCreateFolder(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match /api/folders/:id patterns
|
|
||||||
const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i);
|
|
||||||
if (folderMatch) {
|
|
||||||
const folderId = folderMatch[1];
|
|
||||||
if (method === 'GET') return handleGetFolder(request, env, userId, folderId);
|
|
||||||
if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId);
|
|
||||||
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth requests endpoint (stub - we don't support passwordless login)
|
|
||||||
if (path.startsWith('/api/auth-requests')) {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collections endpoint (stub - no organization support)
|
|
||||||
if (path === '/api/collections' || path.startsWith('/api/collections/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Organizations endpoint (stub - no organization support)
|
|
||||||
if (path === '/api/organizations' || path.startsWith('/api/organizations/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send endpoints
|
|
||||||
if (path === '/api/sends') {
|
|
||||||
if (method === 'GET') return handleGetSends(request, env, userId);
|
|
||||||
if (method === 'POST') return handleCreateSend(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((path === '/api/sends/file/v2' || path === '/api/sends/file') && method === 'POST') {
|
|
||||||
return handleCreateFileSendV2(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i);
|
|
||||||
if (sendMatch) {
|
|
||||||
const sendId = sendMatch[1];
|
|
||||||
const subPath = sendMatch[2] || '';
|
|
||||||
|
|
||||||
if (subPath === '' || subPath === '/') {
|
|
||||||
if (method === 'GET') return handleGetSend(request, env, userId, sendId);
|
|
||||||
if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId);
|
|
||||||
if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) {
|
|
||||||
return handleRemoveSendPassword(request, env, userId, sendId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) {
|
|
||||||
return handleRemoveSendAuth(request, env, userId, sendId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i);
|
|
||||||
if (sendFileUploadMatch) {
|
|
||||||
const fileId = sendFileUploadMatch[1];
|
|
||||||
if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId);
|
|
||||||
if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Policies endpoint (stub - not implemented)
|
|
||||||
if (path === '/api/policies' || path.startsWith('/api/policies/')) {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings domains endpoint (stub)
|
|
||||||
if (path === '/api/settings/domains') {
|
|
||||||
if (method === 'GET') {
|
|
||||||
return jsonResponse({
|
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (method === 'PUT' || method === 'POST') {
|
|
||||||
return jsonResponse({
|
|
||||||
equivalentDomains: [],
|
|
||||||
globalEquivalentDomains: [],
|
|
||||||
object: 'domains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Devices endpoint
|
|
||||||
if (path === '/api/devices' && method === 'GET') {
|
|
||||||
return handleGetDevices(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/devices/authorized') {
|
|
||||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
|
||||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
|
||||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
|
||||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
|
||||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
|
||||||
if (deleteDeviceMatch && method === 'DELETE') {
|
|
||||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
|
||||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin endpoints
|
|
||||||
if (path === '/api/admin/users' && method === 'GET') {
|
|
||||||
return handleAdminListUsers(request, env, currentUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path === '/api/admin/invites') {
|
|
||||||
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
|
||||||
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
|
||||||
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, currentUser);
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
|
||||||
if (adminInviteMatch && method === 'DELETE') {
|
|
||||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
|
||||||
return handleAdminRevokeInvite(request, env, currentUser, inviteCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
|
||||||
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
|
|
||||||
return handleAdminSetUserStatus(request, env, currentUser, adminUserStatusMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
|
|
||||||
if (adminUserDeleteMatch && method === 'DELETE') {
|
|
||||||
return handleAdminDeleteUser(request, env, currentUser, adminUserDeleteMatch[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device push token endpoint (no-op compatibility handler)
|
|
||||||
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
|
||||||
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
|
||||||
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
|
|
||||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found
|
|
||||||
return errorResponse('Not found', 404);
|
return errorResponse('Not found', 404);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Request error:', error);
|
console.error('Request error:', error);
|
||||||
return errorResponse('Internal server error', 500);
|
return errorResponse('Internal server error', 500);
|
||||||
|
|||||||
+47
-15
@@ -7,6 +7,11 @@ import { StorageService } from './storage';
|
|||||||
// This second layer only needs to be non-trivial, not expensive.
|
// This second layer only needs to be non-trivial, not expensive.
|
||||||
const SERVER_HASH_ITERATIONS = 100_000;
|
const SERVER_HASH_ITERATIONS = 100_000;
|
||||||
|
|
||||||
|
export interface VerifiedAccessContext {
|
||||||
|
payload: JWTPayload;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private storage: StorageService;
|
private storage: StorageService;
|
||||||
|
|
||||||
@@ -61,27 +66,27 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
async generateAccessToken(user: User): Promise<string> {
|
async generateAccessToken(user: User, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||||
return createJWT(
|
return createJWT(
|
||||||
{
|
{
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
sstamp: user.securityStamp,
|
sstamp: user.securityStamp,
|
||||||
|
...(device?.identifier ? { did: device.identifier, dstamp: device.sessionStamp } : {}),
|
||||||
},
|
},
|
||||||
this.env.JWT_SECRET
|
this.env.JWT_SECRET
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
async generateRefreshToken(userId: string): Promise<string> {
|
async generateRefreshToken(userId: string, device?: { identifier: string; sessionStamp: string } | null): Promise<string> {
|
||||||
const token = createRefreshToken();
|
const token = createRefreshToken();
|
||||||
await this.storage.saveRefreshToken(token, userId);
|
await this.storage.saveRefreshToken(token, userId, undefined, device?.identifier ?? null, device?.sessionStamp ?? null);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify access token from Authorization header
|
async verifyAccessTokenWithUser(authHeader: string | null): Promise<VerifiedAccessContext | null> {
|
||||||
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
|
||||||
if (!authHeader) return null;
|
if (!authHeader) return null;
|
||||||
|
|
||||||
const parts = authHeader.split(' ');
|
const parts = authHeader.split(' ');
|
||||||
@@ -92,30 +97,57 @@ export class AuthService {
|
|||||||
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
const payload = await verifyJWT(parts[1], this.env.JWT_SECRET);
|
||||||
if (!payload) return null;
|
if (!payload) return null;
|
||||||
|
|
||||||
// Verify security stamp - ensures token is invalidated after password change
|
|
||||||
const user = await this.storage.getUserById(payload.sub);
|
const user = await this.storage.getUserById(payload.sub);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
if (payload.sstamp !== user.securityStamp) {
|
if (payload.sstamp !== user.securityStamp) {
|
||||||
return null; // Token was issued before password change
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
if (payload.did) {
|
||||||
|
const device = await this.storage.getDevice(user.id, payload.did);
|
||||||
|
if (!device) return null;
|
||||||
|
if (!payload.dstamp || payload.dstamp !== device.sessionStamp) return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access token from Authorization header
|
||||||
|
async verifyAccessToken(authHeader: string | null): Promise<JWTPayload | null> {
|
||||||
|
const verified = await this.verifyAccessTokenWithUser(authHeader);
|
||||||
|
return verified?.payload ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh access token
|
// Refresh access token
|
||||||
async refreshAccessToken(refreshToken: string): Promise<{ accessToken: string; user: User } | null> {
|
async refreshAccessToken(
|
||||||
const userId = await this.storage.getRefreshTokenUserId(refreshToken);
|
refreshToken: string
|
||||||
if (!userId) return null;
|
): Promise<{ accessToken: string; user: User; device: { identifier: string; sessionStamp: string } | null } | null> {
|
||||||
|
const record = await this.storage.getRefreshTokenRecord(refreshToken);
|
||||||
|
if (!record?.userId) return null;
|
||||||
|
|
||||||
const user = await this.storage.getUserById(userId);
|
const user = await this.storage.getUserById(record.userId);
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
if (user.status !== 'active') {
|
if (user.status !== 'active') {
|
||||||
await this.storage.deleteRefreshToken(refreshToken);
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await this.generateAccessToken(user);
|
let device: { identifier: string; sessionStamp: string } | null = null;
|
||||||
return { accessToken, user };
|
if (record.deviceIdentifier) {
|
||||||
|
const boundDevice = await this.storage.getDevice(user.id, record.deviceIdentifier);
|
||||||
|
if (!boundDevice) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!record.deviceSessionStamp || boundDevice.sessionStamp !== record.deviceSessionStamp) {
|
||||||
|
await this.storage.deleteRefreshToken(refreshToken);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
device = { identifier: boundDevice.deviceIdentifier, sessionStamp: boundDevice.sessionStamp };
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await this.generateAccessToken(user, device);
|
||||||
|
return { accessToken, user, device };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { zipSync, unzipSync } from 'fflate';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
import { APP_VERSION } from '../../shared/app-version';
|
||||||
|
import {
|
||||||
|
getAttachmentObjectKey,
|
||||||
|
getBlobStorageKind,
|
||||||
|
} from './blob-store';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
|
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||||
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
|
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||||
|
const BACKUP_JSON_INDENT = 2;
|
||||||
|
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||||
|
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface BackupManifest {
|
||||||
|
formatVersion: 1;
|
||||||
|
exportedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
storageKind: 'r2' | 'kv' | null;
|
||||||
|
tableCounts: Record<string, number>;
|
||||||
|
includes: {
|
||||||
|
attachments: boolean;
|
||||||
|
};
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: number;
|
||||||
|
totalBytes: number;
|
||||||
|
largestObjectBytes: number;
|
||||||
|
};
|
||||||
|
attachmentBlobs?: BackupManifestAttachmentBlob[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestAttachmentBlob {
|
||||||
|
cipherId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
blobName: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupPayload {
|
||||||
|
manifest: BackupManifest;
|
||||||
|
db: {
|
||||||
|
config: SqlRow[];
|
||||||
|
users: SqlRow[];
|
||||||
|
user_revisions: SqlRow[];
|
||||||
|
folders: SqlRow[];
|
||||||
|
ciphers: SqlRow[];
|
||||||
|
attachments: SqlRow[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupArchiveBundle {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
fileName: string;
|
||||||
|
manifest: BackupManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupFileIntegrityCheckResult {
|
||||||
|
hasChecksumPrefix: boolean;
|
||||||
|
expectedPrefix: string | null;
|
||||||
|
actualPrefix: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildBackupArchiveOptions {
|
||||||
|
includeAttachments?: boolean;
|
||||||
|
progress?: BackupArchiveBuildProgressReporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupArchiveBuildProgressEvent {
|
||||||
|
step: string;
|
||||||
|
fileName?: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
||||||
|
const parts = [
|
||||||
|
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||||
|
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||||
|
date.getUTCDate().toString().padStart(2, '0'),
|
||||||
|
date.getUTCHours().toString().padStart(2, '0'),
|
||||||
|
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||||
|
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||||
|
];
|
||||||
|
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||||
|
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
|
const normalized = String(fileName || '').trim();
|
||||||
|
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||||
|
return match ? match[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectBackupArchiveFileNameChecksum(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
fileName: string
|
||||||
|
): Promise<BackupFileIntegrityCheckResult> {
|
||||||
|
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||||
|
const actualHash = await sha256Hex(bytes);
|
||||||
|
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
return {
|
||||||
|
hasChecksumPrefix: !!expectedPrefix,
|
||||||
|
expectedPrefix,
|
||||||
|
actualPrefix,
|
||||||
|
matches: !expectedPrefix || actualPrefix === expectedPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
|
||||||
|
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
|
||||||
|
return result.matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchiveSize(bytes: Uint8Array): void {
|
||||||
|
if (bytes.byteLength > MAX_BACKUP_ARCHIVE_BYTES) {
|
||||||
|
throw new Error(`Backup archive is too large. The current restore limit is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))} MiB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||||
|
const entries: string[] = [];
|
||||||
|
for (const row of db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`Backup archive table ${table} is invalid`);
|
||||||
|
}
|
||||||
|
return value as SqlRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZipEntries(files: Record<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
|
||||||
|
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
|
||||||
|
for (const [path, bytes] of Object.entries(files)) {
|
||||||
|
entries[path] = [bytes, { level: BACKUP_TEXT_COMPRESSION_LEVEL }];
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseBackupArchiveOptions {
|
||||||
|
allowExternalAttachmentBlobs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupArchive(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: ParseBackupArchiveOptions = {}
|
||||||
|
): { payload: BackupPayload; files: Record<string, Uint8Array> } {
|
||||||
|
validateArchiveSize(bytes);
|
||||||
|
let zipped: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
zipped = unzipSync(bytes);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryNames = Object.keys(zipped);
|
||||||
|
if (entryNames.length > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||||
|
throw new Error('Backup archive contains too many files');
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalExtractedBytes = 0;
|
||||||
|
for (const entry of entryNames) {
|
||||||
|
const entryBytes = zipped[entry];
|
||||||
|
totalExtractedBytes += entryBytes.byteLength;
|
||||||
|
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||||
|
throw new Error('Backup archive database payload is too large');
|
||||||
|
}
|
||||||
|
if (totalExtractedBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||||
|
throw new Error('Backup archive expands beyond the current restore limit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBytes = zipped['manifest.json'];
|
||||||
|
const dbBytes = zipped['db.json'];
|
||||||
|
if (!manifestBytes || !dbBytes) {
|
||||||
|
throw new Error('Backup archive is missing manifest.json or db.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let manifest: BackupManifest;
|
||||||
|
let db: BackupPayload['db'];
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||||
|
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup archive contains invalid JSON metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||||
|
throw new Error('Unsupported backup format version');
|
||||||
|
}
|
||||||
|
if (!db || typeof db !== 'object') {
|
||||||
|
throw new Error('Backup archive database payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalAttachmentKeys = new Set<string>(
|
||||||
|
options.allowExternalAttachmentBlobs
|
||||||
|
? (manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const requiredEntries = getRequiredZipEntries(db).filter((entry) => !externalAttachmentKeys.has(entry));
|
||||||
|
for (const entry of requiredEntries) {
|
||||||
|
if (!zipped[entry]) {
|
||||||
|
throw new Error(`Backup archive is missing required file: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: { manifest, db },
|
||||||
|
files: zipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidateBackupPayloadOptions {
|
||||||
|
allowExternalAttachmentBlobs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBackupPayloadContents(
|
||||||
|
payload: BackupPayload,
|
||||||
|
files: Record<string, Uint8Array>,
|
||||||
|
options: ValidateBackupPayloadOptions = {}
|
||||||
|
): void {
|
||||||
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
|
const externalAttachmentKeys = new Set<string>(
|
||||||
|
options.allowExternalAttachmentBlobs
|
||||||
|
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
for (const row of userRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const email = String(row.email || '').trim();
|
||||||
|
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
|
||||||
|
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
|
||||||
|
userIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of configRows) {
|
||||||
|
const key = String(row.key || '').trim();
|
||||||
|
if (!key) throw new Error('Backup archive contains an invalid config row');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of revisionRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIds = new Set<string>();
|
||||||
|
for (const row of folderRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
|
||||||
|
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
|
||||||
|
folderIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherIds = new Set<string>();
|
||||||
|
for (const row of cipherRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const folderId = String(row.folder_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
|
||||||
|
if (folderId && !folderIds.has(folderId)) {
|
||||||
|
throw new Error(`Backup archive contains a cipher for an unknown folder: ${folderId}`);
|
||||||
|
}
|
||||||
|
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
|
||||||
|
cipherIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
if (!id || !cipherId || !cipherIds.has(cipherId)) {
|
||||||
|
throw new Error('Backup archive contains an invalid attachment row');
|
||||||
|
}
|
||||||
|
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
|
||||||
|
if (!files[attachmentPath] && !externalAttachmentKeys.has(attachmentPath)) {
|
||||||
|
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBackupArchive(
|
||||||
|
env: Env,
|
||||||
|
date: Date = new Date(),
|
||||||
|
options: BuildBackupArchiveOptions = {}
|
||||||
|
): Promise<BackupArchiveBundle> {
|
||||||
|
const includeAttachments = options.includeAttachments !== false;
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'collect_data',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_archive_progress_collect_title',
|
||||||
|
stageDetail: includeAttachments
|
||||||
|
? 'txt_backup_archive_progress_collect_with_attachments_detail'
|
||||||
|
: 'txt_backup_archive_progress_collect_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||||
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
|
]);
|
||||||
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
|
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
return {
|
||||||
|
cipherId,
|
||||||
|
attachmentId,
|
||||||
|
blobName: getAttachmentObjectKey(cipherId, attachmentId),
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifestBase = {
|
||||||
|
formatVersion: BACKUP_FORMAT_VERSION,
|
||||||
|
exportedAt: date.toISOString(),
|
||||||
|
appVersion: APP_VERSION,
|
||||||
|
storageKind: getBlobStorageKind(env),
|
||||||
|
tableCounts: {
|
||||||
|
config: configRows.length,
|
||||||
|
users: userRows.length,
|
||||||
|
user_revisions: revisionRows.length,
|
||||||
|
folders: folderRows.length,
|
||||||
|
ciphers: cipherRows.length,
|
||||||
|
attachments: exportedAttachmentRows.length,
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
attachments: includeAttachments,
|
||||||
|
},
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: attachmentBlobs.length,
|
||||||
|
totalBytes: attachmentBlobs.reduce((sum, item) => sum + item.sizeBytes, 0),
|
||||||
|
largestObjectBytes: attachmentBlobs.reduce((max, item) => Math.max(max, item.sizeBytes), 0),
|
||||||
|
},
|
||||||
|
attachmentBlobs: includeAttachments ? attachmentBlobs : [],
|
||||||
|
} satisfies BackupManifest;
|
||||||
|
|
||||||
|
const files: Record<string, Uint8Array> = {
|
||||||
|
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)),
|
||||||
|
'db.json': encoder.encode(JSON.stringify({
|
||||||
|
config: configRows,
|
||||||
|
users: userRows,
|
||||||
|
user_revisions: revisionRows,
|
||||||
|
folders: folderRows,
|
||||||
|
ciphers: cipherRows,
|
||||||
|
attachments: exportedAttachmentRows,
|
||||||
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
|
};
|
||||||
|
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'package_archive',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_archive_progress_package_title',
|
||||||
|
stageDetail: includeAttachments
|
||||||
|
? 'txt_backup_archive_progress_package_with_attachments_detail'
|
||||||
|
: 'txt_backup_archive_progress_package_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
const bytes = zipSync(createZipEntries(files));
|
||||||
|
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
const fileName = buildBackupFileName(date, fileHashPrefix);
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'archive_ready',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_archive_progress_ready_title',
|
||||||
|
stageDetail: 'txt_backup_archive_progress_ready_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes,
|
||||||
|
fileName,
|
||||||
|
manifest: manifestBase,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
import {
|
||||||
|
type BackupSettingsPortableEnvelope,
|
||||||
|
decryptBackupSettingsRuntime,
|
||||||
|
encryptBackupSettingsEnvelope,
|
||||||
|
parseBackupSettingsEnvelope,
|
||||||
|
} from './backup-settings-crypto';
|
||||||
|
import {
|
||||||
|
BACKUP_DEFAULT_INTERVAL_HOURS,
|
||||||
|
BACKUP_DEFAULT_START_TIME,
|
||||||
|
BACKUP_DEFAULT_TIMEZONE,
|
||||||
|
type BackupDestinationConfig,
|
||||||
|
type BackupDestinationRecord,
|
||||||
|
type BackupDestinationType,
|
||||||
|
type BackupRuntimeState,
|
||||||
|
type BackupScheduleConfig,
|
||||||
|
type BackupSettings,
|
||||||
|
type E3BackupDestination,
|
||||||
|
type WebDavBackupDestination,
|
||||||
|
createBackupRandomId,
|
||||||
|
createDefaultBackupDestinationName,
|
||||||
|
createDefaultBackupScheduleConfig,
|
||||||
|
createDefaultBackupSettings as createSharedDefaultBackupSettings,
|
||||||
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
|
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||||
|
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BackupDestinationConfig,
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
BackupRuntimeState,
|
||||||
|
BackupScheduleConfig,
|
||||||
|
BackupSettings,
|
||||||
|
E3BackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
|
export interface BackupSettingsInput {
|
||||||
|
destinations?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsRepairState {
|
||||||
|
needsRepair: boolean;
|
||||||
|
portable: BackupSettingsPortableEnvelope | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultScheduleConfig(timezone: string = 'UTC'): BackupScheduleConfig {
|
||||||
|
return { ...createDefaultBackupScheduleConfig(assertValidTimeZone(timezone)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return String(value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value: unknown): string {
|
||||||
|
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidTimeZone(timezone: string): string {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||||
|
return timezone;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup timezone');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (value === null || String(value).trim() === '') return null;
|
||||||
|
const count = Number(value);
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > 1000) {
|
||||||
|
throw new Error('Backup retention count must be between 1 and 1000');
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIntervalHours(value: unknown, fallback: number = BACKUP_DEFAULT_INTERVAL_HOURS): number {
|
||||||
|
const raw = value === undefined || value === null || value === '' ? fallback : Number(value);
|
||||||
|
if (!Number.isInteger(raw) || raw < 1 || raw > 99) {
|
||||||
|
throw new Error('Backup interval hours must be between 1 and 99');
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStartTime(value: unknown, fallback: string = BACKUP_DEFAULT_START_TIME): string {
|
||||||
|
const raw = asTrimmedString(value) || fallback;
|
||||||
|
const match = raw.match(/^(\d{1,2})(?::(\d{1,2}))?$/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Backup start time must be in HH:mm format');
|
||||||
|
}
|
||||||
|
const hour = Number(match[1]);
|
||||||
|
const minute = Number(match[2] ?? '0');
|
||||||
|
if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
|
throw new Error('Backup start time must be in HH:mm format');
|
||||||
|
}
|
||||||
|
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
|
const bucket = asTrimmedString(source.bucket);
|
||||||
|
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
|
const rootPath = normalizePath(source.rootPath);
|
||||||
|
|
||||||
|
if (!allowIncomplete || endpoint) {
|
||||||
|
if (!endpoint) throw new Error('E3 endpoint is required');
|
||||||
|
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || bucket) {
|
||||||
|
if (!bucket) throw new Error('E3 bucket is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || accessKeyId) {
|
||||||
|
if (!accessKeyId) throw new Error('E3 access key is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || secretAccessKey) {
|
||||||
|
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
rootPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebDavDestination(value: unknown, allowIncomplete = false): WebDavBackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const baseUrl = asTrimmedString(source.baseUrl);
|
||||||
|
const username = asTrimmedString(source.username);
|
||||||
|
const password = String(source.password ?? '');
|
||||||
|
const remotePath = normalizePath(source.remotePath);
|
||||||
|
|
||||||
|
if (!allowIncomplete || baseUrl) {
|
||||||
|
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||||
|
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || username) {
|
||||||
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || password) {
|
||||||
|
if (!password) throw new Error('WebDAV password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remotePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDestination(
|
||||||
|
destinationType: BackupDestinationType,
|
||||||
|
destination: unknown,
|
||||||
|
allowIncomplete = false
|
||||||
|
): BackupDestinationConfig {
|
||||||
|
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||||
|
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const asIso = (input: unknown): string | null => {
|
||||||
|
const raw = asTrimmedString(input);
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||||
|
};
|
||||||
|
const asMaybeNumber = (input: unknown): number | null => {
|
||||||
|
if (input === null || input === undefined || input === '') return null;
|
||||||
|
const n = Number(input);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
lastAttemptAt: asIso(source.lastAttemptAt),
|
||||||
|
lastAttemptLocalDate: asTrimmedString(source.lastAttemptLocalDate) || null,
|
||||||
|
lastSuccessAt: asIso(source.lastSuccessAt),
|
||||||
|
lastErrorAt: asIso(source.lastErrorAt),
|
||||||
|
lastErrorMessage: asTrimmedString(source.lastErrorMessage) || null,
|
||||||
|
lastUploadedFileName: asTrimmedString(source.lastUploadedFileName) || null,
|
||||||
|
lastUploadedSizeBytes: asMaybeNumber(source.lastUploadedSizeBytes),
|
||||||
|
lastUploadedDestination: asTrimmedString(source.lastUploadedDestination) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
|
return createDefaultBackupDestinationName(type, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||||
|
const value = asTrimmedString(raw);
|
||||||
|
if (value === 'e3' || value === 'webdav') return value;
|
||||||
|
throw new Error('Backup destination type is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDestinationRecord(
|
||||||
|
input: unknown,
|
||||||
|
previousById: Map<string, BackupDestinationRecord>,
|
||||||
|
index: number,
|
||||||
|
fallbackTimezone: string
|
||||||
|
): BackupDestinationRecord {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
throw new Error('Backup destination is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = asTrimmedString(input.id) || createBackupRandomId();
|
||||||
|
const type = getDestinationType(input.type);
|
||||||
|
const previous = previousById.get(id);
|
||||||
|
const runtime = previous?.runtime ? normalizeRuntime(previous.runtime) : normalizeRuntime(input.runtime);
|
||||||
|
const name = asTrimmedString(input.name) || previous?.name || defaultDestinationName(type, index + 1);
|
||||||
|
const scheduleSource = isPlainObject(input.schedule) ? input.schedule : {};
|
||||||
|
const previousSchedule = previous?.schedule || defaultScheduleConfig(fallbackTimezone);
|
||||||
|
const retentionSource = Object.prototype.hasOwnProperty.call(scheduleSource, 'retentionCount')
|
||||||
|
? scheduleSource.retentionCount
|
||||||
|
: previousSchedule.retentionCount;
|
||||||
|
const schedule: BackupScheduleConfig = {
|
||||||
|
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
|
||||||
|
intervalHours: normalizeIntervalHours(
|
||||||
|
scheduleSource.intervalHours ?? previousSchedule.intervalHours,
|
||||||
|
previousSchedule.intervalHours || BACKUP_DEFAULT_INTERVAL_HOURS
|
||||||
|
),
|
||||||
|
startTime: normalizeStartTime(
|
||||||
|
scheduleSource.startTime ?? previousSchedule.startTime,
|
||||||
|
previousSchedule.startTime || BACKUP_DEFAULT_START_TIME
|
||||||
|
),
|
||||||
|
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
includeAttachments: typeof input.includeAttachments === 'boolean'
|
||||||
|
? input.includeAttachments
|
||||||
|
: previous?.includeAttachments ?? false,
|
||||||
|
destination,
|
||||||
|
schedule,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
||||||
|
const legacyFrequency = asTrimmedString(rawValue.frequency).toLowerCase();
|
||||||
|
const intervalHours = legacyFrequency === 'weekly'
|
||||||
|
? 24 * 7
|
||||||
|
: legacyFrequency === 'monthly'
|
||||||
|
? 24 * 30
|
||||||
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
|
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||||
|
const destinationType: BackupDestinationType =
|
||||||
|
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
||||||
|
? destinationTypeRaw
|
||||||
|
: 'webdav';
|
||||||
|
const destination = {
|
||||||
|
id: createBackupRandomId(),
|
||||||
|
name: defaultDestinationName(destinationType, 1),
|
||||||
|
type: destinationType,
|
||||||
|
includeAttachments: false,
|
||||||
|
destination: normalizeDestination(destinationType, rawValue.destination),
|
||||||
|
schedule: {
|
||||||
|
enabled: !!rawValue.enabled,
|
||||||
|
intervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
|
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
retentionCount: 30,
|
||||||
|
},
|
||||||
|
runtime: normalizeRuntime(rawValue.runtime),
|
||||||
|
} satisfies BackupDestinationRecord;
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinations: [destination],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDestinations(
|
||||||
|
rawDestinations: unknown,
|
||||||
|
previousById: Map<string, BackupDestinationRecord>,
|
||||||
|
fallbackTimezone: string
|
||||||
|
): BackupDestinationRecord[] {
|
||||||
|
if (!Array.isArray(rawDestinations)) {
|
||||||
|
throw new Error('Backup destinations are invalid');
|
||||||
|
}
|
||||||
|
if (rawDestinations.length > MAX_BACKUP_DESTINATIONS) {
|
||||||
|
throw new Error(`You can save up to ${MAX_BACKUP_DESTINATIONS} backup destinations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinations = rawDestinations.map((entry, index) => normalizeDestinationRecord(entry, previousById, index, fallbackTimezone));
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const destination of destinations) {
|
||||||
|
if (ids.has(destination.id)) {
|
||||||
|
throw new Error('Backup destination ids must be unique');
|
||||||
|
}
|
||||||
|
ids.add(destination.id);
|
||||||
|
}
|
||||||
|
return destinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<string, BackupDestinationRecord> {
|
||||||
|
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||||
|
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupSettings(raw: string | null, fallbackTimezone: string = 'UTC'): BackupSettings {
|
||||||
|
if (!raw) return getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (Array.isArray(parsed.destinations)) {
|
||||||
|
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
|
||||||
|
const globalEnabled = !!parsed.enabled;
|
||||||
|
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
|
||||||
|
const globalFrequency = asTrimmedString(parsed.frequency).toLowerCase();
|
||||||
|
const globalIntervalHours = globalFrequency === 'weekly'
|
||||||
|
? 24 * 7
|
||||||
|
: globalFrequency === 'monthly'
|
||||||
|
? 24 * 30
|
||||||
|
: BACKUP_DEFAULT_INTERVAL_HOURS;
|
||||||
|
const previousById = new Map<string, BackupDestinationRecord>();
|
||||||
|
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
|
||||||
|
if (!isPlainObject(entry)) return entry;
|
||||||
|
if (isPlainObject(entry.schedule)) return entry;
|
||||||
|
const entryId = asTrimmedString(entry.id);
|
||||||
|
const scheduleEnabled = globalEnabled && (!activeDestinationIdRaw || entryId === activeDestinationIdRaw);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
schedule: {
|
||||||
|
enabled: scheduleEnabled,
|
||||||
|
intervalHours: globalIntervalHours,
|
||||||
|
startTime: BACKUP_DEFAULT_START_TIME,
|
||||||
|
timezone: globalTimezone,
|
||||||
|
retentionCount: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
destinations: parseDestinations(normalizedEntries, previousById, fallbackTimezone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return parseLegacyBackupSettings(parsed, fallbackTimezone);
|
||||||
|
} catch {
|
||||||
|
return getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupSettingsInput(
|
||||||
|
input: BackupSettingsInput,
|
||||||
|
previous: BackupSettings
|
||||||
|
): BackupSettings {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
throw new Error('Backup settings payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousById = mapDestinationsById(previous.destinations);
|
||||||
|
const rawDestinations = input.destinations ?? previous.destinations;
|
||||||
|
const destinations = parseDestinations(rawDestinations, previousById, BACKUP_DEFAULT_TIMEZONE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||||
|
return JSON.stringify(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
return parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup settings need administrator reactivation after restore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
|
||||||
|
if (normalized !== null) {
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImportedBackupSettingsValue(
|
||||||
|
raw: string | null,
|
||||||
|
env: Env,
|
||||||
|
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
|
||||||
|
fallbackTimezone: string = 'UTC'
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!raw) return null;
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (envelope) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
return serializeBackupSettings(settings);
|
||||||
|
}
|
||||||
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
|
} catch {
|
||||||
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
return serializeBackupSettings(settings);
|
||||||
|
}
|
||||||
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
needsRepair: true,
|
||||||
|
portable: envelope.portable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBackupDestination(
|
||||||
|
settings: BackupSettings,
|
||||||
|
destinationId: string | null | undefined
|
||||||
|
): BackupDestinationRecord | null {
|
||||||
|
const normalizedId = asTrimmedString(destinationId);
|
||||||
|
if (!normalizedId) return null;
|
||||||
|
return settings.destinations.find((destination) => destination.id === normalizedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBackupDestination(settings: BackupSettings, destinationId?: string | null): BackupDestinationRecord {
|
||||||
|
const destination = destinationId ? findBackupDestination(settings, destinationId) : settings.destinations[0] || null;
|
||||||
|
if (!destination) {
|
||||||
|
throw new Error('Backup destination not found');
|
||||||
|
}
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateTimeParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string; minute: string } {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return {
|
||||||
|
year: pick('year'),
|
||||||
|
month: pick('month'),
|
||||||
|
day: pick('day'),
|
||||||
|
hour: pick('hour'),
|
||||||
|
minute: pick('minute'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupLocalDateKey(date: Date, timezone: string): string {
|
||||||
|
const parts = getDateTimeParts(date, timezone);
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupLocalTime(date: Date, timezone: string): string {
|
||||||
|
const parts = getDateTimeParts(date, timezone);
|
||||||
|
return `${parts.hour}:${parts.minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLocalDateKey(dateKey: string): { year: number; month: number; day: number } | null {
|
||||||
|
const match = String(dateKey || '').match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const year = Number(match[1]);
|
||||||
|
const month = Number(match[2]);
|
||||||
|
const day = Number(match[3]);
|
||||||
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) return null;
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUtcDateForLocalTime(timezone: string, year: number, month: number, day: number, hour: number, minute: number): Date {
|
||||||
|
const utcGuess = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
||||||
|
const actual = getDateTimeParts(new Date(utcGuess), timezone);
|
||||||
|
const actualUtc = Date.UTC(
|
||||||
|
Number(actual.year),
|
||||||
|
Number(actual.month) - 1,
|
||||||
|
Number(actual.day),
|
||||||
|
Number(actual.hour),
|
||||||
|
Number(actual.minute),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const desiredUtc = Date.UTC(year, month - 1, day, hour, minute, 0, 0);
|
||||||
|
return new Date(utcGuess - (actualUtc - desiredUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupSlotStartsForLocalDay(
|
||||||
|
dateKey: string,
|
||||||
|
timezone: string,
|
||||||
|
startTime: string,
|
||||||
|
intervalHours: number
|
||||||
|
): Date[] {
|
||||||
|
const parsedDate = parseLocalDateKey(dateKey);
|
||||||
|
const parsedTime = normalizeStartTime(startTime).split(':').map((value) => Number(value));
|
||||||
|
if (!parsedDate || parsedTime.length !== 2) return [];
|
||||||
|
|
||||||
|
const [hour, minute] = parsedTime;
|
||||||
|
const firstSlot = getUtcDateForLocalTime(timezone, parsedDate.year, parsedDate.month, parsedDate.day, hour, minute);
|
||||||
|
const nextLocalDay = new Date(Date.UTC(parsedDate.year, parsedDate.month - 1, parsedDate.day, 0, 0, 0, 0));
|
||||||
|
nextLocalDay.setUTCDate(nextLocalDay.getUTCDate() + 1);
|
||||||
|
const nextDay = getUtcDateForLocalTime(
|
||||||
|
timezone,
|
||||||
|
nextLocalDay.getUTCFullYear(),
|
||||||
|
nextLocalDay.getUTCMonth() + 1,
|
||||||
|
nextLocalDay.getUTCDate(),
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const intervalMs = intervalHours * 60 * 60 * 1000;
|
||||||
|
const slots: Date[] = [];
|
||||||
|
|
||||||
|
for (let slotMs = firstSlot.getTime(); slotMs < nextDay.getTime(); slotMs += intervalMs) {
|
||||||
|
slots.push(new Date(slotMs));
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupDueNow(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
now: Date,
|
||||||
|
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||||
|
): boolean {
|
||||||
|
if (!destination.schedule.enabled) return false;
|
||||||
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
|
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||||
|
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||||
|
? lastAttemptAt.getTime()
|
||||||
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
|
localDateKey,
|
||||||
|
destination.schedule.timezone,
|
||||||
|
destination.schedule.startTime,
|
||||||
|
destination.schedule.intervalHours
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const slotStart of slotStarts) {
|
||||||
|
const slotStartMs = slotStart.getTime();
|
||||||
|
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||||
|
if (lastAttemptMs >= slotStartMs) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,901 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||||
|
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||||
|
import {
|
||||||
|
type BackupManifestAttachmentBlob,
|
||||||
|
type BackupPayload,
|
||||||
|
parseBackupArchive,
|
||||||
|
validateBackupPayloadContents,
|
||||||
|
} from './backup-archive';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
type BackupTableName =
|
||||||
|
| 'config'
|
||||||
|
| 'users'
|
||||||
|
| 'user_revisions'
|
||||||
|
| 'folders'
|
||||||
|
| 'ciphers'
|
||||||
|
| 'attachments';
|
||||||
|
|
||||||
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
|
'config',
|
||||||
|
'users',
|
||||||
|
'user_revisions',
|
||||||
|
'folders',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
];
|
||||||
|
|
||||||
|
function shadowTableName(table: BackupTableName): string {
|
||||||
|
return `${table}__restore`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportResultBody {
|
||||||
|
object: 'instance-backup-import';
|
||||||
|
imported: {
|
||||||
|
config: number;
|
||||||
|
users: number;
|
||||||
|
userRevisions: number;
|
||||||
|
folders: number;
|
||||||
|
ciphers: number;
|
||||||
|
attachments: number;
|
||||||
|
attachmentFiles: number;
|
||||||
|
};
|
||||||
|
skipped: {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
items: Array<{
|
||||||
|
kind: 'attachment';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportExecutionResult {
|
||||||
|
result: BackupImportResultBody;
|
||||||
|
auditActorUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const response = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (response.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.bind(table)
|
||||||
|
.first<{ sql: string | null }>();
|
||||||
|
const sql = String(row?.sql || '').trim();
|
||||||
|
if (!sql) {
|
||||||
|
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
|
||||||
|
}
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
|
||||||
|
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
|
||||||
|
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
|
||||||
|
if (next === createSql) {
|
||||||
|
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
|
||||||
|
}
|
||||||
|
for (const currentTable of BACKUP_TABLES) {
|
||||||
|
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
|
||||||
|
next = next.replace(
|
||||||
|
referencePattern,
|
||||||
|
`REFERENCES "${shadowTableName(currentTable)}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
|
||||||
|
const dropStatements = BACKUP_TABLES
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
|
||||||
|
if (dropStatements.length) {
|
||||||
|
await db.batch(dropStatements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createShadowTables(db: D1Database): Promise<void> {
|
||||||
|
const createStatements: D1PreparedStatement[] = [];
|
||||||
|
for (const table of BACKUP_TABLES) {
|
||||||
|
const createSql = await getTableCreateSql(db, table);
|
||||||
|
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
|
||||||
|
}
|
||||||
|
await db.batch(createStatements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateShadowTableCounts(
|
||||||
|
db: D1Database,
|
||||||
|
expectedCounts: Partial<Record<BackupTableName, number>>
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(BACKUP_TABLES.map(async (table) => {
|
||||||
|
const expected = expectedCounts[table] ?? 0;
|
||||||
|
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
|
||||||
|
const actual = Number(row?.count || 0);
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
|
||||||
|
const statements: D1PreparedStatement[] = [];
|
||||||
|
// Commit by replacing live table contents from validated shadow tables.
|
||||||
|
// This avoids D1 schema-rename edge cases while keeping current data intact
|
||||||
|
// until the final batch succeeds.
|
||||||
|
for (const sql of buildResetImportTargetStatements(db)) {
|
||||||
|
statements.push(sql);
|
||||||
|
}
|
||||||
|
for (const table of BACKUP_TABLES) {
|
||||||
|
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
|
||||||
|
}
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
|
||||||
|
]);
|
||||||
|
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
throw new Error('Backup import requires a fresh instance with no vault or send data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] {
|
||||||
|
return [
|
||||||
|
'DELETE FROM attachments',
|
||||||
|
'DELETE FROM ciphers',
|
||||||
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM user_revisions',
|
||||||
|
'DELETE FROM users',
|
||||||
|
'DELETE FROM config',
|
||||||
|
].map((sql) => db.prepare(sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const attachmentRows = await queryRows(
|
||||||
|
db,
|
||||||
|
`SELECT a.id, a.cipher_id
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id`
|
||||||
|
);
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
|
||||||
|
const BLOB_STORAGE_UNAVAILABLE_SKIP_REASON = 'Attachment storage is not configured';
|
||||||
|
const ATTACHMENT_RESTORE_FAILED_REASON = 'Some attachments could not be restored and were skipped';
|
||||||
|
|
||||||
|
interface BackupImportSkipSummary {
|
||||||
|
reason: string | null;
|
||||||
|
attachments: number;
|
||||||
|
items: Array<{
|
||||||
|
kind: 'attachment';
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreparedBackupImportPayload {
|
||||||
|
payload: BackupPayload;
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentRestoreResult {
|
||||||
|
imported: number;
|
||||||
|
restoredAttachments: SqlRow[];
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RemoteAttachmentSource {
|
||||||
|
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupRestoreProgressEvent {
|
||||||
|
source: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
replaceExisting: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
|
||||||
|
|
||||||
|
function attachmentRowKey(row: SqlRow): string {
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
return `${cipherId}/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRows(rows: SqlRow[]): SqlRow[] {
|
||||||
|
return rows.map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
|
let replaced = false;
|
||||||
|
const nextRows = rows.map((row) => {
|
||||||
|
if (String(row.key || '').trim() !== key) return { ...row };
|
||||||
|
replaced = true;
|
||||||
|
return { ...row, key, value };
|
||||||
|
});
|
||||||
|
if (!replaced) {
|
||||||
|
nextRows.push({ key, value });
|
||||||
|
}
|
||||||
|
return nextRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareImportedConfigRows(
|
||||||
|
env: Env,
|
||||||
|
configRows: SqlRow[],
|
||||||
|
userRows: SqlRow[]
|
||||||
|
): Promise<SqlRow[]> {
|
||||||
|
let nextConfigRows = cloneRows(configRows || []);
|
||||||
|
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||||
|
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||||
|
env,
|
||||||
|
userRows.map((row) => ({
|
||||||
|
id: String(row.id || '').trim(),
|
||||||
|
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
|
||||||
|
role: String(row.role || '').trim() as User['role'],
|
||||||
|
status: String(row.status || '').trim() as User['status'],
|
||||||
|
})),
|
||||||
|
'UTC'
|
||||||
|
);
|
||||||
|
if (normalizedBackupSettings !== null) {
|
||||||
|
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
|
||||||
|
}
|
||||||
|
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
|
||||||
|
return nextConfigRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
|
||||||
|
const preparedDb: BackupPayload['db'] = {
|
||||||
|
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||||
|
users: cloneRows(payload.users || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
verify_devices: row.verify_devices ?? 1,
|
||||||
|
})),
|
||||||
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
folders: cloneRows(payload.folders || []),
|
||||||
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
archived_at: row.archived_at ?? null,
|
||||||
|
})),
|
||||||
|
attachments: cloneRows(payload.attachments || []),
|
||||||
|
};
|
||||||
|
await importBackupRows(db, preparedDb, true);
|
||||||
|
return preparedDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||||
|
const storageKind = getBlobStorageKind(env);
|
||||||
|
if (storageKind === 'r2') {
|
||||||
|
return {
|
||||||
|
payload,
|
||||||
|
skipped: {
|
||||||
|
reason: null,
|
||||||
|
attachments: 0,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storageKind === null) {
|
||||||
|
const skippedItems = (payload.db.attachments || []).map((row) => {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
return {
|
||||||
|
kind: 'attachment' as const,
|
||||||
|
path: `attachments/${cipherId}/${attachmentId}.bin`,
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? BLOB_STORAGE_UNAVAILABLE_SKIP_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversizedAttachmentPaths = new Set<string>();
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const entry of Object.keys(files)) {
|
||||||
|
if (!entry.endsWith('.bin')) continue;
|
||||||
|
const sizeBytes = files[entry].byteLength;
|
||||||
|
if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue;
|
||||||
|
if (entry.startsWith('attachments/')) {
|
||||||
|
oversizedAttachmentPaths.add(entry);
|
||||||
|
skippedItems.push({ kind: 'attachment', path: entry, sizeBytes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachments = (payload.db.attachments || []).filter((row) => {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) return false;
|
||||||
|
return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextPayload: BackupPayload = {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsKvBlobStorage = nextAttachments.length > 0;
|
||||||
|
|
||||||
|
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
|
||||||
|
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
payload: nextPayload,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
const placeholders = `(${columns.map(() => '?').join(', ')})`;
|
||||||
|
const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`;
|
||||||
|
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
|
||||||
|
if (!statements.length) return;
|
||||||
|
try {
|
||||||
|
await db.batch(statements);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Restore insert failed for ${table}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
||||||
|
const restoredAttachments: SqlRow[] = [];
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const row of db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const key = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
const bytes = files[key];
|
||||||
|
if (!bytes) {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: key,
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
restoredAttachments.push(row);
|
||||||
|
} catch {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: key,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imported: restoredAttachments.length,
|
||||||
|
restoredAttachments,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
|
||||||
|
return new Map(
|
||||||
|
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareRemoteAttachmentPayload(
|
||||||
|
env: Env,
|
||||||
|
payload: BackupPayload,
|
||||||
|
files: Record<string, Uint8Array>,
|
||||||
|
source: RemoteAttachmentSource
|
||||||
|
): Promise<PreparedBackupImportPayload> {
|
||||||
|
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
|
||||||
|
const storageKind = getBlobStorageKind(env);
|
||||||
|
const nextAttachments: SqlRow[] = [];
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const row of payload.db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const lookupKey = `${cipherId}/${attachmentId}`;
|
||||||
|
const ref = manifestLookup.get(lookupKey);
|
||||||
|
const sizeBytes = ref?.sizeBytes || Number(row.size || 0) || 0;
|
||||||
|
const path = ref ? `attachments/${ref.blobName}` : `attachments/${lookupKey}`;
|
||||||
|
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
|
||||||
|
if (files[inlinePath]) {
|
||||||
|
nextAttachments.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!ref) {
|
||||||
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (storageKind === 'kv' && sizeBytes > KV_MAX_OBJECT_BYTES) {
|
||||||
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (storageKind === null) {
|
||||||
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextAttachments.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
payload: {
|
||||||
|
...payload,
|
||||||
|
db: {
|
||||||
|
...payload.db,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? 'Some remote attachments were unavailable and were skipped' : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
|
||||||
|
if (!attachmentRows.length) return;
|
||||||
|
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
|
||||||
|
const statements = attachmentRows
|
||||||
|
.map((row) => {
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
if (!attachmentId || !cipherId) return null;
|
||||||
|
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
|
||||||
|
})
|
||||||
|
.filter((statement): statement is D1PreparedStatement => !!statement);
|
||||||
|
if (!statements.length) return;
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreRemoteAttachmentFiles(
|
||||||
|
env: Env,
|
||||||
|
payload: BackupPayload,
|
||||||
|
files: Record<string, Uint8Array>,
|
||||||
|
source: RemoteAttachmentSource
|
||||||
|
): Promise<{
|
||||||
|
imported: number;
|
||||||
|
skipped: BackupImportSkipSummary;
|
||||||
|
restoredAttachments: SqlRow[];
|
||||||
|
}> {
|
||||||
|
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
|
||||||
|
const restoredAttachments: SqlRow[] = [];
|
||||||
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
|
|
||||||
|
for (const row of payload.db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
const ref = manifestLookup.get(`${cipherId}/${attachmentId}`);
|
||||||
|
if (!ref && !files[inlinePath]) {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: `attachments/${cipherId}/${attachmentId}`,
|
||||||
|
sizeBytes: Number(row.size || 0) || 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bytes = files[inlinePath] || (ref ? await source.loadAttachment(ref.blobName) : null);
|
||||||
|
if (!bytes) {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: ref ? `attachments/${ref.blobName}` : inlinePath,
|
||||||
|
sizeBytes: ref?.sizeBytes || Number(row.size || 0) || 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
restoredAttachments.push(row);
|
||||||
|
} catch {
|
||||||
|
skippedItems.push({
|
||||||
|
kind: 'attachment',
|
||||||
|
path: ref ? `attachments/${ref.blobName}` : inlinePath,
|
||||||
|
sizeBytes: bytes.byteLength,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imported: restoredAttachments.length,
|
||||||
|
restoredAttachments,
|
||||||
|
skipped: {
|
||||||
|
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
|
||||||
|
attachments: skippedItems.length,
|
||||||
|
items: skippedItems,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, afterKeys: Set<string>): Promise<void> {
|
||||||
|
const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key));
|
||||||
|
for (const key of staleKeys) {
|
||||||
|
await deleteBlobObject(env, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
|
||||||
|
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('config'),
|
||||||
|
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('users'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('users'),
|
||||||
|
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||||
|
payload.users || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('user_revisions'),
|
||||||
|
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('folders'),
|
||||||
|
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('ciphers'),
|
||||||
|
buildInsertStatements(
|
||||||
|
db,
|
||||||
|
tableName('ciphers'),
|
||||||
|
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||||
|
payload.ciphers || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('attachments'),
|
||||||
|
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBackupArchiveBytes(
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
env: Env,
|
||||||
|
actorUserId: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const parsed = parseBackupArchive(archiveBytes);
|
||||||
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
|
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
|
} catch (error) {
|
||||||
|
if (!replaceExisting) {
|
||||||
|
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_create_shadow',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await createShadowTables(env.DB);
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_import_data',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_data_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_data_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_restore_files',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_files_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_files_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const restored = await restoreBlobFiles(env, db, parsed.files);
|
||||||
|
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||||
|
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||||
|
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||||
|
if (nextBlobKeys) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_complete',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
attachmentFiles: restored.imported,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||||
|
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||||
|
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_failed',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importRemoteBackupArchiveBytes(
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
env: Env,
|
||||||
|
actorUserId: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
source: RemoteAttachmentSource,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
||||||
|
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
||||||
|
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
|
} catch (error) {
|
||||||
|
if (!replaceExisting) {
|
||||||
|
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_create_shadow',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await createShadowTables(env.DB);
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_import_data',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_data_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_restore_files',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_files_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
||||||
|
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||||
|
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||||
|
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||||
|
if (nextBlobKeys) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_complete',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
|
||||||
|
const finalSkippedReason = finalSkippedItems.length
|
||||||
|
? restored.skipped.reason || preparedRemote.skipped.reason
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
attachmentFiles: restored.imported,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: finalSkippedReason,
|
||||||
|
attachments: finalSkippedItems.length,
|
||||||
|
items: finalSkippedItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_failed',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
|
||||||
|
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||||
|
const RUNTIME_INFO = 'runtime';
|
||||||
|
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||||
|
const PORTABLE_HASH = 'SHA-1';
|
||||||
|
const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||||
|
const AES_GCM_IV_BYTES = 12;
|
||||||
|
const PORTABLE_DEK_BYTES = 32;
|
||||||
|
|
||||||
|
export interface BackupSettingsRuntimeEnvelope {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsPortableWrap {
|
||||||
|
userId: string;
|
||||||
|
wrappedKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsPortableEnvelope {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
wraps: BackupSettingsPortableWrap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsEnvelopeV2 {
|
||||||
|
version: 2;
|
||||||
|
runtime: BackupSettingsRuntimeEnvelope;
|
||||||
|
portable: BackupSettingsPortableEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let text = '';
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
text += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
return btoa(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
const binary = atob(normalized);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveRuntimeKey(secret: string): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
'HKDF',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: encoder.encode(RUNTIME_SALT),
|
||||||
|
info: encoder.encode(RUNTIME_INFO),
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
return crypto.subtle.importKey('raw', bits, { name: AES_GCM_ALGORITHM }, false, ['encrypt', 'decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptAesGcm(plaintext: Uint8Array, key: CryptoKey): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: AES_GCM_ALGORITHM, iv },
|
||||||
|
key,
|
||||||
|
plaintext
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { iv, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptAesGcm(ciphertext: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
|
||||||
|
return new Uint8Array(
|
||||||
|
await crypto.subtle.decrypt(
|
||||||
|
{ name: AES_GCM_ALGORITHM, iv },
|
||||||
|
key,
|
||||||
|
ciphertext
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPortablePublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
base64ToBytes(publicKeyBase64),
|
||||||
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEligiblePortableUsers(users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]): Array<Pick<User, 'id' | 'publicKey'>> {
|
||||||
|
return users
|
||||||
|
.filter(
|
||||||
|
(user) =>
|
||||||
|
user.role === 'admin' &&
|
||||||
|
user.status === 'active' &&
|
||||||
|
typeof user.publicKey === 'string' &&
|
||||||
|
user.publicKey.trim().length > 0
|
||||||
|
)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
publicKey: user.publicKey!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsEnvelopeV2 | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (!isPlainObject(parsed) || Number(parsed.version) !== 2) return null;
|
||||||
|
const runtime = parsed.runtime;
|
||||||
|
const portable = parsed.portable;
|
||||||
|
if (!isPlainObject(runtime) || !isPlainObject(portable)) return null;
|
||||||
|
if (!Array.isArray(portable.wraps)) return null;
|
||||||
|
if (typeof runtime.iv !== 'string' || typeof runtime.ciphertext !== 'string') return null;
|
||||||
|
if (typeof portable.iv !== 'string' || typeof portable.ciphertext !== 'string') return null;
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
runtime: {
|
||||||
|
iv: runtime.iv,
|
||||||
|
ciphertext: runtime.ciphertext,
|
||||||
|
},
|
||||||
|
portable: {
|
||||||
|
iv: portable.iv,
|
||||||
|
ciphertext: portable.ciphertext,
|
||||||
|
wraps: portable.wraps
|
||||||
|
.filter((entry): entry is Record<string, unknown> => isPlainObject(entry))
|
||||||
|
.map((entry) => ({
|
||||||
|
userId: String(entry.userId || '').trim(),
|
||||||
|
wrappedKey: String(entry.wrappedKey || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.userId && entry.wrappedKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptBackupSettingsEnvelope(
|
||||||
|
plaintext: string,
|
||||||
|
env: Env,
|
||||||
|
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]
|
||||||
|
): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const eligibleUsers = getEligiblePortableUsers(users);
|
||||||
|
if (!eligibleUsers.length) {
|
||||||
|
throw new Error('No active administrator public keys are available for backup settings recovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
|
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||||
|
|
||||||
|
const portableDek = crypto.getRandomValues(new Uint8Array(PORTABLE_DEK_BYTES));
|
||||||
|
const portableKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
portableDek,
|
||||||
|
{ name: AES_GCM_ALGORITHM },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
const portableCipher = await encryptAesGcm(encoder.encode(plaintext), portableKey);
|
||||||
|
|
||||||
|
const wraps: BackupSettingsPortableWrap[] = [];
|
||||||
|
for (const user of eligibleUsers) {
|
||||||
|
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||||
|
const wrappedKey = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: PORTABLE_ALGORITHM },
|
||||||
|
publicKey,
|
||||||
|
portableDek
|
||||||
|
)
|
||||||
|
);
|
||||||
|
wraps.push({
|
||||||
|
userId: user.id,
|
||||||
|
wrappedKey: bytesToBase64(wrappedKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope: BackupSettingsEnvelopeV2 = {
|
||||||
|
version: 2,
|
||||||
|
runtime: {
|
||||||
|
iv: bytesToBase64(runtime.iv),
|
||||||
|
ciphertext: bytesToBase64(runtime.ciphertext),
|
||||||
|
},
|
||||||
|
portable: {
|
||||||
|
iv: bytesToBase64(portableCipher.iv),
|
||||||
|
ciphertext: bytesToBase64(portableCipher.ciphertext),
|
||||||
|
wraps,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptBackupSettingsRuntime(raw: string, env: Env): Promise<string> {
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
throw new Error('Backup settings envelope is invalid');
|
||||||
|
}
|
||||||
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
|
const plaintext = await decryptAesGcm(
|
||||||
|
base64ToBytes(envelope.runtime.ciphertext),
|
||||||
|
base64ToBytes(envelope.runtime.iv),
|
||||||
|
runtimeKey
|
||||||
|
);
|
||||||
|
return new TextDecoder().decode(plaintext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
import {
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
E3BackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from './backup-config';
|
||||||
|
|
||||||
|
export interface BackupUploadResult {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupListResult {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
currentPath: string;
|
||||||
|
parentPath: string | null;
|
||||||
|
items: RemoteBackupItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFile {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFilePutOptions {
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBackupArchiveName(name: string): boolean {
|
||||||
|
return /\.zip$/i.test(String(name || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePathSegments(path: string): string {
|
||||||
|
return path
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlashes(value: string): string {
|
||||||
|
let next = String(value || '');
|
||||||
|
while (next.startsWith('/')) next = next.slice(1);
|
||||||
|
while (next.endsWith('/')) next = next.slice(0, -1);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJoinedPath(...segments: string[]): string {
|
||||||
|
return segments.map(trimSlashes).filter(Boolean).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(path: string): string {
|
||||||
|
const normalized = trimSlashes(path).replace(/\\/g, '/');
|
||||||
|
if (!normalized) return '';
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (parts.some((part) => part === '.' || part === '..')) {
|
||||||
|
throw new Error('Invalid remote backup path');
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(path: string): string {
|
||||||
|
const normalized = trimSlashes(path);
|
||||||
|
if (!normalized) return '';
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path: string): string | null {
|
||||||
|
const normalized = normalizeRelativePath(path);
|
||||||
|
if (!normalized) return null;
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
parts.pop();
|
||||||
|
return parts.length ? parts.join('/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] {
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments';
|
||||||
|
const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments';
|
||||||
|
if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1;
|
||||||
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name, 'en');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlText(value: string): string {
|
||||||
|
return value.replace(/&(amp|lt|gt|quot|#39);/g, (_match, entity) => {
|
||||||
|
switch (entity) {
|
||||||
|
case 'amp':
|
||||||
|
return '&';
|
||||||
|
case 'lt':
|
||||||
|
return '<';
|
||||||
|
case 'gt':
|
||||||
|
return '>';
|
||||||
|
case 'quot':
|
||||||
|
return '"';
|
||||||
|
case '#39':
|
||||||
|
return "'";
|
||||||
|
default:
|
||||||
|
return _match;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHttpDate(value: string): string | null {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlBlocks(xml: string, tagName: string): string[] {
|
||||||
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'gi');
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(xml))) {
|
||||||
|
blocks.push(match[1]);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlFirst(xml: string, tagName: string): string | null {
|
||||||
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'i');
|
||||||
|
const match = xml.match(pattern);
|
||||||
|
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(value: Uint8Array | string): Promise<string> {
|
||||||
|
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise<Uint8Array> {
|
||||||
|
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
||||||
|
return new Uint8Array(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBasicAuthHeader(username: string, password: string): string {
|
||||||
|
const token = btoa(`${username}:${password}`);
|
||||||
|
return `Basic ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCanonicalQueryString(url: URL): string {
|
||||||
|
const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
|
||||||
|
if (aKey === bKey) return aValue.localeCompare(bValue);
|
||||||
|
return aKey.localeCompare(bKey);
|
||||||
|
});
|
||||||
|
return params
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAwsV4Authorization(
|
||||||
|
method: string,
|
||||||
|
url: URL,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
payloadHashHex: string,
|
||||||
|
accessKeyId: string,
|
||||||
|
secretAccessKey: string,
|
||||||
|
region: string
|
||||||
|
): Promise<string> {
|
||||||
|
const amzDate = headers['x-amz-date'];
|
||||||
|
const shortDate = amzDate.slice(0, 8);
|
||||||
|
const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const canonicalHeaders = headerEntries
|
||||||
|
.map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`)
|
||||||
|
.join('\n');
|
||||||
|
const signedHeaders = headerEntries.map(([name]) => name).join(';');
|
||||||
|
const canonicalRequest = [
|
||||||
|
method.toUpperCase(),
|
||||||
|
url.pathname || '/',
|
||||||
|
buildCanonicalQueryString(url),
|
||||||
|
`${canonicalHeaders}\n`,
|
||||||
|
signedHeaders,
|
||||||
|
payloadHashHex,
|
||||||
|
].join('\n');
|
||||||
|
const credentialScope = `${shortDate}/${region}/s3/aws4_request`;
|
||||||
|
const stringToSign = [
|
||||||
|
'AWS4-HMAC-SHA256',
|
||||||
|
amzDate,
|
||||||
|
credentialScope,
|
||||||
|
await sha256Hex(canonicalRequest),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
|
||||||
|
const kRegion = await hmacSha256Raw(kDate, region);
|
||||||
|
const kService = await hmacSha256Raw(kRegion, 's3');
|
||||||
|
const kSigning = await hmacSha256Raw(kService, 'aws4_request');
|
||||||
|
const signatureBytes = await hmacSha256Raw(kSigning, stringToSign);
|
||||||
|
const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDestinationConfigReady(destination: BackupDestinationRecord): void {
|
||||||
|
if (destination.type === 'webdav') {
|
||||||
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
|
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||||
|
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||||
|
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (destination.type === 'e3') {
|
||||||
|
const config = destination.destination as E3BackupDestination;
|
||||||
|
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
||||||
|
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
||||||
|
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
||||||
|
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
||||||
|
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebDavUrl(baseUrl: string, relativePath: string): string {
|
||||||
|
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string {
|
||||||
|
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
|
||||||
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = buildJoinedPath(current, segment);
|
||||||
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
|
||||||
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebDavDirectoryCached(
|
||||||
|
baseUrl: string,
|
||||||
|
directoryPath: string,
|
||||||
|
authHeader: string,
|
||||||
|
ensuredDirectories: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = buildJoinedPath(current, segment);
|
||||||
|
if (ensuredDirectories.has(current)) continue;
|
||||||
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
||||||
|
ensuredDirectories.add(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putToWebDav(
|
||||||
|
config: WebDavBackupDestination,
|
||||||
|
relativePath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: RemoteBackupFilePutOptions = {},
|
||||||
|
ensuredDirectories?: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
||||||
|
const remoteDir = parentPath(remoteFilePath);
|
||||||
|
|
||||||
|
if (remoteDir) {
|
||||||
|
if (ensuredDirectories) {
|
||||||
|
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
|
||||||
|
} else {
|
||||||
|
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'Content-Type': options.contentType || 'application/octet-stream',
|
||||||
|
'Content-Length': String(bytes.byteLength),
|
||||||
|
},
|
||||||
|
body: bytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
|
await putToWebDav(config, fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: buildJoinedPath(config.remotePath, fileName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWebDavResponsePath(baseUrl: string, href: string): string {
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
const target = new URL(href, base);
|
||||||
|
const basePath = trimSlashes(decodeURIComponent(base.pathname));
|
||||||
|
const entryPath = trimSlashes(decodeURIComponent(target.pathname));
|
||||||
|
if (!basePath) return entryPath;
|
||||||
|
if (entryPath === basePath) return '';
|
||||||
|
return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
|
const targetFullPath = webDavFullPath(config, currentPath);
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
Depth: '1',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const rootFullPath = trimSlashes(config.remotePath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
for (const block of extractXmlBlocks(xml, 'response')) {
|
||||||
|
const href = extractXmlFirst(block, 'href');
|
||||||
|
if (!href) continue;
|
||||||
|
const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href));
|
||||||
|
if (!fullPath) continue;
|
||||||
|
if (fullPath === targetFullPath) continue;
|
||||||
|
if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue;
|
||||||
|
const relative = rootFullPath
|
||||||
|
? fullPath === rootFullPath
|
||||||
|
? ''
|
||||||
|
: fullPath.slice(rootFullPath.length + 1)
|
||||||
|
: fullPath;
|
||||||
|
if (!relative) continue;
|
||||||
|
const directParent = parentPath(relative);
|
||||||
|
if ((directParent || '') !== currentPath) continue;
|
||||||
|
|
||||||
|
const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || '';
|
||||||
|
const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock);
|
||||||
|
const sizeRaw = extractXmlFirst(block, 'getcontentlength');
|
||||||
|
const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified');
|
||||||
|
items.push({
|
||||||
|
path: relative,
|
||||||
|
name: basename(relative) || relative,
|
||||||
|
isDirectory,
|
||||||
|
size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null,
|
||||||
|
modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: sortRemoteItems(items),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
throw new Error('Please select a backup file');
|
||||||
|
}
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, normalized);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: normalized,
|
||||||
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`WebDAV delete failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'HEAD',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.status === 404) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
||||||
|
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
||||||
|
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signedE3Request(
|
||||||
|
config: E3BackupDestination,
|
||||||
|
method: 'GET' | 'PUT' | 'DELETE' | 'HEAD',
|
||||||
|
url: URL,
|
||||||
|
body?: Uint8Array,
|
||||||
|
contentType?: string
|
||||||
|
): Promise<Response> {
|
||||||
|
const payloadHashHex = await sha256Hex(body || new Uint8Array());
|
||||||
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
host: url.host,
|
||||||
|
'x-amz-content-sha256': payloadHashHex,
|
||||||
|
'x-amz-date': amzDate,
|
||||||
|
};
|
||||||
|
if (method === 'PUT') headers['content-type'] = contentType || 'application/octet-stream';
|
||||||
|
|
||||||
|
const authorization = await buildAwsV4Authorization(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
payloadHashHex,
|
||||||
|
config.accessKeyId,
|
||||||
|
config.secretAccessKey,
|
||||||
|
config.region || 'auto'
|
||||||
|
);
|
||||||
|
|
||||||
|
return fetch(url.toString(), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
'X-Amz-Content-Sha256': headers['x-amz-content-sha256'],
|
||||||
|
'X-Amz-Date': headers['x-amz-date'],
|
||||||
|
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putToE3(
|
||||||
|
config: E3BackupDestination,
|
||||||
|
relativePath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: RemoteBackupFilePutOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
|
await putToE3(config, fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
remotePath: normalizeE3ObjectKey(config, fileName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
|
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
||||||
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
|
const url = e3BucketBaseUrl(config);
|
||||||
|
url.searchParams.set('list-type', '2');
|
||||||
|
url.searchParams.set('delimiter', '/');
|
||||||
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
|
|
||||||
|
const response = await signedE3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
|
||||||
|
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||||
|
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||||
|
if (!fullPrefix) continue;
|
||||||
|
const relative = rootPrefix
|
||||||
|
? fullPrefix === rootPrefix
|
||||||
|
? ''
|
||||||
|
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||||
|
? fullPrefix.slice(rootPrefix.length + 1)
|
||||||
|
: ''
|
||||||
|
: fullPrefix;
|
||||||
|
const normalizedRelative = trimSlashes(relative);
|
||||||
|
if (!normalizedRelative) continue;
|
||||||
|
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||||
|
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||||
|
items.push({
|
||||||
|
path: itemPath,
|
||||||
|
name: basename(itemPath) || itemPath,
|
||||||
|
isDirectory: true,
|
||||||
|
size: null,
|
||||||
|
modifiedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||||
|
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||||
|
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||||
|
const relative = rootPrefix
|
||||||
|
? fullKey.startsWith(`${rootPrefix}/`)
|
||||||
|
? fullKey.slice(rootPrefix.length + 1)
|
||||||
|
: ''
|
||||||
|
: fullKey;
|
||||||
|
const normalizedRelative = trimSlashes(relative);
|
||||||
|
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||||
|
items.push({
|
||||||
|
path: normalizedRelative,
|
||||||
|
name: basename(normalizedRelative) || normalizedRelative,
|
||||||
|
isDirectory: false,
|
||||||
|
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||||
|
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = new Map<string, RemoteBackupItem>();
|
||||||
|
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: sortRemoteItems(Array.from(deduped.values())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
throw new Error('Please select a backup file');
|
||||||
|
}
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, normalized);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
remotePath: normalized,
|
||||||
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'DELETE', url);
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`E3 delete failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function existsInE3(config: E3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'HEAD', url);
|
||||||
|
if (response.status === 404) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 existence check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguredDestinationAdapter {
|
||||||
|
provider: 'webdav' | 'e3';
|
||||||
|
config: WebDavBackupDestination | E3BackupDestination;
|
||||||
|
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||||
|
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
|
||||||
|
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||||
|
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
|
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
|
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupTransferSession {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
|
||||||
|
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
|
||||||
|
list(relativePath: string): Promise<RemoteBackupListResult>;
|
||||||
|
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||||
|
deleteFile(relativePath: string): Promise<void>;
|
||||||
|
exists(relativePath: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredDestinationAdapter(
|
||||||
|
destination: BackupDestinationRecord
|
||||||
|
): ConfiguredDestinationAdapter {
|
||||||
|
ensureDestinationConfigReady(destination);
|
||||||
|
|
||||||
|
if (destination.type === 'webdav') {
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
config: destination.destination as WebDavBackupDestination,
|
||||||
|
upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName),
|
||||||
|
putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options),
|
||||||
|
list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath),
|
||||||
|
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (destination.type === 'e3') {
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
config: destination.destination as E3BackupDestination,
|
||||||
|
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
||||||
|
putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options),
|
||||||
|
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
||||||
|
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
||||||
|
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
||||||
|
exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unsupported backup destination type');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
|
||||||
|
|
||||||
|
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (adapter.provider === 'webdav' && ensuredDirectories) {
|
||||||
|
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: adapter.provider,
|
||||||
|
uploadArchive: async (archive: Uint8Array, fileName: string) => {
|
||||||
|
await putFile(fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: adapter.provider,
|
||||||
|
remotePath: adapter.provider === 'webdav'
|
||||||
|
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||||
|
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
putFile,
|
||||||
|
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
|
||||||
|
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||||
|
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadBackupArchive(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
archive: Uint8Array,
|
||||||
|
fileName: string
|
||||||
|
): Promise<BackupUploadResult> {
|
||||||
|
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
return createRemoteBackupTransferSession(destination).list(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
return createRemoteBackupTransferSession(destination).download(relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||||
|
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||||
|
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
return createRemoteBackupTransferSession(destination).exists(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadRemoteBackupFile(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
relativePath: string,
|
||||||
|
bytes: Uint8Array,
|
||||||
|
options: RemoteBackupFilePutOptions = {}
|
||||||
|
): Promise<void> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||||
|
if (preferredFileName) {
|
||||||
|
const aPreferred = a.name === preferredFileName ? 1 : 0;
|
||||||
|
const bPreferred = b.name === preferredFileName ? 1 : 0;
|
||||||
|
if (aPreferred !== bPreferred) return bPreferred - aPreferred;
|
||||||
|
}
|
||||||
|
const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
|
||||||
|
const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
|
||||||
|
if (aTime !== bTime) return bTime - aTime;
|
||||||
|
return b.name.localeCompare(a.name, 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneRemoteBackupArchives(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
retentionCount: number | null,
|
||||||
|
preferredFileName?: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (retentionCount === null) return 0;
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
const listing = await adapter.list(adapter.config, '');
|
||||||
|
const backupFiles = listing.items
|
||||||
|
.filter((item) => !item.isDirectory && isBackupArchiveName(item.name))
|
||||||
|
.sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName));
|
||||||
|
if (backupFiles.length <= retentionCount) return 0;
|
||||||
|
for (const item of backupFiles.slice(retentionCount)) {
|
||||||
|
await adapter.deleteFile(adapter.config, item.path);
|
||||||
|
}
|
||||||
|
return backupFiles.length - retentionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureRemoteRestoreCandidate(relativePath: string): string {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || !/\.zip$/i.test(normalized)) {
|
||||||
|
throw new Error('Please select a backup ZIP file');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
|
||||||
|
const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
||||||
|
export const KV_MAX_OBJECT_BYTES = 25 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface KVBlobMetadata {
|
||||||
|
size?: number;
|
||||||
|
contentType?: string;
|
||||||
|
customMetadata?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlobObject {
|
||||||
|
body: ReadableStream | null;
|
||||||
|
size: number;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PutBlobOptions {
|
||||||
|
size: number;
|
||||||
|
contentType?: string;
|
||||||
|
customMetadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasR2Storage(env: Env): env is Env & { ATTACHMENTS: R2Bucket } {
|
||||||
|
return !!env.ATTACHMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKvStorage(env: Env): env is Env & { ATTACHMENTS_KV: KVNamespace } {
|
||||||
|
return !!env.ATTACHMENTS_KV;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobStorageKind(env: Env): 'r2' | 'kv' | null {
|
||||||
|
// Keep R2 as preferred backend when both are bound.
|
||||||
|
if (hasR2Storage(env)) return 'r2';
|
||||||
|
if (hasKvStorage(env)) return 'kv';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBlobStorageMaxBytes(env: Env, configuredLimit: number): number {
|
||||||
|
if (getBlobStorageKind(env) === 'kv') {
|
||||||
|
return Math.min(configuredLimit, KV_MAX_OBJECT_BYTES);
|
||||||
|
}
|
||||||
|
return configuredLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAttachmentObjectKey(cipherId: string, attachmentId: string): string {
|
||||||
|
return `${cipherId}/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSendFileObjectKey(sendId: string, fileId: string): string {
|
||||||
|
return `sends/${sendId}/${fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putBlobObject(
|
||||||
|
env: Env,
|
||||||
|
key: string,
|
||||||
|
value: string | ArrayBuffer | ArrayBufferView | ReadableStream,
|
||||||
|
options: PutBlobOptions
|
||||||
|
): Promise<void> {
|
||||||
|
const contentType = options.contentType || DEFAULT_CONTENT_TYPE;
|
||||||
|
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
await env.ATTACHMENTS.put(key, value, {
|
||||||
|
httpMetadata: { contentType },
|
||||||
|
customMetadata: options.customMetadata,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
if (options.size > KV_MAX_OBJECT_BYTES) {
|
||||||
|
throw new Error('KV object too large');
|
||||||
|
}
|
||||||
|
const metadata: KVBlobMetadata = {
|
||||||
|
size: options.size,
|
||||||
|
contentType,
|
||||||
|
customMetadata: options.customMetadata || null,
|
||||||
|
};
|
||||||
|
await env.ATTACHMENTS_KV.put(key, value, { metadata });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Attachment storage is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlobObject(env: Env, key: string): Promise<BlobObject | null> {
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
const object = await env.ATTACHMENTS.get(key);
|
||||||
|
if (!object) return null;
|
||||||
|
return {
|
||||||
|
body: object.body,
|
||||||
|
size: Number(object.size) || 0,
|
||||||
|
contentType: object.httpMetadata?.contentType || DEFAULT_CONTENT_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
const result = await env.ATTACHMENTS_KV.getWithMetadata<KVBlobMetadata>(key, 'arrayBuffer');
|
||||||
|
if (!result.value) return null;
|
||||||
|
|
||||||
|
const sizeFromMeta = Number(result.metadata?.size || 0);
|
||||||
|
const size = sizeFromMeta > 0 ? sizeFromMeta : result.value.byteLength;
|
||||||
|
const body = new Response(result.value).body;
|
||||||
|
|
||||||
|
return {
|
||||||
|
body,
|
||||||
|
size,
|
||||||
|
contentType: result.metadata?.contentType || DEFAULT_CONTENT_TYPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBlobObject(env: Env, key: string): Promise<void> {
|
||||||
|
if (hasR2Storage(env)) {
|
||||||
|
await env.ATTACHMENTS.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasKvStorage(env)) {
|
||||||
|
await env.ATTACHMENTS_KV.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
-7
@@ -184,14 +184,174 @@ export class RateLimitService {
|
|||||||
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async consumeBudgetWithWindow(
|
||||||
|
identifier: string,
|
||||||
|
maxRequests: number,
|
||||||
|
windowSeconds: number
|
||||||
|
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
|
||||||
|
return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getClientIdentifier(request: Request): string {
|
function parseIpv4Octets(input: string): number[] | null {
|
||||||
const cfIp = request.headers.get('CF-Connecting-IP');
|
const parts = input.split('.');
|
||||||
if (cfIp) return cfIp;
|
if (parts.length !== 4) return null;
|
||||||
|
|
||||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
const octets: number[] = [];
|
||||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
for (const part of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return null;
|
||||||
return 'unknown';
|
const value = Number(part);
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > 255) return null;
|
||||||
|
octets.push(value);
|
||||||
|
}
|
||||||
|
return octets;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv6Hextets(input: string): number[] | null {
|
||||||
|
let value = input.trim().toLowerCase();
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
if (value.startsWith('[') && value.endsWith(']')) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
const zoneIndex = value.indexOf('%');
|
||||||
|
if (zoneIndex >= 0) {
|
||||||
|
value = value.slice(0, zoneIndex);
|
||||||
|
}
|
||||||
|
if (!value.includes(':')) return null;
|
||||||
|
|
||||||
|
// Handle IPv4-mapped tail (e.g. ::ffff:192.0.2.1).
|
||||||
|
if (value.includes('.')) {
|
||||||
|
const lastColon = value.lastIndexOf(':');
|
||||||
|
if (lastColon < 0) return null;
|
||||||
|
const ipv4Tail = value.slice(lastColon + 1);
|
||||||
|
const octets = parseIpv4Octets(ipv4Tail);
|
||||||
|
if (!octets) return null;
|
||||||
|
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
||||||
|
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
||||||
|
value = `${value.slice(0, lastColon)}:${high}:${low}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubleColon = value.indexOf('::');
|
||||||
|
if (doubleColon !== value.lastIndexOf('::')) return null;
|
||||||
|
|
||||||
|
const parsePart = (part: string): number | null => {
|
||||||
|
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
|
||||||
|
const n = parseInt(part, 16);
|
||||||
|
return Number.isNaN(n) ? null : n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseParts = (parts: string[]): number[] | null => {
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!p) return null;
|
||||||
|
const n = parsePart(p);
|
||||||
|
if (n === null) return null;
|
||||||
|
out.push(n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (doubleColon >= 0) {
|
||||||
|
const [headRaw, tailRaw] = value.split('::');
|
||||||
|
const head = headRaw ? headRaw.split(':') : [];
|
||||||
|
const tail = tailRaw ? tailRaw.split(':') : [];
|
||||||
|
|
||||||
|
const headNums = parseParts(head);
|
||||||
|
const tailNums = parseParts(tail);
|
||||||
|
if (!headNums || !tailNums) return null;
|
||||||
|
|
||||||
|
const missing = 8 - (headNums.length + tailNums.length);
|
||||||
|
if (missing < 1) return null;
|
||||||
|
|
||||||
|
return [...headNums, ...new Array<number>(missing).fill(0), ...tailNums];
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = parseParts(value.split(':'));
|
||||||
|
if (!all || all.length !== 8) return null;
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClientIpForRateLimit(rawIp: string): string | null {
|
||||||
|
const input = rawIp.trim();
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
const ipv4 = parseIpv4Octets(input);
|
||||||
|
if (ipv4) {
|
||||||
|
return `ip4:${ipv4.join('.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipv6 = parseIpv6Hextets(input);
|
||||||
|
if (!ipv6) return null;
|
||||||
|
|
||||||
|
// Handle IPv4-mapped / IPv4-compatible IPv6 as IPv4 identity.
|
||||||
|
// Examples: ::ffff:192.0.2.1, ::192.0.2.1
|
||||||
|
if (
|
||||||
|
ipv6[0] === 0 &&
|
||||||
|
ipv6[1] === 0 &&
|
||||||
|
ipv6[2] === 0 &&
|
||||||
|
ipv6[3] === 0 &&
|
||||||
|
ipv6[4] === 0 &&
|
||||||
|
(ipv6[5] === 0xffff || ipv6[5] === 0)
|
||||||
|
) {
|
||||||
|
const octets = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff];
|
||||||
|
return `ip4:${octets.join('.')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse to /64 to reduce brute-force bypass via IPv6 address rotation.
|
||||||
|
const prefix64 = ipv6
|
||||||
|
.slice(0, 4)
|
||||||
|
.map(part => part.toString(16).padStart(4, '0'))
|
||||||
|
.join(':');
|
||||||
|
return `ip6:${prefix64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalRequest(request: Request): boolean {
|
||||||
|
const isLoopbackHost = (host: string | null): boolean => {
|
||||||
|
if (!host) return false;
|
||||||
|
const normalized = host.split(':')[0].trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized === '127.0.0.1' ||
|
||||||
|
normalized === '0.0.0.0' ||
|
||||||
|
normalized === '::1' ||
|
||||||
|
normalized === '[::1]'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isLoopbackHost(new URL(request.url).hostname)) return true;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed URL and fall back to Host header check.
|
||||||
|
}
|
||||||
|
|
||||||
|
return isLoopbackHost(request.headers.get('Host'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClientIdentifier(request: Request): string | null {
|
||||||
|
// Strict fallback order:
|
||||||
|
// 1) CF-Connecting-IP
|
||||||
|
// 2) X-Real-IP
|
||||||
|
// 3) first item of X-Forwarded-For
|
||||||
|
// If none are present/valid, treat client IP as unavailable.
|
||||||
|
const candidates: Array<string | null> = [
|
||||||
|
request.headers.get('CF-Connecting-IP'),
|
||||||
|
request.headers.get('X-Real-IP'),
|
||||||
|
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() || null,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const raw of candidates) {
|
||||||
|
if (!raw) continue;
|
||||||
|
const normalized = normalizeClientIpForRateLimit(raw);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local dev (wrangler dev / localhost): allow a deterministic loopback identifier.
|
||||||
|
if (isLocalRequest(request)) {
|
||||||
|
return 'ip4:127.0.0.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { AuditLog, Invite } from '../types';
|
||||||
|
|
||||||
|
export async function createInvite(db: D1Database, invite: Invite): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvite(db: D1Database, code: string): Promise<Invite | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?')
|
||||||
|
.bind(code)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
usedBy: row.used_by ?? null,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise<Invite[]> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const predicate = includeInactive
|
||||||
|
? '1 = 1'
|
||||||
|
: "(status = 'active' AND expires_at > ?)";
|
||||||
|
const query =
|
||||||
|
'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' +
|
||||||
|
`WHERE ${predicate} ORDER BY created_at DESC`;
|
||||||
|
const res = includeInactive
|
||||||
|
? await db.prepare(query).all<any>()
|
||||||
|
: await db.prepare(query).bind(now).all<any>();
|
||||||
|
|
||||||
|
return (res.results || []).map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
createdBy: row.created_by,
|
||||||
|
usedBy: row.used_by ?? null,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
|
||||||
|
)
|
||||||
|
.bind(userId, now, code, now)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'")
|
||||||
|
.bind(now, code)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllInvites(db: D1Database): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM invites').run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAuditLog(db: D1Database, log: AuditLog): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Attachment, Cipher } from '../types';
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
|
type GetCipher = (id: string) => Promise<Cipher | null>;
|
||||||
|
type SaveCipher = (cipher: Cipher) => Promise<void>;
|
||||||
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
|
|
||||||
|
export async function getAttachment(db: D1Database, id: string): Promise<Attachment | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key'
|
||||||
|
);
|
||||||
|
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttachment(db: D1Database, id: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||||
|
.bind(cipherId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
cipherId: r.cipher_id,
|
||||||
|
fileName: r.file_name,
|
||||||
|
size: r.size,
|
||||||
|
sizeName: r.size_name,
|
||||||
|
key: r.key,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentsByCipherIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
cipherIds: string[]
|
||||||
|
): Promise<Map<string, Attachment[]>> {
|
||||||
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
if (cipherIds.length === 0) return grouped;
|
||||||
|
|
||||||
|
const uniqueCipherIds = [...new Set(cipherIds)];
|
||||||
|
const chunkSize = sqlChunkSize(0);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueCipherIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`)
|
||||||
|
.bind(...chunk)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
for (const row of res.results || []) {
|
||||||
|
const item: Attachment = {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
const list = grouped.get(item.cipherId);
|
||||||
|
if (list) list.push(item);
|
||||||
|
else grouped.set(item.cipherId, [item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise<Map<string, Attachment[]>> {
|
||||||
|
const grouped = new Map<string, Attachment[]>();
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id
|
||||||
|
WHERE c.user_id = ?`
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
for (const row of res.results || []) {
|
||||||
|
const item: Attachment = {
|
||||||
|
id: row.id,
|
||||||
|
cipherId: row.cipher_id,
|
||||||
|
fileName: row.file_name,
|
||||||
|
size: row.size,
|
||||||
|
sizeName: row.size_name,
|
||||||
|
key: row.key,
|
||||||
|
};
|
||||||
|
const list = grouped.get(item.cipherId);
|
||||||
|
if (list) list.push(item);
|
||||||
|
else grouped.set(item.cipherId, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise<void> {
|
||||||
|
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise<void> {
|
||||||
|
void cipherId;
|
||||||
|
void attachmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCipherRevisionDate(
|
||||||
|
getCipherById: GetCipher,
|
||||||
|
saveCipherRecord: SaveCipher,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
cipherId: string
|
||||||
|
): Promise<{ userId: string; revisionDate: string } | null> {
|
||||||
|
const cipher = await getCipherById(cipherId);
|
||||||
|
if (!cipher) return null;
|
||||||
|
cipher.updatedAt = new Date().toISOString();
|
||||||
|
await saveCipherRecord(cipher);
|
||||||
|
const revisionDate = await updateRevisionDate(cipher.userId);
|
||||||
|
return { userId: cipher.userId, revisionDate };
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
|
||||||
|
|
||||||
|
export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
|
'jti TEXT PRIMARY KEY, ' +
|
||||||
|
'expires_at INTEGER NOT NULL' +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function consumeAttachmentDownloadToken(
|
||||||
|
db: D1Database,
|
||||||
|
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
|
||||||
|
lastCleanupAt: number,
|
||||||
|
cleanupIntervalMs: number,
|
||||||
|
jti: string,
|
||||||
|
expUnixSeconds: number
|
||||||
|
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
|
||||||
|
const nowMs = Date.now();
|
||||||
|
let cleanedUpAt: number | null = null;
|
||||||
|
|
||||||
|
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
|
||||||
|
await db
|
||||||
|
.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?')
|
||||||
|
.bind(nowMs)
|
||||||
|
.run();
|
||||||
|
cleanedUpAt = nowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresAtMs = expUnixSeconds * 1000;
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(jti) DO NOTHING'
|
||||||
|
)
|
||||||
|
.bind(jti, expiresAtMs)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
consumed: (result.meta.changes ?? 0) > 0,
|
||||||
|
cleanedUpAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import type { Cipher } from '../types';
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
|
|
||||||
|
interface CipherRow {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
type: number | null;
|
||||||
|
folder_id: string | null;
|
||||||
|
name: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
favorite: number | null;
|
||||||
|
data: string;
|
||||||
|
reprompt: number | null;
|
||||||
|
key: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
archived_at: string | null;
|
||||||
|
deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
||||||
|
if (!row?.data) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.data) as Cipher;
|
||||||
|
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
|
||||||
|
return {
|
||||||
|
...parsed,
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
type: Number(row.type) || Number(parsed.type) || 1,
|
||||||
|
folderId,
|
||||||
|
name: row.name ?? parsed.name ?? null,
|
||||||
|
notes: row.notes ?? parsed.notes ?? null,
|
||||||
|
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
||||||
|
reprompt: row.reprompt ?? parsed.reprompt ?? 0,
|
||||||
|
key: row.key ?? parsed.key ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
archivedAt: row.archived_at ?? parsed.archivedAt ?? parsed.archivedDate ?? null,
|
||||||
|
deletedAt: row.deleted_at ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.error('Corrupted cipher data, id:', row.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCipherColumns(): string {
|
||||||
|
return 'id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCipher(db: D1Database, id: string): Promise<Cipher | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ?`)
|
||||||
|
.bind(id)
|
||||||
|
.first<CipherRow>();
|
||||||
|
return parseCipherRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||||
|
const folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const data = JSON.stringify({
|
||||||
|
...cipher,
|
||||||
|
folderId,
|
||||||
|
});
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at'
|
||||||
|
);
|
||||||
|
await safeBind(
|
||||||
|
stmt,
|
||||||
|
cipher.id,
|
||||||
|
cipher.userId,
|
||||||
|
Number(cipher.type) || 1,
|
||||||
|
folderId,
|
||||||
|
cipher.name,
|
||||||
|
cipher.notes,
|
||||||
|
cipher.favorite ? 1 : 0,
|
||||||
|
data,
|
||||||
|
cipher.reprompt ?? 0,
|
||||||
|
cipher.key,
|
||||||
|
cipher.createdAt,
|
||||||
|
cipher.updatedAt,
|
||||||
|
cipher.archivedAt ?? null,
|
||||||
|
cipher.deletedAt
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIds(ids: string[]): string[] {
|
||||||
|
return Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCipher(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkSoftDeleteCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ deletedAt: now, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkRestoreCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ deletedAt: null, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(3);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db.prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllCiphers(db: D1Database, userId: string): Promise<Cipher[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC`)
|
||||||
|
.bind(userId)
|
||||||
|
.all<CipherRow>();
|
||||||
|
return (res.results || []).flatMap((row) => {
|
||||||
|
const cipher = parseCipherRow(row);
|
||||||
|
return cipher ? [cipher] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCiphersPage(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
includeDeleted: boolean,
|
||||||
|
limit: number,
|
||||||
|
offset: number
|
||||||
|
): Promise<Cipher[]> {
|
||||||
|
const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL';
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${selectCipherColumns()} FROM ciphers
|
||||||
|
WHERE user_id = ?
|
||||||
|
${whereDeleted}
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
)
|
||||||
|
.bind(userId, limit, offset)
|
||||||
|
.all<CipherRow>();
|
||||||
|
return (res.results || []).flatMap((row) => {
|
||||||
|
const cipher = parseCipherRow(row);
|
||||||
|
return cipher ? [cipher] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCiphersByIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<Cipher[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return [];
|
||||||
|
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
const out: Cipher[] = [];
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const stmt = db.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`);
|
||||||
|
const res = await stmt.bind(userId, ...chunk).all<CipherRow>();
|
||||||
|
out.push(
|
||||||
|
...(res.results || []).flatMap((row) => {
|
||||||
|
const cipher = parseCipherRow(row);
|
||||||
|
return cipher ? [cipher] : [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkMoveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
folderId: string | null,
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkArchiveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ archivedAt: now, archivedDate: now, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET archived_at = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders}) AND deleted_at IS NULL`
|
||||||
|
)
|
||||||
|
.bind(now, now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkUnarchiveCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = sanitizeIds(ids);
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const patch = JSON.stringify({ archivedAt: null, archivedDate: null, updatedAt: now });
|
||||||
|
const chunkSize = sqlChunkSize(3);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE ciphers
|
||||||
|
SET archived_at = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(now, patch, userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export async function isRegistered(db: D1Database): Promise<boolean> {
|
||||||
|
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>();
|
||||||
|
return row?.value === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfigValue(db: D1Database, key: string): Promise<string | null> {
|
||||||
|
const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
|
||||||
|
return typeof row?.value === 'string' ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setConfigValue(db: D1Database, key: string, value: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind(key, value)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRegistered(db: D1Database): Promise<void> {
|
||||||
|
await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind('registered', 'true')
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { Device, TrustedDeviceTokenSummary, User } from '../types';
|
||||||
|
|
||||||
|
type GetUserByEmail = (email: string) => Promise<User | null>;
|
||||||
|
type TrustedTokenKeyFn = (token: string) => Promise<string>;
|
||||||
|
|
||||||
|
function mapDeviceRow(row: any): Device {
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
deviceIdentifier: row.device_identifier,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
sessionStamp: row.session_stamp || '',
|
||||||
|
encryptedUserKey: row.encrypted_user_key ?? null,
|
||||||
|
encryptedPublicKey: row.encrypted_public_key ?? null,
|
||||||
|
encryptedPrivateKey: row.encrypted_private_key ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertDevice(
|
||||||
|
db: D1Database,
|
||||||
|
getDeviceById: (userId: string, deviceIdentifier: string) => Promise<Device | null>,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
name: string,
|
||||||
|
type: number,
|
||||||
|
sessionStamp?: string,
|
||||||
|
keys?: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || '';
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' +
|
||||||
|
'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' +
|
||||||
|
'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' +
|
||||||
|
'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' +
|
||||||
|
'updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
userId,
|
||||||
|
deviceIdentifier,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
effectiveSessionStamp,
|
||||||
|
keys?.encryptedUserKey ?? null,
|
||||||
|
keys?.encryptedPublicKey ?? null,
|
||||||
|
keys?.encryptedPrivateKey ?? null,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDeviceKeys(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
keys: {
|
||||||
|
encryptedUserKey?: string | null;
|
||||||
|
encryptedPublicKey?: string | null;
|
||||||
|
encryptedPrivateKey?: string | null;
|
||||||
|
}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE devices SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, updated_at = ? ' +
|
||||||
|
'WHERE user_id = ? AND device_identifier = ?'
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
keys.encryptedUserKey ?? null,
|
||||||
|
keys.encryptedPublicKey ?? null,
|
||||||
|
keys.encryptedPrivateKey ?? null,
|
||||||
|
now,
|
||||||
|
userId,
|
||||||
|
deviceIdentifier
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearDeviceKeys(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifiers: string[]
|
||||||
|
): Promise<number> {
|
||||||
|
const uniqueIds = Array.from(
|
||||||
|
new Set(deviceIdentifiers.map((id) => String(id || '').trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
if (!uniqueIds.length) return 0;
|
||||||
|
|
||||||
|
const placeholders = uniqueIds.map(() => '?').join(',');
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE devices
|
||||||
|
SET encrypted_user_key = NULL,
|
||||||
|
encrypted_public_key = NULL,
|
||||||
|
encrypted_private_key = NULL,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE user_id = ? AND device_identifier IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(new Date().toISOString(), userId, ...uniqueIds)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<{ '1': number }>();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isKnownDeviceByEmail(
|
||||||
|
getUserByEmail: GetUserByEmail,
|
||||||
|
isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise<boolean>,
|
||||||
|
email: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const user = await getUserByEmail(email);
|
||||||
|
if (!user) return false;
|
||||||
|
return isKnownDeviceForUser(user.id, deviceIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevicesByUserId(db: D1Database, userId: string): Promise<Device[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||||
|
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map(mapDeviceRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<Device | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, created_at, updated_at ' +
|
||||||
|
'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1'
|
||||||
|
)
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.first<any>();
|
||||||
|
return row ? mapDeviceRow(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
||||||
|
const now = Date.now();
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
|
||||||
|
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
|
||||||
|
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
|
||||||
|
return (res.results || []).map((row) => ({
|
||||||
|
deviceIdentifier: row.device_identifier,
|
||||||
|
expiresAt: Number(row.expires_at || 0),
|
||||||
|
tokenCount: Number(row.token_count || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTrustedTwoFactorDeviceToken(
|
||||||
|
db: D1Database,
|
||||||
|
trustedTokenKey: TrustedTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
deviceIdentifier: string,
|
||||||
|
expiresAtMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const tokenKey = await trustedTokenKey(token);
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, userId, deviceIdentifier, expiresAtMs)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrustedTwoFactorDeviceTokenUserId(
|
||||||
|
db: D1Database,
|
||||||
|
trustedTokenKey: TrustedTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
deviceIdentifier: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
const tokenKey = await trustedTokenKey(token);
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?')
|
||||||
|
.bind(tokenKey, deviceIdentifier)
|
||||||
|
.first<{ user_id: string; expires_at: number }>();
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at && row.expires_at < now) {
|
||||||
|
await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row.user_id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Cipher, Folder } from '../types';
|
||||||
|
|
||||||
|
function mapFolderRow(row: any): Folder {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
name: row.name,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFolder(db: D1Database, id: string): Promise<Folder | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?')
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapFolderRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at'
|
||||||
|
)
|
||||||
|
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolder(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearFolderFromCiphers(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
folderId: string,
|
||||||
|
saveCipher: (cipher: Cipher) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const res = await db
|
||||||
|
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||||
|
.bind(userId, folderId)
|
||||||
|
.all<{ data: string }>();
|
||||||
|
|
||||||
|
for (const row of (res.results || [])) {
|
||||||
|
let cipher: Cipher;
|
||||||
|
try {
|
||||||
|
cipher = JSON.parse(row.data) as Cipher;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cipher.folderId = null;
|
||||||
|
cipher.updatedAt = now;
|
||||||
|
await saveCipher(cipher);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteFolders(
|
||||||
|
db: D1Database,
|
||||||
|
userId: string,
|
||||||
|
ids: string[],
|
||||||
|
sqlChunkSize: (fixedBindCount: number) => number,
|
||||||
|
saveCipher: (cipher: Cipher) => Promise<void>,
|
||||||
|
updateRevisionDate: (userId: string) => Promise<string>
|
||||||
|
): Promise<string | null> {
|
||||||
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||||
|
.bind(userId, ...chunk)
|
||||||
|
.all<{ data: string }>();
|
||||||
|
|
||||||
|
for (const row of res.results || []) {
|
||||||
|
let cipher: Cipher;
|
||||||
|
try {
|
||||||
|
cipher = JSON.parse(row.data) as Cipher;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cipher.folderId = null;
|
||||||
|
cipher.updatedAt = now;
|
||||||
|
await saveCipher(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||||
|
.bind(userId, ...chunk)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllFolders(db: D1Database, userId: string): Promise<Folder[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC')
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapFolderRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Folder[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||||
|
)
|
||||||
|
.bind(userId, limit, offset)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapFolderRow(row));
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { RefreshTokenRecord } from '../types';
|
||||||
|
|
||||||
|
type RefreshTokenKeyFn = (token: string) => Promise<string>;
|
||||||
|
type CleanupExpiredFn = (nowMs: number) => Promise<void>;
|
||||||
|
|
||||||
|
export async function saveRefreshToken(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAtMs: number,
|
||||||
|
deviceIdentifier?: string | null,
|
||||||
|
deviceSessionStamp?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
await maybeCleanupExpiredRefreshTokens(Date.now());
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp'
|
||||||
|
)
|
||||||
|
.bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRefreshTokenRecord(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
maybeCleanupExpiredRefreshTokens: CleanupExpiredFn,
|
||||||
|
saveRefreshTokenRecord: (
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAtMs?: number,
|
||||||
|
deviceIdentifier?: string | null,
|
||||||
|
deviceSessionStamp?: string | null
|
||||||
|
) => Promise<void>,
|
||||||
|
deleteRefreshTokenRecord: (token: string) => Promise<void>,
|
||||||
|
token: string
|
||||||
|
): Promise<RefreshTokenRecord | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
await maybeCleanupExpiredRefreshTokens(now);
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
|
let row = await db
|
||||||
|
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
|
.bind(tokenKey)
|
||||||
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
const legacyRow = await db
|
||||||
|
.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?')
|
||||||
|
.bind(token)
|
||||||
|
.first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>();
|
||||||
|
|
||||||
|
if (legacyRow) {
|
||||||
|
if (legacyRow.expires_at && legacyRow.expires_at < now) {
|
||||||
|
await deleteRefreshTokenRecord(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
await saveRefreshTokenRecord(
|
||||||
|
token,
|
||||||
|
legacyRow.user_id,
|
||||||
|
legacyRow.expires_at,
|
||||||
|
legacyRow.device_identifier ?? null,
|
||||||
|
legacyRow.device_session_stamp ?? null
|
||||||
|
);
|
||||||
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||||
|
return {
|
||||||
|
userId: legacyRow.user_id,
|
||||||
|
expiresAt: legacyRow.expires_at,
|
||||||
|
deviceIdentifier: legacyRow.device_identifier ?? null,
|
||||||
|
deviceSessionStamp: legacyRow.device_session_stamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at && row.expires_at < now) {
|
||||||
|
await deleteRefreshTokenRecord(token);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
deviceIdentifier: row.device_identifier ?? null,
|
||||||
|
deviceSessionStamp: row.device_session_stamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise<void> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run();
|
||||||
|
await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise<number> {
|
||||||
|
const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise<number> {
|
||||||
|
const result = await db
|
||||||
|
.prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?')
|
||||||
|
.bind(userId, deviceIdentifier)
|
||||||
|
.run();
|
||||||
|
return Number(result.meta.changes ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function constrainRefreshTokenExpiry(
|
||||||
|
db: D1Database,
|
||||||
|
refreshTokenKey: RefreshTokenKeyFn,
|
||||||
|
token: string,
|
||||||
|
maxExpiresAtMs: number
|
||||||
|
): Promise<void> {
|
||||||
|
const tokenKey = await refreshTokenKey(token);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE refresh_tokens ' +
|
||||||
|
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||||
|
'WHERE token = ?'
|
||||||
|
)
|
||||||
|
.bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE refresh_tokens ' +
|
||||||
|
'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' +
|
||||||
|
'WHERE token = ?'
|
||||||
|
)
|
||||||
|
.bind(maxExpiresAtMs, maxExpiresAtMs, token)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export async function getRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?')
|
||||||
|
.bind(userId)
|
||||||
|
.first<{ revision_date: string }>();
|
||||||
|
|
||||||
|
if (row?.revision_date) return row.revision_date;
|
||||||
|
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO NOTHING'
|
||||||
|
)
|
||||||
|
.bind(userId, date)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRevisionDate(db: D1Database, userId: string): Promise<string> {
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' +
|
||||||
|
'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date'
|
||||||
|
)
|
||||||
|
.bind(userId, date)
|
||||||
|
.run();
|
||||||
|
return date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// IMPORTANT:
|
||||||
|
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||||
|
// Any new table/column/index must be added to both places together.
|
||||||
|
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||||
|
'CREATE TABLE IF NOT EXISTS users (' +
|
||||||
|
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||||
|
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||||
|
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||||
|
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||||
|
'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||||
|
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||||
|
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1',
|
||||||
|
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||||
|
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||||
|
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||||
|
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'ALTER TABLE ciphers ADD COLUMN archived_at TEXT',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||||
|
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||||
|
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||||
|
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS sends (' +
|
||||||
|
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
|
||||||
|
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
|
||||||
|
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||||
|
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||||
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
||||||
|
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS invites (' +
|
||||||
|
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
|
||||||
|
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS audit_logs (' +
|
||||||
|
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||||
|
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||||
|
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||||
|
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||||
|
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT',
|
||||||
|
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||||
|
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||||
|
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||||
|
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||||
|
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||||
|
'PRIMARY KEY (identifier, window_start))',
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||||
|
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||||
|
|
||||||
|
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||||
|
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.prepare(statement).run();
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||||
|
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAdminUserExists(db: D1Database): Promise<void> {
|
||||||
|
const admin = await db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
|
||||||
|
if (admin?.id) return;
|
||||||
|
|
||||||
|
const firstUser = await db
|
||||||
|
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
|
||||||
|
.first<{ id: string }>();
|
||||||
|
if (!firstUser?.id) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
|
||||||
|
.bind(new Date().toISOString(), firstUser.id)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureStorageSchema(db: D1Database): Promise<void> {
|
||||||
|
await db.prepare('PRAGMA foreign_keys = ON').run();
|
||||||
|
await db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||||
|
for (const stmt of SCHEMA_STATEMENTS) {
|
||||||
|
await executeSchemaStatement(db, stmt);
|
||||||
|
}
|
||||||
|
await ensureAdminUserExists(db);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { Send } from '../types';
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
|
|
||||||
|
function mapSendRow(row: any): Send {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
type: row.type,
|
||||||
|
name: row.name,
|
||||||
|
notes: row.notes,
|
||||||
|
data: row.data,
|
||||||
|
key: row.key,
|
||||||
|
passwordHash: row.password_hash,
|
||||||
|
passwordSalt: row.password_salt,
|
||||||
|
passwordIterations: row.password_iterations,
|
||||||
|
authType: row.auth_type ?? 0,
|
||||||
|
emails: row.emails ?? null,
|
||||||
|
maxAccessCount: row.max_access_count,
|
||||||
|
accessCount: row.access_count,
|
||||||
|
disabled: !!row.disabled,
|
||||||
|
hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
expirationDate: row.expiration_date,
|
||||||
|
deletionDate: row.deletion_date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSend(db: D1Database, id: string): Promise<Send | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?'
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapSendRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' +
|
||||||
|
'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' +
|
||||||
|
'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' +
|
||||||
|
'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date'
|
||||||
|
);
|
||||||
|
|
||||||
|
await safeBind(
|
||||||
|
stmt,
|
||||||
|
send.id,
|
||||||
|
send.userId,
|
||||||
|
Number(send.type) || 0,
|
||||||
|
send.name,
|
||||||
|
send.notes,
|
||||||
|
send.data,
|
||||||
|
send.key,
|
||||||
|
send.passwordHash,
|
||||||
|
send.passwordSalt,
|
||||||
|
send.passwordIterations,
|
||||||
|
send.authType,
|
||||||
|
send.emails,
|
||||||
|
send.maxAccessCount,
|
||||||
|
send.accessCount,
|
||||||
|
send.disabled ? 1 : 0,
|
||||||
|
send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0,
|
||||||
|
send.createdAt,
|
||||||
|
send.updatedAt,
|
||||||
|
send.expirationDate,
|
||||||
|
send.deletionDate
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise<boolean> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const result = await db
|
||||||
|
.prepare(
|
||||||
|
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
|
||||||
|
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)'
|
||||||
|
)
|
||||||
|
.bind(now, sendId)
|
||||||
|
.run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSend(db: D1Database, id: string, userId: string): Promise<void> {
|
||||||
|
await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSendsByIds(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<Send[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!uniqueIds.length) return [];
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
const out: Send[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date
|
||||||
|
FROM sends
|
||||||
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
|
)
|
||||||
|
.bind(userId, ...chunk)
|
||||||
|
.all<any>();
|
||||||
|
out.push(...(res.results || []).map((row) => mapSendRow(row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkDeleteSends(
|
||||||
|
db: D1Database,
|
||||||
|
sqlChunkSize: SqlChunkSize,
|
||||||
|
updateRevisionDate: UpdateRevisionDate,
|
||||||
|
ids: string[],
|
||||||
|
userId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (ids.length === 0) return null;
|
||||||
|
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||||
|
if (!uniqueIds.length) return null;
|
||||||
|
const chunkSize = sqlChunkSize(1);
|
||||||
|
|
||||||
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
|
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||||
|
const placeholders = chunk.map(() => '?').join(',');
|
||||||
|
await db.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
return updateRevisionDate(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSends(db: D1Database, userId: string): Promise<Send[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC'
|
||||||
|
)
|
||||||
|
.bind(userId)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapSendRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise<Send[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
|
||||||
|
)
|
||||||
|
.bind(userId, limit, offset)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapSendRow(row));
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { User } from '../types';
|
||||||
|
|
||||||
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
|
const USER_SELECT_COLUMNS =
|
||||||
|
'id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, ' +
|
||||||
|
'kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, ' +
|
||||||
|
'totp_secret, totp_recovery_code, created_at, updated_at';
|
||||||
|
|
||||||
|
function mapUserRow(row: any): User {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
name: row.name,
|
||||||
|
masterPasswordHint: row.master_password_hint ?? null,
|
||||||
|
masterPasswordHash: row.master_password_hash,
|
||||||
|
key: row.key,
|
||||||
|
privateKey: row.private_key,
|
||||||
|
publicKey: row.public_key,
|
||||||
|
kdfType: row.kdf_type,
|
||||||
|
kdfIterations: row.kdf_iterations,
|
||||||
|
kdfMemory: row.kdf_memory ?? undefined,
|
||||||
|
kdfParallelism: row.kdf_parallelism ?? undefined,
|
||||||
|
securityStamp: row.security_stamp,
|
||||||
|
role: row.role === 'admin' ? 'admin' : 'user',
|
||||||
|
status: row.status === 'banned' ? 'banned' : 'active',
|
||||||
|
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices,
|
||||||
|
totpSecret: row.totp_secret ?? null,
|
||||||
|
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser(db: D1Database, email: string): Promise<User | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE email = ?`)
|
||||||
|
.bind(email.toLowerCase())
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapUserRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(db: D1Database, id: string): Promise<User | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users WHERE id = ?`)
|
||||||
|
.bind(id)
|
||||||
|
.first<any>();
|
||||||
|
if (!row) return null;
|
||||||
|
return mapUserRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserCount(db: D1Database): Promise<number> {
|
||||||
|
const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>();
|
||||||
|
return Number(row?.count || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllUsers(db: D1Database): Promise<User[]> {
|
||||||
|
const res = await db
|
||||||
|
.prepare(`SELECT ${USER_SELECT_COLUMNS} FROM users ORDER BY created_at ASC`)
|
||||||
|
.all<any>();
|
||||||
|
return (res.results || []).map((row) => mapUserRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||||
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
|
'ON CONFLICT(id) DO UPDATE SET ' +
|
||||||
|
'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
|
||||||
|
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, verify_devices=excluded.verify_devices, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
|
||||||
|
);
|
||||||
|
await safeBind(
|
||||||
|
stmt,
|
||||||
|
user.id,
|
||||||
|
email,
|
||||||
|
user.name,
|
||||||
|
user.masterPasswordHint,
|
||||||
|
user.masterPasswordHash,
|
||||||
|
user.key,
|
||||||
|
user.privateKey,
|
||||||
|
user.publicKey,
|
||||||
|
user.kdfType,
|
||||||
|
user.kdfIterations,
|
||||||
|
user.kdfMemory,
|
||||||
|
user.kdfParallelism,
|
||||||
|
user.securityStamp,
|
||||||
|
user.role,
|
||||||
|
user.status,
|
||||||
|
user.verifyDevices ? 1 : 0,
|
||||||
|
user.totpSecret,
|
||||||
|
user.totpRecoveryCode,
|
||||||
|
user.createdAt,
|
||||||
|
user.updatedAt
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise<void> {
|
||||||
|
await saveUser(db, safeBind, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise<boolean> {
|
||||||
|
const email = user.email.toLowerCase();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at) ' +
|
||||||
|
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||||
|
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||||
|
);
|
||||||
|
const result = await safeBind(
|
||||||
|
stmt,
|
||||||
|
user.id,
|
||||||
|
email,
|
||||||
|
user.name,
|
||||||
|
user.masterPasswordHint,
|
||||||
|
user.masterPasswordHash,
|
||||||
|
user.key,
|
||||||
|
user.privateKey,
|
||||||
|
user.publicKey,
|
||||||
|
user.kdfType,
|
||||||
|
user.kdfIterations,
|
||||||
|
user.kdfMemory,
|
||||||
|
user.kdfParallelism,
|
||||||
|
user.securityStamp,
|
||||||
|
user.role,
|
||||||
|
user.status,
|
||||||
|
user.verifyDevices ? 1 : 0,
|
||||||
|
user.totpSecret,
|
||||||
|
user.totpRecoveryCode,
|
||||||
|
user.createdAt,
|
||||||
|
user.updatedAt
|
||||||
|
).run();
|
||||||
|
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserById(db: D1Database, id: string): Promise<boolean> {
|
||||||
|
const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run();
|
||||||
|
return (result.meta.changes ?? 0) > 0;
|
||||||
|
}
|
||||||
+316
-828
File diff suppressed because it is too large
Load Diff
+75
-1
@@ -1,7 +1,14 @@
|
|||||||
// Environment bindings
|
// Environment bindings
|
||||||
export interface Env {
|
export interface Env {
|
||||||
DB: D1Database;
|
DB: D1Database;
|
||||||
ATTACHMENTS: R2Bucket;
|
NOTIFICATIONS_HUB: DurableObjectNamespace;
|
||||||
|
ASSETS?: {
|
||||||
|
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
||||||
|
};
|
||||||
|
// Prefer R2 when available. Optional to support KV-only deployments.
|
||||||
|
ATTACHMENTS?: R2Bucket;
|
||||||
|
// Optional fallback for attachment/send file storage (no credit card required).
|
||||||
|
ATTACHMENTS_KV?: KVNamespace;
|
||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
TOTP_SECRET?: string;
|
TOTP_SECRET?: string;
|
||||||
}
|
}
|
||||||
@@ -28,6 +35,7 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
masterPasswordHint: string | null;
|
||||||
masterPasswordHash: string;
|
masterPasswordHash: string;
|
||||||
key: string;
|
key: string;
|
||||||
privateKey: string | null;
|
privateKey: string | null;
|
||||||
@@ -39,6 +47,7 @@ export interface User {
|
|||||||
securityStamp: string;
|
securityStamp: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
|
verifyDevices?: boolean;
|
||||||
totpSecret: string | null;
|
totpSecret: string | null;
|
||||||
totpRecoveryCode: string | null;
|
totpRecoveryCode: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -161,6 +170,7 @@ export interface Cipher {
|
|||||||
key: string | null;
|
key: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
archivedAt: string | null;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
/** Allow unknown fields from Bitwarden clients to be stored and passed through transparently. */
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
@@ -180,10 +190,55 @@ export interface Device {
|
|||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: number;
|
type: number;
|
||||||
|
sessionStamp: string;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
encryptedPrivateKey: string | null;
|
||||||
|
devicePendingAuthRequest?: DevicePendingAuthRequest | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DevicePendingAuthRequest {
|
||||||
|
id: string;
|
||||||
|
creationDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceResponse {
|
||||||
|
id: string;
|
||||||
|
userId?: string | null;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
type: number;
|
||||||
|
creationDate: string;
|
||||||
|
revisionDate: string;
|
||||||
|
isTrusted: boolean;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||||
|
object: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectedDeviceResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
identifier: string;
|
||||||
|
type: number;
|
||||||
|
creationDate: string;
|
||||||
|
encryptedUserKey: string | null;
|
||||||
|
encryptedPublicKey: string | null;
|
||||||
|
object: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshTokenRecord {
|
||||||
|
userId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
deviceIdentifier: string | null;
|
||||||
|
deviceSessionStamp: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrustedDeviceTokenSummary {
|
export interface TrustedDeviceTokenSummary {
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
@@ -254,6 +309,8 @@ export interface JWTPayload {
|
|||||||
email_verified: boolean; // required by mobile client
|
email_verified: boolean; // required by mobile client
|
||||||
amr: string[]; // authentication methods reference - required by mobile client
|
amr: string[]; // authentication methods reference - required by mobile client
|
||||||
sstamp: string; // security stamp - invalidates token when user changes password
|
sstamp: string; // security stamp - invalidates token when user changes password
|
||||||
|
did?: string; // device identifier - invalidates per-device sessions
|
||||||
|
dstamp?: string; // device session stamp
|
||||||
iat: number;
|
iat: number;
|
||||||
exp: number;
|
exp: number;
|
||||||
iss: string;
|
iss: string;
|
||||||
@@ -281,6 +338,8 @@ export interface UserDecryptionOptions {
|
|||||||
Object: string;
|
Object: string;
|
||||||
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||||
|
TrustedDeviceOption: null;
|
||||||
|
KeyConnectorOption: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Response types
|
// API Response types
|
||||||
@@ -300,7 +359,14 @@ export interface TokenResponse {
|
|||||||
ResetMasterPassword: boolean;
|
ResetMasterPassword: boolean;
|
||||||
scope: string;
|
scope: string;
|
||||||
unofficialServer: boolean;
|
unofficialServer: boolean;
|
||||||
|
MasterPasswordPolicy?: {
|
||||||
|
Object: string;
|
||||||
|
} | null;
|
||||||
|
ApiUseKeyConnector?: boolean;
|
||||||
|
AccountKeys?: any | null;
|
||||||
|
accountKeys?: any | null;
|
||||||
UserDecryptionOptions: UserDecryptionOptions;
|
UserDecryptionOptions: UserDecryptionOptions;
|
||||||
|
userDecryptionOptions?: UserDecryptionOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
@@ -324,6 +390,7 @@ export interface ProfileResponse {
|
|||||||
forcePasswordReset: boolean;
|
forcePasswordReset: boolean;
|
||||||
avatarColor: string | null;
|
avatarColor: string | null;
|
||||||
creationDate: string;
|
creationDate: string;
|
||||||
|
verifyDevices?: boolean;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
object: string;
|
object: string;
|
||||||
@@ -382,6 +449,13 @@ export interface SyncResponse {
|
|||||||
domains: any;
|
domains: any;
|
||||||
policies: any[];
|
policies: any[];
|
||||||
sends: SendResponse[];
|
sends: SendResponse[];
|
||||||
|
UserDecryption?: {
|
||||||
|
MasterPasswordUnlock: MasterPasswordUnlock | null;
|
||||||
|
TrustedDeviceOption?: null;
|
||||||
|
KeyConnectorOption?: null;
|
||||||
|
WebAuthnPrfOption?: null;
|
||||||
|
Object?: string;
|
||||||
|
} | null;
|
||||||
// PascalCase for desktop/browser clients
|
// PascalCase for desktop/browser clients
|
||||||
UserDecryptionOptions: UserDecryptionOptions | null;
|
UserDecryptionOptions: UserDecryptionOptions | null;
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||||
|
|||||||
@@ -72,3 +72,7 @@ export function readKnownDeviceProbe(request: Request): { email: string | null;
|
|||||||
return { email, deviceIdentifier };
|
return { email, deviceIdentifier };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readActingDeviceIdentifier(request: Request): string | null {
|
||||||
|
return normalizeDeviceIdentifier(request.headers.get('X-NodeWarden-Acting-Device-Id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { LIMITS } from '../config/limits';
|
||||||
|
import { DEFAULT_DEV_SECRET, Env } from '../types';
|
||||||
|
import { errorResponse } from './response';
|
||||||
|
|
||||||
|
export interface DirectUploadPayload {
|
||||||
|
body: ReadableStream;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParseDirectUploadOptions {
|
||||||
|
expectedSize?: number | null;
|
||||||
|
expectedFileName?: string | null;
|
||||||
|
maxFileSize: number;
|
||||||
|
tooLargeMessage: string;
|
||||||
|
missingBodyMessage?: string;
|
||||||
|
contentLengthRequiredMessage?: string;
|
||||||
|
sizeMismatchMessage?: string;
|
||||||
|
fileNameMismatchMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
|
||||||
|
const version = '2023-11-03';
|
||||||
|
const expiresAt = '2099-12-31T23:59:59Z';
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
return `${origin}${path}?sv=${encodeURIComponent(version)}&se=${encodeURIComponent(expiresAt)}&token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeJwtSecret(env: Env): string | null {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentLength(request: Request): number | null {
|
||||||
|
const raw = request.headers.get('content-length');
|
||||||
|
if (!raw) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value) || value < 0) return null;
|
||||||
|
return Math.floor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseDirectUploadPayload(
|
||||||
|
request: Request,
|
||||||
|
options: ParseDirectUploadOptions
|
||||||
|
): Promise<DirectUploadPayload | Response> {
|
||||||
|
const {
|
||||||
|
expectedSize = null,
|
||||||
|
expectedFileName = null,
|
||||||
|
maxFileSize,
|
||||||
|
tooLargeMessage,
|
||||||
|
missingBodyMessage = 'No file uploaded',
|
||||||
|
contentLengthRequiredMessage = 'Content-Length is required for direct uploads',
|
||||||
|
sizeMismatchMessage,
|
||||||
|
fileNameMismatchMessage,
|
||||||
|
} = options;
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('data') as File | null;
|
||||||
|
if (!file) {
|
||||||
|
return errorResponse(missingBodyMessage, 400);
|
||||||
|
}
|
||||||
|
if (file.size > maxFileSize) {
|
||||||
|
return errorResponse(tooLargeMessage, 413);
|
||||||
|
}
|
||||||
|
if (expectedFileName && file.name !== expectedFileName) {
|
||||||
|
return errorResponse(fileNameMismatchMessage || 'File name does not match.', 400);
|
||||||
|
}
|
||||||
|
if (expectedSize !== null && expectedSize !== undefined && file.size !== expectedSize) {
|
||||||
|
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
body: file.stream(),
|
||||||
|
contentType: file.type || 'application/octet-stream',
|
||||||
|
size: file.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.body) {
|
||||||
|
return errorResponse(missingBodyMessage, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredSize = parseContentLength(request);
|
||||||
|
const uploadSize = declaredSize ?? (expectedSize && expectedSize > 0 ? expectedSize : null);
|
||||||
|
if (uploadSize === null) {
|
||||||
|
return errorResponse(contentLengthRequiredMessage, 400);
|
||||||
|
}
|
||||||
|
if (uploadSize > maxFileSize) {
|
||||||
|
return errorResponse(tooLargeMessage, 413);
|
||||||
|
}
|
||||||
|
if (expectedSize !== null && expectedSize !== undefined && uploadSize !== expectedSize) {
|
||||||
|
return errorResponse(sizeMismatchMessage || 'File size does not match.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
body: request.body,
|
||||||
|
contentType: contentType || 'application/octet-stream',
|
||||||
|
size: uploadSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -104,6 +104,13 @@ export interface FileDownloadClaims {
|
|||||||
exp: number;
|
exp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentUploadClaims {
|
||||||
|
userId: string;
|
||||||
|
cipherId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Create file download token (short-lived, 5 minutes)
|
// Create file download token (short-lived, 5 minutes)
|
||||||
export async function createFileDownloadToken(
|
export async function createFileDownloadToken(
|
||||||
cipherId: string,
|
cipherId: string,
|
||||||
@@ -178,7 +185,82 @@ export async function verifyFileDownloadToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createAttachmentUploadToken(
|
||||||
|
userId: string,
|
||||||
|
cipherId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: AttachmentUploadClaims = {
|
||||||
|
userId,
|
||||||
|
cipherId,
|
||||||
|
attachmentId,
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyAttachmentUploadToken(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<AttachmentUploadClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: AttachmentUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (!payload.userId || !payload.cipherId || !payload.attachmentId) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendFileDownloadClaims {
|
export interface SendFileDownloadClaims {
|
||||||
|
sendId: string;
|
||||||
|
fileId: string;
|
||||||
|
jti: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendFileUploadClaims {
|
||||||
|
userId: string;
|
||||||
sendId: string;
|
sendId: string;
|
||||||
fileId: string;
|
fileId: string;
|
||||||
exp: number;
|
exp: number;
|
||||||
@@ -194,6 +276,7 @@ export async function createSendFileDownloadToken(
|
|||||||
const payload: SendFileDownloadClaims = {
|
const payload: SendFileDownloadClaims = {
|
||||||
sendId,
|
sendId,
|
||||||
fileId,
|
fileId,
|
||||||
|
jti: createRefreshToken(),
|
||||||
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -240,6 +323,15 @@ export async function verifySendFileDownloadToken(
|
|||||||
if (!valid) return null;
|
if (!valid) return null;
|
||||||
|
|
||||||
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
const payload: SendFileDownloadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
if (
|
||||||
|
typeof payload.sendId !== 'string' ||
|
||||||
|
typeof payload.fileId !== 'string' ||
|
||||||
|
typeof payload.jti !== 'string' ||
|
||||||
|
!payload.jti ||
|
||||||
|
typeof payload.exp !== 'number'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (payload.exp < now) return null;
|
if (payload.exp < now) return null;
|
||||||
|
|
||||||
@@ -249,6 +341,73 @@ export async function verifySendFileDownloadToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createSendFileUploadToken(
|
||||||
|
userId: string,
|
||||||
|
sendId: string,
|
||||||
|
fileId: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<string> {
|
||||||
|
const header = { alg: 'HS256', typ: 'JWT' };
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload: SendFileUploadClaims = {
|
||||||
|
userId,
|
||||||
|
sendId,
|
||||||
|
fileId,
|
||||||
|
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const headerB64 = base64UrlEncode(encoder.encode(JSON.stringify(header)));
|
||||||
|
const payloadB64 = base64UrlEncode(encoder.encode(JSON.stringify(payload)));
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||||
|
const signatureB64 = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
return `${data}.${signatureB64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySendFileUploadToken(
|
||||||
|
token: string,
|
||||||
|
secret: string
|
||||||
|
): Promise<SendFileUploadClaims | null> {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['verify']
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = `${headerB64}.${payloadB64}`;
|
||||||
|
const signature = base64UrlDecode(signatureB64);
|
||||||
|
const valid = await crypto.subtle.verify('HMAC', key, signature, encoder.encode(data));
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
const payload: SendFileUploadClaims = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now) return null;
|
||||||
|
if (!payload.userId || !payload.sendId || !payload.fileId) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendAccessTokenClaims {
|
export interface SendAccessTokenClaims {
|
||||||
sub: string; // send id
|
sub: string; // send id
|
||||||
typ: 'send_access';
|
typ: 'send_access';
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
const RECOVERY_ALPHABET_LENGTH = RECOVERY_ALPHABET.length;
|
||||||
|
const RECOVERY_MAX_UNBIASED_BYTE = Math.floor(256 / RECOVERY_ALPHABET_LENGTH) * RECOVERY_ALPHABET_LENGTH;
|
||||||
|
|
||||||
function normalizeRecoveryCode(raw: string): string {
|
function normalizeRecoveryCode(raw: string): string {
|
||||||
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
|
||||||
@@ -9,15 +11,14 @@ function formatRecoveryCode(compact: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createRecoveryCode(): string {
|
export function createRecoveryCode(): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(20));
|
|
||||||
let compact = '';
|
let compact = '';
|
||||||
for (const b of bytes) {
|
|
||||||
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length];
|
|
||||||
}
|
|
||||||
// 20 bytes -> 20 chars in this simple mapping. Expand to 32 chars for friendlier grouping.
|
|
||||||
while (compact.length < 32) {
|
while (compact.length < 32) {
|
||||||
const extra = crypto.getRandomValues(new Uint8Array(1))[0];
|
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||||
compact += RECOVERY_ALPHABET[extra % RECOVERY_ALPHABET.length];
|
for (const b of bytes) {
|
||||||
|
if (b >= RECOVERY_MAX_UNBIASED_BYTE) continue;
|
||||||
|
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET_LENGTH];
|
||||||
|
if (compact.length >= 32) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return formatRecoveryCode(compact.slice(0, 32));
|
return formatRecoveryCode(compact.slice(0, 32));
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-29
@@ -1,40 +1,48 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
|
||||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
|
const DEFAULT_CORS_HEADERS = [
|
||||||
|
'Content-Type',
|
||||||
function isTrustedClientOrigin(origin: string): boolean {
|
'Authorization',
|
||||||
// Official browser extension / desktop-webview common origins.
|
'Accept',
|
||||||
if (origin.startsWith('chrome-extension://')) return true;
|
'Device-Type',
|
||||||
if (origin.startsWith('moz-extension://')) return true;
|
'Device-Identifier',
|
||||||
if (origin.startsWith('safari-web-extension://')) return true;
|
'Device-Name',
|
||||||
if (origin.startsWith('app://')) return true;
|
'Bitwarden-Client-Name',
|
||||||
if (origin.startsWith('capacitor://')) return true;
|
'Bitwarden-Client-Version',
|
||||||
if (origin.startsWith('ionic://')) return true;
|
'Bitwarden-Package-Type',
|
||||||
return false;
|
'Is-Prerelease',
|
||||||
}
|
'X-Request-Email',
|
||||||
|
'X-Device-Identifier',
|
||||||
|
'X-Device-Name',
|
||||||
|
];
|
||||||
|
|
||||||
function getAllowedOrigin(request: Request): string | null {
|
function getAllowedOrigin(request: Request): string | null {
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
if (!origin) return null;
|
if (!origin) return '*';
|
||||||
|
return origin;
|
||||||
const targetOrigin = new URL(request.url).origin;
|
|
||||||
if (origin === targetOrigin) return origin;
|
|
||||||
if (isTrustedClientOrigin(origin)) return origin;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
|
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||||
|
.split(',')
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const allowHeaders = Array.from(new Set([...DEFAULT_CORS_HEADERS, ...requestedHeaders]));
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Access-Control-Allow-Methods': CORS_METHODS,
|
'Access-Control-Allow-Methods': CORS_METHODS,
|
||||||
'Access-Control-Allow-Headers': CORS_HEADERS,
|
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||||
|
'Access-Control-Expose-Headers': '*',
|
||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
|
'Access-Control-Allow-Private-Network': 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedOrigin = getAllowedOrigin(request);
|
const allowedOrigin = getAllowedOrigin(request);
|
||||||
if (allowedOrigin) {
|
if (allowedOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
||||||
headers['Vary'] = 'Origin';
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||||
}
|
}
|
||||||
|
|
||||||
return headers;
|
return headers;
|
||||||
@@ -44,6 +52,12 @@ export function applyCors(
|
|||||||
request: Request,
|
request: Request,
|
||||||
response: Response
|
response: Response
|
||||||
): Response {
|
): Response {
|
||||||
|
// WebSocket upgrade responses must be returned untouched.
|
||||||
|
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||||
|
if (response.status === 101 || webSocket) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
const corsHeaders = buildCorsHeaders(request);
|
const corsHeaders = buildCorsHeaders(request);
|
||||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
@@ -53,7 +67,7 @@ export function applyCors(
|
|||||||
headers.set('X-Frame-Options', 'DENY');
|
headers.set('X-Frame-Options', 'DENY');
|
||||||
headers.set('X-Content-Type-Options', 'nosniff');
|
headers.set('X-Content-Type-Options', 'nosniff');
|
||||||
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
|
||||||
return new Response(response.body, {
|
return new Response(response.body, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
@@ -104,14 +118,6 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
export function handleCors(request: Request): Response {
|
export function handleCors(request: Request): Response {
|
||||||
const origin = request.headers.get('Origin');
|
|
||||||
if (origin) {
|
|
||||||
const allowedOrigin = getAllowedOrigin(request);
|
|
||||||
if (!allowedOrigin) {
|
|
||||||
return new Response(null, { status: 403 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: buildCorsHeaders(request),
|
headers: buildCorsHeaders(request),
|
||||||
|
|||||||
+10
-1
@@ -3,7 +3,16 @@ const TOTP_DIGITS = 6;
|
|||||||
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||||
|
|
||||||
function normalizeBase32(input: string): string {
|
function normalizeBase32(input: string): string {
|
||||||
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
const raw = String(input || '').toUpperCase();
|
||||||
|
let out = '';
|
||||||
|
for (const char of raw) {
|
||||||
|
if (char === ' ' || char === '\t' || char === '\n' || char === '\r' || char === '-') continue;
|
||||||
|
out += char;
|
||||||
|
}
|
||||||
|
while (out.endsWith('=')) {
|
||||||
|
out = out.slice(0, -1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function base32Decode(input: string): Uint8Array | null {
|
function base32Decode(input: string): Uint8Array | null {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { User, UserDecryptionOptions } from '../types';
|
||||||
|
|
||||||
|
function normalizeOptionalPublicKey(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||||
|
if (!user.privateKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = normalizeOptionalPublicKey(user.publicKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyEncryptionKeyPair: {
|
||||||
|
wrappedPrivateKey: user.privateKey,
|
||||||
|
publicKey,
|
||||||
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
|
},
|
||||||
|
Object: 'privateKeys',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMasterPasswordUnlock(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): UserDecryptionOptions['MasterPasswordUnlock'] {
|
||||||
|
return {
|
||||||
|
Kdf: {
|
||||||
|
KdfType: user.kdfType,
|
||||||
|
Iterations: user.kdfIterations,
|
||||||
|
Memory: user.kdfMemory ?? null,
|
||||||
|
Parallelism: user.kdfParallelism ?? null,
|
||||||
|
},
|
||||||
|
MasterKeyEncryptedUserKey: user.key,
|
||||||
|
MasterKeyWrappedUserKey: user.key,
|
||||||
|
Salt: user.email.toLowerCase(),
|
||||||
|
Object: 'masterPasswordUnlock',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserDecryptionOptions(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): UserDecryptionOptions {
|
||||||
|
return {
|
||||||
|
HasMasterPassword: true,
|
||||||
|
Object: 'userDecryptionOptions',
|
||||||
|
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||||
|
TrustedDeviceOption: null,
|
||||||
|
KeyConnectorOption: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserDecryptionCompat(
|
||||||
|
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
masterPasswordUnlock: {
|
||||||
|
kdf: {
|
||||||
|
kdfType: user.kdfType,
|
||||||
|
iterations: user.kdfIterations,
|
||||||
|
memory: user.kdfMemory ?? null,
|
||||||
|
parallelism: user.kdfParallelism ?? null,
|
||||||
|
},
|
||||||
|
masterKeyWrappedUserKey: user.key,
|
||||||
|
masterKeyEncryptedUserKey: user.key,
|
||||||
|
salt: user.email.toLowerCase(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -15,6 +15,6 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false
|
"noUnusedParameters": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "shared/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://icons.bitwarden.net; connect-src 'self' https://cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' https://cloudflareinsights.com https://*.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cloudflareinsights.com https://*.cloudflareinsights.com; connect-src 'self' https://api.pwnedpasswords.com https://cloudflareinsights.com https://*.cloudflareinsights.com; font-src 'self'; form-action 'self'; base-uri 'self';" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.ico" />
|
<link rel="icon" type="image/png" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>NodeWarden</title>
|
<title>NodeWarden</title>
|
||||||
|
|||||||
+808
-707
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
import { ChevronLeft, ChevronRight, Clipboard, Plus, RefreshCw, Trash2, UserCheck, UserX } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import type { AdminInvite, AdminUser } from '@/lib/types';
|
import type { AdminInvite, AdminUser } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ interface AdminPageProps {
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onCreateInvite: (hours: number) => Promise<void>;
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
onDeleteAllInvites: () => Promise<void>;
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
onToggleUserStatus: (userId: string, currentStatus: string) => Promise<void>;
|
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => Promise<void>;
|
||||||
onDeleteUser: (userId: string) => Promise<void>;
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -56,11 +57,11 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{props.users.map((user) => (
|
{props.users.map((user) => (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td>{user.email}</td>
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
<td>{user.name || t('txt_dash')}</td>
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
<td>{roleText(user.role)}</td>
|
<td data-label={t('txt_role')}>{roleText(user.role)}</td>
|
||||||
<td>{statusText(user.status)}</td>
|
<td data-label={t('txt_status')}>{statusText(user.status)}</td>
|
||||||
<td>
|
<td data-label={t('txt_actions')}>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -126,15 +127,15 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{pagedInvites.map((invite) => (
|
{pagedInvites.map((invite) => (
|
||||||
<tr key={invite.code}>
|
<tr key={invite.code}>
|
||||||
<td>{invite.code}</td>
|
<td data-label={t('txt_code')}>{invite.code}</td>
|
||||||
<td>{statusText(invite.status)}</td>
|
<td data-label={t('txt_status')}>{statusText(invite.status)}</td>
|
||||||
<td>{formatExpiresAt(invite.expiresAt)}</td>
|
<td data-label={t('txt_expires_at')}>{formatExpiresAt(invite.expiresAt)}</td>
|
||||||
<td>
|
<td data-label={t('txt_actions')}>
|
||||||
<div className="actions invite-row-actions">
|
<div className="actions invite-row-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => navigator.clipboard.writeText(invite.inviteLink || '')}
|
onClick={() => void copyTextToClipboard(invite.inviteLink || '', { successMessage: t('txt_link_copied') })}
|
||||||
>
|
>
|
||||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
|
import { Link } from 'wouter';
|
||||||
|
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||||
|
import ThemeSwitch from '@/components/ThemeSwitch';
|
||||||
|
import type { AppMainRoutesProps } from '@/components/AppMainRoutes';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Profile } from '@/lib/types';
|
||||||
|
|
||||||
|
interface AppAuthenticatedShellProps {
|
||||||
|
profile: Profile | null;
|
||||||
|
location: string;
|
||||||
|
mobilePrimaryRoute: string;
|
||||||
|
currentPageTitle: string;
|
||||||
|
showSidebarToggle: boolean;
|
||||||
|
sidebarToggleTitle: string;
|
||||||
|
settingsAccountRoute: string;
|
||||||
|
importRoute: string;
|
||||||
|
isImportRoute: boolean;
|
||||||
|
darkMode: boolean;
|
||||||
|
themeToggleTitle: string;
|
||||||
|
onLock: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
mainRoutesProps: AppMainRoutesProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
|
||||||
|
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<img src="/logo-64.png" alt="NodeWarden logo" className="brand-logo" />
|
||||||
|
<span className="brand-name">NodeWarden</span>
|
||||||
|
<span className="mobile-page-title">{props.currentPageTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="topbar-actions">
|
||||||
|
<div className="user-chip">
|
||||||
|
<ShieldUser size={16} />
|
||||||
|
<span>{props.profile?.email}</span>
|
||||||
|
</div>
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onLock}>
|
||||||
|
<Lock size={14} className="btn-icon" /> {t('txt_lock')}
|
||||||
|
</button>
|
||||||
|
{props.showSidebarToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small mobile-sidebar-toggle"
|
||||||
|
aria-label={props.sidebarToggleTitle}
|
||||||
|
title={props.sidebarToggleTitle}
|
||||||
|
onClick={() => window.dispatchEvent(new CustomEvent('nodewarden:toggle-sidebar'))}
|
||||||
|
>
|
||||||
|
<FolderIcon size={16} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="mobile-theme-btn">
|
||||||
|
<ThemeSwitch checked={props.darkMode} title={props.themeToggleTitle} onToggle={props.onToggleTheme} />
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-lock-btn" aria-label={t('txt_lock')} title={t('txt_lock')} onClick={props.onLock}>
|
||||||
|
<Lock size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onLogout}>
|
||||||
|
<LogOut size={14} className="btn-icon" /> {t('txt_sign_out')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="app-main">
|
||||||
|
<aside className="app-side">
|
||||||
|
<Link href="/vault" className={`side-link ${props.location === '/vault' ? 'active' : ''}`}>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
<span>{t('nav_my_vault')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/vault/totp" className={`side-link ${props.location === '/vault/totp' ? 'active' : ''}`}>
|
||||||
|
<Clock3 size={16} />
|
||||||
|
<span>{t('txt_verification_code')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/sends" className={`side-link ${props.location === '/sends' ? 'active' : ''}`}>
|
||||||
|
<SendIcon size={16} />
|
||||||
|
<span>{t('nav_sends')}</span>
|
||||||
|
</Link>
|
||||||
|
{props.profile?.role === 'admin' && (
|
||||||
|
<Link href="/admin" className={`side-link ${props.location === '/admin' ? 'active' : ''}`}>
|
||||||
|
<ShieldUser size={16} />
|
||||||
|
<span>{t('nav_admin_panel')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href={props.settingsAccountRoute} className={`side-link ${props.location === props.settingsAccountRoute ? 'active' : ''}`}>
|
||||||
|
<SettingsIcon size={16} />
|
||||||
|
<span>{t('nav_account_settings')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/security/devices" className={`side-link ${props.location === '/security/devices' ? 'active' : ''}`}>
|
||||||
|
<Shield size={16} />
|
||||||
|
<span>{t('nav_device_management')}</span>
|
||||||
|
</Link>
|
||||||
|
{props.profile?.role === 'admin' && (
|
||||||
|
<Link href="/backup" className={`side-link ${props.location === '/backup' ? 'active' : ''}`}>
|
||||||
|
<Cloud size={16} />
|
||||||
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href={props.importRoute} className={`side-link ${props.isImportRoute ? 'active' : ''}`}>
|
||||||
|
<ArrowUpDown size={14} />
|
||||||
|
<span>{t('nav_import_export')}</span>
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
<main className="content">
|
||||||
|
<div key={routeAnimationKey} className="route-stage">
|
||||||
|
<AppMainRoutes {...props.mainRoutesProps} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mobile-tabbar" aria-label={t('txt_menu')}>
|
||||||
|
<Link href="/vault" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault' ? 'active' : ''}`}>
|
||||||
|
<KeyRound size={18} />
|
||||||
|
<span>{t('nav_my_vault')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/vault/totp" className={`mobile-tab ${props.mobilePrimaryRoute === '/vault/totp' ? 'active' : ''}`}>
|
||||||
|
<Clock3 size={18} />
|
||||||
|
<span>{t('txt_verification_code')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||||
|
<SendIcon size={18} />
|
||||||
|
<span>{t('nav_sends')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/settings" className={`mobile-tab ${props.mobilePrimaryRoute === '/settings' ? 'active' : ''}`}>
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
<span>{t('txt_settings')}</span>
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import ToastHost from '@/components/ToastHost';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { ToastMessage } from '@/lib/types';
|
||||||
|
|
||||||
|
export interface AppConfirmState {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
danger?: boolean;
|
||||||
|
showIcon?: boolean;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
hideCancel?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppGlobalOverlaysProps {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
onCloseToast: (id: string) => void;
|
||||||
|
confirm: AppConfirmState | null;
|
||||||
|
onCancelConfirm: () => void;
|
||||||
|
pendingTotpOpen: boolean;
|
||||||
|
totpCode: string;
|
||||||
|
rememberDevice: boolean;
|
||||||
|
onTotpCodeChange: (value: string) => void;
|
||||||
|
onRememberDeviceChange: (checked: boolean) => void;
|
||||||
|
onConfirmTotp: () => void;
|
||||||
|
onCancelTotp: () => void;
|
||||||
|
onUseRecoveryCode: () => void;
|
||||||
|
disableTotpOpen: boolean;
|
||||||
|
disableTotpPassword: string;
|
||||||
|
onDisableTotpPasswordChange: (value: string) => void;
|
||||||
|
onConfirmDisableTotp: () => void;
|
||||||
|
onCancelDisableTotp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!props.confirm}
|
||||||
|
title={props.confirm?.title || ''}
|
||||||
|
message={props.confirm?.message || ''}
|
||||||
|
danger={props.confirm?.danger}
|
||||||
|
showIcon={props.confirm?.showIcon}
|
||||||
|
confirmText={props.confirm?.confirmText}
|
||||||
|
cancelText={props.confirm?.cancelText}
|
||||||
|
hideCancel={props.confirm?.hideCancel}
|
||||||
|
onConfirm={() => props.confirm?.onConfirm()}
|
||||||
|
onCancel={props.onCancelConfirm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.pendingTotpOpen}
|
||||||
|
title={t('txt_two_step_verification')}
|
||||||
|
message={t('txt_password_is_already_verified')}
|
||||||
|
confirmText={t('txt_verify')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
onConfirm={props.onConfirmTotp}
|
||||||
|
onCancel={props.onCancelTotp}
|
||||||
|
afterActions={(
|
||||||
|
<div className="dialog-extra">
|
||||||
|
<div className="dialog-divider" />
|
||||||
|
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onUseRecoveryCode}>
|
||||||
|
{t('txt_use_recovery_code')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_totp_code')}</span>
|
||||||
|
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
<label className="check-line" style={{ marginBottom: 0 }}>
|
||||||
|
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||||
|
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.disableTotpOpen}
|
||||||
|
title={t('txt_disable_totp')}
|
||||||
|
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||||
|
confirmText={t('txt_disable_totp')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
showIcon={false}
|
||||||
|
onConfirm={props.onConfirmDisableTotp}
|
||||||
|
onCancel={props.onCancelDisableTotp}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input className="input" type="password" autoComplete="current-password" value={props.disableTotpPassword} onInput={(e) => props.onDisableTotpPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ToastHost toasts={props.toasts} onClose={props.onCloseToast} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
|
import { useEffect } from 'preact/hooks';
|
||||||
|
import { Link, Route, Switch } from 'wouter';
|
||||||
|
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||||
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
|
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||||
|
import type { ExportRequest } from '@/lib/export-formats';
|
||||||
|
|
||||||
|
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||||
|
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||||
|
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||||
|
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||||
|
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||||
|
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||||
|
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||||
|
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||||
|
|
||||||
|
function RouteContentFallback() {
|
||||||
|
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyBackupRedirect(props: { onNavigate: (path: string) => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
props.onNavigate('/backup');
|
||||||
|
}, [props]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppMainRoutesProps {
|
||||||
|
profile: Profile | null;
|
||||||
|
session: SessionState | null;
|
||||||
|
mobileLayout: boolean;
|
||||||
|
importRoute: string;
|
||||||
|
settingsHomeRoute: string;
|
||||||
|
settingsAccountRoute: string;
|
||||||
|
decryptedCiphers: Cipher[];
|
||||||
|
decryptedFolders: VaultFolder[];
|
||||||
|
decryptedSends: Send[];
|
||||||
|
ciphersLoading: boolean;
|
||||||
|
foldersLoading: boolean;
|
||||||
|
sendsLoading: boolean;
|
||||||
|
users: AdminUser[];
|
||||||
|
invites: AdminInvite[];
|
||||||
|
totpEnabled: boolean;
|
||||||
|
authorizedDevices: AuthorizedDevice[];
|
||||||
|
authorizedDevicesLoading: boolean;
|
||||||
|
onNavigate: (path: string) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
onImport: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
onImportEncryptedRaw: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
onExport: (request: ExportRequest) => Promise<void>;
|
||||||
|
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||||
|
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||||
|
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onArchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onUnarchiveVaultItem: (cipher: Cipher) => Promise<void>;
|
||||||
|
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkArchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkUnarchiveVaultItems: (ids: string[]) => Promise<void>;
|
||||||
|
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||||
|
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||||
|
onCreateFolder: (name: string) => Promise<void>;
|
||||||
|
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||||
|
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||||
|
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
|
uploadingAttachmentName: string;
|
||||||
|
attachmentUploadPercent: number | null;
|
||||||
|
onRefreshVault: () => Promise<void>;
|
||||||
|
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
|
onDeleteSend: (send: Send) => Promise<void>;
|
||||||
|
onBulkDeleteSends: (ids: string[]) => Promise<void>;
|
||||||
|
uploadingSendFileName: string;
|
||||||
|
sendUploadPercent: number | null;
|
||||||
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
|
onOpenDisableTotp: () => void;
|
||||||
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
|
onRevokeAllDeviceTrust: () => void;
|
||||||
|
onRemoveAllDevices: () => void;
|
||||||
|
onCreateInvite: (hours: number) => Promise<void>;
|
||||||
|
onRefreshAdmin: () => void;
|
||||||
|
onDeleteAllInvites: () => Promise<void>;
|
||||||
|
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||||
|
onDeleteUser: (userId: string) => Promise<void>;
|
||||||
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
|
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||||
|
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
|
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||||
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
|
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
|
const importPageContent = (
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<ImportPage
|
||||||
|
onImport={props.onImport}
|
||||||
|
onImportEncryptedRaw={props.onImportEncryptedRaw}
|
||||||
|
accountKeys={props.session?.symEncKey && props.session?.symMacKey ? { encB64: props.session.symEncKey, macB64: props.session.symMacKey } : null}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
folders={props.decryptedFolders}
|
||||||
|
onExport={props.onExport}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderImportPageRoute = () => (
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importPageContent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Route path="/sends">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<SendsPage
|
||||||
|
sends={props.decryptedSends}
|
||||||
|
loading={props.sendsLoading}
|
||||||
|
onRefresh={props.onRefreshVault}
|
||||||
|
onCreate={props.onCreateSend}
|
||||||
|
onUpdate={props.onUpdateSend}
|
||||||
|
onDelete={props.onDeleteSend}
|
||||||
|
onBulkDelete={props.onBulkDeleteSends}
|
||||||
|
uploadingSendFileName={props.uploadingSendFileName}
|
||||||
|
sendUploadPercent={props.sendUploadPercent}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path="/vault/totp">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path="/vault">
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<VaultPage
|
||||||
|
ciphers={props.decryptedCiphers}
|
||||||
|
folders={props.decryptedFolders}
|
||||||
|
loading={props.ciphersLoading || props.foldersLoading}
|
||||||
|
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||||
|
onRefresh={props.onRefreshVault}
|
||||||
|
onCreate={props.onCreateVaultItem}
|
||||||
|
onUpdate={props.onUpdateVaultItem}
|
||||||
|
onDelete={props.onDeleteVaultItem}
|
||||||
|
onArchive={props.onArchiveVaultItem}
|
||||||
|
onUnarchive={props.onUnarchiveVaultItem}
|
||||||
|
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||||
|
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||||
|
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||||
|
onBulkArchive={props.onBulkArchiveVaultItems}
|
||||||
|
onBulkUnarchive={props.onBulkUnarchiveVaultItems}
|
||||||
|
onBulkMove={props.onBulkMoveVaultItems}
|
||||||
|
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
onCreateFolder={props.onCreateFolder}
|
||||||
|
onDeleteFolder={props.onDeleteFolder}
|
||||||
|
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||||
|
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||||
|
downloadingAttachmentKey={props.downloadingAttachmentKey}
|
||||||
|
attachmentDownloadPercent={props.attachmentDownloadPercent}
|
||||||
|
uploadingAttachmentName={props.uploadingAttachmentName}
|
||||||
|
attachmentUploadPercent={props.attachmentUploadPercent}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Route>
|
||||||
|
<Route path={props.settingsAccountRoute}>
|
||||||
|
{props.profile && (
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<SettingsPage
|
||||||
|
profile={props.profile}
|
||||||
|
totpEnabled={props.totpEnabled}
|
||||||
|
onChangePassword={props.onChangePassword}
|
||||||
|
onSavePasswordHint={props.onSavePasswordHint}
|
||||||
|
onEnableTotp={props.onEnableTotp}
|
||||||
|
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||||
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
<Route path="/settings">
|
||||||
|
{props.profile && (
|
||||||
|
<section className="card mobile-settings-card">
|
||||||
|
<div className="mobile-settings-links">
|
||||||
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
|
<SettingsIcon size={18} />
|
||||||
|
<span>{t('nav_account_settings')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/security/devices" className="mobile-settings-link">
|
||||||
|
<Shield size={18} />
|
||||||
|
<span>{t('nav_device_management')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href={props.importRoute} className="mobile-settings-link">
|
||||||
|
<ArrowUpDown size={18} />
|
||||||
|
<span>{t('nav_import_export')}</span>
|
||||||
|
</Link>
|
||||||
|
{props.profile.role === 'admin' && (
|
||||||
|
<Link href="/admin" className="mobile-settings-link">
|
||||||
|
<ShieldUser size={18} />
|
||||||
|
<span>{t('nav_admin_panel')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{props.profile.role === 'admin' && (
|
||||||
|
<Link href="/backup" className="mobile-settings-link">
|
||||||
|
<Cloud size={18} />
|
||||||
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||||
|
<LogOut size={14} className="btn-icon" />
|
||||||
|
{t('txt_sign_out')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</Route>
|
||||||
|
<Route path="/security/devices">
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<SecurityDevicesPage
|
||||||
|
devices={props.authorizedDevices}
|
||||||
|
loading={props.authorizedDevicesLoading}
|
||||||
|
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||||
|
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||||
|
onRemoveDevice={props.onRemoveDevice}
|
||||||
|
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||||
|
onRemoveAll={props.onRemoveAllDevices}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
<Route path="/admin">
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<AdminPage
|
||||||
|
currentUserId={props.profile?.id || ''}
|
||||||
|
users={props.users}
|
||||||
|
invites={props.invites}
|
||||||
|
onRefresh={props.onRefreshAdmin}
|
||||||
|
onCreateInvite={props.onCreateInvite}
|
||||||
|
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||||
|
onToggleUserStatus={props.onToggleUserStatus}
|
||||||
|
onDeleteUser={props.onDeleteUser}
|
||||||
|
onRevokeInvite={props.onRevokeInvite}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</Route>
|
||||||
|
{importRoutePaths.map((path) => (
|
||||||
|
<Route key={path} path={path}>
|
||||||
|
{renderImportPageRoute()}
|
||||||
|
</Route>
|
||||||
|
))}
|
||||||
|
<Route path="/help">
|
||||||
|
<LegacyBackupRedirect onNavigate={props.onNavigate} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/backup">
|
||||||
|
{props.profile?.role === 'admin' ? (
|
||||||
|
<div className="stack">
|
||||||
|
{props.mobileLayout && (
|
||||||
|
<div className="mobile-settings-subhead">
|
||||||
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||||
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<BackupCenterPage
|
||||||
|
currentUserId={props.profile?.id || null}
|
||||||
|
onExport={props.onExportBackup}
|
||||||
|
onImport={props.onImportBackup}
|
||||||
|
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
|
||||||
|
onLoadSettings={props.onLoadBackupSettings}
|
||||||
|
onListRemoteBackups={props.onListRemoteBackups}
|
||||||
|
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
||||||
|
onInspectRemoteBackup={props.onInspectRemoteBackup}
|
||||||
|
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
||||||
|
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
|
||||||
|
onSaveSettings={props.onSaveBackupSettings}
|
||||||
|
onRunRemoteBackup={props.onRunRemoteBackup}
|
||||||
|
onNotify={props.onNotify}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Route>
|
||||||
|
</Switch>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,15 +13,19 @@ interface RegisterValues {
|
|||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
password2: string;
|
password2: string;
|
||||||
|
passwordHint: string;
|
||||||
inviteCode: string;
|
inviteCode: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthViewsProps {
|
interface AuthViewsProps {
|
||||||
mode: 'login' | 'register' | 'locked';
|
mode: 'login' | 'register' | 'locked';
|
||||||
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
|
unlockReady: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
emailForLock: string;
|
emailForLock: string;
|
||||||
|
loginHintLoading: boolean;
|
||||||
onChangeLogin: (next: LoginValues) => void;
|
onChangeLogin: (next: LoginValues) => void;
|
||||||
onChangeRegister: (next: RegisterValues) => void;
|
onChangeRegister: (next: RegisterValues) => void;
|
||||||
onChangeUnlock: (password: string) => void;
|
onChangeUnlock: (password: string) => void;
|
||||||
@@ -31,6 +35,8 @@ interface AuthViewsProps {
|
|||||||
onGotoLogin: () => void;
|
onGotoLogin: () => void;
|
||||||
onGotoRegister: () => void;
|
onGotoRegister: () => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
|
onTogglePasswordHint: () => void;
|
||||||
|
onShowLockedPasswordHint: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordField(props: {
|
function PasswordField(props: {
|
||||||
@@ -38,6 +44,7 @@ function PasswordField(props: {
|
|||||||
value: string;
|
value: string;
|
||||||
onInput: (v: string) => void;
|
onInput: (v: string) => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
autoComplete?: string;
|
||||||
}) {
|
}) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +57,7 @@ function PasswordField(props: {
|
|||||||
value={props.value}
|
value={props.value}
|
||||||
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => props.onInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
autoFocus={props.autoFocus}
|
autoFocus={props.autoFocus}
|
||||||
|
autoComplete={props.autoComplete}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
<button type="button" className="eye-btn" onClick={() => setShow((v) => !v)}>
|
||||||
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
{show ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
@@ -60,26 +68,50 @@ function PasswordField(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthViews(props: AuthViewsProps) {
|
export default function AuthViews(props: AuthViewsProps) {
|
||||||
|
const loginBusy = props.pendingAction === 'login';
|
||||||
|
const registerBusy = props.pendingAction === 'register';
|
||||||
|
const unlockBusy = props.pendingAction === 'unlock';
|
||||||
|
|
||||||
if (props.mode === 'locked') {
|
if (props.mode === 'locked') {
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
<StandalonePageFrame title={t('txt_unlock_vault')}>
|
||||||
<p className="muted standalone-muted">{props.emailForLock}</p>
|
<form
|
||||||
<PasswordField
|
onSubmit={(e) => {
|
||||||
label={t('txt_master_password')}
|
e.preventDefault();
|
||||||
value={props.unlockPassword}
|
props.onSubmitUnlock();
|
||||||
autoFocus
|
}}
|
||||||
onInput={props.onChangeUnlock}
|
>
|
||||||
/>
|
<p className="muted standalone-muted">{props.emailForLock}</p>
|
||||||
<button type="button" className="btn btn-primary full" onClick={props.onSubmitUnlock}>
|
<input type="text" value={props.emailForLock} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||||
<Unlock size={16} className="btn-icon" />
|
<PasswordField
|
||||||
{t('txt_unlock')}
|
label={t('txt_master_password')}
|
||||||
</button>
|
value={props.unlockPassword}
|
||||||
<div className="or">{t('txt_or')}</div>
|
autoFocus
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}>
|
autoComplete="current-password"
|
||||||
<LogOut size={16} className="btn-icon" />
|
onInput={props.onChangeUnlock}
|
||||||
{t('txt_log_out')}
|
/>
|
||||||
</button>
|
<div className="auth-support-row">
|
||||||
|
<span />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="auth-link-btn"
|
||||||
|
onClick={props.onShowLockedPasswordHint}
|
||||||
|
disabled={unlockBusy}
|
||||||
|
>
|
||||||
|
{t('txt_show_password_hint')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
|
||||||
|
<Unlock size={16} className="btn-icon" />
|
||||||
|
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||||
|
</button>
|
||||||
|
<div className="or">{t('txt_or')}</div>
|
||||||
|
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||||
|
<LogOut size={16} className="btn-icon" />
|
||||||
|
{t('txt_log_out')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</StandalonePageFrame>
|
</StandalonePageFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -89,56 +121,80 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_create_account')}>
|
<StandalonePageFrame title={t('txt_create_account')}>
|
||||||
<label className="field">
|
<form
|
||||||
<span>{t('txt_name')}</span>
|
onSubmit={(e) => {
|
||||||
<input
|
e.preventDefault();
|
||||||
className="input"
|
props.onSubmitRegister();
|
||||||
value={props.registerValues.name}
|
}}
|
||||||
onInput={(e) =>
|
>
|
||||||
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
|
<label className="field">
|
||||||
}
|
<span>{t('txt_name')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.registerValues.name}
|
||||||
|
autoComplete="name"
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChangeRegister({ ...props.registerValues, name: (e.currentTarget as HTMLInputElement).value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_email')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={props.registerValues.email}
|
||||||
|
autoComplete="email"
|
||||||
|
onInput={(e) =>
|
||||||
|
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<PasswordField
|
||||||
|
label={t('txt_master_password')}
|
||||||
|
value={props.registerValues.password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
|
||||||
/>
|
/>
|
||||||
</label>
|
<PasswordField
|
||||||
<label className="field">
|
label={t('txt_confirm_master_password')}
|
||||||
<span>{t('txt_email')}</span>
|
value={props.registerValues.password2}
|
||||||
<input
|
autoComplete="new-password"
|
||||||
className="input"
|
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
|
||||||
type="email"
|
|
||||||
value={props.registerValues.email}
|
|
||||||
onInput={(e) =>
|
|
||||||
props.onChangeRegister({ ...props.registerValues, email: (e.currentTarget as HTMLInputElement).value })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<label className="field">
|
||||||
<PasswordField
|
<span>{t('txt_password_hint_optional')}</span>
|
||||||
label={t('txt_master_password')}
|
<input
|
||||||
value={props.registerValues.password}
|
className="input"
|
||||||
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password: v })}
|
maxLength={120}
|
||||||
/>
|
value={props.registerValues.passwordHint}
|
||||||
<PasswordField
|
placeholder={t('txt_password_hint_register_placeholder')}
|
||||||
label={t('txt_confirm_master_password')}
|
onInput={(e) =>
|
||||||
value={props.registerValues.password2}
|
props.onChangeRegister({ ...props.registerValues, passwordHint: (e.currentTarget as HTMLInputElement).value })
|
||||||
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
|
}
|
||||||
/>
|
/>
|
||||||
<label className="field">
|
</label>
|
||||||
<span>{t('txt_invite_code_optional')}</span>
|
<label className="field">
|
||||||
<input
|
<span>{t('txt_invite_code_optional')}</span>
|
||||||
className="input"
|
<input
|
||||||
value={props.registerValues.inviteCode}
|
className="input"
|
||||||
onInput={(e) =>
|
value={props.registerValues.inviteCode}
|
||||||
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
autoComplete="off"
|
||||||
}
|
onInput={(e) =>
|
||||||
/>
|
props.onChangeRegister({ ...props.registerValues, inviteCode: (e.currentTarget as HTMLInputElement).value })
|
||||||
</label>
|
}
|
||||||
<button type="button" className="btn btn-primary full" onClick={props.onSubmitRegister}>
|
/>
|
||||||
<UserPlus size={16} className="btn-icon" />
|
</label>
|
||||||
{t('txt_create_account')}
|
<button type="submit" className="btn btn-primary full" disabled={registerBusy}>
|
||||||
</button>
|
<UserPlus size={16} className="btn-icon" />
|
||||||
<div className="or">{t('txt_or')}</div>
|
{registerBusy ? t('txt_registering') : t('txt_create_account')}
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}>
|
</button>
|
||||||
<ArrowLeft size={16} className="btn-icon" />
|
<div className="or">{t('txt_or')}</div>
|
||||||
{t('txt_back_to_login')}
|
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={registerBusy}>
|
||||||
</button>
|
<ArrowLeft size={16} className="btn-icon" />
|
||||||
|
{t('txt_back_to_login')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</StandalonePageFrame>
|
</StandalonePageFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -147,30 +203,52 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_log_in')}>
|
<StandalonePageFrame title={t('txt_log_in')}>
|
||||||
<label className="field">
|
<form
|
||||||
<span>{t('txt_email')}</span>
|
onSubmit={(e) => {
|
||||||
<input
|
e.preventDefault();
|
||||||
className="input"
|
props.onSubmitLogin();
|
||||||
type="email"
|
}}
|
||||||
value={props.loginValues.email}
|
>
|
||||||
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
<label className="field">
|
||||||
|
<span>{t('txt_email')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={props.loginValues.email}
|
||||||
|
autoComplete="username"
|
||||||
|
onInput={(e) => props.onChangeLogin({ ...props.loginValues, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<PasswordField
|
||||||
|
label={t('txt_master_password')}
|
||||||
|
value={props.loginValues.password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
<div className="auth-support-row">
|
||||||
<PasswordField
|
<span />
|
||||||
label={t('txt_master_password')}
|
<button
|
||||||
value={props.loginValues.password}
|
type="button"
|
||||||
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
|
className="auth-link-btn"
|
||||||
autoFocus
|
onClick={props.onTogglePasswordHint}
|
||||||
/>
|
disabled={loginBusy || !props.loginValues.email.trim()}
|
||||||
<button type="button" className="btn btn-primary full" onClick={props.onSubmitLogin}>
|
>
|
||||||
<LogIn size={16} className="btn-icon" />
|
{props.loginHintLoading
|
||||||
{t('txt_log_in')}
|
? t('txt_loading_password_hint')
|
||||||
</button>
|
: t('txt_show_password_hint')}
|
||||||
<div className="or">{t('txt_or')}</div>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}>
|
</div>
|
||||||
<UserPlus size={16} className="btn-icon" />
|
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
|
||||||
{t('txt_create_account')}
|
<LogIn size={16} className="btn-icon" />
|
||||||
</button>
|
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||||
|
</button>
|
||||||
|
<div className="or">{t('txt_or')}</div>
|
||||||
|
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
||||||
|
<UserPlus size={16} className="btn-icon" />
|
||||||
|
{t('txt_create_account')}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</StandalonePageFrame>
|
</StandalonePageFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,40 +1,144 @@
|
|||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { TriangleAlert } from 'lucide-preact';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
variant?: 'default' | 'warning';
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
|
hideCancel?: boolean;
|
||||||
|
confirmDisabled?: boolean;
|
||||||
|
cancelDisabled?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
children?: ComponentChildren;
|
children?: ComponentChildren;
|
||||||
afterActions?: ComponentChildren;
|
afterActions?: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementDialogBodyLock() {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const body = document.body;
|
||||||
|
const nextCount = Number(body.dataset.dialogCount || '0') + 1;
|
||||||
|
body.dataset.dialogCount = String(nextCount);
|
||||||
|
body.classList.add('dialog-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementDialogBodyLock() {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const body = document.body;
|
||||||
|
const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1);
|
||||||
|
if (nextCount === 0) {
|
||||||
|
delete body.dataset.dialogCount;
|
||||||
|
body.classList.remove('dialog-open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.dataset.dialogCount = String(nextCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
incrementDialogBodyLock();
|
||||||
|
return () => decrementDialogBodyLock();
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !onCancel || typeof window === 'undefined') return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [active, onCancel]);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
if (!props.open) return null;
|
const [present, setPresent] = useState(props.open);
|
||||||
return (
|
const [closing, setClosing] = useState(false);
|
||||||
<div className="dialog-mask">
|
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
|
||||||
<div className="dialog-card">
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.open) {
|
||||||
|
setPresent(true);
|
||||||
|
setClosing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!present) return;
|
||||||
|
setClosing(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPresent(false);
|
||||||
|
setClosing(false);
|
||||||
|
}, 240);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [props.open, present]);
|
||||||
|
|
||||||
|
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||||
|
|
||||||
|
if (!present || typeof document === 'undefined') return null;
|
||||||
|
return createPortal((
|
||||||
|
<div
|
||||||
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={props.title}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (props.confirmDisabled || closing) return;
|
||||||
|
props.onConfirm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.variant === 'warning' ? (
|
||||||
|
<>
|
||||||
|
<div className="dialog-warning-strip" aria-hidden="true" />
|
||||||
|
<div className="dialog-warning-head">
|
||||||
|
<div className="dialog-warning-badge" aria-hidden="true">
|
||||||
|
<TriangleAlert size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<h3 className="dialog-title">{props.title}</h3>
|
<h3 className="dialog-title">{props.title}</h3>
|
||||||
<div className="dialog-message">{props.message}</div>
|
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||||
{props.children}
|
{props.children}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||||
onClick={props.onConfirm}
|
disabled={props.confirmDisabled}
|
||||||
>
|
>
|
||||||
{props.confirmText || t('txt_yes')}
|
{props.confirmText || t('txt_yes')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
|
{!props.hideCancel && (
|
||||||
{props.cancelText || t('txt_no')}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="btn btn-secondary dialog-btn"
|
||||||
|
disabled={props.cancelDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (props.cancelDisabled) return;
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.cancelText || t('txt_no')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{props.afterActions}
|
{props.afterActions}
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
), document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Cloud } from 'lucide-preact';
|
|
||||||
import { t } from '@/lib/i18n';
|
|
||||||
|
|
||||||
export default function HelpPage() {
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
<section className="card">
|
|
||||||
<h3>{t('backup_strategy_title')}</h3>
|
|
||||||
<div className="empty" style={{ minHeight: 180 }}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Cloud size={34} style={{ color: '#64748b', marginBottom: 8 }} />
|
|
||||||
<div>{t('backup_strategy_under_construction')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { ArrowUpDown } from 'lucide-preact';
|
|
||||||
import { t } from '@/lib/i18n';
|
|
||||||
|
|
||||||
export default function ImportExportPage() {
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
<section className="card">
|
|
||||||
<h3>{t('import_export_title')}</h3>
|
|
||||||
<div className="empty" style={{ minHeight: 180 }}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<ArrowUpDown size={34} style={{ color: '#64748b', marginBottom: 8 }} />
|
|
||||||
<div>{t('import_export_under_construction')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,881 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
|
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
|
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
|
import {
|
||||||
|
type EncryptedJsonMode,
|
||||||
|
EXPORT_FORMATS,
|
||||||
|
type ExportFormatId,
|
||||||
|
type ExportRequest,
|
||||||
|
} from '@/lib/export-formats';
|
||||||
|
import {
|
||||||
|
parseImportPayloadBySource,
|
||||||
|
} from '@/lib/import-formats';
|
||||||
|
import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources';
|
||||||
|
import {
|
||||||
|
type BitwardenJsonInput,
|
||||||
|
normalizeBitwardenEncryptedAccountImport,
|
||||||
|
normalizeBitwardenImport,
|
||||||
|
} from '@/lib/import-formats-bitwarden';
|
||||||
|
import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Folder } from '@/lib/types';
|
||||||
|
|
||||||
|
configureZipJs({ useWebWorkers: false });
|
||||||
|
|
||||||
|
export interface ImportAttachmentFile {
|
||||||
|
sourceCipherId: string | null;
|
||||||
|
sourceCipherIndex: number | null;
|
||||||
|
fileName: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportPageProps {
|
||||||
|
onImport: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
onImportEncryptedRaw: (
|
||||||
|
payload: CiphersImportPayload,
|
||||||
|
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||||
|
attachments?: ImportAttachmentFile[]
|
||||||
|
) => Promise<ImportResultSummary>;
|
||||||
|
accountKeys?: { encB64: string; macB64: string } | null;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
folders: Folder[];
|
||||||
|
onExport: (request: ExportRequest) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResultSummary {
|
||||||
|
totalItems: number;
|
||||||
|
folderCount: number;
|
||||||
|
typeCounts: Array<{ label: string; count: number }>;
|
||||||
|
attachmentCount: number;
|
||||||
|
importedAttachmentCount: number;
|
||||||
|
failedAttachments: Array<{ fileName: string; reason: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||||
|
encrypted: true;
|
||||||
|
passwordProtected: true;
|
||||||
|
salt?: string;
|
||||||
|
kdfIterations?: number;
|
||||||
|
kdfMemory?: number;
|
||||||
|
kdfParallelism?: number;
|
||||||
|
kdfType?: number;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
|
||||||
|
'bitwarden_json',
|
||||||
|
'bitwarden_csv',
|
||||||
|
'bitwarden_zip',
|
||||||
|
'nodewarden_json',
|
||||||
|
'onepassword_1pux',
|
||||||
|
'onepassword_1pif',
|
||||||
|
'onepassword_mac_csv',
|
||||||
|
'onepassword_win_csv',
|
||||||
|
'protonpass_json',
|
||||||
|
'chrome',
|
||||||
|
'edge',
|
||||||
|
'brave',
|
||||||
|
'opera',
|
||||||
|
'vivaldi',
|
||||||
|
'firefox_csv',
|
||||||
|
'safari_csv',
|
||||||
|
'lastpass',
|
||||||
|
'dashlane_csv',
|
||||||
|
'dashlane_json',
|
||||||
|
'keepass_xml',
|
||||||
|
'keepassx_csv',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPasswordProtectedExport(value: unknown): value is BitwardenPasswordProtectedInput {
|
||||||
|
return isRecord(value) && value.encrypted === true && value.passwordProtected === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function derivePasswordProtectedFileKey(
|
||||||
|
parsed: BitwardenPasswordProtectedInput,
|
||||||
|
password: string
|
||||||
|
): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||||
|
const salt = String(parsed.salt || '').trim();
|
||||||
|
const iterations = Number(parsed.kdfIterations || 0);
|
||||||
|
const kdfType = Number(parsed.kdfType);
|
||||||
|
if (!salt || !Number.isFinite(iterations) || iterations <= 0) {
|
||||||
|
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyMaterial: Uint8Array;
|
||||||
|
if (kdfType === 0) {
|
||||||
|
keyMaterial = await pbkdf2(password, salt, iterations, 32);
|
||||||
|
} else if (kdfType === 1) {
|
||||||
|
const memoryMiB = Number(parsed.kdfMemory || 0);
|
||||||
|
const parallelism = Number(parsed.kdfParallelism || 0);
|
||||||
|
if (!Number.isFinite(memoryMiB) || memoryMiB <= 0 || !Number.isFinite(parallelism) || parallelism <= 0) {
|
||||||
|
throw new Error(t('txt_invalid_argon2id_params'));
|
||||||
|
}
|
||||||
|
const memoryKiB = Math.floor(memoryMiB * 1024);
|
||||||
|
const maxmem = memoryKiB * 1024 + 1024 * 1024;
|
||||||
|
keyMaterial = await argon2idAsync(new TextEncoder().encode(password), new TextEncoder().encode(salt), {
|
||||||
|
t: Math.floor(iterations),
|
||||||
|
m: memoryKiB,
|
||||||
|
p: Math.floor(parallelism),
|
||||||
|
dkLen: 32,
|
||||||
|
maxmem,
|
||||||
|
asyncTick: 10,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error(t('txt_unsupported_kdf_type', { type: String(kdfType) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = await hkdfExpand(keyMaterial, 'enc', 32);
|
||||||
|
const mac = await hkdfExpand(keyMaterial, 'mac', 32);
|
||||||
|
return { enc, mac };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptPasswordProtectedExport(parsed: BitwardenPasswordProtectedInput, password: string): Promise<unknown> {
|
||||||
|
if (!parsed.encKeyValidation_DO_NOT_EDIT || !parsed.data) {
|
||||||
|
throw new Error(t('txt_import_invalid_password_protected_file'));
|
||||||
|
}
|
||||||
|
const pass = String(password || '').trim();
|
||||||
|
if (!pass) {
|
||||||
|
throw new Error(t('txt_import_file_password_required'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await derivePasswordProtectedFileKey(parsed, pass);
|
||||||
|
try {
|
||||||
|
await decryptStr(parsed.encKeyValidation_DO_NOT_EDIT, key.enc, key.mac);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_invalid_file_password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainJson = await decryptStr(parsed.data, key.enc, key.mac);
|
||||||
|
try {
|
||||||
|
return JSON.parse(plainJson);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_decrypt_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isZipPayload(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
|
||||||
|
const unzipped = unzipSync(bytes);
|
||||||
|
const fileNames = Object.keys(unzipped);
|
||||||
|
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
|
||||||
|
const preferred = source === 'onepassword_1pux' ? ['export.data', 'export.json'] : ['protonpass.json', 'export.json'];
|
||||||
|
for (const p of preferred) {
|
||||||
|
const hit = fileNames.find((n) => n.toLowerCase().endsWith(p.toLowerCase()));
|
||||||
|
if (hit) return strFromU8(unzipped[hit]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstJson = fileNames.find((n) => n.toLowerCase().endsWith('.json') || n.toLowerCase().endsWith('.data'));
|
||||||
|
if (firstJson) return strFromU8(unzipped[firstJson]);
|
||||||
|
throw new Error(t('txt_import_no_json_found_in_zip'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImportText(file: File, source: ImportSourceId): Promise<string> {
|
||||||
|
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
|
||||||
|
return file.text();
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
if (isZipPayload(bytes)) return readZipText(bytes, source);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingPasswordImportContext {
|
||||||
|
parsed: BitwardenPasswordProtectedInput;
|
||||||
|
source: 'bitwarden_json' | 'nodewarden_json' | 'bitwarden_zip';
|
||||||
|
attachments: ImportAttachmentFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZipNeedsPasswordError extends Error {}
|
||||||
|
class ZipInvalidPasswordError extends Error {}
|
||||||
|
|
||||||
|
function looksLikeZipPasswordError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? String(error.message || '').toLowerCase() : '';
|
||||||
|
if (!message) return false;
|
||||||
|
return message.includes('password') || message.includes('encrypted');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBitwardenZipPayload(
|
||||||
|
file: File,
|
||||||
|
passwordRaw: string
|
||||||
|
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
|
||||||
|
const password = String(passwordRaw || '').trim();
|
||||||
|
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
|
||||||
|
try {
|
||||||
|
const entries = await reader.getEntries();
|
||||||
|
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
|
||||||
|
|
||||||
|
let jsonText = '';
|
||||||
|
const attachments: ImportAttachmentFile[] = [];
|
||||||
|
const options = password ? { password } : undefined;
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.directory) continue;
|
||||||
|
const name = String(entry.filename || '').trim().replace(/\\/g, '/');
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const bytes = await entry.getData(new Uint8ArrayWriter(), options);
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
if (lower === 'data.json') {
|
||||||
|
jsonText = new TextDecoder().decode(bytes);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i);
|
||||||
|
if (!attachmentMatch) continue;
|
||||||
|
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
|
||||||
|
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
|
||||||
|
attachments.push({
|
||||||
|
sourceCipherId,
|
||||||
|
sourceCipherIndex: null,
|
||||||
|
fileName,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jsonText) throw new Error(t('txt_import_data_json_not_found'));
|
||||||
|
return { jsonText, attachments };
|
||||||
|
} catch (error) {
|
||||||
|
if (looksLikeZipPasswordError(error)) {
|
||||||
|
if (!password) throw new ZipNeedsPasswordError(t('txt_import_zip_password_required'));
|
||||||
|
throw new ZipInvalidPasswordError(t('txt_import_invalid_zip_password'));
|
||||||
|
}
|
||||||
|
if (!password && error instanceof Error && /invalid|corrupt|unsupported/.test(error.message.toLowerCase())) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await reader.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodeWardenAttachmentArray(raw: unknown): ImportAttachmentFile[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
const out: ImportAttachmentFile[] = [];
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (!entry || typeof entry !== 'object') continue;
|
||||||
|
const row = entry as Record<string, unknown>;
|
||||||
|
const fileName = String(row.fileName || '').trim() || 'attachment.bin';
|
||||||
|
const base64 = String(row.data || '').trim();
|
||||||
|
if (!base64) continue;
|
||||||
|
try {
|
||||||
|
const bytes = base64ToBytes(base64);
|
||||||
|
const sourceCipherId = String(row.cipherId || '').trim() || null;
|
||||||
|
const indexRaw = Number(row.cipherIndex);
|
||||||
|
out.push({
|
||||||
|
sourceCipherId,
|
||||||
|
sourceCipherIndex: Number.isFinite(indexRaw) ? indexRaw : null,
|
||||||
|
fileName,
|
||||||
|
bytes,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// skip malformed attachment row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys, onNotify, folders, onExport }: ImportPageProps) {
|
||||||
|
const [source, setSource] = useState<ImportSourceId>('bitwarden_json');
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false);
|
||||||
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
|
const [importPassword, setImportPassword] = useState('');
|
||||||
|
const [pendingPasswordImport, setPendingPasswordImport] = useState<PendingPasswordImportContext | null>(null);
|
||||||
|
const [zipPasswordDialogOpen, setZipPasswordDialogOpen] = useState(false);
|
||||||
|
const [zipImportPassword, setZipImportPassword] = useState('');
|
||||||
|
const [pendingZipFile, setPendingZipFile] = useState<File | null>(null);
|
||||||
|
const [isZipPasswordSubmitting, setIsZipPasswordSubmitting] = useState(false);
|
||||||
|
const [folderMode, setFolderMode] = useState<'original' | 'none' | 'target'>('original');
|
||||||
|
const [targetFolderId, setTargetFolderId] = useState('');
|
||||||
|
const [exportFormat, setExportFormat] = useState<ExportFormatId>('bitwarden_json');
|
||||||
|
const [encryptedJsonMode, setEncryptedJsonMode] = useState<EncryptedJsonMode>('account');
|
||||||
|
const [exportPassword, setExportPassword] = useState('');
|
||||||
|
const [zipPassword, setZipPassword] = useState('');
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||||
|
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||||
|
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||||
|
|
||||||
|
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
|
||||||
|
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||||
|
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||||
|
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||||
|
|
||||||
|
async function runBitwardenJsonImport(parsed: unknown, attachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||||
|
if (isRecord(parsed) && parsed.encrypted === true) {
|
||||||
|
const accountEncrypted = parsed as BitwardenJsonInput;
|
||||||
|
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||||
|
throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
}
|
||||||
|
const validation = String(accountEncrypted.encKeyValidation_DO_NOT_EDIT || '').trim();
|
||||||
|
if (!validation) throw new Error(t('txt_invalid_encrypted_export'));
|
||||||
|
const accountEncKey = base64ToBytes(accountKeys.encB64);
|
||||||
|
const accountMacKey = base64ToBytes(accountKeys.macB64);
|
||||||
|
try {
|
||||||
|
await decryptStr(validation, accountEncKey, accountMacKey);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_export_belongs_to_another_account'));
|
||||||
|
}
|
||||||
|
return onImportEncryptedRaw(
|
||||||
|
normalizeBitwardenEncryptedAccountImport(accountEncrypted),
|
||||||
|
{
|
||||||
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
attachments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return onImport(
|
||||||
|
normalizeBitwardenImport(parsed),
|
||||||
|
{
|
||||||
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
attachments
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractNodeWardenAttachments(parsed: unknown): Promise<ImportAttachmentFile[]> {
|
||||||
|
if (!isRecord(parsed)) return [];
|
||||||
|
const direct = parseNodeWardenAttachmentArray(parsed.nodewardenAttachments);
|
||||||
|
if (direct.length) return direct;
|
||||||
|
|
||||||
|
const encryptedPayload = String(parsed.nodewardenAttachmentsEnc || '').trim();
|
||||||
|
if (!encryptedPayload) return [];
|
||||||
|
if (!accountKeys?.encB64 || !accountKeys?.macB64) {
|
||||||
|
throw new Error(t('txt_vault_key_unavailable'));
|
||||||
|
}
|
||||||
|
const accountEnc = base64ToBytes(accountKeys.encB64);
|
||||||
|
const accountMac = base64ToBytes(accountKeys.macB64);
|
||||||
|
const plain = await decryptStr(encryptedPayload, accountEnc, accountMac);
|
||||||
|
const unpacked = JSON.parse(plain) as Record<string, unknown>;
|
||||||
|
return parseNodeWardenAttachmentArray(unpacked.nodewardenAttachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNodeWardenJsonImport(parsed: unknown, extraAttachments: ImportAttachmentFile[] = []): Promise<ImportResultSummary> {
|
||||||
|
const bundled = await extractNodeWardenAttachments(parsed);
|
||||||
|
return runBitwardenJsonImport(parsed, [...bundled, ...extraAttachments]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPasswordProtectedImport(ctx: PendingPasswordImportContext): Promise<ImportResultSummary> {
|
||||||
|
const parsed = await decryptPasswordProtectedExport(ctx.parsed, importPassword);
|
||||||
|
if (ctx.source === 'nodewarden_json') {
|
||||||
|
return runNodeWardenJsonImport(parsed, ctx.attachments);
|
||||||
|
}
|
||||||
|
return runBitwardenJsonImport(parsed, ctx.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!file) {
|
||||||
|
onNotify('error', t('txt_please_select_a_file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (source === 'bitwarden_zip') {
|
||||||
|
try {
|
||||||
|
const bundle = await readBitwardenZipPayload(file, '');
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bundle.jsonText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
|
}
|
||||||
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source: 'bitwarden_zip',
|
||||||
|
attachments: bundle.attachments,
|
||||||
|
});
|
||||||
|
setImportPassword('');
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||||
|
setImportSummary(summary);
|
||||||
|
setFile(null);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZipNeedsPasswordError) {
|
||||||
|
setPendingZipFile(file);
|
||||||
|
setZipImportPassword('');
|
||||||
|
setZipPasswordDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await readImportText(file, source);
|
||||||
|
if (source === 'bitwarden_json' || source === 'nodewarden_json') {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
|
}
|
||||||
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source,
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
setImportPassword('');
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary =
|
||||||
|
source === 'nodewarden_json'
|
||||||
|
? await runNodeWardenJsonImport(parsed)
|
||||||
|
: await runBitwardenJsonImport(parsed);
|
||||||
|
setImportSummary(summary);
|
||||||
|
} else {
|
||||||
|
const summary = await onImport(
|
||||||
|
parseImportPayloadBySource(source, text),
|
||||||
|
{
|
||||||
|
folderMode,
|
||||||
|
targetFolderId: folderMode === 'target' ? targetFolderId || null : null,
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
setImportSummary(summary);
|
||||||
|
}
|
||||||
|
setFile(null);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordImportConfirm() {
|
||||||
|
if (!pendingPasswordImport) return;
|
||||||
|
setIsPasswordSubmitting(true);
|
||||||
|
try {
|
||||||
|
const summary = await processPasswordProtectedImport(pendingPasswordImport);
|
||||||
|
setImportSummary(summary);
|
||||||
|
setFile(null);
|
||||||
|
setImportPassword('');
|
||||||
|
setPendingPasswordImport(null);
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsPasswordSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleZipPasswordImportConfirm() {
|
||||||
|
if (!pendingZipFile) return;
|
||||||
|
setIsZipPasswordSubmitting(true);
|
||||||
|
try {
|
||||||
|
const bundle = await readBitwardenZipPayload(pendingZipFile, zipImportPassword);
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bundle.jsonText);
|
||||||
|
} catch {
|
||||||
|
throw new Error(t('txt_import_invalid_json_file'));
|
||||||
|
}
|
||||||
|
if (isPasswordProtectedExport(parsed)) {
|
||||||
|
setPendingPasswordImport({
|
||||||
|
parsed,
|
||||||
|
source: 'bitwarden_zip',
|
||||||
|
attachments: bundle.attachments,
|
||||||
|
});
|
||||||
|
setImportPassword('');
|
||||||
|
setPasswordDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
const summary = await runBitwardenJsonImport(parsed, bundle.attachments);
|
||||||
|
setImportSummary(summary);
|
||||||
|
setFile(null);
|
||||||
|
}
|
||||||
|
setZipPasswordDialogOpen(false);
|
||||||
|
setPendingZipFile(null);
|
||||||
|
setZipImportPassword('');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZipInvalidPasswordError) {
|
||||||
|
onNotify('error', t('txt_import_invalid_zip_password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_import_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsZipPasswordSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportNeedsMode =
|
||||||
|
exportFormat === 'bitwarden_encrypted_json' ||
|
||||||
|
exportFormat === 'bitwarden_encrypted_json_zip' ||
|
||||||
|
exportFormat === 'nodewarden_encrypted_json';
|
||||||
|
const exportNeedsFilePassword = exportNeedsMode && encryptedJsonMode === 'password';
|
||||||
|
const exportIsZip = exportFormat === 'bitwarden_json_zip' || exportFormat === 'bitwarden_encrypted_json_zip';
|
||||||
|
|
||||||
|
async function runExportWithMasterPassword(masterPassword: string) {
|
||||||
|
const filePassword = exportPassword.trim();
|
||||||
|
const zipPass = zipPassword.trim();
|
||||||
|
if (exportNeedsFilePassword && !filePassword) {
|
||||||
|
onNotify('error', t('txt_import_file_password_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
try {
|
||||||
|
await onExport({
|
||||||
|
format: exportFormat,
|
||||||
|
encryptedJsonMode: exportNeedsMode ? encryptedJsonMode : undefined,
|
||||||
|
filePassword,
|
||||||
|
zipPassword: exportIsZip ? zipPass : '',
|
||||||
|
masterPassword,
|
||||||
|
});
|
||||||
|
onNotify('success', t('txt_export_completed'));
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_export_failed');
|
||||||
|
onNotify('error', message);
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExportConfirmPassword() {
|
||||||
|
const masterPassword = String(exportAuthPassword || '').trim();
|
||||||
|
if (!masterPassword) {
|
||||||
|
onNotify('error', t('txt_master_password_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runExportWithMasterPassword(masterPassword);
|
||||||
|
if (!isExporting) {
|
||||||
|
setExportAuthPassword('');
|
||||||
|
setExportAuthDialogOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
setExportAuthPassword('');
|
||||||
|
setExportAuthDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="import-export-page">
|
||||||
|
<div className="import-export-panels">
|
||||||
|
<section className="card import-export-panel">
|
||||||
|
<h3>{t('txt_import')}</h3>
|
||||||
|
<p className="backup-inline-note">{t('txt_import_vault_data_hint')}</p>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_format')}</span>
|
||||||
|
<select className="input" value={source} onChange={(e) => setSource((e.currentTarget as HTMLSelectElement).value as ImportSourceId)}>
|
||||||
|
{commonSources.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{otherSources.length > 0 && (
|
||||||
|
<option disabled value="__separator__">
|
||||||
|
--------------------
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{otherSources.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_source_file')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="file"
|
||||||
|
accept={getFileAcceptBySource(source)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = (e.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||||
|
setFile(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_folder_handling')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={folderMode}
|
||||||
|
onChange={(e) => setFolderMode((e.currentTarget as HTMLSelectElement).value as 'original' | 'none' | 'target')}
|
||||||
|
>
|
||||||
|
<option value="original">{t('txt_import_folder_mode_original')}</option>
|
||||||
|
<option value="none">{t('txt_import_folder_mode_none')}</option>
|
||||||
|
<option value="target">{t('txt_import_folder_mode_target')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{folderMode === 'target' && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_target_folder')}</span>
|
||||||
|
<select className="input" value={targetFolderId} onChange={(e) => setTargetFolderId((e.currentTarget as HTMLSelectElement).value)}>
|
||||||
|
<option value="">{t('txt_select_folder_placeholder')}</option>
|
||||||
|
{folders
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => String(a.decName || a.name || '').localeCompare(String(b.decName || b.name || '')))
|
||||||
|
.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.decName || folder.name || folder.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSubmitting || (folderMode === 'target' && !targetFolderId)}
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
>
|
||||||
|
<FileUp size={15} /> {isSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card import-export-panel">
|
||||||
|
<h3>{t('txt_export')}</h3>
|
||||||
|
<p className="backup-inline-note">{t('txt_export_vault_data_hint')}</p>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_format')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = (e.currentTarget as HTMLSelectElement).value as ExportFormatId;
|
||||||
|
setExportFormat(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EXPORT_FORMATS.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{exportNeedsMode && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_encrypted_mode')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={encryptedJsonMode}
|
||||||
|
onChange={(e) => setEncryptedJsonMode((e.currentTarget as HTMLSelectElement).value as EncryptedJsonMode)}
|
||||||
|
>
|
||||||
|
<option value="account">{t('txt_account_verification')}</option>
|
||||||
|
<option value="password">{t('txt_password_verification')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exportNeedsFilePassword && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_file_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={exportPassword}
|
||||||
|
onInput={(e) => setExportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exportIsZip && (
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_zip_password_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={zipPassword}
|
||||||
|
onInput={(e) => setZipPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={isExporting} onClick={() => void handleExport()}>
|
||||||
|
<Download size={15} className="btn-icon" />
|
||||||
|
{isExporting ? t('txt_loading') : t('txt_export')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={exportAuthDialogOpen}
|
||||||
|
title={t('txt_export')}
|
||||||
|
message={t('txt_enter_master_password_to_view_this_item')}
|
||||||
|
confirmText={isExporting ? t('txt_loading') : t('txt_verify')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
onConfirm={() => void handleExportConfirmPassword()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isExporting) return;
|
||||||
|
setExportAuthDialogOpen(false);
|
||||||
|
setExportAuthPassword('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={exportAuthPassword}
|
||||||
|
onInput={(e) => setExportAuthPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={passwordDialogOpen}
|
||||||
|
title={t('txt_import_encrypted_file_title')}
|
||||||
|
message={t('txt_import_encrypted_file_message')}
|
||||||
|
confirmText={isPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
onConfirm={() => void handlePasswordImportConfirm()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isPasswordSubmitting) return;
|
||||||
|
setPasswordDialogOpen(false);
|
||||||
|
setImportPassword('');
|
||||||
|
setPendingPasswordImport(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_file_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={importPassword}
|
||||||
|
onInput={(e) => setImportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={zipPasswordDialogOpen}
|
||||||
|
title={t('txt_import_encrypted_zip_title')}
|
||||||
|
message={t('txt_import_encrypted_zip_message')}
|
||||||
|
confirmText={isZipPasswordSubmitting ? t('txt_loading') : t('txt_import')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
showIcon={false}
|
||||||
|
onConfirm={() => void handleZipPasswordImportConfirm()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (isZipPasswordSubmitting) return;
|
||||||
|
setZipPasswordDialogOpen(false);
|
||||||
|
setZipImportPassword('');
|
||||||
|
setPendingZipFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_zip_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={zipImportPassword}
|
||||||
|
onInput={(e) => setZipImportPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
{importSummary && typeof document !== 'undefined' ? createPortal((
|
||||||
|
<div
|
||||||
|
className="dialog-mask"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
setImportSummary(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="import-summary-close"
|
||||||
|
onClick={() => setImportSummary(null)}
|
||||||
|
aria-label={t('txt_close')}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</button>
|
||||||
|
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
||||||
|
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
||||||
|
{importSummary.attachmentCount > 0 && (
|
||||||
|
<div className="dialog-message">
|
||||||
|
{t('txt_import_attachment_summary', {
|
||||||
|
imported: String(importSummary.importedAttachmentCount),
|
||||||
|
total: String(importSummary.attachmentCount),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{importSummary.failedAttachments.length > 0 && (
|
||||||
|
<div className="import-summary-failed-list">
|
||||||
|
<div className="import-summary-failed-title">
|
||||||
|
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{importSummary.failedAttachments.map((row, index) => (
|
||||||
|
<li key={`${row.fileName}-${index}`}>
|
||||||
|
<strong>{row.fileName}</strong>
|
||||||
|
{`: ${row.reason}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="import-summary-table-wrap">
|
||||||
|
<table className="import-summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t('txt_type')}</th>
|
||||||
|
<th>{t('txt_total')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{importSummary.typeCounts.map((row) => (
|
||||||
|
<tr key={row.label}>
|
||||||
|
<td>{row.label}</td>
|
||||||
|
<td>{row.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr>
|
||||||
|
<td>{t('txt_folder')}</td>
|
||||||
|
<td>{importSummary.folderCount}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary dialog-btn" onClick={() => setImportSummary(null)}>
|
||||||
|
{t('txt_confirm')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
), document.body) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from 'preact/hooks';
|
import { useMemo, useState } from 'preact/hooks';
|
||||||
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
|
import { AlertTriangle, Copy, RefreshCw } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -8,6 +9,9 @@ interface JwtWarningPageProps {
|
|||||||
minLength: number;
|
minLength: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CLOUDFLARE_SETTINGS_URL =
|
||||||
|
'https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings';
|
||||||
|
|
||||||
export default function JwtWarningPage(props: JwtWarningPageProps) {
|
export default function JwtWarningPage(props: JwtWarningPageProps) {
|
||||||
const [seed, setSeed] = useState(0);
|
const [seed, setSeed] = useState(0);
|
||||||
const [copyHint, setCopyHint] = useState('');
|
const [copyHint, setCopyHint] = useState('');
|
||||||
@@ -24,7 +28,8 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
|||||||
const isMissing = props.reason === 'missing';
|
const isMissing = props.reason === 'missing';
|
||||||
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
|
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
|
||||||
const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength });
|
const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength });
|
||||||
const fixStep2 = isMissing ? t('txt_jwt_add_step_2') : t('txt_jwt_replace_step_2');
|
const fixStep2Prefix = isMissing ? t('txt_jwt_add_step_2_prefix') : t('txt_jwt_replace_step_2_prefix');
|
||||||
|
const fixStep2Suffix = isMissing ? t('txt_jwt_add_step_2_suffix') : t('txt_jwt_replace_step_2_suffix');
|
||||||
const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3');
|
const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,10 +41,38 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="jwt-warning-box">
|
<div className="jwt-warning-box">
|
||||||
|
<div className="jwt-warning-label">{t('txt_jwt_what_is')}</div>
|
||||||
|
<p className="jwt-warning-copy">{t('txt_jwt_what_is_body')}</p>
|
||||||
|
|
||||||
<div className="jwt-warning-label">{fixTitle}</div>
|
<div className="jwt-warning-label">{fixTitle}</div>
|
||||||
<ol className="jwt-warning-list">
|
<ol className="jwt-warning-list">
|
||||||
<li>{fixStep1}</li>
|
<li>{fixStep1}</li>
|
||||||
<li>{fixStep2}</li>
|
<li>
|
||||||
|
{fixStep2Prefix}
|
||||||
|
<a
|
||||||
|
href={CLOUDFLARE_SETTINGS_URL}
|
||||||
|
className="jwt-inline-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('txt_settings')}
|
||||||
|
</a>
|
||||||
|
{fixStep2Suffix}
|
||||||
|
<div className="jwt-secret-fields">
|
||||||
|
<div className="jwt-secret-row">
|
||||||
|
<span>{t('txt_jwt_secret_type_label')}</span>
|
||||||
|
<strong>{t('txt_jwt_secret_type_value')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="jwt-secret-row">
|
||||||
|
<span>{t('txt_jwt_secret_name_label')}</span>
|
||||||
|
<strong>JWT_SECRET</strong>
|
||||||
|
</div>
|
||||||
|
<div className="jwt-secret-row">
|
||||||
|
<span>{t('txt_jwt_secret_value_label')}</span>
|
||||||
|
<strong>{t('txt_jwt_secret_value_requirement', { min: props.minLength })}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
<li>{fixStep3}</li>
|
<li>{fixStep3}</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
@@ -55,8 +88,10 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await navigator.clipboard.writeText(generatedSecret);
|
await copyTextToClipboard(generatedSecret, {
|
||||||
setCopyHint(t('txt_copied'));
|
onSuccess: () => setCopyHint(t('txt_copied')),
|
||||||
|
onError: () => setCopyHint(t('txt_copy_failed')),
|
||||||
|
});
|
||||||
window.setTimeout(() => setCopyHint(''), 1500);
|
window.setTimeout(() => setCopyHint(''), 1500);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -74,10 +109,15 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
|
|||||||
|
|
||||||
function generateJwtSecret(length: number): string {
|
function generateJwtSecret(length: number): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
|
||||||
let out = '';
|
let out = '';
|
||||||
for (let i = 0; i < length; i += 1) {
|
const maxUnbiasedByte = Math.floor(256 / chars.length) * chars.length;
|
||||||
out += chars[bytes[i] % chars.length];
|
while (out.length < length) {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
for (const value of bytes) {
|
||||||
|
if (value >= maxUnbiasedByte) continue;
|
||||||
|
out += chars[value % chars.length];
|
||||||
|
if (out.length >= length) break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sendData, setSendData] = useState<any>(null);
|
const [sendData, setSendData] = useState<any>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [downloadPercent, setDownloadPercent] = useState<number | null>(null);
|
||||||
|
|
||||||
async function loadSend(pass?: string): Promise<void> {
|
async function loadSend(pass?: string): Promise<void> {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -48,12 +50,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
async function downloadFile(): Promise<void> {
|
async function downloadFile(): Promise<void> {
|
||||||
if (!sendData?.id || !sendData?.file?.id) return;
|
if (!sendData?.id || !sendData?.file?.id) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setDownloadPercent(null);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
const url = await accessPublicSendFile(sendData.id, sendData.file.id, props.keyPart, password || undefined);
|
||||||
const resp = await fetch(url);
|
const resp = await fetch(url);
|
||||||
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
if (!resp.ok) throw new Error(t('txt_download_failed'));
|
||||||
const encryptedBytes = await resp.arrayBuffer();
|
const encryptedBytes = await readResponseBytesWithProgress(resp, (progress) => setDownloadPercent(progress.percent));
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
if (props.keyPart) {
|
if (props.keyPart) {
|
||||||
try {
|
try {
|
||||||
@@ -66,19 +69,17 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
} else {
|
} else {
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
const obj = URL.createObjectURL(blob);
|
downloadBytesAsFile(
|
||||||
const a = document.createElement('a');
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
a.href = obj;
|
sendData.decFileName || sendData.file?.fileName || t('txt_send_file'),
|
||||||
a.download = sendData.decFileName || sendData.file?.fileName || t('txt_send_file');
|
'application/octet-stream'
|
||||||
document.body.appendChild(a);
|
);
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(obj);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error;
|
const err = e as Error;
|
||||||
setError(err.message || t('txt_download_failed'));
|
setError(err.message || t('txt_download_failed'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setDownloadPercent(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,12 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
{loading && <p className="muted">{t('txt_loading')}</p>}
|
{loading && <p className="muted">{t('txt_loading')}</p>}
|
||||||
|
|
||||||
{!loading && needPassword && (
|
{!loading && needPassword && (
|
||||||
<>
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void loadSend(password);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_password')}</span>
|
<span>{t('txt_password')}</span>
|
||||||
<div className="password-wrap">
|
<div className="password-wrap">
|
||||||
@@ -100,14 +106,15 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
|
autoComplete="current-password"
|
||||||
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => setPassword((e.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void loadSend(password)}>
|
<button type="submit" className="btn btn-primary full" disabled={busy}>
|
||||||
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
|
<Lock size={14} className="btn-icon" /> {t('txt_unlock_send')}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && sendData && (
|
{!loading && sendData && (
|
||||||
@@ -124,7 +131,7 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
<strong>{sendData.decFileName || sendData.file?.fileName || sendData.file?.sizeName || t('txt_encrypted_file')}</strong>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
|
<button type="button" className="btn btn-primary full" disabled={busy} onClick={() => void downloadFile()}>
|
||||||
<Download size={14} className="btn-icon" /> {t('txt_download')}
|
<Download size={14} className="btn-icon" /> {downloadPercent == null ? (busy ? t('txt_downloading') : t('txt_download')) : t('txt_downloading_percent', { percent: downloadPercent })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,52 +16,62 @@ export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
|
<StandalonePageFrame title={t('txt_recover_two_step_login')}>
|
||||||
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
props.onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="muted standalone-muted">{t('txt_use_your_one_time_recovery_code_to_disable_two_step_verification')}</p>
|
||||||
|
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_email')}</span>
|
<span>{t('txt_email')}</span>
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
type="email"
|
|
||||||
value={props.values.email}
|
|
||||||
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_master_password')}</span>
|
|
||||||
<div className="password-wrap">
|
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="email"
|
||||||
value={props.values.password}
|
value={props.values.email}
|
||||||
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
|
autoComplete="username"
|
||||||
|
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
|
</label>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={props.values.password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
|
||||||
|
/>
|
||||||
|
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_recovery_code')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.values.recoveryCode}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="field-grid">
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
<Send size={14} className="btn-icon" />
|
||||||
|
{t('txt_submit')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</form>
|
||||||
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_recovery_code')}</span>
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
value={props.values.recoveryCode}
|
|
||||||
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="field-grid">
|
|
||||||
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
|
|
||||||
<Send size={14} className="btn-icon" />
|
|
||||||
{t('txt_submit')}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
|
|
||||||
<X size={14} className="btn-icon" />
|
|
||||||
{t('txt_cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</StandalonePageFrame>
|
</StandalonePageFrame>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface SecurityDevicesPageProps {
|
|||||||
onRevokeTrust: (device: AuthorizedDevice) => void;
|
onRevokeTrust: (device: AuthorizedDevice) => void;
|
||||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||||
onRevokeAll: () => void;
|
onRevokeAll: () => void;
|
||||||
|
onRemoveAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined): string {
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
@@ -47,7 +48,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
<h3 style={{ margin: 0 }}>{t('txt_device_management')}</h3>
|
||||||
<div className="muted-inline" style={{ marginTop: 4 }}>
|
<div className="muted-inline" style={{ marginTop: 4 }}>
|
||||||
{t('txt_manage_authorized_devices_and_30_day_totp_trusted_sessions')}
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
@@ -59,6 +60,10 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<ShieldOff size={14} className="btn-icon" />
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
{t('txt_revoke_all_trusted')}
|
{t('txt_revoke_all_trusted')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove_all_devices')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -70,6 +75,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tr>
|
<tr>
|
||||||
<th>{t('txt_device')}</th>
|
<th>{t('txt_device')}</th>
|
||||||
<th>{t('txt_type')}</th>
|
<th>{t('txt_type')}</th>
|
||||||
|
<th>{t('txt_status')}</th>
|
||||||
<th>{t('txt_added')}</th>
|
<th>{t('txt_added')}</th>
|
||||||
<th>{t('txt_last_seen')}</th>
|
<th>{t('txt_last_seen')}</th>
|
||||||
<th>{t('txt_trusted_until')}</th>
|
<th>{t('txt_trusted_until')}</th>
|
||||||
@@ -79,14 +85,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{props.devices.map((device) => (
|
{props.devices.map((device) => (
|
||||||
<tr key={device.identifier}>
|
<tr key={device.identifier}>
|
||||||
<td>
|
<td data-label={t('txt_device')}>
|
||||||
<div>{device.name || t('txt_unknown_device')}</div>
|
<div>{device.name || t('txt_unknown_device')}</div>
|
||||||
<div className="muted-inline">{device.identifier}</div>
|
<div className="muted-inline">{device.identifier}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{mapDeviceTypeName(device.type)}</td>
|
<td data-label={t('txt_type')}>{mapDeviceTypeName(device.type)}</td>
|
||||||
<td>{formatDateTime(device.creationDate)}</td>
|
<td data-label={t('txt_status')}>
|
||||||
<td>{formatDateTime(device.revisionDate)}</td>
|
<span className={`device-status-pill ${device.online ? 'online' : 'offline'}`}>
|
||||||
<td>
|
{device.online ? t('txt_online') : t('txt_offline')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-label={t('txt_added')}>{formatDateTime(device.creationDate)}</td>
|
||||||
|
<td data-label={t('txt_last_seen')}>{formatDateTime(device.revisionDate)}</td>
|
||||||
|
<td data-label={t('txt_trusted_until')}>
|
||||||
{device.trusted ? (
|
{device.trusted ? (
|
||||||
<div className="trusted-cell">
|
<div className="trusted-cell">
|
||||||
<Clock3 size={13} />
|
<Clock3 size={13} />
|
||||||
@@ -96,7 +107,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
<span className="muted-inline">{t('txt_not_trusted')}</span>
|
<span className="muted-inline">{t('txt_not_trusted')}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label={t('txt_actions')}>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -117,7 +128,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
))}
|
))}
|
||||||
{!props.loading && props.devices.length === 0 && (
|
{!props.loading && props.devices.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6}>
|
<td colSpan={7}>
|
||||||
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
|
<div className="empty" style={{ minHeight: 80 }}>{t('txt_no_devices_found')}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Send as SendIcon, Trash2 } from 'lucide-preact';
|
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
@@ -11,11 +12,14 @@ interface SendsPageProps {
|
|||||||
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
onUpdate: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||||
onDelete: (send: Send) => Promise<void>;
|
onDelete: (send: Send) => Promise<void>;
|
||||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||||
|
uploadingSendFileName: string;
|
||||||
|
sendUploadPercent: number | null;
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SendTypeFilter = 'all' | 'text' | 'file';
|
type SendTypeFilter = 'all' | 'text' | 'file';
|
||||||
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
const AUTO_COPY_KEY = 'nodewarden.send.auto_copy_link.v1';
|
||||||
|
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||||
|
|
||||||
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
function daysFromNow(iso: string | null | undefined, fallback: number): string {
|
||||||
if (!iso) return String(fallback);
|
if (!iso) return String(fallback);
|
||||||
@@ -58,6 +62,10 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SendsPage(props: SendsPageProps) {
|
export default function SendsPage(props: SendsPageProps) {
|
||||||
|
const getInitialIsMobileLayout = () =>
|
||||||
|
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia(MOBILE_LAYOUT_QUERY).matches
|
||||||
|
: false;
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
const [typeFilter, setTypeFilter] = useState<SendTypeFilter>('all');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
@@ -67,6 +75,9 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
const [draft, setDraft] = useState<SendDraft | null>(null);
|
const [draft, setDraft] = useState<SendDraft | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||||
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
const [autoCopyLink, setAutoCopyLink] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
return localStorage.getItem(AUTO_COPY_KEY) === '1';
|
||||||
@@ -74,6 +85,34 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const sendUploadLabel =
|
||||||
|
props.sendUploadPercent == null
|
||||||
|
? t('txt_uploading_file_named', { name: props.uploadingSendFileName || t('txt_file') })
|
||||||
|
: t('txt_uploading_file_named_percent', {
|
||||||
|
name: props.uploadingSendFileName || t('txt_file'),
|
||||||
|
percent: props.sendUploadPercent,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||||
|
const media = window.matchMedia(MOBILE_LAYOUT_QUERY);
|
||||||
|
const sync = () => setIsMobileLayout(media.matches);
|
||||||
|
sync();
|
||||||
|
if (typeof media.addEventListener === 'function') {
|
||||||
|
media.addEventListener('change', sync);
|
||||||
|
return () => media.removeEventListener('change', sync);
|
||||||
|
}
|
||||||
|
media.addListener(sync);
|
||||||
|
return () => media.removeListener(sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onToggleSidebar = () => {
|
||||||
|
setMobileSidebarOpen((open) => !open);
|
||||||
|
};
|
||||||
|
window.addEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||||
|
return () => window.removeEventListener('nodewarden:toggle-sidebar', onToggleSidebar);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -83,6 +122,19 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
}
|
}
|
||||||
}, [autoCopyLink]);
|
}, [autoCopyLink]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobileLayout) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isEditing) {
|
||||||
|
setMobilePanel('edit');
|
||||||
|
} else if (!selectedId) {
|
||||||
|
setMobilePanel('list');
|
||||||
|
}
|
||||||
|
}, [isMobileLayout, isEditing, selectedId]);
|
||||||
|
|
||||||
const filteredSends = useMemo(() => {
|
const filteredSends = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return props.sends.filter((send) => {
|
return props.sends.filter((send) => {
|
||||||
@@ -141,6 +193,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -153,6 +206,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
if (selectedId === send.id) setSelectedId(null);
|
if (selectedId === send.id) setSelectedId(null);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('list');
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -171,13 +225,29 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
|
|
||||||
function copyAccessUrl(send: Send): void {
|
function copyAccessUrl(send: Send): void {
|
||||||
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
|
const url = send.shareUrl || `${window.location.origin}/#/send/${send.accessId}`;
|
||||||
void navigator.clipboard.writeText(url);
|
void copyTextToClipboard(url, { successMessage: t('txt_link_copied') });
|
||||||
props.onNotify('success', t('txt_link_copied'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vault-grid">
|
<div className={`vault-grid ${isMobileLayout ? `mobile-panel-${mobilePanel}` : ''}`}>
|
||||||
<aside className="sidebar">
|
{isMobileLayout && (
|
||||||
|
<div
|
||||||
|
className={`mobile-sidebar-mask ${mobileSidebarOpen ? 'open' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!mobileSidebarOpen) return;
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<aside className={`sidebar ${isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${isMobileLayout && mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
|
{isMobileLayout && (
|
||||||
|
<div className="mobile-sidebar-head">
|
||||||
|
<div className="mobile-sidebar-title">{t('txt_all_sends')}</div>
|
||||||
|
<button type="button" className="mobile-sidebar-close" onClick={() => setMobileSidebarOpen(false)} aria-label={t('txt_close')}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="sidebar-block">
|
<div className="sidebar-block">
|
||||||
<div className="sidebar-title">{t('txt_all_sends')}</div>
|
<div className="sidebar-title">{t('txt_all_sends')}</div>
|
||||||
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
<button type="button" className={`tree-btn ${typeFilter === 'all' ? 'active' : ''}`} onClick={() => setTypeFilter('all')}>
|
||||||
@@ -206,7 +276,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
value={search}
|
value={search}
|
||||||
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={busy || props.loading} onClick={() => void props.onRefresh()}>
|
||||||
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,34 +294,55 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
setSelectedMap(map);
|
setSelectedMap(map);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<CheckCheck size={14} className="btn-icon" />
|
||||||
{t('txt_select_all')}
|
{t('txt_select_all')}
|
||||||
</button>
|
</button>
|
||||||
{!!selectedCount && (
|
{!!selectedCount && (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
<button type="button" className="btn btn-secondary small" onClick={() => setSelectedMap({})}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
{t('txt_cancel')}
|
{t('txt_cancel')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary small"
|
className="btn btn-primary small mobile-fab-trigger"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
|
aria-label={t('txt_add')}
|
||||||
|
title={t('txt_add')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setDraft(buildDefaultDraft());
|
setDraft(buildDefaultDraft());
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel('edit');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={14} className="btn-icon" /> {t('txt_add')}
|
<Plus size={14} className="btn-icon" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-panel">
|
<div className="list-panel">
|
||||||
{filteredSends.map((send) => (
|
{filteredSends.map((send, index) => (
|
||||||
<div key={send.id} className={`list-item ${selectedId === send.id ? 'active' : ''}`}>
|
<div
|
||||||
|
key={send.id}
|
||||||
|
className={`list-item stagger-item ${selectedId === send.id ? 'active' : ''}`}
|
||||||
|
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
setSelectedId(send.id);
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="row-check"
|
className="row-check"
|
||||||
checked={!!selectedMap[send.id]}
|
checked={!!selectedMap[send.id]}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
onInput={(e) =>
|
onInput={(e) =>
|
||||||
setSelectedMap((prev) => ({
|
setSelectedMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -267,6 +358,8 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
setDraft(null);
|
setDraft(null);
|
||||||
|
if (isMobileLayout) setMobilePanel('detail');
|
||||||
|
setMobileSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="list-icon-wrap">
|
<div className="list-icon-wrap">
|
||||||
@@ -287,11 +380,35 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="detail-col">
|
<section className={`detail-col ${isMobileLayout ? 'mobile-detail-sheet' : ''} ${isMobileLayout && mobilePanel !== 'list' ? 'open' : ''}`}>
|
||||||
|
{isMobileLayout && mobilePanel !== 'list' && (
|
||||||
|
<div className="mobile-panel-head">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small mobile-panel-back"
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
setMobilePanel(selectedSend ? 'detail' : 'list');
|
||||||
|
} else {
|
||||||
|
setMobilePanel('list');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} className="btn-icon" />
|
||||||
|
{t('txt_back')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{isEditing && draft && (
|
{isEditing && draft && (
|
||||||
<div className="card">
|
<div key={`send-editor-${draft.id || selectedSend?.id || 'new'}-${draft.type}`} className="detail-switch-stage">
|
||||||
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
<div className="card stagger-item" style={{ animationDelay: '0ms' }}>
|
||||||
<div className="field-grid">
|
<h3 className="detail-title">{isCreating ? t('txt_new_send') : t('txt_edit_send')}</h3>
|
||||||
|
{!!props.uploadingSendFileName && <div className="detail-sub">{sendUploadLabel}</div>}
|
||||||
|
<div className="field-grid">
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_name')}</span>
|
<span>{t('txt_name')}</span>
|
||||||
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
<input className="input" value={draft.name} onInput={(e) => setDraft({ ...draft, name: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
@@ -362,22 +479,38 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
<label><input type="checkbox" checked={autoCopyLink} onInput={(e) => setAutoCopyLink((e.currentTarget as HTMLInputElement).checked)} /> {t('txt_auto_copy_link_after_save')}</label>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>{t('txt_save')}</button>
|
<button type="button" className="btn btn-primary small" disabled={busy} onClick={() => void saveDraft()}>
|
||||||
<button type="button" className="btn btn-secondary small" disabled={busy} onClick={() => { setIsEditing(false); setIsCreating(false); setDraft(null); setShowPassword(false); }}>{t('txt_cancel')}</button>
|
<Save size={14} className="btn-icon" /> {t('txt_save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsCreating(false);
|
||||||
|
setDraft(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
if (isMobileLayout) setMobilePanel(selectedSend ? 'detail' : 'list');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing && selectedSend && (
|
{!isEditing && selectedSend && (
|
||||||
<>
|
<div key={`send-detail-${selectedSend.id}`} className="detail-switch-stage">
|
||||||
<div className="card">
|
<div className="card stagger-item" style={{ animationDelay: '36ms' }}>
|
||||||
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
<h3 className="detail-title">{selectedSend.decName || t('txt_no_name')}</h3>
|
||||||
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
<div className="detail-sub">{Number(selectedSend.type) === 1 ? t('txt_file_send') : t('txt_text_send')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card stagger-item" style={{ animationDelay: '72ms' }}>
|
||||||
<h4>{t('txt_send_details')}</h4>
|
<h4>{t('txt_send_details')}</h4>
|
||||||
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
<div className="kv-line"><span>{t('txt_access_count')}</span><strong>{selectedSend.accessCount || 0}</strong></div>
|
||||||
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
<div className="kv-line"><span>{t('txt_deletion_date')}</span><strong>{selectedSend.deletionDate || t('txt_dash')}</strong></div>
|
||||||
@@ -400,7 +533,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!!(selectedSend.decNotes || '').trim() && (
|
{!!(selectedSend.decNotes || '').trim() && (
|
||||||
<div className="card">
|
<div className="card stagger-item" style={{ animationDelay: '108ms' }}>
|
||||||
<h4>{t('txt_notes')}</h4>
|
<h4>{t('txt_notes')}</h4>
|
||||||
<div className="notes">{selectedSend.decNotes || ''}</div>
|
<div className="notes">{selectedSend.decNotes || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,7 +552,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -8,6 +9,7 @@ interface SettingsPageProps {
|
|||||||
profile: Profile;
|
profile: Profile;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
@@ -16,9 +18,16 @@ interface SettingsPageProps {
|
|||||||
|
|
||||||
function randomBase32Secret(length: number): string {
|
function randomBase32Secret(length: number): string {
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
const random = crypto.getRandomValues(new Uint8Array(length));
|
|
||||||
let out = '';
|
let out = '';
|
||||||
for (const x of random) out += alphabet[x % alphabet.length];
|
const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length;
|
||||||
|
while (out.length < length) {
|
||||||
|
const random = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
for (const x of random) {
|
||||||
|
if (x >= maxUnbiasedByte) continue;
|
||||||
|
out += alphabet[x % alphabet.length];
|
||||||
|
if (out.length >= length) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +41,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [currentPassword, setCurrentPassword] = useState('');
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [newPassword2, setNewPassword2] = useState('');
|
const [newPassword2, setNewPassword2] = useState('');
|
||||||
|
const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || '');
|
||||||
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
|
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
@@ -46,6 +56,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
setTotpLocked(true);
|
setTotpLocked(true);
|
||||||
}, [props.totpEnabled]);
|
}, [props.totpEnabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPasswordHint(props.profile.masterPasswordHint || '');
|
||||||
|
}, [props.profile.masterPasswordHint]);
|
||||||
|
|
||||||
const qrDataUrl = useMemo(() => {
|
const qrDataUrl = useMemo(() => {
|
||||||
const qr = qrcode(0, 'M');
|
const qr = qrcode(0, 'M');
|
||||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||||
@@ -55,10 +69,14 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
}, [props.profile.email, secret]);
|
}, [props.profile.email, secret]);
|
||||||
|
|
||||||
async function enableTotp(): Promise<void> {
|
async function enableTotp(): Promise<void> {
|
||||||
await props.onEnableTotp(secret, token);
|
try {
|
||||||
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
await props.onEnableTotp(secret, token);
|
||||||
localStorage.removeItem(totpSecretStorageKey);
|
// Secret is now stored on the server; remove plaintext copy from localStorage.
|
||||||
setTotpLocked(true);
|
localStorage.removeItem(totpSecretStorageKey);
|
||||||
|
setTotpLocked(true);
|
||||||
|
} catch {
|
||||||
|
// Keep inputs editable after a failed attempt.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecoveryCode(): Promise<void> {
|
async function loadRecoveryCode(): Promise<void> {
|
||||||
@@ -69,6 +87,28 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
|
<section className="card">
|
||||||
|
<h3>{t('txt_profile')}</h3>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_password_hint_optional')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
maxLength={120}
|
||||||
|
value={passwordHint}
|
||||||
|
placeholder={t('txt_password_hint_placeholder')}
|
||||||
|
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||||
|
>
|
||||||
|
{t('txt_save_profile')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h3>{t('txt_change_master_password')}</h3>
|
<h3>{t('txt_change_master_password')}</h3>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@@ -133,8 +173,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={totpLocked}
|
disabled={totpLocked}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(secret);
|
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||||
props.onNotify?.('success', t('txt_secret_copied'));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Clipboard size={14} className="btn-icon" />
|
<Clipboard size={14} className="btn-icon" />
|
||||||
@@ -174,10 +213,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={!recoveryCode}
|
disabled={!recoveryCode}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void navigator.clipboard.writeText(recoveryCode);
|
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
|
||||||
props.onNotify?.('success', t('txt_recovery_code_copied'));
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
{t('txt_copy_code')}
|
{t('txt_copy_code')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { APP_VERSION } from '@shared/app-version';
|
||||||
|
|
||||||
interface StandalonePageFrameProps {
|
interface StandalonePageFrameProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -24,6 +25,8 @@ export default function StandalonePageFrame(props: StandalonePageFrameProps) {
|
|||||||
<a href="https://github.com/shuaiplus/NodeWarden" target="_blank" rel="noreferrer">NodeWarden Repository</a>
|
<a href="https://github.com/shuaiplus/NodeWarden" target="_blank" rel="noreferrer">NodeWarden Repository</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
|
<a href="https://github.com/shuaiplus" target="_blank" rel="noreferrer">Author: @shuaiplus</a>
|
||||||
|
<span> | </span>
|
||||||
|
<span className="standalone-version">v{APP_VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface ThemeSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
title: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemeSwitch(props: ThemeSwitchProps) {
|
||||||
|
return (
|
||||||
|
<div className="theme-switch-wrap" title={props.title}>
|
||||||
|
<label className="theme-switch" aria-label={props.title}>
|
||||||
|
<span className="sun" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<g fill="#ffd43b">
|
||||||
|
<circle r={5} cy={12} cx={12} />
|
||||||
|
<path d="m21 13h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm-17 0h-1a1 1 0 0 1 0-2h1a1 1 0 0 1 0 2zm13.66-5.66a1 1 0 0 1 -.66-.29 1 1 0 0 1 0-1.41l.71-.71a1 1 0 1 1 1.41 1.41l-.71.71a1 1 0 0 1 -.75.29zm-12.02 12.02a1 1 0 0 1 -.71-.29 1 1 0 0 1 0-1.41l.71-.66a1 1 0 0 1 1.41 1.41l-.71.71a1 1 0 0 1 -.7.24zm6.36-14.36a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm0 17a1 1 0 0 1 -1-1v-1a1 1 0 0 1 2 0v1a1 1 0 0 1 -1 1zm-5.66-14.66a1 1 0 0 1 -.7-.29l-.71-.71a1 1 0 0 1 1.41-1.41l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.29zm12.02 12.02a1 1 0 0 1 -.7-.29l-.66-.71a1 1 0 0 1 1.36-1.36l.71.71a1 1 0 0 1 0 1.41 1 1 0 0 1 -.71.24z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="moon" aria-hidden="true">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512">
|
||||||
|
<path d="m223.5 32c-123.5 0-223.5 100.3-223.5 224s100 224 223.5 224c60.6 0 115.5-24.2 155.8-63.4 5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6-96.9 0-175.5-78.8-175.5-176 0-65.8 36-123.1 89.3-153.3 6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input type="checkbox" className="theme-switch-input" checked={props.checked} onInput={props.onToggle} />
|
||||||
|
<span className="theme-switch-slider" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
useSortable,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
|
import { calcTotpNow } from '@/lib/crypto';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
import { isCipherVisibleInNormalVault, websiteIconUrl } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
|
interface TotpCodesPageProps {
|
||||||
|
ciphers: Cipher[];
|
||||||
|
loading: boolean;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOTP_PERIOD_SECONDS = 30;
|
||||||
|
const TOTP_RING_RADIUS = 14;
|
||||||
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
const TOTP_ORDER_STORAGE_KEY = 'nodewarden.totp-order';
|
||||||
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
|
function formatTotp(code: string): string {
|
||||||
|
if (!code) return code;
|
||||||
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
|
if (code.length < 6) return code;
|
||||||
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCipherUri(cipher: Cipher): string {
|
||||||
|
const uris = cipher.login?.uris || [];
|
||||||
|
for (const uri of uris) {
|
||||||
|
const raw = uri.decUri || uri.uri || '';
|
||||||
|
if (raw.trim()) return raw.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromUri(uri: string): string {
|
||||||
|
if (!uri.trim()) return '';
|
||||||
|
try {
|
||||||
|
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||||
|
return new URL(normalized).hostname || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
|
const uri = firstCipherUri(cipher);
|
||||||
|
const host = hostFromUri(uri);
|
||||||
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
|
if (host && !errored) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="list-icon"
|
||||||
|
src={websiteIconUrl(host)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => {
|
||||||
|
failedIconHosts.add(host);
|
||||||
|
setErrored(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="list-icon-fallback">
|
||||||
|
<Globe size={18} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableTotpRowProps {
|
||||||
|
cipher: Cipher;
|
||||||
|
live: { code: string; remain: number } | null;
|
||||||
|
onCopy: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableTotpRow(props: SortableTotpRowProps) {
|
||||||
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props.cipher.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className={`totp-code-row${isDragging ? ' is-dragging' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
className="btn btn-secondary small totp-drag-btn"
|
||||||
|
title={t('txt_drag_to_reorder')}
|
||||||
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<div className="totp-code-info">
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<TotpListIcon cipher={props.cipher} />
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-meta">
|
||||||
|
<div className="totp-code-name" title={name}>{name}</div>
|
||||||
|
<div className="totp-code-username" title={username}>{username || t('txt_no_username')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="totp-code-main">
|
||||||
|
<strong>{props.live ? formatTotp(props.live.code) : t('txt_text_3')}</strong>
|
||||||
|
<div
|
||||||
|
className="totp-timer"
|
||||||
|
title={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
||||||
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.live ? props.live.remain : 0 })}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
||||||
|
<circle className="totp-ring-track" cx="18" cy="18" r={TOTP_RING_RADIUS} />
|
||||||
|
<circle
|
||||||
|
className="totp-ring-progress"
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r={TOTP_RING_RADIUS}
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
|
strokeDashoffset: String(
|
||||||
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
|
TOTP_RING_CIRCUMFERENCE *
|
||||||
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="totp-timer-value">{props.live ? props.live.remain : 0}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small totp-copy-btn" onClick={() => props.onCopy(props.live?.code || '')} aria-label={t('txt_copy')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
|
const [totpMap, setTotpMap] = useState<Record<string, { code: string; remain: number } | null>>({});
|
||||||
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
|
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(window.localStorage.getItem(TOTP_ORDER_STORAGE_KEY) || '[]'));
|
||||||
|
return Array.isArray(parsed) ? parsed.map((id) => String(id || '').trim()).filter(Boolean) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hasLoadedTotpItemsRef = useRef(false);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 120,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function copyToClipboard(value: string): Promise<void> {
|
||||||
|
await copyTextWithFeedback(value, { successMessage: t('txt_code_copied') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTotpItems = useMemo(
|
||||||
|
() =>
|
||||||
|
props.ciphers
|
||||||
|
.filter((cipher) => isCipherVisibleInNormalVault(cipher) && !!cipher.login?.decTotp)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||||
|
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
}),
|
||||||
|
[props.ciphers]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totpItems = useMemo(() => {
|
||||||
|
if (!baseTotpItems.length) return [];
|
||||||
|
const orderMap = new Map(orderedIds.map((id, index) => [id, index]));
|
||||||
|
return [...baseTotpItems].sort((a, b) => {
|
||||||
|
const orderA = orderMap.get(a.id);
|
||||||
|
const orderB = orderMap.get(b.id);
|
||||||
|
if (orderA != null && orderB != null) return orderA - orderB;
|
||||||
|
if (orderA != null) return -1;
|
||||||
|
if (orderB != null) return 1;
|
||||||
|
const nameA = (a.decName || a.name || '').trim().toLowerCase();
|
||||||
|
const nameB = (b.decName || b.name || '').trim().toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
}, [baseTotpItems, orderedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!baseTotpItems.length) return;
|
||||||
|
hasLoadedTotpItemsRef.current = true;
|
||||||
|
const validIds = new Set(baseTotpItems.map((cipher) => cipher.id));
|
||||||
|
setOrderedIds((prev) => {
|
||||||
|
const filtered = prev.filter((id) => validIds.has(id));
|
||||||
|
const missing = baseTotpItems.map((cipher) => cipher.id).filter((id) => !filtered.includes(id));
|
||||||
|
const next = [...filtered, ...missing];
|
||||||
|
if (next.length === prev.length && next.every((id, index) => id === prev[index])) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [baseTotpItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
if (!hasLoadedTotpItemsRef.current) return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(TOTP_ORDER_STORAGE_KEY, JSON.stringify(orderedIds));
|
||||||
|
} catch {
|
||||||
|
// ignore storage write failures
|
||||||
|
}
|
||||||
|
}, [orderedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!totpItems.length) {
|
||||||
|
setTotpMap({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stopped = false;
|
||||||
|
let timer = 0;
|
||||||
|
const tick = async () => {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
totpItems.map(async (cipher) => {
|
||||||
|
try {
|
||||||
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
|
return [cipher.id, next] as const;
|
||||||
|
} catch {
|
||||||
|
return [cipher.id, null] as const;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!stopped) setTotpMap(Object.fromEntries(entries));
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
timer = window.setInterval(() => void tick(), 1000);
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [totpItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = listRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const gap = 10;
|
||||||
|
const minCardWidth = 320;
|
||||||
|
const maxColumns = 4;
|
||||||
|
|
||||||
|
const updateColumns = () => {
|
||||||
|
const width = element.clientWidth;
|
||||||
|
if (!width) return;
|
||||||
|
const next = Math.max(1, Math.min(maxColumns, Math.floor((width + gap) / (minCardWidth + gap))));
|
||||||
|
setColumnCount(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateColumns();
|
||||||
|
const observer = new ResizeObserver(() => updateColumns());
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
if (!overId || activeId === overId) return;
|
||||||
|
const fromIndex = orderedIds.indexOf(activeId);
|
||||||
|
const toIndex = orderedIds.indexOf(overId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
||||||
|
setOrderedIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="totp-codes-page">
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3 className="detail-title">{t('txt_verification_code')}</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="totp-codes-list"
|
||||||
|
style={{ '--totp-columns': String(columnCount) } as Record<string, string>}
|
||||||
|
>
|
||||||
|
{!totpItems.length && !props.loading && <div className="empty">{t('txt_no_verification_codes')}</div>}
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={totpItems.map((cipher) => cipher.id)} strategy={rectSortingStrategy}>
|
||||||
|
{totpItems.map((cipher) => (
|
||||||
|
<SortableTotpRow
|
||||||
|
key={cipher.id}
|
||||||
|
cipher={cipher}
|
||||||
|
live={totpMap[cipher.id] || null}
|
||||||
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+620
-1036
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,524 @@
|
|||||||
|
import { CloudUpload, Save, Trash2 } from 'lucide-preact';
|
||||||
|
import type {
|
||||||
|
BackupDestinationRecord,
|
||||||
|
E3BackupDestination,
|
||||||
|
RemoteBackupBrowserResponse,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from '@/lib/api/backup';
|
||||||
|
import { COMMON_TIME_ZONES, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||||
|
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
|
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||||
|
|
||||||
|
const INTERVAL_HOUR_PRESETS = [1, 6, 12, 24];
|
||||||
|
|
||||||
|
interface BackupDestinationDetailProps {
|
||||||
|
selectedRecommendedProvider: RecommendedProvider | null;
|
||||||
|
selectedDestination: BackupDestinationRecord | null;
|
||||||
|
selectedDestinationIsSaved: boolean;
|
||||||
|
canRunSelectedDestination: boolean;
|
||||||
|
canBrowseSelectedDestination: boolean;
|
||||||
|
disableWhileBusy: boolean;
|
||||||
|
loadingSettings: boolean;
|
||||||
|
savingSettings: boolean;
|
||||||
|
runningRemoteBackup: boolean;
|
||||||
|
availableTimeZones: string[];
|
||||||
|
remoteBrowser: RemoteBackupBrowserResponse | null;
|
||||||
|
remoteBrowserVisibleItems: RemoteBackupBrowserResponse['items'];
|
||||||
|
remoteBrowserCurrentPage: number;
|
||||||
|
remoteBrowserTotalPages: number;
|
||||||
|
loadingRemoteBrowser: boolean;
|
||||||
|
downloadingRemotePath: string;
|
||||||
|
downloadingRemotePercent: number | null;
|
||||||
|
restoringRemotePath: string;
|
||||||
|
deletingRemotePath: string;
|
||||||
|
onSaveSettings: () => void;
|
||||||
|
onToggleSchedule: () => void;
|
||||||
|
onRunRemoteBackup: () => void;
|
||||||
|
onPromptDeleteDestination: () => void;
|
||||||
|
onUpdateDestination: (mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) => void;
|
||||||
|
onRefreshRemoteBrowser: () => void;
|
||||||
|
onShowRemoteBrowserPath: (path: string) => void;
|
||||||
|
onDownloadRemoteBackup: (path: string) => void;
|
||||||
|
onRestoreRemoteBackup: (path: string) => void;
|
||||||
|
onPromptDeleteRemoteBackup: (path: string) => void;
|
||||||
|
onChangeRemoteBrowserPage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
||||||
|
switch (provider.id) {
|
||||||
|
case 'koofr':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||||
|
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||||
|
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||||
|
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||||
|
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-inline-note">{t('txt_backup_recommend_koofr_dav_intro')}</div>
|
||||||
|
<div className="backup-recommendation-dav-list">
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>{t('txt_backup_recommend_koofr_dav_self')}</strong>
|
||||||
|
<code>https://app.koofr.net/dav/Koofr</code>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>Google Drive</strong>
|
||||||
|
<code>https://app.koofr.net/dav/Google Drive</code>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>OneDrive</strong>
|
||||||
|
<code>https://app.koofr.net/dav/OneDrive</code>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>Dropbox</strong>
|
||||||
|
<code>https://app.koofr.net/dav/Dropbox</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'pcloud':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'infinicloud':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||||
|
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
|
||||||
|
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||||
|
const timeZones = Array.from(new Set([
|
||||||
|
...COMMON_TIME_ZONES,
|
||||||
|
...props.availableTimeZones,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (props.selectedRecommendedProvider) {
|
||||||
|
return (
|
||||||
|
<section className="backup-detail-panel">
|
||||||
|
<div className="backup-recommendation-card">
|
||||||
|
<div className="backup-recommendation-header">
|
||||||
|
<div>
|
||||||
|
<strong>{props.selectedRecommendedProvider.name}</strong>
|
||||||
|
<div className="backup-inline-note">
|
||||||
|
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
||||||
|
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
|
||||||
|
: t('txt_backup_recommend_pcloud_summary')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="backup-destination-type">{props.selectedRecommendedProvider.capacity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-actions">
|
||||||
|
<a className="btn btn-primary small" href={props.selectedRecommendedProvider.signupUrl} target="_blank" rel="noreferrer">
|
||||||
|
{props.selectedRecommendedProvider.hasAffiliateLink ? t('txt_backup_recommend_open_signup_aff') : t('txt_backup_recommend_open_signup')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{renderRecommendedProviderDetails(props.selectedRecommendedProvider)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="backup-detail-panel">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_destination_detail_title')}</h3>
|
||||||
|
{props.selectedDestination ? (
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onSaveSettings}>
|
||||||
|
<Save size={14} className="btn-icon" />
|
||||||
|
{props.savingSettings ? t('txt_backup_saving') : t('txt_backup_save_settings')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onToggleSchedule}>
|
||||||
|
{props.selectedDestination.schedule.enabled ? t('txt_backup_disable_action') : t('txt_backup_enable_action')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || !props.canRunSelectedDestination} onClick={props.onRunRemoteBackup}>
|
||||||
|
<CloudUpload size={14} className="btn-icon" />
|
||||||
|
{props.runningRemoteBackup ? t('txt_backup_running_now') : t('txt_backup_run_manual')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onPromptDeleteDestination}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_delete_destination')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!props.selectedDestination ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_select_destination')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="backup-name-row">
|
||||||
|
<label className="field backup-name-field">
|
||||||
|
<span>{t('txt_backup_destination_name')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.selectedDestination.name}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, name: (event.currentTarget as HTMLInputElement).value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field backup-type-field">
|
||||||
|
<span>{t('txt_backup_type')}</span>
|
||||||
|
<input className="input" value={getDestinationTypeLabel(props.selectedDestination.type)} disabled />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-grid backup-detail-schedule-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_interval_hours')}</span>
|
||||||
|
<div className="backup-interval-row">
|
||||||
|
<div className="backup-inline-suffix-wrap">
|
||||||
|
<input
|
||||||
|
className="input backup-inline-suffix-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => {
|
||||||
|
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||||
|
const value = Math.min(99, Math.max(1, Number(raw || 1)));
|
||||||
|
props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
intervalHours: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="backup-inline-suffix">{t('txt_backup_interval_hours_suffix')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||||
|
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||||
|
const active = preset === props.selectedDestination.schedule.intervalHours;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
className={`backup-interval-preset${active ? ' active' : ''}`}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onClick={() => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
intervalHours: preset,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{preset}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_start_time')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="time"
|
||||||
|
step={300}
|
||||||
|
value={props.selectedDestination.schedule.startTime || '03:00'}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
startTime: (event.currentTarget as HTMLInputElement).value || '03:00',
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_timezone')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={props.selectedDestination.schedule.timezone}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
timezone: (event.currentTarget as HTMLSelectElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{timeZones.map((timezone) => (
|
||||||
|
<option key={timezone} value={timezone}>{timezone}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_retention_count')}</span>
|
||||||
|
<div className="backup-inline-suffix-wrap">
|
||||||
|
<input
|
||||||
|
className="input backup-inline-suffix-input"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="30"
|
||||||
|
onInput={(event) => {
|
||||||
|
const nextValue = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '').trim();
|
||||||
|
props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
retentionCount: nextValue ? Number(nextValue) : null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="backup-inline-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-schedule-attachments-row">
|
||||||
|
<BackupIncludeAttachmentsField
|
||||||
|
checked={props.selectedDestination.includeAttachments}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(checked) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
includeAttachments: checked,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.selectedDestination.type === 'webdav' ? (
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_webdav_url')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).baseUrl}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="https://dav.example.com/remote.php/dav/files/admin"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
baseUrl: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_webdav_username')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).username}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
username: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_webdav_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).password}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
password: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_webdav_path')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="nodewarden/backups"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
remotePath: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{props.selectedDestination.type === 'e3' ? (
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_e3_endpoint')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="https://s3.example.com"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
endpoint: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_bucket')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
bucket: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_region')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).region}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="auto"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
region: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_access_key')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
accessKeyId: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_secret_key')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_e3_path')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="nodewarden/backups"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
rootPath: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RemoteBackupBrowser
|
||||||
|
canBrowse={props.canBrowseSelectedDestination}
|
||||||
|
destinationIsSaved={props.selectedDestinationIsSaved}
|
||||||
|
disableWhileBusy={props.disableWhileBusy}
|
||||||
|
loadingRemoteBrowser={props.loadingRemoteBrowser}
|
||||||
|
remoteBrowser={props.remoteBrowser}
|
||||||
|
visibleItems={props.remoteBrowserVisibleItems}
|
||||||
|
currentPage={props.remoteBrowserCurrentPage}
|
||||||
|
totalPages={props.remoteBrowserTotalPages}
|
||||||
|
downloadingRemotePath={props.downloadingRemotePath}
|
||||||
|
downloadingRemotePercent={props.downloadingRemotePercent}
|
||||||
|
restoringRemotePath={props.restoringRemotePath}
|
||||||
|
deletingRemotePath={props.deletingRemotePath}
|
||||||
|
onRefresh={props.onRefreshRemoteBrowser}
|
||||||
|
onShowPath={props.onShowRemoteBrowserPath}
|
||||||
|
onDownload={props.onDownloadRemoteBackup}
|
||||||
|
onRestore={props.onRestoreRemoteBackup}
|
||||||
|
onPromptDelete={props.onPromptDeleteRemoteBackup}
|
||||||
|
onChangePage={props.onChangeRemoteBrowserPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Plus } from 'lucide-preact';
|
||||||
|
import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api/backup';
|
||||||
|
import { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface BackupDestinationSidebarProps {
|
||||||
|
destinations: BackupDestinationRecord[];
|
||||||
|
selectedDestinationId: string | null;
|
||||||
|
disableWhileBusy: boolean;
|
||||||
|
showAddChooser: boolean;
|
||||||
|
onSelectDestination: (destinationId: string) => void;
|
||||||
|
onToggleAddChooser: () => void;
|
||||||
|
onAddDestination: (type: BackupDestinationType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="backup-destination-sidebar">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_destinations_title')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-destination-list">
|
||||||
|
{props.destinations.map((destination) => {
|
||||||
|
const isSelected = destination.id === props.selectedDestinationId;
|
||||||
|
const isScheduled = destination.schedule.enabled;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={destination.id}
|
||||||
|
type="button"
|
||||||
|
className={`backup-destination-item ${isSelected ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectDestination(destination.id)}
|
||||||
|
>
|
||||||
|
<span className="backup-destination-top">
|
||||||
|
<span className="backup-destination-name">{destination.name || getDestinationTypeLabel(destination.type)}</span>
|
||||||
|
<span className="backup-destination-type">{getDestinationTypeLabel(destination.type)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="backup-destination-meta">
|
||||||
|
{isScheduled ? t('txt_backup_destination_active_badge') : t('txt_backup_destination_idle_badge')}
|
||||||
|
</span>
|
||||||
|
<span className="backup-destination-meta">
|
||||||
|
{destination.runtime.lastSuccessAt
|
||||||
|
? t('txt_backup_destination_last_success', { time: formatDateTime(destination.runtime.lastSuccessAt) })
|
||||||
|
: t('txt_backup_destination_never_run')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions backup-destination-addbar">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy} onClick={props.onToggleAddChooser}>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_add_destination')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.showAddChooser ? (
|
||||||
|
<div className="backup-add-chooser">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
|
||||||
|
{t('txt_backup_protocol_webdav')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
|
||||||
|
{t('txt_backup_protocol_e3')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface BackupIncludeAttachmentsFieldProps {
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
showHelp?: boolean;
|
||||||
|
showLabel?: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFieldProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="backup-option-field">
|
||||||
|
<label className="backup-option-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={props.checked}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
{props.showLabel !== false ? <span>{t('txt_backup_include_attachments')}</span> : null}
|
||||||
|
</label>
|
||||||
|
{props.showHelp !== false ? (
|
||||||
|
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="backup-help-trigger"
|
||||||
|
aria-label={t('txt_backup_include_attachments_help_button')}
|
||||||
|
aria-expanded={open ? 'true' : 'false'}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</button>
|
||||||
|
<div className="backup-help-bubble" role="tooltip">
|
||||||
|
{t('txt_backup_include_attachments_help')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
|
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
|
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||||
|
|
||||||
|
interface BackupOperationsSidebarProps {
|
||||||
|
disableWhileBusy: boolean;
|
||||||
|
exporting: boolean;
|
||||||
|
importing: boolean;
|
||||||
|
exportIncludeAttachments: boolean;
|
||||||
|
selectedProviderId: string | null;
|
||||||
|
recommendedWebDavProviders: RecommendedProvider[];
|
||||||
|
recommendedS3Providers: RecommendedProvider[];
|
||||||
|
onExport: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
onExportIncludeAttachmentsChange: (checked: boolean) => void;
|
||||||
|
onSelectProvider: (providerId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className="backup-operations-sidebar">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_manual')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="backup-actions-stack">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={props.disableWhileBusy} onClick={props.onExport}>
|
||||||
|
<Download size={14} className="btn-icon" />
|
||||||
|
{props.exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||||
|
</button>
|
||||||
|
<BackupIncludeAttachmentsField
|
||||||
|
checked={props.exportIncludeAttachments}
|
||||||
|
disabled={props.disableWhileBusy}
|
||||||
|
showHelp={false}
|
||||||
|
onChange={props.onExportIncludeAttachmentsChange}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={props.disableWhileBusy} onClick={props.onImport}>
|
||||||
|
<FileUp size={14} className="btn-icon" />
|
||||||
|
{props.importing ? t('txt_backup_restoring') : t('txt_backup_import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-divider" />
|
||||||
|
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_recommend_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-group">
|
||||||
|
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_webdav')}</h4>
|
||||||
|
<div className="backup-recommendation-list">
|
||||||
|
{props.recommendedWebDavProviders.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectProvider(provider.id)}
|
||||||
|
>
|
||||||
|
<span className="backup-recommendation-row">
|
||||||
|
<span className="backup-destination-name">{provider.name}</span>
|
||||||
|
<span className="backup-destination-meta">{provider.capacity}</span>
|
||||||
|
</span>
|
||||||
|
{hasLinkedStorages(provider) && provider.linkedStorages.length ? (
|
||||||
|
<span className="backup-recommendation-linked">
|
||||||
|
{provider.linkedStorages.map((storage) => (
|
||||||
|
<span key={`${provider.id}-${storage.name}`} className="backup-recommendation-linked-item">
|
||||||
|
<span>{storage.name}</span>
|
||||||
|
<span>{storage.capacity}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-group">
|
||||||
|
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_s3')}</h4>
|
||||||
|
{props.recommendedS3Providers.length ? (
|
||||||
|
<div className="backup-recommendation-list">
|
||||||
|
{props.recommendedS3Providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectProvider(provider.id)}
|
||||||
|
>
|
||||||
|
<span className="backup-recommendation-row">
|
||||||
|
<span className="backup-destination-name">{provider.name}</span>
|
||||||
|
<span className="backup-destination-meta">{provider.capacity}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_recommend_empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
||||||
|
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
|
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface RemoteBackupBrowserProps {
|
||||||
|
canBrowse: boolean;
|
||||||
|
destinationIsSaved: boolean;
|
||||||
|
disableWhileBusy: boolean;
|
||||||
|
loadingRemoteBrowser: boolean;
|
||||||
|
remoteBrowser: RemoteBackupBrowserResponse | null;
|
||||||
|
visibleItems: RemoteBackupBrowserResponse['items'];
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
downloadingRemotePath: string;
|
||||||
|
downloadingRemotePercent: number | null;
|
||||||
|
restoringRemotePath: string;
|
||||||
|
deletingRemotePath: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onShowPath: (path: string) => void;
|
||||||
|
onDownload: (path: string) => void;
|
||||||
|
onRestore: (path: string) => void;
|
||||||
|
onPromptDelete: (path: string) => void;
|
||||||
|
onChangePage: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
||||||
|
const getDownloadLabel = (path: string) => {
|
||||||
|
if (props.downloadingRemotePath !== path) return t('txt_backup_remote_download');
|
||||||
|
return props.downloadingRemotePercent == null
|
||||||
|
? t('txt_downloading')
|
||||||
|
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="backup-divider" />
|
||||||
|
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_remote_title')}</h3>
|
||||||
|
{props.canBrowse ? (
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!props.destinationIsSaved ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
|
||||||
|
) : !props.remoteBrowser ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="backup-browser-path">
|
||||||
|
<strong>{t('txt_backup_remote_current_path')}</strong>
|
||||||
|
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions backup-browser-nav">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||||
|
<FolderOpen size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_root')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
||||||
|
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_up')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.loadingRemoteBrowser ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
|
||||||
|
) : props.remoteBrowser.items.length ? (
|
||||||
|
<>
|
||||||
|
<div className="backup-browser-list">
|
||||||
|
{props.visibleItems.map((item) => (
|
||||||
|
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`backup-browser-entry ${item.isDirectory ? 'dir' : 'file'}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.isDirectory) props.onShowPath(item.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
||||||
|
<span className="backup-browser-name">{item.name}</span>
|
||||||
|
</button>
|
||||||
|
<div className="backup-browser-meta">
|
||||||
|
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
|
||||||
|
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="actions backup-browser-actions">
|
||||||
|
{item.isDirectory ? (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
||||||
|
<FolderOpen size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_open')}
|
||||||
|
</button>
|
||||||
|
) : isZipCandidate(item) ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path} onClick={() => props.onDownload(item.path)}>
|
||||||
|
<Download size={14} className="btn-icon" />
|
||||||
|
{getDownloadLabel(item.path)}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path} onClick={() => props.onRestore(item.path)}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" />
|
||||||
|
{props.restoringRemotePath === item.path ? t('txt_backup_restoring') : t('txt_backup_remote_restore')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path} onClick={() => props.onPromptDelete(item.path)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{props.deletingRemotePath === item.path ? t('txt_backup_remote_deleting') : t('txt_delete')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{props.totalPages > 1 ? (
|
||||||
|
<div className="backup-browser-pagination">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.currentPage <= 1} onClick={() => props.onChangePage(props.currentPage - 1)}>
|
||||||
|
{t('txt_prev')}
|
||||||
|
</button>
|
||||||
|
<span className="backup-browser-page-indicator">
|
||||||
|
{props.currentPage} / {props.totalPages}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.currentPage >= props.totalPages} onClick={() => props.onChangePage(props.currentPage + 1)}>
|
||||||
|
{t('txt_next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_empty')}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Paperclip, Pencil, RotateCcw, Trash2 } from 'lucide-preact';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
TOTP_PERIOD_SECONDS,
|
||||||
|
TOTP_RING_CIRCUMFERENCE,
|
||||||
|
copyToClipboard,
|
||||||
|
formatAttachmentSize,
|
||||||
|
formatHistoryTime,
|
||||||
|
formatTotp,
|
||||||
|
maskSecret,
|
||||||
|
openUri,
|
||||||
|
parseFieldType,
|
||||||
|
toBooleanFieldValue,
|
||||||
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
|
interface VaultDetailViewProps {
|
||||||
|
selectedCipher: Cipher;
|
||||||
|
repromptApprovedCipherId: string | null;
|
||||||
|
showPassword: boolean;
|
||||||
|
totpLive: { code: string; remain: number } | null;
|
||||||
|
passkeyCreatedAt: string | null;
|
||||||
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
|
folderName: (id: string | null | undefined) => string;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
|
onOpenReprompt: () => void;
|
||||||
|
onToggleShowPassword: () => void;
|
||||||
|
onToggleHiddenField: (index: number) => void;
|
||||||
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onDelete: (cipher: Cipher) => void;
|
||||||
|
onArchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
|
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||||
|
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||||
|
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||||
|
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||||
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
|
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||||
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
|
return props.attachmentDownloadPercent == null
|
||||||
|
? t('txt_downloading')
|
||||||
|
: t('txt_downloading_percent', { percent: props.attachmentDownloadPercent });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Number(props.selectedCipher.reprompt || 0) === 1 && props.repromptApprovedCipherId !== props.selectedCipher.id && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_master_password_reprompt_2')}</h4>
|
||||||
|
<div className="detail-sub">{t('txt_this_item_requires_master_password_every_time_before_viewing_details')}</div>
|
||||||
|
<div className="actions" style={{ marginTop: '10px' }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={props.onOpenReprompt}>
|
||||||
|
<Eye size={14} className="btn-icon" /> {t('txt_unlock_details')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(Number(props.selectedCipher.reprompt || 0) !== 1 || props.repromptApprovedCipherId === props.selectedCipher.id) && (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="detail-title">{props.selectedCipher.decName || t('txt_no_name')}</h3>
|
||||||
|
<div className="detail-sub">{props.folderName(props.selectedCipher.folderId)}</div>
|
||||||
|
{isArchived && <div className="list-badge" style={{ marginTop: '8px', width: 'fit-content' }}>{t('txt_archived')}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.selectedCipher.login && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_login_credentials')}</h4>
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_username')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong className="value-ellipsis" title={props.selectedCipher.login.decUsername || ''}>{props.selectedCipher.login.decUsername || ''}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decUsername || '')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_password')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong>{props.showPassword ? props.selectedCipher.login.decPassword || '' : maskSecret(props.selectedCipher.login.decPassword || '')}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onToggleShowPassword}>
|
||||||
|
{props.showPassword ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
|
{props.showPassword ? t('txt_hide') : t('txt_reveal')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!!props.selectedCipher.login.decTotp && (
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_totp')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<div className="totp-inline">
|
||||||
|
<strong>{props.totpLive ? formatTotp(props.totpLive.code) : t('txt_text_3')}</strong>
|
||||||
|
<div
|
||||||
|
className="totp-timer"
|
||||||
|
title={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
|
||||||
|
aria-label={t('txt_refresh_in_seconds_s', { seconds: props.totpLive ? props.totpLive.remain : 0 })}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 36 36" className="totp-ring" role="presentation" aria-hidden="true">
|
||||||
|
<circle className="totp-ring-track" cx="18" cy="18" r="15.9155" />
|
||||||
|
<circle
|
||||||
|
className="totp-ring-progress"
|
||||||
|
cx="18"
|
||||||
|
cy="18"
|
||||||
|
r="15.9155"
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
|
strokeDashoffset: String(
|
||||||
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
|
TOTP_RING_CIRCUMFERENCE *
|
||||||
|
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="totp-timer-value">{props.totpLive ? props.totpLive.remain : 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.totpLive?.code || '')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!props.passkeyCreatedAt && (
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_passkey')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(props.selectedCipher.login?.uris || []).length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_autofill_options')}</h4>
|
||||||
|
{(props.selectedCipher.login?.uris || []).map((uri, index) => {
|
||||||
|
const value = uri.decUri || uri.uri || '';
|
||||||
|
if (!value.trim()) return null;
|
||||||
|
return (
|
||||||
|
<div key={`view-uri-${index}`} className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_website')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong className="value-ellipsis" title={value}>{value}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => openUri(value)}>
|
||||||
|
<ExternalLink size={14} className="btn-icon" /> {t('txt_open')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(value)}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.selectedCipher.card && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_card_details')}</h4>
|
||||||
|
<div className="kv-line"><span>{t('txt_cardholder_name')}</span><strong>{props.selectedCipher.card.decCardholderName || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_number')}</span><strong>{props.selectedCipher.card.decNumber || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_brand')}</span><strong>{props.selectedCipher.card.decBrand || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_expiry')}</span><strong>{`${props.selectedCipher.card.decExpMonth || ''}/${props.selectedCipher.card.decExpYear || ''}`}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_security_code')}</span><strong>{props.selectedCipher.card.decCode || ''}</strong></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.selectedCipher.identity && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_identity_details')}</h4>
|
||||||
|
<div className="kv-line"><span>{t('txt_name')}</span><strong>{`${props.selectedCipher.identity.decFirstName || ''} ${props.selectedCipher.identity.decLastName || ''}`.trim()}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_username')}</span><strong>{props.selectedCipher.identity.decUsername || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_email')}</span><strong>{props.selectedCipher.identity.decEmail || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_phone')}</span><strong>{props.selectedCipher.identity.decPhone || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_company')}</span><strong>{props.selectedCipher.identity.decCompany || ''}</strong></div>
|
||||||
|
<div className="kv-line"><span>{t('txt_address')}</span><strong>{[props.selectedCipher.identity.decAddress1, props.selectedCipher.identity.decAddress2, props.selectedCipher.identity.decAddress3, props.selectedCipher.identity.decCity, props.selectedCipher.identity.decState, props.selectedCipher.identity.decPostalCode, props.selectedCipher.identity.decCountry].filter(Boolean).join(', ')}</strong></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.selectedCipher.sshKey && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_ssh_key')}</h4>
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_private_key')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong
|
||||||
|
className="value-ellipsis"
|
||||||
|
title={showSshPrivateKey ? props.selectedCipher.sshKey.decPrivateKey || '' : maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
|
||||||
|
>
|
||||||
|
{showSshPrivateKey ? props.selectedCipher.sshKey.decPrivateKey || '' : maskSecret(props.selectedCipher.sshKey.decPrivateKey || '')}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => setShowSshPrivateKey((value) => !value)}>
|
||||||
|
{showSshPrivateKey ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
|
{showSshPrivateKey ? t('txt_hide') : t('txt_reveal')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decPrivateKey || '')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_public_key')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decPublicKey || ''}>
|
||||||
|
{props.selectedCipher.sshKey.decPublicKey || ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decPublicKey || '')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_fingerprint')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong className="value-ellipsis" title={props.selectedCipher.sshKey.decFingerprint || ''}>
|
||||||
|
{props.selectedCipher.sshKey.decFingerprint || ''}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.sshKey?.decFingerprint || '')}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!!(props.selectedCipher.decNotes || '').trim() && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_notes')}</h4>
|
||||||
|
<div className="notes">{props.selectedCipher.decNotes || ''}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(props.selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_custom_fields')}</h4>
|
||||||
|
{(props.selectedCipher.fields || [])
|
||||||
|
.filter((x) => parseFieldType(x.type) !== 3)
|
||||||
|
.map((field, index) => {
|
||||||
|
const fieldType = parseFieldType(field.type);
|
||||||
|
const fieldName = field.decName || t('txt_field');
|
||||||
|
const rawValue = field.decValue || '';
|
||||||
|
const isHiddenVisible = !!props.hiddenFieldVisibleMap[index];
|
||||||
|
if (fieldType === 2) {
|
||||||
|
const checked = toBooleanFieldValue(rawValue);
|
||||||
|
return (
|
||||||
|
<div key={`view-field-${index}`} className="custom-field-card">
|
||||||
|
<div className="custom-field-label">{fieldName}</div>
|
||||||
|
<div className="custom-field-body">
|
||||||
|
<div className="custom-field-value">
|
||||||
|
<label className="check-line cf-check view custom-field-check">
|
||||||
|
<input type="checkbox" checked={checked} disabled />
|
||||||
|
<span className="boolean-text value-ellipsis" title={checked ? t('txt_checked') : t('txt_unchecked')}>
|
||||||
|
{checked ? t('txt_checked') : t('txt_unchecked')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={`view-field-${index}`} className="custom-field-card">
|
||||||
|
<div className="custom-field-label" title={fieldName}>{fieldName}</div>
|
||||||
|
<div className="custom-field-body">
|
||||||
|
<div className="custom-field-value">
|
||||||
|
<strong className="value-ellipsis" title={fieldType === 1 && !isHiddenVisible ? '' : rawValue}>
|
||||||
|
{fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
{fieldType === 1 && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onToggleHiddenField(index)}>
|
||||||
|
{isHiddenVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||||
|
{isHiddenVisible ? t('txt_hide') : t('txt_reveal')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(rawValue)}>
|
||||||
|
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedAttachments.some((attachment) => String(attachment?.id || '').trim()) && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_attachments')}</h4>
|
||||||
|
<div className="attachment-list">
|
||||||
|
{selectedAttachments.map((attachment) => {
|
||||||
|
const attachmentId = String(attachment?.id || '').trim();
|
||||||
|
if (!attachmentId) return null;
|
||||||
|
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
|
||||||
|
return (
|
||||||
|
<div key={`view-attachment-${attachmentId}`} className="attachment-row">
|
||||||
|
<div className="attachment-main">
|
||||||
|
<Paperclip size={14} />
|
||||||
|
<div className="attachment-text">
|
||||||
|
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
|
||||||
|
<span>{formatAttachmentSize(attachment)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.downloadingAttachmentKey === `${props.selectedCipher.id}:${attachmentId}`}
|
||||||
|
onClick={() => props.onDownloadAttachment(props.selectedCipher, attachmentId)}
|
||||||
|
>
|
||||||
|
<Download size={14} className="btn-icon" /> {formatDownloadLabel(attachmentId)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(props.selectedCipher.creationDate || props.selectedCipher.revisionDate) && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_item_history')}</h4>
|
||||||
|
<div className="detail-sub">{t('txt_last_edited_value', { value: formatHistoryTime(props.selectedCipher.revisionDate) })}</div>
|
||||||
|
<div className="detail-sub">{t('txt_created_value', { value: formatHistoryTime(props.selectedCipher.creationDate) })}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-actions">
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={props.onStartEdit}>
|
||||||
|
<Pencil size={14} className="btn-icon" /> {t('txt_edit')}
|
||||||
|
</button>
|
||||||
|
{isArchived ? (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void props.onUnarchive(props.selectedCipher)}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => void props.onArchive(props.selectedCipher)}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-danger" onClick={() => props.onDelete(props.selectedCipher)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import type { CustomFieldType, Folder } from '@/lib/types';
|
||||||
|
import { FIELD_TYPE_OPTIONS, toBooleanFieldValue } from '@/components/vault/vault-page-helpers';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
|
interface VaultDialogsProps {
|
||||||
|
fieldModalOpen: boolean;
|
||||||
|
fieldType: CustomFieldType;
|
||||||
|
fieldLabel: string;
|
||||||
|
fieldValue: string;
|
||||||
|
archiveConfirmOpen: boolean;
|
||||||
|
bulkArchiveOpen: boolean;
|
||||||
|
pendingDeleteOpen: boolean;
|
||||||
|
bulkDeleteOpen: boolean;
|
||||||
|
sidebarTrashMode: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
moveOpen: boolean;
|
||||||
|
moveFolderId: string;
|
||||||
|
folders: Folder[];
|
||||||
|
createFolderOpen: boolean;
|
||||||
|
newFolderName: string;
|
||||||
|
pendingDeleteFolder: Folder | null;
|
||||||
|
deleteAllFoldersOpen: boolean;
|
||||||
|
repromptOpen: boolean;
|
||||||
|
repromptPassword: string;
|
||||||
|
onConfirmAddField: () => void;
|
||||||
|
onCancelFieldModal: () => void;
|
||||||
|
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||||
|
onFieldLabelChange: (value: string) => void;
|
||||||
|
onFieldValueChange: (value: string) => void;
|
||||||
|
onConfirmArchive: () => void;
|
||||||
|
onCancelArchive: () => void;
|
||||||
|
onConfirmBulkArchive: () => void;
|
||||||
|
onCancelBulkArchive: () => void;
|
||||||
|
onConfirmDelete: () => void;
|
||||||
|
onCancelDelete: () => void;
|
||||||
|
onConfirmBulkDelete: () => void;
|
||||||
|
onCancelBulkDelete: () => void;
|
||||||
|
onConfirmMove: () => void;
|
||||||
|
onCancelMove: () => void;
|
||||||
|
onMoveFolderIdChange: (value: string) => void;
|
||||||
|
onConfirmCreateFolder: () => void;
|
||||||
|
onCancelCreateFolder: () => void;
|
||||||
|
onNewFolderNameChange: (value: string) => void;
|
||||||
|
onConfirmDeleteFolder: () => void;
|
||||||
|
onCancelDeleteFolder: () => void;
|
||||||
|
onConfirmDeleteAllFolders: () => void;
|
||||||
|
onCancelDeleteAllFolders: () => void;
|
||||||
|
onConfirmReprompt: () => void;
|
||||||
|
onCancelReprompt: () => void;
|
||||||
|
onRepromptPasswordChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.fieldModalOpen}
|
||||||
|
title={t('txt_add_field')}
|
||||||
|
message={t('txt_configure_custom_field_values')}
|
||||||
|
confirmText={t('txt_add')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
onConfirm={props.onConfirmAddField}
|
||||||
|
onCancel={props.onCancelFieldModal}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_field_type')}</span>
|
||||||
|
<select className="input" value={props.fieldType} onInput={(e) => props.onFieldTypeChange(Number((e.currentTarget as HTMLSelectElement).value) as CustomFieldType)}>
|
||||||
|
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_field_label')}</span>
|
||||||
|
<input className="input" value={props.fieldLabel} onInput={(e) => props.onFieldLabelChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
{props.fieldType === 2 ? (
|
||||||
|
<label className="check-line">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toBooleanFieldValue(props.fieldValue)}
|
||||||
|
onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).checked ? 'true' : 'false')}
|
||||||
|
/>
|
||||||
|
{t('txt_enabled')}
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_field_value')}</span>
|
||||||
|
<input className="input" value={props.fieldValue} onInput={(e) => props.onFieldValueChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.archiveConfirmOpen}
|
||||||
|
title={t('txt_archive_item')}
|
||||||
|
message={t('txt_archive_item_message')}
|
||||||
|
confirmText={t('txt_archive')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
onConfirm={props.onConfirmArchive}
|
||||||
|
onCancel={props.onCancelArchive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.bulkArchiveOpen}
|
||||||
|
title={t('txt_archive_selected_items')}
|
||||||
|
message={t('txt_archive_selected_items_message', { count: props.selectedCount })}
|
||||||
|
confirmText={t('txt_archive')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
onConfirm={props.onConfirmBulkArchive}
|
||||||
|
onCancel={props.onCancelBulkArchive}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog open={props.pendingDeleteOpen} title={t('txt_delete_item')} message={t('txt_are_you_sure_you_want_to_delete_this_item')} danger onConfirm={props.onConfirmDelete} onCancel={props.onCancelDelete} />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.bulkDeleteOpen}
|
||||||
|
title={props.sidebarTrashMode ? t('txt_delete_selected_items_permanently') : t('txt_delete_selected_items')}
|
||||||
|
message={
|
||||||
|
props.sidebarTrashMode
|
||||||
|
? t('txt_are_you_sure_you_want_to_delete_count_selected_items_permanently', { count: props.selectedCount })
|
||||||
|
: t('txt_are_you_sure_you_want_to_delete_count_selected_items', { count: props.selectedCount })
|
||||||
|
}
|
||||||
|
danger
|
||||||
|
onConfirm={props.onConfirmBulkDelete}
|
||||||
|
onCancel={props.onCancelBulkDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog open={props.moveOpen} title={t('txt_move_selected_items')} message={t('txt_choose_destination_folder')} confirmText={t('txt_move')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmMove} onCancel={props.onCancelMove}>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_folder')}</span>
|
||||||
|
<select className="input" value={props.moveFolderId} onInput={(e) => props.onMoveFolderIdChange((e.currentTarget as HTMLSelectElement).value)}>
|
||||||
|
<option value="__none__">{t('txt_no_folder')}</option>
|
||||||
|
{props.folders.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.decName || folder.name || folder.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog open={props.createFolderOpen} title={t('txt_create_folder')} message={t('txt_enter_a_folder_name')} confirmText={t('txt_create')} cancelText={t('txt_cancel')} onConfirm={props.onConfirmCreateFolder} onCancel={props.onCancelCreateFolder}>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_folder_name')}</span>
|
||||||
|
<input className="input" value={props.newFolderName} onInput={(e) => props.onNewFolderNameChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!props.pendingDeleteFolder}
|
||||||
|
title={t('txt_delete_folder')}
|
||||||
|
message={t('txt_delete_folder_message', { name: props.pendingDeleteFolder?.decName || props.pendingDeleteFolder?.name || props.pendingDeleteFolder?.id || '' })}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={props.onConfirmDeleteFolder}
|
||||||
|
onCancel={props.onCancelDeleteFolder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog open={props.deleteAllFoldersOpen} title={t('txt_delete_all_folders')} message={t('txt_delete_all_folders_message')} confirmText={t('txt_delete')} cancelText={t('txt_cancel')} danger onConfirm={props.onConfirmDeleteAllFolders} onCancel={props.onCancelDeleteAllFolders} />
|
||||||
|
|
||||||
|
<ConfirmDialog open={props.repromptOpen} title={t('txt_unlock_item')} message={t('txt_enter_master_password_to_view_this_item')} confirmText={t('txt_unlock')} cancelText={t('txt_cancel')} showIcon={false} onConfirm={props.onConfirmReprompt} onCancel={props.onCancelReprompt}>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import type { RefObject } from 'preact';
|
||||||
|
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
type DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
TouchSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
|
interface VaultEditorProps {
|
||||||
|
draft: VaultDraft;
|
||||||
|
isCreating: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
folders: Folder[];
|
||||||
|
selectedCipher: Cipher | null;
|
||||||
|
editExistingAttachments: Array<any>;
|
||||||
|
removedAttachmentIds: Record<string, boolean>;
|
||||||
|
removedAttachmentCount: number;
|
||||||
|
attachmentQueue: File[];
|
||||||
|
attachmentInputRef: RefObject<HTMLInputElement>;
|
||||||
|
localError: string;
|
||||||
|
downloadingAttachmentKey: string;
|
||||||
|
attachmentDownloadPercent: number | null;
|
||||||
|
uploadingAttachmentName: string;
|
||||||
|
attachmentUploadPercent: number | null;
|
||||||
|
onUpdateDraft: (patch: Partial<VaultDraft>) => void;
|
||||||
|
onSeedSshDefaults: (force?: boolean) => void;
|
||||||
|
onUpdateSshPublicKey: (value: string) => void;
|
||||||
|
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||||
|
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||||
|
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||||
|
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||||
|
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||||
|
onRemoveQueuedAttachment: (index: number) => void;
|
||||||
|
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => void;
|
||||||
|
onPatchDraftCustomField: (index: number, patch: Partial<VaultDraftField>) => void;
|
||||||
|
onUpdateDraftCustomFields: (fields: VaultDraftField[]) => void;
|
||||||
|
onOpenFieldModal: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDeleteSelected: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableWebsiteRowProps {
|
||||||
|
id: string;
|
||||||
|
uriEntry: VaultDraft['loginUris'][number];
|
||||||
|
index: number;
|
||||||
|
canRemove: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
onUpdateUri: (index: number, value: string) => void;
|
||||||
|
onUpdateMatch: (index: number, value: number | null) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
||||||
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: props.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`website-row${isDragging || props.isDragging ? ' is-dragging' : ''}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={setActivatorNodeRef}
|
||||||
|
className="btn btn-secondary small website-drag-btn"
|
||||||
|
title={t('txt_drag_to_reorder')}
|
||||||
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.uriEntry.uri}
|
||||||
|
onInput={(e) => props.onUpdateUri(props.index, (e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="input website-match-select"
|
||||||
|
value={props.uriEntry.match == null ? '' : String(props.uriEntry.match)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const raw = (e.currentTarget as HTMLSelectElement).value;
|
||||||
|
props.onUpdateMatch(props.index, raw === '' ? null : Number(raw));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{WEBSITE_MATCH_OPTIONS.map((option) => (
|
||||||
|
<option key={`website-match-${String(option.value)}`} value={option.value == null ? '' : String(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{props.canRemove && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onRemove(props.index)}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VaultEditor(props: VaultEditorProps) {
|
||||||
|
const uriIdSeedRef = useRef(0);
|
||||||
|
const [uriItemIds, setUriItemIds] = useState<string[]>([]);
|
||||||
|
const [activeUriId, setActiveUriId] = useState<string | null>(null);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 6,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 120,
|
||||||
|
tolerance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const createUriId = () => `login-uri-${uriIdSeedRef.current++}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUriItemIds((prev) => {
|
||||||
|
if (prev.length === props.draft.loginUris.length) return prev;
|
||||||
|
if (prev.length < props.draft.loginUris.length) {
|
||||||
|
return [...prev, ...Array.from({ length: props.draft.loginUris.length - prev.length }, () => createUriId())];
|
||||||
|
}
|
||||||
|
return prev.slice(0, props.draft.loginUris.length);
|
||||||
|
});
|
||||||
|
}, [props.draft.loginUris.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUriItemIds(props.draft.loginUris.map(() => createUriId()));
|
||||||
|
setActiveUriId(null);
|
||||||
|
}, [props.draft.id, props.isCreating]);
|
||||||
|
|
||||||
|
const formatDownloadLabel = (attachmentId: string) => {
|
||||||
|
const downloadKey = `${props.selectedCipher?.id || ''}:${attachmentId}`;
|
||||||
|
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||||
|
return props.attachmentDownloadPercent == null
|
||||||
|
? t('txt_downloading')
|
||||||
|
: t('txt_downloading_percent', { percent: props.attachmentDownloadPercent });
|
||||||
|
};
|
||||||
|
const uploadLabel =
|
||||||
|
props.attachmentUploadPercent == null
|
||||||
|
? t('txt_uploading_attachment_named', { name: props.uploadingAttachmentName || t('txt_attachment') })
|
||||||
|
: t('txt_uploading_attachment_named_percent', {
|
||||||
|
name: props.uploadingAttachmentName || t('txt_attachment'),
|
||||||
|
percent: props.attachmentUploadPercent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLoginUri = () => {
|
||||||
|
setUriItemIds((prev) => [...prev, createUriId()]);
|
||||||
|
props.onUpdateDraft({ loginUris: [...props.draft.loginUris, createEmptyLoginUri()] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLoginUri = (index: number) => {
|
||||||
|
setUriItemIds((prev) => prev.filter((_, itemIndex) => itemIndex !== index));
|
||||||
|
props.onUpdateDraft({ loginUris: props.draft.loginUris.filter((_, itemIndex) => itemIndex !== index) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebsiteDragStart = (event: DragStartEvent) => {
|
||||||
|
setActiveUriId(String(event.active.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWebsiteDragEnd = (event: DragEndEvent) => {
|
||||||
|
const activeId = String(event.active.id);
|
||||||
|
const overId = event.over ? String(event.over.id) : null;
|
||||||
|
setActiveUriId(null);
|
||||||
|
if (!overId || activeId === overId) return;
|
||||||
|
const fromIndex = uriItemIds.indexOf(activeId);
|
||||||
|
const toIndex = uriItemIds.indexOf(overId);
|
||||||
|
if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return;
|
||||||
|
setUriItemIds((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||||
|
props.onReorderDraftLoginUri(fromIndex, toIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3 className="detail-title">{props.isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(props.draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(props.draft.type) })}</h3>
|
||||||
|
<button type="button" className={`btn btn-secondary small ${props.draft.favorite ? 'star-on' : ''}`} onClick={() => props.onUpdateDraft({ favorite: !props.draft.favorite })}>
|
||||||
|
{props.draft.favorite ? <Star size={14} className="btn-icon" /> : <StarOff size={14} className="btn-icon" />}
|
||||||
|
{t('txt_favorite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_type')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={props.draft.type}
|
||||||
|
disabled={!props.isCreating}
|
||||||
|
onInput={(e) => {
|
||||||
|
const nextType = Number((e.currentTarget as HTMLSelectElement).value);
|
||||||
|
props.onUpdateDraft({ type: nextType });
|
||||||
|
if (nextType === 5) props.onSeedSshDefaults();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.type} value={option.type}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_folder')}</span>
|
||||||
|
<select className="input" value={props.draft.folderId} onInput={(e) => props.onUpdateDraft({ folderId: (e.currentTarget as HTMLSelectElement).value })}>
|
||||||
|
<option value="">{t('txt_no_folder')}</option>
|
||||||
|
{props.folders.map((folder) => (
|
||||||
|
<option key={folder.id} value={folder.id}>
|
||||||
|
{folder.decName || folder.name || folder.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_name')}</span>
|
||||||
|
<input className="input" value={props.draft.name} onInput={(e) => props.onUpdateDraft({ name: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.draft.type === 1 && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_login_credentials')}</h4>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_username')}</span>
|
||||||
|
<input className="input" value={props.draft.loginUsername} onInput={(e) => props.onUpdateDraft({ loginUsername: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_password')}</span>
|
||||||
|
<input className="input" value={props.draft.loginPassword} onInput={(e) => props.onUpdateDraft({ loginPassword: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_totp_secret')}</span>
|
||||||
|
<input className="input" value={props.draft.loginTotp} onInput={(e) => props.onUpdateDraft({ loginTotp: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<div className="section-head">
|
||||||
|
<h4>{t('txt_websites')}</h4>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={addLoginUri}>
|
||||||
|
<Plus size={14} className="btn-icon" /> {t('txt_add_website')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleWebsiteDragStart} onDragEnd={handleWebsiteDragEnd}>
|
||||||
|
<SortableContext items={uriItemIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{props.draft.loginUris.map((uriEntry, index) => (
|
||||||
|
<SortableWebsiteRow
|
||||||
|
key={uriItemIds[index] ?? `uri-${index}`}
|
||||||
|
id={uriItemIds[index] ?? `uri-fallback-${index}`}
|
||||||
|
uriEntry={uriEntry}
|
||||||
|
index={index}
|
||||||
|
canRemove={props.draft.loginUris.length > 1}
|
||||||
|
isDragging={activeUriId === uriItemIds[index]}
|
||||||
|
onUpdateUri={props.onUpdateDraftLoginUri}
|
||||||
|
onUpdateMatch={props.onUpdateDraftLoginUriMatch}
|
||||||
|
onRemove={removeLoginUri}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.draft.type === 3 && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_card_details')}</h4>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field"><span>{t('txt_cardholder_name')}</span><input className="input" value={props.draft.cardholderName} onInput={(e) => props.onUpdateDraft({ cardholderName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_number')}</span><input className="input" value={props.draft.cardNumber} onInput={(e) => props.onUpdateDraft({ cardNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_brand')}</span><input className="input" value={props.draft.cardBrand} onInput={(e) => props.onUpdateDraft({ cardBrand: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_security_code_cvv')}</span><input className="input" value={props.draft.cardCode} onInput={(e) => props.onUpdateDraft({ cardCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_expiry_month')}</span><input className="input" value={props.draft.cardExpMonth} onInput={(e) => props.onUpdateDraft({ cardExpMonth: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_expiry_year')}</span><input className="input" value={props.draft.cardExpYear} onInput={(e) => props.onUpdateDraft({ cardExpYear: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.draft.type === 4 && (
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_identity_details')}</h4>
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field"><span>{t('txt_title')}</span><input className="input" value={props.draft.identTitle} onInput={(e) => props.onUpdateDraft({ identTitle: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.identFirstName} onInput={(e) => props.onUpdateDraft({ identFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.identMiddleName} onInput={(e) => props.onUpdateDraft({ identMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.identLastName} onInput={(e) => props.onUpdateDraft({ identLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_username')}</span><input className="input" value={props.draft.identUsername} onInput={(e) => props.onUpdateDraft({ identUsername: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_company')}</span><input className="input" value={props.draft.identCompany} onInput={(e) => props.onUpdateDraft({ identCompany: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_ssn')}</span><input className="input" value={props.draft.identSsn} onInput={(e) => props.onUpdateDraft({ identSsn: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.identPassportNumber} onInput={(e) => props.onUpdateDraft({ identPassportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.identLicenseNumber} onInput={(e) => props.onUpdateDraft({ identLicenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_email')}</span><input className="input" value={props.draft.identEmail} onInput={(e) => props.onUpdateDraft({ identEmail: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_phone')}</span><input className="input" value={props.draft.identPhone} onInput={(e) => props.onUpdateDraft({ identPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_address_1')}</span><input className="input" value={props.draft.identAddress1} onInput={(e) => props.onUpdateDraft({ identAddress1: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_address_2')}</span><input className="input" value={props.draft.identAddress2} onInput={(e) => props.onUpdateDraft({ identAddress2: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_address_3')}</span><input className="input" value={props.draft.identAddress3} onInput={(e) => props.onUpdateDraft({ identAddress3: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_city_town')}</span><input className="input" value={props.draft.identCity} onInput={(e) => props.onUpdateDraft({ identCity: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_state_province')}</span><input className="input" value={props.draft.identState} onInput={(e) => props.onUpdateDraft({ identState: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_postal_code')}</span><input className="input" value={props.draft.identPostalCode} onInput={(e) => props.onUpdateDraft({ identPostalCode: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
<label className="field"><span>{t('txt_country')}</span><input className="input" value={props.draft.identCountry} onInput={(e) => props.onUpdateDraft({ identCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.draft.type === 5 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-head">
|
||||||
|
<h4>{t('txt_ssh_key')}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={!props.isCreating}
|
||||||
|
onClick={() => props.onSeedSshDefaults(true)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_regenerate')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_private_key')}</span>
|
||||||
|
<textarea
|
||||||
|
className="input textarea"
|
||||||
|
value={props.draft.sshPrivateKey}
|
||||||
|
disabled={!props.isCreating}
|
||||||
|
onInput={(e) => props.onUpdateDraft({ sshPrivateKey: (e.currentTarget as HTMLTextAreaElement).value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_public_key')}</span>
|
||||||
|
<textarea
|
||||||
|
className="input textarea"
|
||||||
|
value={props.draft.sshPublicKey}
|
||||||
|
disabled={!props.isCreating}
|
||||||
|
onInput={(e) => props.onUpdateSshPublicKey((e.currentTarget as HTMLTextAreaElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_fingerprint')}</span>
|
||||||
|
<input className="input input-readonly" value={props.draft.sshFingerprint} readOnly />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="section-head attachment-head">
|
||||||
|
<h4>{t('txt_attachments')}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small attachment-add-btn"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={() => props.attachmentInputRef.current?.click()}
|
||||||
|
title={t('txt_upload_attachments')}
|
||||||
|
aria-label={t('txt_upload_attachments')}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!!props.uploadingAttachmentName && <div className="detail-sub">{uploadLabel}</div>}
|
||||||
|
{!props.isCreating && props.selectedCipher && props.editExistingAttachments.length > 0 && (
|
||||||
|
<div className="attachment-list">
|
||||||
|
{props.editExistingAttachments.map((attachment) => {
|
||||||
|
const attachmentId = String(attachment?.id || '').trim();
|
||||||
|
if (!attachmentId) return null;
|
||||||
|
const removed = !!props.removedAttachmentIds[attachmentId];
|
||||||
|
const fileName = String(attachment.decFileName || attachment.fileName || attachmentId).trim() || attachmentId;
|
||||||
|
return (
|
||||||
|
<div key={`edit-attachment-${attachmentId}`} className={`attachment-row ${removed ? 'is-removed' : ''}`}>
|
||||||
|
<div className="attachment-main">
|
||||||
|
<Paperclip size={14} />
|
||||||
|
<div className="attachment-text">
|
||||||
|
<strong className="value-ellipsis" title={fileName}>{fileName}</strong>
|
||||||
|
<span>{formatAttachmentSize(attachment)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.busy || removed || props.downloadingAttachmentKey === `${props.selectedCipher?.id || ''}:${attachmentId}`}
|
||||||
|
onClick={() => props.onDownloadAttachment(props.selectedCipher as Cipher, attachmentId)}
|
||||||
|
>
|
||||||
|
<Download size={14} className="btn-icon" /> {formatDownloadLabel(attachmentId)}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onToggleExistingAttachmentRemoval(attachmentId)}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{removed ? t('txt_cancel') : t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!props.removedAttachmentCount && <div className="detail-sub">{t('txt_marked_for_removal_count', { count: props.removedAttachmentCount })}</div>}
|
||||||
|
<input
|
||||||
|
ref={props.attachmentInputRef}
|
||||||
|
type="file"
|
||||||
|
className="attachment-file-input"
|
||||||
|
multiple
|
||||||
|
disabled={props.busy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
|
props.onQueueAttachmentFiles(input.files);
|
||||||
|
input.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!props.attachmentQueue.length && (
|
||||||
|
<div className="attachment-list">
|
||||||
|
<div className="attachment-queue-title">{t('txt_new_attachments')}</div>
|
||||||
|
{props.attachmentQueue.map((file, index) => (
|
||||||
|
<div key={`queued-attachment-${index}-${file.name}`} className="attachment-row">
|
||||||
|
<div className="attachment-main">
|
||||||
|
<Upload size={14} />
|
||||||
|
<div className="attachment-text">
|
||||||
|
<strong className="value-ellipsis" title={file.name}>{file.name}</strong>
|
||||||
|
<span>{formatAttachmentSize({ size: file.size })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={() => props.onRemoveQueuedAttachment(index)}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h4>{t('txt_additional_options')}</h4>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_notes')}</span>
|
||||||
|
<textarea className="input textarea" value={props.draft.notes} onInput={(e) => props.onUpdateDraft({ notes: (e.currentTarget as HTMLTextAreaElement).value })} />
|
||||||
|
</label>
|
||||||
|
<label className="check-line">
|
||||||
|
<input type="checkbox" checked={props.draft.reprompt} onInput={(e) => props.onUpdateDraft({ reprompt: (e.currentTarget as HTMLInputElement).checked })} />
|
||||||
|
{t('txt_master_password_reprompt')}
|
||||||
|
</label>
|
||||||
|
<div className="section-head">
|
||||||
|
<h4>{t('txt_custom_fields')}</h4>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onOpenFieldModal}>
|
||||||
|
<Plus size={14} className="btn-icon" /> {t('txt_add_field')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{props.draft.customFields
|
||||||
|
.map((field, originalIndex) => ({ field, originalIndex }))
|
||||||
|
.filter((entry) => entry.field.type !== 3)
|
||||||
|
.map(({ field, originalIndex }) => (
|
||||||
|
<div key={`field-${originalIndex}`} className="custom-field-card">
|
||||||
|
<label className="field custom-field-label">
|
||||||
|
<span>{t('txt_field_label')}</span>
|
||||||
|
<input className="input" value={field.label} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { label: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
</label>
|
||||||
|
<div className="custom-field-body">
|
||||||
|
<div className="custom-field-value">
|
||||||
|
{field.type === 2 ? (
|
||||||
|
<label className="check-line cf-check custom-field-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toBooleanFieldValue(field.value)}
|
||||||
|
onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).checked ? 'true' : 'false' })}
|
||||||
|
/>
|
||||||
|
<span>{toBooleanFieldValue(field.value) ? t('txt_checked') : t('txt_unchecked')}</span>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<input className="input" value={field.value} onInput={(e) => props.onPatchDraftCustomField(originalIndex, { value: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small custom-field-remove" onClick={() => props.onUpdateDraftCustomFields(props.draft.customFields.filter((_, i) => i !== originalIndex))}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-actions">
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={props.busy} onClick={props.onSave}>
|
||||||
|
<CheckCheck size={14} className="btn-icon" />
|
||||||
|
{t('txt_confirm')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={props.busy} onClick={props.onCancel}>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!props.isCreating && props.selectedCipher && (
|
||||||
|
<button type="button" className="btn btn-danger" disabled={props.busy} onClick={props.onDeleteSelected}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{props.localError && <div className="local-error">{props.localError}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type { RefObject } from 'preact';
|
||||||
|
import { Archive, ArrowUpDown, Check, CheckCheck, FolderInput, Plus, RefreshCw, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
|
import type { Cipher } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import {
|
||||||
|
CREATE_TYPE_OPTIONS,
|
||||||
|
CreateTypeIcon,
|
||||||
|
VAULT_SORT_OPTIONS,
|
||||||
|
VaultListIcon,
|
||||||
|
type SidebarFilter,
|
||||||
|
type VaultSortMode,
|
||||||
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
|
interface VirtualRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
padTop: number;
|
||||||
|
padBottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VaultListPanelProps {
|
||||||
|
busy: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
searchInput: string;
|
||||||
|
sortMode: VaultSortMode;
|
||||||
|
sortMenuOpen: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCipherCount: number;
|
||||||
|
filteredCiphers: Cipher[];
|
||||||
|
visibleCiphers: Cipher[];
|
||||||
|
virtualRange: VirtualRange;
|
||||||
|
selectedCipherId: string;
|
||||||
|
selectedMap: Record<string, boolean>;
|
||||||
|
sidebarFilter: SidebarFilter;
|
||||||
|
createMenuOpen: boolean;
|
||||||
|
createMenuRef: RefObject<HTMLDivElement>;
|
||||||
|
sortMenuRef: RefObject<HTMLDivElement>;
|
||||||
|
listPanelRef: RefObject<HTMLDivElement>;
|
||||||
|
onSearchInput: (value: string) => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
|
onSearchCompositionStart: () => void;
|
||||||
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
|
onToggleSortMenu: () => void;
|
||||||
|
onSelectSortMode: (value: VaultSortMode) => void;
|
||||||
|
onSyncVault: () => void;
|
||||||
|
onOpenBulkDelete: () => void;
|
||||||
|
onSelectDuplicates: () => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onToggleCreateMenu: () => void;
|
||||||
|
onStartCreate: (type: number) => void;
|
||||||
|
onBulkRestore: () => void;
|
||||||
|
onBulkArchive: () => void;
|
||||||
|
onBulkUnarchive: () => void;
|
||||||
|
onOpenMove: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onScroll: (top: number) => void;
|
||||||
|
onToggleSelected: (cipherId: string, checked: boolean) => void;
|
||||||
|
onSelectCipher: (cipherId: string) => void;
|
||||||
|
listSubtitle: (cipher: Cipher) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VaultListPanel(props: VaultListPanelProps) {
|
||||||
|
return (
|
||||||
|
<section className="list-col">
|
||||||
|
<div className="list-head">
|
||||||
|
<div className="search-input-wrap">
|
||||||
|
<input
|
||||||
|
className="search-input"
|
||||||
|
placeholder={t('txt_search_your_secure_vault')}
|
||||||
|
value={props.searchInput}
|
||||||
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
|
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||||
|
e.preventDefault();
|
||||||
|
props.onClearSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!props.searchInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear-btn"
|
||||||
|
aria-label={t('txt_clear_search')}
|
||||||
|
title={t('txt_clear_search_esc')}
|
||||||
|
onClick={props.onClearSearch}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary small sort-trigger ${props.sortMenuOpen ? 'active' : ''}`}
|
||||||
|
aria-label={t('txt_sort')}
|
||||||
|
title={t('txt_sort')}
|
||||||
|
onClick={props.onToggleSortMenu}
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
{props.sortMenuOpen && (
|
||||||
|
<div className="sort-menu">
|
||||||
|
{VAULT_SORT_OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={`sort-menu-item ${props.sortMode === option.value ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectSortMode(option.value)}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{props.sortMode === option.value ? <Check size={14} /> : <span className="sort-menu-check-placeholder" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="list-count" title={t('txt_total_items_count', { count: props.totalCipherCount })}>
|
||||||
|
{t('txt_total_items_count', { count: props.totalCipherCount })}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary small list-icon-btn" disabled={props.busy || props.loading} onClick={props.onSyncVault}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar actions">
|
||||||
|
{props.sidebarFilter.kind === 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
|
||||||
|
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && props.sidebarFilter.kind === 'trash' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkRestore}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" /> {t('txt_restore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && props.sidebarFilter.kind === 'archive' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkUnarchive}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" /> {t('txt_unarchive')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onBulkArchive}>
|
||||||
|
<Archive size={14} className="btn-icon" /> {t('txt_archive_selected')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && props.sidebarFilter.kind !== 'trash' && props.sidebarFilter.kind !== 'archive' && props.sidebarFilter.kind !== 'duplicates' && (
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.busy} onClick={props.onOpenMove}>
|
||||||
|
<FolderInput size={14} className="btn-icon" /> {t('txt_move')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{props.selectedCount > 0 && (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={props.onClearSelection}>
|
||||||
|
<X size={14} className="btn-icon" /> {t('txt_cancel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
|
||||||
|
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
|
||||||
|
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
|
||||||
|
</button>
|
||||||
|
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary small mobile-fab-trigger"
|
||||||
|
aria-label={t('txt_add')}
|
||||||
|
title={t('txt_add')}
|
||||||
|
onClick={props.onToggleCreateMenu}
|
||||||
|
>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
</button>
|
||||||
|
{props.createMenuOpen && (
|
||||||
|
<div className="create-menu">
|
||||||
|
{CREATE_TYPE_OPTIONS.map((option) => (
|
||||||
|
<button key={option.type} type="button" className="create-menu-item" onClick={() => props.onStartCreate(option.type)}>
|
||||||
|
<CreateTypeIcon type={option.type} />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
|
||||||
|
{!!props.filteredCiphers.length && (
|
||||||
|
<div style={{ paddingTop: `${props.virtualRange.padTop}px`, paddingBottom: `${props.virtualRange.padBottom}px` }}>
|
||||||
|
{props.visibleCiphers.map((cipher, index) => (
|
||||||
|
<div
|
||||||
|
key={cipher.id}
|
||||||
|
className={`list-item stagger-item ${props.selectedCipherId === cipher.id ? 'active' : ''}`}
|
||||||
|
style={{ animationDelay: `${Math.min(index, 10) * 26}ms` }}
|
||||||
|
onClick={(event) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest('.row-check')) return;
|
||||||
|
props.onSelectCipher(cipher.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="row-check"
|
||||||
|
checked={!!props.selectedMap[cipher.id]}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onInput={(e) => props.onToggleSelected(cipher.id, (e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="row-main" onClick={() => props.onSelectCipher(cipher.id)}>
|
||||||
|
<div className="list-icon-wrap">
|
||||||
|
<VaultListIcon cipher={cipher} />
|
||||||
|
</div>
|
||||||
|
<div className="list-text">
|
||||||
|
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
||||||
|
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
||||||
|
</span>
|
||||||
|
<span className="list-sub" title={props.listSubtitle(cipher)}>{props.listSubtitle(cipher)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!props.filteredCiphers.length && <div className="empty">{t('txt_no_items')}</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
Copy,
|
||||||
|
CreditCard,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
FolderPlus,
|
||||||
|
FolderX,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
LayoutGrid,
|
||||||
|
ShieldUser,
|
||||||
|
Star,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-preact';
|
||||||
|
import type { Folder } from '@/lib/types';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { SidebarFilter } from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
|
interface VaultSidebarProps {
|
||||||
|
folders: Folder[];
|
||||||
|
sidebarFilter: SidebarFilter;
|
||||||
|
busy: boolean;
|
||||||
|
isMobileLayout: boolean;
|
||||||
|
mobileSidebarOpen: boolean;
|
||||||
|
onCloseMobileSidebar: () => void;
|
||||||
|
onChangeFilter: (filter: SidebarFilter) => void;
|
||||||
|
onOpenDeleteAllFolders: () => void;
|
||||||
|
onOpenCreateFolder: () => void;
|
||||||
|
onOpenDeleteFolder: (folder: Folder) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VaultSidebar(props: VaultSidebarProps) {
|
||||||
|
return (
|
||||||
|
<aside className={`sidebar ${props.isMobileLayout ? 'mobile-sidebar-sheet' : ''} ${props.isMobileLayout && props.mobileSidebarOpen ? 'open' : ''}`}>
|
||||||
|
{props.isMobileLayout && (
|
||||||
|
<div className="mobile-sidebar-head">
|
||||||
|
<div className="mobile-sidebar-title">{t('txt_folders')}</div>
|
||||||
|
<button type="button" className="mobile-sidebar-close" onClick={props.onCloseMobileSidebar} aria-label={t('txt_close')}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
||||||
|
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'favorite' })}>
|
||||||
|
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'archive' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'archive' })}>
|
||||||
|
<Archive size={14} className="tree-icon" /> <span className="tree-label">{t('txt_archive')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'trash' })}>
|
||||||
|
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'duplicates' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'duplicates' })}>
|
||||||
|
<Copy size={14} className="tree-icon" /> <span className="tree-label">{t('txt_duplicates')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title">{t('txt_type')}</div>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'login' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'login' })}>
|
||||||
|
<Globe size={14} className="tree-icon" /> <span className="tree-label">{t('txt_login')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}>
|
||||||
|
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}>
|
||||||
|
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}>
|
||||||
|
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'ssh' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'ssh' })}>
|
||||||
|
<KeyRound size={14} className="tree-icon" /> <span className="tree-label">{t('txt_ssh_key')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-block">
|
||||||
|
<div className="sidebar-title-row">
|
||||||
|
<div className="sidebar-title">{t('txt_folders')}</div>
|
||||||
|
<div className="folder-title-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="folder-delete-btn"
|
||||||
|
title={t('txt_delete_all_folders')}
|
||||||
|
aria-label={t('txt_delete_all_folders')}
|
||||||
|
disabled={props.busy || props.folders.length === 0}
|
||||||
|
onClick={props.onOpenDeleteAllFolders}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="folder-add-btn" onClick={props.onOpenCreateFolder}>
|
||||||
|
<FolderPlus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === null ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'folder', folderId: null })}>
|
||||||
|
<FolderX size={14} className="tree-icon" /> <span className="tree-label">{t('txt_no_folder')}</span>
|
||||||
|
</button>
|
||||||
|
{props.folders.map((folder) => (
|
||||||
|
<div key={folder.id} className="folder-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tree-btn ${props.sidebarFilter.kind === 'folder' && props.sidebarFilter.folderId === folder.id ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onChangeFilter({ kind: 'folder', folderId: folder.id })}
|
||||||
|
>
|
||||||
|
<FolderIcon size={14} className="tree-icon" />
|
||||||
|
<span className="tree-label" title={folder.decName || folder.name || folder.id}>
|
||||||
|
{folder.decName || folder.name || folder.id}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="folder-delete-btn"
|
||||||
|
title={t('txt_delete')}
|
||||||
|
aria-label={t('txt_delete')}
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.onOpenDeleteFolder(folder);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
FileKey2,
|
||||||
|
Globe,
|
||||||
|
KeyRound,
|
||||||
|
ShieldUser,
|
||||||
|
StickyNote,
|
||||||
|
} from 'lucide-preact';
|
||||||
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
|
||||||
|
|
||||||
|
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
|
||||||
|
export type VaultSortMode = 'edited' | 'created' | 'name';
|
||||||
|
export type SidebarFilter =
|
||||||
|
| { kind: 'all' }
|
||||||
|
| { kind: 'favorite' }
|
||||||
|
| { kind: 'archive' }
|
||||||
|
| { kind: 'trash' }
|
||||||
|
| { kind: 'duplicates' }
|
||||||
|
| { kind: 'type'; value: TypeFilter }
|
||||||
|
| { kind: 'folder'; folderId: string | null };
|
||||||
|
|
||||||
|
interface TypeOption {
|
||||||
|
type: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||||
|
{ type: 1, label: t('txt_login') },
|
||||||
|
{ type: 3, label: t('txt_card') },
|
||||||
|
{ type: 4, label: t('txt_identity') },
|
||||||
|
{ type: 2, label: t('txt_note') },
|
||||||
|
{ type: 5, label: t('txt_ssh_key') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||||
|
export const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||||
|
export const VAULT_LIST_ROW_HEIGHT = 66;
|
||||||
|
export const VAULT_LIST_OVERSCAN = 10;
|
||||||
|
export const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||||
|
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||||
|
{ value: 'created', label: t('txt_sort_created') },
|
||||||
|
{ value: 'name', label: t('txt_sort_name') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [
|
||||||
|
{ value: 0, label: t('txt_text') },
|
||||||
|
{ value: 1, label: t('txt_hidden') },
|
||||||
|
{ value: 2, label: t('txt_boolean') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WEBSITE_MATCH_OPTIONS: Array<{ value: number | null; label: string }> = [
|
||||||
|
{ value: null, label: t('txt_uri_match_default_base_domain') },
|
||||||
|
{ value: 0, label: t('txt_uri_match_base_domain') },
|
||||||
|
{ value: 1, label: t('txt_uri_match_host') },
|
||||||
|
{ value: 3, label: t('txt_uri_match_exact') },
|
||||||
|
{ value: 5, label: t('txt_uri_match_never') },
|
||||||
|
{ value: 2, label: t('txt_uri_match_starts_with') },
|
||||||
|
{ value: 4, label: t('txt_uri_match_regular_expression') },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOTP_PERIOD_SECONDS = 30;
|
||||||
|
export const TOTP_RING_RADIUS = 14;
|
||||||
|
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
|
||||||
|
export function CreateTypeIcon({ type }: { type: number }) {
|
||||||
|
if (type === 1) return <Globe size={15} />;
|
||||||
|
if (type === 3) return <CreditCard size={15} />;
|
||||||
|
if (type === 4) return <ShieldUser size={15} />;
|
||||||
|
if (type === 2) return <StickyNote size={15} />;
|
||||||
|
if (type === 5) return <KeyRound size={15} />;
|
||||||
|
return <FileKey2 size={15} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cipherTypeKey(type: number): TypeFilter {
|
||||||
|
if (type === 1) return 'login';
|
||||||
|
if (type === 3) return 'card';
|
||||||
|
if (type === 4) return 'identity';
|
||||||
|
if (type === 2) return 'note';
|
||||||
|
return 'ssh';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cipherDeletedValue(cipher: Cipher): boolean {
|
||||||
|
return !!(cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cipherArchivedValue(cipher: Cipher): boolean {
|
||||||
|
return !!(cipher.archivedDate || (cipher as { archivedAt?: string | null }).archivedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherDeleted(cipher: Cipher): boolean {
|
||||||
|
return cipherDeletedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherArchived(cipher: Cipher): boolean {
|
||||||
|
return cipherArchivedValue(cipher) && !cipherDeletedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherVisibleInNormalVault(cipher: Cipher): boolean {
|
||||||
|
return !cipherDeletedValue(cipher) && !cipherArchivedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherVisibleInArchive(cipher: Cipher): boolean {
|
||||||
|
return !cipherDeletedValue(cipher) && cipherArchivedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCipherVisibleInTrash(cipher: Cipher): boolean {
|
||||||
|
return cipherDeletedValue(cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cipherTypeLabel(type: number): string {
|
||||||
|
if (type === 1) return t('txt_login');
|
||||||
|
if (type === 3) return t('txt_card');
|
||||||
|
if (type === 4) return t('txt_identity');
|
||||||
|
if (type === 2) return t('txt_secure_note');
|
||||||
|
if (type === 5) return t('txt_ssh_key');
|
||||||
|
return t('txt_item');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TypeIcon({ type }: { type: number }) {
|
||||||
|
if (type === 1) return <Globe size={18} />;
|
||||||
|
if (type === 3) return <CreditCard size={18} />;
|
||||||
|
if (type === 4) return <ShieldUser size={18} />;
|
||||||
|
if (type === 2) return <StickyNote size={18} />;
|
||||||
|
if (type === 5) return <KeyRound size={18} />;
|
||||||
|
return <FileKey2 size={18} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFieldType(value: number | string | null | undefined): CustomFieldType {
|
||||||
|
if (value === 1 || value === 2 || value === 3) return value;
|
||||||
|
if (value === '1' || String(value).toLowerCase() === 'hidden') return 1;
|
||||||
|
if (value === '2' || String(value).toLowerCase() === 'boolean') return 2;
|
||||||
|
if (value === '3' || String(value).toLowerCase() === 'linked') return 3;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBooleanFieldValue(raw: string): boolean {
|
||||||
|
const v = String(raw || '').trim().toLowerCase();
|
||||||
|
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstCipherUri(cipher: Cipher): string {
|
||||||
|
const uris = cipher.login?.uris || [];
|
||||||
|
for (const uri of uris) {
|
||||||
|
const raw = uri.decUri || uri.uri || '';
|
||||||
|
if (raw.trim()) return raw.trim();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostFromUri(uri: string): string {
|
||||||
|
if (!uri.trim()) return '';
|
||||||
|
try {
|
||||||
|
const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`;
|
||||||
|
return new URL(normalized).hostname || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function websiteIconUrl(host: string): string {
|
||||||
|
return `/icons/${encodeURIComponent(host)}/icon.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyLoginUri(): VaultDraftLoginUri {
|
||||||
|
return { uri: '', match: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function websiteMatchLabel(value: number | null | undefined): string {
|
||||||
|
const normalized = typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
return WEBSITE_MATCH_OPTIONS.find((option) => option.value === normalized)?.label || t('txt_uri_match_default_base_domain');
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueOrFallback(value: string | null | undefined): string {
|
||||||
|
return String(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||||
|
const normalized = {
|
||||||
|
type: Number(cipher.type || 1),
|
||||||
|
folderId: cipher.folderId || null,
|
||||||
|
favorite: !!cipher.favorite,
|
||||||
|
reprompt: Number(cipher.reprompt || 0),
|
||||||
|
name: valueOrFallback(cipher.decName ?? cipher.name),
|
||||||
|
notes: valueOrFallback(cipher.decNotes ?? cipher.notes),
|
||||||
|
login: cipher.login
|
||||||
|
? {
|
||||||
|
username: valueOrFallback(cipher.login.decUsername ?? cipher.login.username),
|
||||||
|
password: valueOrFallback(cipher.login.decPassword ?? cipher.login.password),
|
||||||
|
totp: valueOrFallback(cipher.login.decTotp ?? cipher.login.totp),
|
||||||
|
uris: (cipher.login.uris || []).map((uri) => ({
|
||||||
|
uri: valueOrFallback(uri.decUri ?? uri.uri),
|
||||||
|
match: uri.match ?? null,
|
||||||
|
})),
|
||||||
|
fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({
|
||||||
|
creationDate: valueOrFallback(credential.creationDate),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
card: cipher.card
|
||||||
|
? {
|
||||||
|
cardholderName: valueOrFallback(cipher.card.decCardholderName ?? cipher.card.cardholderName),
|
||||||
|
number: valueOrFallback(cipher.card.decNumber ?? cipher.card.number),
|
||||||
|
brand: valueOrFallback(cipher.card.decBrand ?? cipher.card.brand),
|
||||||
|
expMonth: valueOrFallback(cipher.card.decExpMonth ?? cipher.card.expMonth),
|
||||||
|
expYear: valueOrFallback(cipher.card.decExpYear ?? cipher.card.expYear),
|
||||||
|
code: valueOrFallback(cipher.card.decCode ?? cipher.card.code),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
identity: cipher.identity
|
||||||
|
? {
|
||||||
|
title: valueOrFallback(cipher.identity.decTitle ?? cipher.identity.title),
|
||||||
|
firstName: valueOrFallback(cipher.identity.decFirstName ?? cipher.identity.firstName),
|
||||||
|
middleName: valueOrFallback(cipher.identity.decMiddleName ?? cipher.identity.middleName),
|
||||||
|
lastName: valueOrFallback(cipher.identity.decLastName ?? cipher.identity.lastName),
|
||||||
|
username: valueOrFallback(cipher.identity.decUsername ?? cipher.identity.username),
|
||||||
|
company: valueOrFallback(cipher.identity.decCompany ?? cipher.identity.company),
|
||||||
|
ssn: valueOrFallback(cipher.identity.decSsn ?? cipher.identity.ssn),
|
||||||
|
passportNumber: valueOrFallback(cipher.identity.decPassportNumber ?? cipher.identity.passportNumber),
|
||||||
|
licenseNumber: valueOrFallback(cipher.identity.decLicenseNumber ?? cipher.identity.licenseNumber),
|
||||||
|
email: valueOrFallback(cipher.identity.decEmail ?? cipher.identity.email),
|
||||||
|
phone: valueOrFallback(cipher.identity.decPhone ?? cipher.identity.phone),
|
||||||
|
address1: valueOrFallback(cipher.identity.decAddress1 ?? cipher.identity.address1),
|
||||||
|
address2: valueOrFallback(cipher.identity.decAddress2 ?? cipher.identity.address2),
|
||||||
|
address3: valueOrFallback(cipher.identity.decAddress3 ?? cipher.identity.address3),
|
||||||
|
city: valueOrFallback(cipher.identity.decCity ?? cipher.identity.city),
|
||||||
|
state: valueOrFallback(cipher.identity.decState ?? cipher.identity.state),
|
||||||
|
postalCode: valueOrFallback(cipher.identity.decPostalCode ?? cipher.identity.postalCode),
|
||||||
|
country: valueOrFallback(cipher.identity.decCountry ?? cipher.identity.country),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
sshKey: cipher.sshKey
|
||||||
|
? {
|
||||||
|
privateKey: valueOrFallback(cipher.sshKey.decPrivateKey ?? cipher.sshKey.privateKey),
|
||||||
|
publicKey: valueOrFallback(cipher.sshKey.decPublicKey ?? cipher.sshKey.publicKey),
|
||||||
|
fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
secureNoteType: cipher.secureNote?.type ?? null,
|
||||||
|
fields: (cipher.fields || []).map((field) => ({
|
||||||
|
type: field.type ?? null,
|
||||||
|
name: valueOrFallback(field.decName ?? field.name),
|
||||||
|
value: valueOrFallback(field.decValue ?? field.value),
|
||||||
|
linkedId: field.linkedId ?? null,
|
||||||
|
})),
|
||||||
|
passwordHistory: (cipher.passwordHistory || []).map((entry) => ({
|
||||||
|
password: valueOrFallback(entry.password),
|
||||||
|
lastUsedDate: valueOrFallback(entry.lastUsedDate),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return JSON.stringify(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEmptyDraft(type: number): VaultDraft {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
favorite: false,
|
||||||
|
name: '',
|
||||||
|
folderId: '',
|
||||||
|
notes: '',
|
||||||
|
reprompt: false,
|
||||||
|
loginUsername: '',
|
||||||
|
loginPassword: '',
|
||||||
|
loginTotp: '',
|
||||||
|
loginUris: [createEmptyLoginUri()],
|
||||||
|
loginFido2Credentials: [],
|
||||||
|
cardholderName: '',
|
||||||
|
cardNumber: '',
|
||||||
|
cardBrand: '',
|
||||||
|
cardExpMonth: '',
|
||||||
|
cardExpYear: '',
|
||||||
|
cardCode: '',
|
||||||
|
identTitle: '',
|
||||||
|
identFirstName: '',
|
||||||
|
identMiddleName: '',
|
||||||
|
identLastName: '',
|
||||||
|
identUsername: '',
|
||||||
|
identCompany: '',
|
||||||
|
identSsn: '',
|
||||||
|
identPassportNumber: '',
|
||||||
|
identLicenseNumber: '',
|
||||||
|
identEmail: '',
|
||||||
|
identPhone: '',
|
||||||
|
identAddress1: '',
|
||||||
|
identAddress2: '',
|
||||||
|
identAddress3: '',
|
||||||
|
identCity: '',
|
||||||
|
identState: '',
|
||||||
|
identPostalCode: '',
|
||||||
|
identCountry: '',
|
||||||
|
sshPrivateKey: '',
|
||||||
|
sshPublicKey: '',
|
||||||
|
sshFingerprint: '',
|
||||||
|
customFields: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||||
|
const draft = createEmptyDraft(Number(cipher.type || 1));
|
||||||
|
draft.id = cipher.id;
|
||||||
|
draft.favorite = !!cipher.favorite;
|
||||||
|
draft.name = cipher.decName || '';
|
||||||
|
draft.folderId = cipher.folderId || '';
|
||||||
|
draft.notes = cipher.decNotes || '';
|
||||||
|
draft.reprompt = Number(cipher.reprompt || 0) === 1;
|
||||||
|
|
||||||
|
if (cipher.login) {
|
||||||
|
draft.loginUsername = cipher.login.decUsername || '';
|
||||||
|
draft.loginPassword = cipher.login.decPassword || '';
|
||||||
|
draft.loginTotp = cipher.login.decTotp || '';
|
||||||
|
draft.loginUris = (cipher.login.uris || []).map((x) => ({
|
||||||
|
uri: x.decUri || x.uri || '',
|
||||||
|
match: x.match ?? null,
|
||||||
|
}));
|
||||||
|
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||||
|
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||||
|
: [];
|
||||||
|
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||||
|
}
|
||||||
|
if (cipher.card) {
|
||||||
|
draft.cardholderName = cipher.card.decCardholderName || '';
|
||||||
|
draft.cardNumber = cipher.card.decNumber || '';
|
||||||
|
draft.cardBrand = cipher.card.decBrand || '';
|
||||||
|
draft.cardExpMonth = cipher.card.decExpMonth || '';
|
||||||
|
draft.cardExpYear = cipher.card.decExpYear || '';
|
||||||
|
draft.cardCode = cipher.card.decCode || '';
|
||||||
|
}
|
||||||
|
if (cipher.identity) {
|
||||||
|
draft.identTitle = cipher.identity.decTitle || '';
|
||||||
|
draft.identFirstName = cipher.identity.decFirstName || '';
|
||||||
|
draft.identMiddleName = cipher.identity.decMiddleName || '';
|
||||||
|
draft.identLastName = cipher.identity.decLastName || '';
|
||||||
|
draft.identUsername = cipher.identity.decUsername || '';
|
||||||
|
draft.identCompany = cipher.identity.decCompany || '';
|
||||||
|
draft.identSsn = cipher.identity.decSsn || '';
|
||||||
|
draft.identPassportNumber = cipher.identity.decPassportNumber || '';
|
||||||
|
draft.identLicenseNumber = cipher.identity.decLicenseNumber || '';
|
||||||
|
draft.identEmail = cipher.identity.decEmail || '';
|
||||||
|
draft.identPhone = cipher.identity.decPhone || '';
|
||||||
|
draft.identAddress1 = cipher.identity.decAddress1 || '';
|
||||||
|
draft.identAddress2 = cipher.identity.decAddress2 || '';
|
||||||
|
draft.identAddress3 = cipher.identity.decAddress3 || '';
|
||||||
|
draft.identCity = cipher.identity.decCity || '';
|
||||||
|
draft.identState = cipher.identity.decState || '';
|
||||||
|
draft.identPostalCode = cipher.identity.decPostalCode || '';
|
||||||
|
draft.identCountry = cipher.identity.decCountry || '';
|
||||||
|
}
|
||||||
|
if (cipher.sshKey) {
|
||||||
|
draft.sshPrivateKey = cipher.sshKey.decPrivateKey || '';
|
||||||
|
draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
|
||||||
|
draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
|
||||||
|
}
|
||||||
|
draft.customFields = (cipher.fields || []).map((field) => ({
|
||||||
|
type: parseFieldType(field.type),
|
||||||
|
label: field.decName || '',
|
||||||
|
value: field.decValue || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskSecret(value: string): string {
|
||||||
|
if (!value) return '';
|
||||||
|
return '*'.repeat(Math.max(8, Math.min(24, value.length)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTotp(code: string): string {
|
||||||
|
if (!code) return code;
|
||||||
|
if (code.length === 5) return `${code.slice(0, 2)} ${code.slice(2)}`;
|
||||||
|
if (code.length < 6) return code;
|
||||||
|
return `${code.slice(0, 3)} ${code.slice(3, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHistoryTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return t('txt_dash');
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!Number.isFinite(date.getTime())) return value;
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAttachmentSizeBytes(attachment: CipherAttachment): number {
|
||||||
|
const raw = attachment?.size;
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAttachmentSize(attachment: CipherAttachment): string {
|
||||||
|
const sizeName = String(attachment?.sizeName || '').trim();
|
||||||
|
if (sizeName) return sizeName;
|
||||||
|
const bytes = parseAttachmentSizeBytes(attachment);
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortTimeValue(cipher: Cipher): number {
|
||||||
|
const candidates = [cipher.revisionDate, cipher.creationDate];
|
||||||
|
for (const value of candidates) {
|
||||||
|
const time = new Date(String(value || '')).getTime();
|
||||||
|
if (Number.isFinite(time)) return time;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function creationTimeValue(cipher: Cipher): number {
|
||||||
|
const time = new Date(String(cipher.creationDate || '')).getTime();
|
||||||
|
return Number.isFinite(time) ? time : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||||
|
const credentials = cipher?.login?.fido2Credentials;
|
||||||
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
|
for (const credential of credentials) {
|
||||||
|
const raw = String(credential?.creationDate || '').trim();
|
||||||
|
if (raw) return raw;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
|
const uri = firstCipherUri(cipher);
|
||||||
|
const host = hostFromUri(uri);
|
||||||
|
const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false));
|
||||||
|
if (host && !errored) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
className="list-icon"
|
||||||
|
src={websiteIconUrl(host)}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onError={() => {
|
||||||
|
failedIconHosts.add(host);
|
||||||
|
setErrored(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="list-icon-fallback">
|
||||||
|
<TypeIcon type={Number(cipher.type || 1)} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyToClipboard(value: string): void {
|
||||||
|
if (!value.trim()) return;
|
||||||
|
void copyTextToClipboard(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openUri(raw: string): void {
|
||||||
|
const value = raw.trim();
|
||||||
|
if (!value) return;
|
||||||
|
const url = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
||||||
|
window.open(url, '_blank', 'noopener');
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { useMemo } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
changeMasterPassword,
|
||||||
|
deleteAllAuthorizedDevices,
|
||||||
|
deleteAuthorizedDevice,
|
||||||
|
deriveLoginHash,
|
||||||
|
getCurrentDeviceIdentifier,
|
||||||
|
getTotpRecoveryCode,
|
||||||
|
revokeAuthorizedDeviceTrust,
|
||||||
|
revokeAllAuthorizedDeviceTrust,
|
||||||
|
setTotp,
|
||||||
|
updateProfile,
|
||||||
|
} from '@/lib/api/auth';
|
||||||
|
import { t } from '@/lib/i18n';
|
||||||
|
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||||
|
import type { AuthedFetch } from '@/lib/api/shared';
|
||||||
|
import type { AuthorizedDevice, Profile } from '@/lib/types';
|
||||||
|
|
||||||
|
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
|
|
||||||
|
interface UseAccountSecurityActionsOptions {
|
||||||
|
authedFetch: AuthedFetch;
|
||||||
|
profile: Profile | null;
|
||||||
|
defaultKdfIterations: number;
|
||||||
|
disableTotpPassword: string;
|
||||||
|
clearDisableTotpDialog: () => void;
|
||||||
|
onLogoutNow: () => void;
|
||||||
|
onNotify: Notify;
|
||||||
|
onProfileUpdated: (profile: Profile) => void;
|
||||||
|
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||||
|
refetchTotpStatus: () => Promise<unknown>;
|
||||||
|
refetchAuthorizedDevices: () => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useAccountSecurityActions(options: UseAccountSecurityActionsOptions) {
|
||||||
|
const {
|
||||||
|
authedFetch,
|
||||||
|
profile,
|
||||||
|
defaultKdfIterations,
|
||||||
|
disableTotpPassword,
|
||||||
|
clearDisableTotpDialog,
|
||||||
|
onLogoutNow,
|
||||||
|
onNotify,
|
||||||
|
onProfileUpdated,
|
||||||
|
onSetConfirm,
|
||||||
|
refetchTotpStatus,
|
||||||
|
refetchAuthorizedDevices,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
|
||||||
|
if (!profile) return;
|
||||||
|
if (!currentPassword || !nextPassword) {
|
||||||
|
onNotify('error', t('txt_current_new_password_is_required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextPassword.length < 12) {
|
||||||
|
onNotify('error', t('txt_new_password_must_be_at_least_12_chars'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextPassword !== nextPassword2) {
|
||||||
|
onNotify('error', t('txt_new_passwords_do_not_match'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_change_master_password'),
|
||||||
|
message: t('txt_change_password_confirm_and_sign_out_all_devices'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await changeMasterPassword(authedFetch, {
|
||||||
|
email: profile.email,
|
||||||
|
currentPassword,
|
||||||
|
newPassword: nextPassword,
|
||||||
|
currentIterations: defaultKdfIterations,
|
||||||
|
profileKey: profile.key,
|
||||||
|
});
|
||||||
|
onNotify('success', t('txt_master_password_changed_signing_out_everywhere'));
|
||||||
|
onLogoutNow();
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_change_password_failed'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async savePasswordHint(masterPasswordHint: string) {
|
||||||
|
if (!profile) return;
|
||||||
|
const normalized = String(masterPasswordHint || '').trim();
|
||||||
|
if (normalized.length > 120) {
|
||||||
|
onNotify('error', t('txt_password_hint_too_long'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const nextProfile = await updateProfile(authedFetch, { masterPasswordHint: normalized });
|
||||||
|
onProfileUpdated(nextProfile);
|
||||||
|
onNotify('success', t('txt_profile_updated'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_save_profile_failed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableTotp(secret: string, token: string) {
|
||||||
|
if (!secret.trim() || !token.trim()) {
|
||||||
|
const error = new Error(t('txt_secret_and_code_are_required'));
|
||||||
|
onNotify('error', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() });
|
||||||
|
onNotify('success', t('txt_totp_enabled'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async disableTotp() {
|
||||||
|
if (!profile) return;
|
||||||
|
if (!disableTotpPassword) {
|
||||||
|
onNotify('error', t('txt_please_input_master_password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||||
|
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||||
|
if (profile.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`);
|
||||||
|
clearDisableTotpDialog();
|
||||||
|
await refetchTotpStatus();
|
||||||
|
onNotify('success', t('txt_totp_disabled'));
|
||||||
|
} catch (error) {
|
||||||
|
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||||
|
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||||
|
const normalized = String(masterPassword || '');
|
||||||
|
if (!normalized) throw new Error(t('txt_master_password_is_required'));
|
||||||
|
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
|
||||||
|
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
|
||||||
|
if (!code) throw new Error(t('txt_recovery_code_is_empty'));
|
||||||
|
return code;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshAuthorizedDevices() {
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
},
|
||||||
|
|
||||||
|
openRevokeDeviceTrust(device: AuthorizedDevice) {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_revoke_device_authorization'),
|
||||||
|
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_device_authorization_revoked'));
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openRemoveDevice(device: AuthorizedDevice) {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_remove_device'),
|
||||||
|
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
await deleteAuthorizedDevice(authedFetch, device.identifier);
|
||||||
|
if (device.identifier === getCurrentDeviceIdentifier()) {
|
||||||
|
onNotify('success', t('txt_device_removed'));
|
||||||
|
onLogoutNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_device_removed'));
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openRevokeAllDeviceTrust() {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_revoke_all_trusted_devices'),
|
||||||
|
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
await revokeAllAuthorizedDeviceTrust(authedFetch);
|
||||||
|
await refetchAuthorizedDevices();
|
||||||
|
onNotify('success', t('txt_all_device_authorizations_revoked'));
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
openRemoveAllDevices() {
|
||||||
|
onSetConfirm({
|
||||||
|
title: t('txt_remove_all_devices'),
|
||||||
|
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => {
|
||||||
|
onSetConfirm(null);
|
||||||
|
void (async () => {
|
||||||
|
await deleteAllAuthorizedDevices(authedFetch);
|
||||||
|
onNotify('success', t('txt_all_devices_removed'));
|
||||||
|
onLogoutNow();
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
authedFetch,
|
||||||
|
clearDisableTotpDialog,
|
||||||
|
defaultKdfIterations,
|
||||||
|
disableTotpPassword,
|
||||||
|
onLogoutNow,
|
||||||
|
onNotify,
|
||||||
|
onProfileUpdated,
|
||||||
|
onSetConfirm,
|
||||||
|
profile,
|
||||||
|
refetchAuthorizedDevices,
|
||||||
|
refetchTotpStatus,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user