Compare commits

..
56 Commits
Author SHA1 Message Date
rootphantomerandShuai 1ec6ed44a1 fix: reject plaintext FIDO2, SSH keys, and password history on import
Validate encrypted-string fields in validateCipherEncryptedFieldsForCompatibility
before they reach storage:

- FIDO2 credentials (12 fields: 8 required + 4 optional)
- SSH key (privateKey, publicKey, keyFingerprint/fingerprint)
- Password history (password per entry)

This closes a defense gap where plaintext in these positions was silently
accepted on import and later discarded at response time.
2026-07-02 17:36:06 +08:00
shuaiplus 6284c632de Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-02 17:23:47 +08:00
shuaiplus 60dd298dee fix(security): harden jwt config and password rotation 2026-07-02 17:20:51 +08:00
shuaiplus 439683d350 fix(identity): add security stamp and invalidate user cache on token handling 2026-07-02 16:57:24 +08:00
shuaiplus 1545881eae fix(auth): hash stored api keys 2026-07-02 16:27:20 +08:00
shuaiplus 680e287c8d fix(ci): validate global domains sync ref 2026-07-02 16:11:19 +08:00
shuaiplus baf569983d fix(security): scope storage reads by user 2026-07-02 16:03:49 +08:00
Matt Van HornandShuai 73bbe8b268 perf: throttle jsQR camera fallback to a few decodes per second 2026-07-01 17:07:30 +08:00
Matt Van HornandShuai d024798548 fix: composite transparent QR uploads over white before jsQR decode 2026-07-01 17:07:30 +08:00
Matt Van HornandShuai b0a679b1c2 fix: decode uploaded TOTP QR images when BarcodeDetector is unavailable
The TOTP QR reader relied solely on window.BarcodeDetector. On desktop
Chrome/Edge (Windows/Linux) that interface exists but has no working
backend, so detect() returns an empty array: uploading a valid QR image
fell through to "no QR code found" and the camera path bailed to
"unsupported" with an empty preview.

Add a dependency-free jsQR canvas fallback. decodeTotpQrImage now tries
BarcodeDetector first when present, then decodes the image via jsQR
before reporting not-found. The camera reader no longer hard-returns
"unsupported" when only BarcodeDetector is missing: it starts the camera
whenever getUserMedia is available and decodes frames with jsQR, which
also lets the preview render.

Fixes #276
2026-07-01 17:07:30 +08:00
shuaiplus ce3674669e feat: update version to 1.7.2 in package.json, package-lock.json, and app-version.ts 2026-07-01 13:36:59 +08:00
shuaiplus aa7b87e041 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-07-01 13:28:26 +08:00
shuaiplus e4215b4025 feat: add fill-assist handlers and update device response type 2026-07-01 13:28:22 +08:00
dependabot[bot]andShuai 8d292ca7b8 chore(deps): bump the npm-minor-and-patch group across 1 directory with 15 updates
Bumps the npm-minor-and-patch group with 15 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@noble/hashes](https://github.com/paulmillr/noble-hashes) | `2.0.1` | `2.2.0` |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.1` | `13.3.2` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.90.21` | `5.101.2` |
| [@zip.js/zip.js](https://github.com/gildas-lormeau/zip.js) | `2.8.22` | `2.8.26` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [preact](https://github.com/preactjs/preact) | `10.28.4` | `10.29.3` |
| [wouter](https://github.com/molefrog/wouter) | `3.9.0` | `3.10.0` |
| [@cloudflare/workers-types](https://github.com/cloudflare/workerd) | `4.20260609.1` | `4.20260630.1` |
| [@preact/preset-vite](https://github.com/preactjs/preset-vite) | `2.10.3` | `2.10.5` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.21` | `10.5.2` |
| [opencc-js](https://github.com/nk2028/opencc-js) | `1.0.5` | `1.3.2` |
| [postcss](https://github.com/postcss/postcss) | `8.5.15` | `8.5.16` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `3.4.17` | `3.4.19` |
| [tsx](https://github.com/privatenumber/tsx) | `4.21.0` | `4.22.4` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.98.0` | `4.105.0` |



Updates `@noble/hashes` from 2.0.1 to 2.2.0
- [Release notes](https://github.com/paulmillr/noble-hashes/releases)
- [Commits](https://github.com/paulmillr/noble-hashes/compare/2.0.1...2.2.0)

Updates `@simplewebauthn/server` from 13.3.1 to 13.3.2
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.2/packages/server)

Updates `@tanstack/react-query` from 5.90.21 to 5.101.2
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.2/packages/react-query)

Updates `@zip.js/zip.js` from 2.8.22 to 2.8.26
- [Release notes](https://github.com/gildas-lormeau/zip.js/releases)
- [Commits](https://github.com/gildas-lormeau/zip.js/compare/v2.8.22...v2.8.26)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

Updates `preact` from 10.28.4 to 10.29.3
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.28.4...10.29.3)

Updates `wouter` from 3.9.0 to 3.10.0
- [Release notes](https://github.com/molefrog/wouter/releases)
- [Commits](https://github.com/molefrog/wouter/commits)

Updates `@cloudflare/workers-types` from 4.20260609.1 to 4.20260630.1
- [Release notes](https://github.com/cloudflare/workerd/releases)
- [Changelog](https://github.com/cloudflare/workerd/blob/main/RELEASE.md)
- [Commits](https://github.com/cloudflare/workerd/commits)

Updates `@preact/preset-vite` from 2.10.3 to 2.10.5
- [Release notes](https://github.com/preactjs/preset-vite/releases)
- [Commits](https://github.com/preactjs/preset-vite/compare/2.10.3...2.10.5)

Updates `autoprefixer` from 10.4.21 to 10.5.2
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.21...10.5.2)

Updates `opencc-js` from 1.0.5 to 1.3.2
- [Release notes](https://github.com/nk2028/opencc-js/releases)
- [Changelog](https://github.com/nk2028/opencc-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/nk2028/opencc-js/compare/v1.0.5...v1.3.2)

Updates `postcss` from 8.5.15 to 8.5.16
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.15...8.5.16)

Updates `tailwindcss` from 3.4.17 to 3.4.19
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v3.4.19/packages/tailwindcss)

Updates `tsx` from 4.21.0 to 4.22.4
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.4)

Updates `wrangler` from 4.98.0 to 4.105.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.105.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@cloudflare/workers-types"
  dependency-version: 4.20260630.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@noble/hashes"
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@preact/preset-vite"
  dependency-version: 2.10.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@zip.js/zip.js"
  dependency-version: 2.8.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: autoprefixer
  dependency-version: 10.5.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: opencc-js
  dependency-version: 1.3.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: preact
  dependency-version: 10.29.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: tailwindcss
  dependency-version: 3.4.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: tsx
  dependency-version: 4.22.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wouter
  dependency-version: 3.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.105.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 13:55:20 +08:00
shuaiplus 5dd9dff045 ci: update actions/checkout and other actions to specific versions 2026-06-30 13:49:33 +08:00
shuaiplus 709a8c1768 Merge pull request #279 from dependabot/github_actions/github-actions-db669df06a 2026-06-30 13:44:53 +08:00
shuaiplus e2c3516ce9 Merge pull request #284 from dependabot/npm_and_yarn/lucide-preact-1.22.0 2026-06-30 13:43:17 +08:00
shuaiplus 55b5c57f9e Merge pull request #283 from dependabot/npm_and_yarn/typescript-6.0.3 2026-06-30 13:43:07 +08:00
shuaiplus b6fb62603b Merge pull request #282 from dependabot/npm_and_yarn/types/node-26.0.1 2026-06-30 13:42:57 +08:00
shuaiplus 35071c2719 ci: limit risky dependabot updates 2026-06-30 13:36:00 +08:00
dependabot[bot]andGitHub 5bd7dab277 chore(deps): bump lucide-preact from 0.575.0 to 1.22.0
Bumps [lucide-preact](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-preact) from 0.575.0 to 1.22.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.22.0/packages/lucide-preact)

---
updated-dependencies:
- dependency-name: lucide-preact
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:28:03 +00:00
dependabot[bot]andGitHub 99f2d7f444 chore(deps-dev): bump typescript from 5.9.3 to 6.0.3
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:28:02 +00:00
dependabot[bot]andGitHub fb9a2aeda1 chore(deps-dev): bump @types/node from 25.2.3 to 26.0.1
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.2.3 to 26.0.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:27:57 +00:00
dependabot[bot]andGitHub c87e6ac984 chore(deps): bump the github-actions group with 3 updates
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-node](https://github.com/actions/setup-node) and [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request).


Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `peter-evans/create-pull-request` from 6 to 8
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 05:26:28 +00:00
shuaiplus a6f1c6dea2 Merge branch 'beta' 2026-06-30 13:25:22 +08:00
shuaiplus 49c872a8ec ci: fix sync workflow shell lint 2026-06-30 13:18:02 +08:00
shuaiplus 78af1f9bdd ci: skip scorecard outside main 2026-06-30 13:14:23 +08:00
shuaiplus 32b3d2ade1 chore: override ws vulnerability 2026-06-30 13:11:07 +08:00
shuaiplus 64f26e76f6 chore: add security automation workflows 2026-06-30 13:05:04 +08:00
rootphantomerandShuai 68c42a0330 fix: preserve multiline values (e.g. SSH private keys) during CSV import
parseBitwardenCsvFieldLines previously discarded any field line that did not
contain the ': ' delimiter, truncating multiline values like OpenSSH private
keys to only their first line.

Replace the map+filter pipeline with a reduce that accumulates continuation
lines (lines without ': ') into the previous entry's value, joined by '\n'.
This preserves the full private key content through a CSV round-trip.

Fixes: CSV export to import of SSH key items where the private key body was
silently dropped.
2026-06-30 11:44:03 +08:00
shuaiplus 0d1bb196e2 feat: add functionality to delete invalid invites and update related components 2026-06-29 12:12:13 +08:00
shuaiplus e31f82c0d6 feat: update project wiki link and remove obsolete security scripts and workflows 2026-06-29 11:57:53 +08:00
shuaiplus f82dcc3c17 feat: rename revokeInvite to deleteInvite and update related functionality 2026-06-29 11:47:05 +08:00
shuaiplus 4378e1b430 feat: add pendingAuthRequestsRefreshing state to improve loading feedback in auth request components 2026-06-29 11:29:35 +08:00
shuaiplus 5eeaf4e32e feat: enhance Bitwarden CSV parsing with custom field handling and metadata restoration 2026-06-29 11:10:41 +08:00
shuaiplus 82f968e51f feat: add validFolderIds support for cipher responses and update folder handling in storage 2026-06-28 19:43:27 +08:00
shuaiplus a5ad16ac27 feat: add device selection and removal functionality in SecurityDevicesPage 2026-06-28 15:31:29 +08:00
shuaiplus 6a1a8357bf feat: refactor PRF extension handling in credential options 2026-06-28 14:13:06 +08:00
shuaiplus 31cfd19b6b feat: add support for excluding PRF extensions in credential options 2026-06-28 14:02:51 +08:00
shuaiplus 4cd9ad00d2 Add backup-related error messages and improve UI styles
- Updated English, Spanish, Russian, Simplified Chinese, and Traditional Chinese locale files to include new error messages related to backup and restore processes.
- Added prefix and suffix strings for the "cached empty" message to enhance clarity in user prompts.
- Enhanced the management CSS with new styles for the backup browser refresh prompt to improve layout and user experience.
2026-06-27 12:38:41 +08:00
shuaiplus 31dcc76ee2 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-06-26 20:58:22 +08:00
soncmsandShuai bf6ac7b405 Enable WebAuthn related origins support 2026-06-26 20:51:33 +08:00
shuaiplus 1bfb9a647d feat: refine app-shell styles for improved layout and dark mode consistency 2026-06-26 19:12:20 +08:00
shuaiplus e9272ec29a feat: enhance backup import functionality with locking mechanism and checksum support 2026-06-26 18:45:23 +08:00
shuaiplus 8942e5bd49 feat: add support for PRF extension request based on browser compatibility 2026-06-26 11:58:49 +08:00
shuaiplus d722815999 feat: add fullscreen layout support with toggle and localization updates 2026-06-26 11:26:02 +08:00
shuaiplus ff85698edb feat: add Tigris backup provider support with recommendations and localization updates 2026-06-25 21:11:57 +08:00
shuaiplus c3dc53bac1 feat: add Cloudflare R2 support with detailed backup recommendations and localization updates 2026-06-25 19:45:09 +08:00
shuaiplus 1acc31eda0 feat: add Backblaze B2 support with recommendations and styling updates 2026-06-25 18:42:34 +08:00
shuaiplusandClaude c694f1bfce refactor: consolidate security devices UI and remove pending auth requests from settings
- Merge device management and authorized devices sections into a single card in SecurityDevicesPage
- Remove PendingAuthRequestsPanel from SettingsPage and its related props
- Clean up unused auth request prop drilling in AppMainRoutes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 01:49:17 +08:00
shuaiplus bf51309fbb fix: update version to v1.7.1 2026-06-24 01:55:09 +08:00
shuaiplusandShuai 23b23f39b9 fix: require reauthentication for auth request approval 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 0daad46591 chore: add package overrides for undici, @babel/core, and esbuild 2026-06-24 01:44:50 +08:00
shuaiplusandShuai a2a8f1c7b6 fix:Harden authentication and sensitive file handling 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 850fe0f044 fix: two-phase invite consumption to prevent registration race condition 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 7279668955 fix: address security issue 2026-06-24 01:44:50 +08:00
90 changed files with 4222 additions and 1995 deletions
-5
View File
@@ -1,5 +0,0 @@
# JWT Secret for signing tokens (required)
# IMPORTANT: change this value before any real deployment.
# Generate one with: openssl rand -hex 32
# (Example only, 64 hex chars = 32 bytes)
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Project Wiki/ 项目文档 - name: Project Wiki/ 项目文档
url: https://github.com/shuaiplus/nodewarden/wiki url: https://nodewarden.app
about: | about: |
Please check the documentation for common questions and troubleshooting steps. Please check the documentation for common questions and troubleshooting steps.
请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。 请先查看文档,常见问题和排查步骤可能已经覆盖了你的问题。
+33
View File
@@ -0,0 +1,33 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
groups:
npm-minor-and-patch:
update-types:
- "minor"
- "patch"
ignore:
- dependency-name: "tailwindcss"
update-types:
- "version-update:semver-major"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "05:10"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 0
groups:
github-actions:
patterns:
- "*"
-467
View File
@@ -1,467 +0,0 @@
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: '💡 *由 NodeWarden 安全工作流生成。透明度是我们的承诺。*',
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 the NodeWarden security workflow. 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) => `![${label}](https://img.shields.io/badge/${label.replace(/ /g, '_')}-${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 '![Pass](https://img.shields.io/badge/Status-PASS-success?style=for-the-badge)';
if (status === 'WARN' || status === 'INFO') return '![Warning](https://img.shields.io/badge/Status-NOTICE-orange?style=for-the-badge)';
return '![Fail](https://img.shields.io/badge/Status-FAIL-red?style=for-the-badge)';
}
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);
});
+44
View File
@@ -0,0 +1,44 @@
name: "CodeQL Advanced"
on:
push:
branches:
- "**"
permissions:
contents: read
actions: read
security-events: write
packages: read
jobs:
analyze:
name: CodeQL Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
category: "/language:${{ matrix.language }}"
+200
View File
@@ -0,0 +1,200 @@
name: "Extra Security Scan"
on:
push:
branches:
- "**"
permissions:
contents: read
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
fetch-depth: 0
persist-credentials: false
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLEAKS_ENABLE_SUMMARY: "true"
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
# 如果仓库属于 GitHub Organization,需要在 Settings -> Secrets 里加 GITLEAKS_LICENSE
# GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
osv:
name: OSV Dependency Scan
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2
permissions:
contents: read
actions: read
security-events: write
with:
scan-args: |-
--recursive
./
upload-sarif: true
fail-on-vuln: true
pnpm-audit:
name: pnpm audit
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: 22
- name: Run pnpm audit
shell: bash
run: |
if [ ! -f pnpm-lock.yaml ]; then
echo "pnpm-lock.yaml not found, skip pnpm audit."
exit 0
fi
corepack enable
corepack prepare pnpm@10 --activate
pnpm audit --audit-level=high
semgrep:
name: Semgrep CE Scan
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run Semgrep CE
shell: bash
run: |
docker run --rm \
-v "${PWD}:/src" \
-w /src \
semgrep/semgrep:latest \
semgrep scan --config p/default --sarif --output semgrep.sarif . || true
if [ ! -f semgrep.sarif ]; then
cat > semgrep.sarif <<'EOF'
{
"version": "2.1.0",
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"runs": [
{
"tool": {
"driver": {
"name": "Semgrep",
"informationUri": "https://semgrep.dev",
"rules": []
}
},
"results": []
}
]
}
EOF
fi
- name: Upload Semgrep SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: semgrep.sarif
category: semgrep
actionlint:
name: GitHub Actions Syntax Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run actionlint
shell: bash
run: |
docker run --rm \
-v "${PWD}:/repo" \
-w /repo \
rhysd/actionlint:latest
zizmor:
name: GitHub Actions Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa
with:
persona: auditor
min-severity: medium
min-confidence: medium
scorecard:
name: OpenSSF Scorecard
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
persist-credentials: false
- name: Run OpenSSF Scorecard
uses: ossf/scorecard-action@99c09fe975337306107572b4fdf4db224cf8e2f2
with:
results_file: scorecard.sarif
results_format: sarif
publish_results: false
- name: Upload Scorecard SARIF
uses: github/codeql-action/upload-sarif@411bbbe57033eedfc1a82d68c01345aa96c737d7
with:
sarif_file: scorecard.sarif
category: openssf-scorecard
-142
View File
@@ -1,142 +0,0 @@
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 -4
View File
@@ -19,20 +19,29 @@ jobs:
sync-global-domains: sync-global-domains:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-node@v4 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with: with:
node-version: 22 node-version: 22
- name: Sync generated Bitwarden domains - name: Sync generated Bitwarden domains
run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}" env:
BITWARDEN_REF: ${{ inputs.bitwarden_ref || 'main' }}
run: |
case "$BITWARDEN_REF" in
"" | *[!A-Za-z0-9._/-]* )
echo "Invalid bitwarden_ref"
exit 1
;;
esac
npm run domains:sync -- --ref "$BITWARDEN_REF"
- name: Verify custom domains were not touched - name: Verify custom domains were not touched
run: git diff --exit-code -- src/static/global_domains.custom.json run: git diff --exit-code -- src/static/global_domains.custom.json
- name: Create pull request - name: Create pull request
uses: peter-evans/create-pull-request@v6 uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1
with: with:
branch: chore/sync-bitwarden-global-domains branch: chore/sync-bitwarden-global-domains
delete-branch: true delete-branch: true
-143
View File
@@ -1,143 +0,0 @@
name: Sync upstream
on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
target_commit:
description: 'Commit hash (leave blank to use latest commit)'
required: false
type: string
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Add upstream
run: |
git remote add upstream https://github.com/shuaiplus/NodeWarden.git || true
git fetch upstream --tags
- name: Resolve target commit
id: resolve
run: |
TRIGGER="${{ github.event_name }}"
MANUAL_INPUT="${{ github.event.inputs.target_commit }}"
if [ "$TRIGGER" = "schedule" ]; then
# Auto mode: resolve latest upstream release tag
LATEST_TAG=$(curl -s https://api.github.com/repos/shuaiplus/NodeWarden/releases/latest | jq -r .tag_name)
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found in upstream."
exit 1
fi
TARGET_SHA=$(git rev-list -n 1 "$LATEST_TAG" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Tag '$LATEST_TAG' not found after fetch."
exit 1
fi
echo "mode=auto" >> $GITHUB_OUTPUT
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Auto mode — latest release: $LATEST_TAG ($TARGET_SHA)"
elif [ -n "$MANUAL_INPUT" ]; then
# Manual mode: use provided commit hash or tag
TARGET_SHA=$(git rev-parse "$MANUAL_INPUT" 2>/dev/null)
if [ -z "$TARGET_SHA" ]; then
echo "Cannot resolve '$MANUAL_INPUT' to a commit."
exit 1
fi
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — target: $MANUAL_INPUT ($TARGET_SHA)"
else
# Manual mode, blank input: use latest commit on upstream/main
TARGET_SHA=$(git rev-parse upstream/main)
echo "mode=manual" >> $GITHUB_OUTPUT
echo "target_sha=$TARGET_SHA" >> $GITHUB_OUTPUT
echo "Manual mode — latest commit: $TARGET_SHA"
fi
- name: Check if update is needed
id: check
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
if [ "$MODE" = "manual" ]; then
# Manual: skip only if HEAD is exactly this commit
CURRENT_SHA=$(git rev-parse HEAD)
if [ "$CURRENT_SHA" = "$TARGET_SHA" ]; then
echo "Already at $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Switching to $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
else
# Auto: skip if target is already in ancestry
if git merge-base --is-ancestor "$TARGET_SHA" HEAD 2>/dev/null; then
echo "Already up to date with $TARGET_SHA — skipping."
echo "needs_update=false" >> $GITHUB_OUTPUT
else
echo "Update needed — target: $TARGET_SHA"
echo "needs_update=true" >> $GITHUB_OUTPUT
fi
fi
- name: Apply update
if: steps.check.outputs.needs_update == 'true'
run: |
TARGET_SHA="${{ steps.resolve.outputs.target_sha }}"
MODE="${{ steps.resolve.outputs.mode }}"
git checkout main
if [ "$MODE" = "manual" ]; then
# Hard reset allows both upgrade and rollback
git reset --hard "$TARGET_SHA"
else
git merge "$TARGET_SHA" --no-edit
fi
- name: Restore workflow file
if: steps.check.outputs.needs_update == 'true'
run: |
# Always keep our own workflow file, never let upstream overwrite it
git checkout HEAD@{1} -- .github/workflows/sync-upstream.yml 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: restore sync-upstream workflow after sync"
fi
- name: Push
if: steps.check.outputs.needs_update == 'true'
run: |
if [ "${{ steps.resolve.outputs.mode }}" = "manual" ]; then
git push origin main --force
else
git push origin main
fi
- name: Summary
run: |
if [ "${{ steps.check.outputs.needs_update }}" = "true" ]; then
echo "### Synced successfully" >> $GITHUB_STEP_SUMMARY
echo "- **Mode:** ${{ steps.resolve.outputs.mode }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag:** ${{ steps.resolve.outputs.latest_tag || 'N/A (manual)' }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${{ steps.resolve.outputs.target_sha }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "### Nothing to update" >> $GITHUB_STEP_SUMMARY
fi
+2
View File
@@ -56,9 +56,11 @@ NodeWarden-compat/
.codex-upstream/bitwarden-browser/ .codex-upstream/bitwarden-browser/
.reasonix/ .reasonix/
.upstream/
# Compatibility analysis documents # Compatibility analysis documents
BITWARDEN_COMPATIBILITY_ANALYSIS.md BITWARDEN_COMPATIBILITY_ANALYSIS.md
security-audits/
.mcp.json .mcp.json
opencode.jsonc opencode.jsonc
.cursor/ .cursor/
+10
View File
@@ -228,6 +228,16 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier); ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
+817 -585
View File
File diff suppressed because it is too large Load Diff
+26 -19
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.0", "version": "1.7.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",
@@ -42,28 +42,35 @@
} }
} }
}, },
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1",
"ws": "8.21.0"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260630.1",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.5",
"@types/node": "^25.2.3", "@types/node": "^26.0.1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.5.2",
"opencc-js": "^1.0.5", "opencc-js": "^1.3.2",
"postcss": "^8.5.6", "postcss": "^8.5.16",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.19",
"tsx": "^4.21.0", "tsx": "^4.22.4",
"typescript": "^5.9.3", "typescript": "^6.0.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"wrangler": "^4.71.0" "wrangler": "^4.105.0"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.2.0",
"@simplewebauthn/server": "^13.3.1", "@simplewebauthn/server": "^13.3.2",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.22", "@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.2", "fflate": "^0.8.3",
"lucide-preact": "^0.575.0", "jsqr": "1.4.0",
"preact": "^10.28.4", "lucide-preact": "^1.22.0",
"preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"wouter": "^3.9.0" "wouter": "^3.10.0"
} }
} }
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.0'; export const APP_VERSION = '1.7.2';
+4 -3
View File
@@ -9,7 +9,8 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_S3_REGION = 'auto'; export const BACKUP_DEFAULT_S3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
region: BACKUP_DEFAULT_S3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH, rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
}; };
} }
return { return {
baseUrl: '', baseUrl: '',
username: '', username: '',
password: '', password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH, remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
}; };
} }
+12 -1
View File
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
} }
let completed = 0; let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try { try {
await this.touchJob(token); await this.touchJob(token);
const storage = new StorageService(this.env.DB); const storage = new StorageService(this.env.DB);
@@ -230,6 +231,7 @@ export class BackupTransferRunner {
scanStartMs = now.getTime(); scanStartMs = now.getTime();
for (const destination of dueDestinations) { for (const destination of dueDestinations) {
await this.touchJob(token); await this.touchJob(token);
try {
await executeConfiguredBackup( await executeConfiguredBackup(
this.env, this.env,
storage, storage,
@@ -239,12 +241,20 @@ export class BackupTransferRunner {
() => this.touchJob(token) () => this.touchJob(token)
); );
completed += 1; completed += 1;
} catch (error) {
failures.push({
destinationId: destination.id,
error: error instanceof Error ? error.message : 'Scheduled backup failed',
});
}
} }
} }
return new Response(JSON.stringify({ return new Response(JSON.stringify({
ok: true, ok: true,
completed, completed,
failed: failures.length,
failures,
}), { }), {
status: 200, status: 200,
headers: { headers: {
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
replaceExisting, replaceExisting,
!checksumOk, !checksumOk,
body.auditMetadata || null, body.auditMetadata || null,
targetDeviceIdentifier targetDeviceIdentifier,
() => this.touchJob(token)
); );
return new Response(JSON.stringify(result.result), { return new Response(JSON.stringify(result.result), {
+42 -16
View File
@@ -1,4 +1,4 @@
import { Env, User, DEFAULT_DEV_SECRET } from '../types'; import { Env, User } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
@@ -6,6 +6,7 @@ import { auditRequestMetadata, writeAuditEvent, safeWriteAuditEvent } from '../s
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { hashApiKey } from '../utils/api-key';
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'; import { buildAccountKeys } from '../utils/user-decryption';
@@ -149,10 +150,9 @@ function normalizeMasterPasswordHint(input: string | null | undefined): string |
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
@@ -241,8 +241,6 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
if (unsafe) { if (unsafe) {
const message = unsafe === 'missing' const message = unsafe === 'missing'
? 'JWT_SECRET is not set' ? 'JWT_SECRET is not set'
: unsafe === 'default'
? 'JWT_SECRET is using the default/sample value. Please change it.'
: 'JWT_SECRET must be at least 32 characters'; : 'JWT_SECRET must be at least 32 characters';
return errorResponse(message, 400); return errorResponse(message, 400);
} }
@@ -353,20 +351,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403); return errorResponse('Invite code is required', 403);
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try { try {
await storage.createUser(user); await storage.createUser(user);
} catch (error) { } catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) { if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409); return errorResponse('Email already registered', 409);
} }
console.error('Registration failed after invite reservation:', error);
throw error; throw error;
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id); try {
if (!inviteMarked) { const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
await storage.deleteUserById(user.id); if (!assigned) {
return errorResponse('Invite code is invalid or expired', 403); console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
} }
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -891,7 +900,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
} }
// PUT /api/accounts/totp // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -899,7 +908,13 @@ export async function handleSetTotpStatus(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: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
@@ -908,12 +923,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) { if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || ''); const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) { if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400); return errorResponse('Invalid TOTP secret', 400);
} }
if (!body.token) { if (!body.token) {
return errorResponse('TOTP token is required', 400); return errorResponse('TOTP token is required', 400);
} }
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token); const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) { if (!verified) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
@@ -1165,9 +1192,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email); const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash, user.email);
if (!valid) return errorResponse('Invalid password', 400); if (!valid) return errorResponse('Invalid password', 400);
if (rotate || user.apiKey === null) { // Only the fresh secret is returned once; the database stores a hash.
// Upstream apikeys are 30-character random alphanumeric strings const plainApiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength);
user.apiKey = randomStringAlphanum(LIMITS.auth.clientSecretLength); user.apiKey = await hashApiKey(plainApiKey);
if (rotate) { if (rotate) {
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
@@ -1184,10 +1211,9 @@ async function apiKey(request: Request, env: Env, userId: string, rotate: boolea
targetId: user.id, targetId: user.id,
metadata: auditRequestMetadata(request), metadata: auditRequestMetadata(request),
}); });
}
return jsonResponse({ return jsonResponse({
apiKey: user.apiKey, apiKey: plainApiKey,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'apiKey', object: 'apiKey',
}); });
+17 -6
View File
@@ -249,7 +249,7 @@ export async function handleAdminListInvites(
} }
// DELETE /api/admin/invites/:code // DELETE /api/admin/invites/:code
export async function handleAdminRevokeInvite( export async function handleAdminDeleteInvite(
request: Request, request: Request,
env: Env, env: Env,
actorUser: User, actorUser: User,
@@ -260,12 +260,14 @@ export async function handleAdminRevokeInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const revoked = await storage.revokeInvite(code); const deleted = await storage.deleteInvite(code);
if (!revoked) { if (!deleted) {
return errorResponse('Invite not found or already inactive', 404); return errorResponse('Invite not found', 404);
} }
await writeAuditLog(storage, actorUser.id, 'admin.invite.revoke', 'invite', null, null, request); await writeAuditLog(storage, actorUser.id, 'admin.invite.delete', 'invite', null, {
code,
}, request);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
} }
@@ -275,12 +277,21 @@ export async function handleAdminDeleteAllInvites(
env: Env, env: Env,
actorUser: User actorUser: User
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') {
const deleted = await storage.deleteInvalidInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_invalid', 'invite', null, {
deleted,
}, request);
return jsonResponse({ deleted }, 200);
}
const deleted = await storage.deleteAllInvites(); const deleted = await storage.deleteAllInvites();
await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, { await writeAuditLog(storage, actorUser.id, 'admin.invite.delete_all', 'invite', null, {
deleted, deleted,
+21 -22
View File
@@ -1,9 +1,10 @@
import { Env, Attachment, Cipher, DEFAULT_DEV_SECRET } from '../types'; import { Env, Attachment, Cipher } from '../types';
import { notifyUserCipherUpdate, notifyUserVaultSync } from '../durable/notifications-hub'; import { notifyUserCipherUpdate, 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 { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createAttachmentUploadToken, createAttachmentUploadToken,
createFileDownloadToken, createFileDownloadToken,
@@ -166,7 +167,7 @@ export async function handleCreateAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
@@ -204,7 +205,7 @@ export async function handleCreateAttachment(
await storage.saveAttachment(attachment); await storage.saveAttachment(attachment);
// Add attachment to cipher // Add attachment to cipher
await storage.addAttachmentToCipher(cipherId, attachmentId); await storage.addAttachmentToCipherForUser(cipherId, attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
@@ -214,7 +215,7 @@ export async function handleCreateAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const jwtSecret = getSafeJwtSecret(env); const jwtSecret = getSafeJwtSecret(env);
if (!jwtSecret) { if (!jwtSecret) {
@@ -243,13 +244,13 @@ export async function handleUploadAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -282,12 +283,12 @@ export async function handlePublicUploadAttachment(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, claims.userId);
if (!cipher || cipher.userId !== claims.userId) { if (!cipher || cipher.userId !== claims.userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, claims.userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -307,13 +308,13 @@ export async function handleGetAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -348,12 +349,12 @@ export async function handleUpdateAttachmentMetadata(
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -404,10 +405,8 @@ export async function handlePublicDownloadAttachment(
cipherId: string, cipherId: string,
attachmentId: string attachmentId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const secret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret) return errorResponse('Server configuration error', 500);
return errorResponse('Server configuration error', 500);
}
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get('token'); const token = url.searchParams.get('token');
@@ -417,7 +416,7 @@ export async function handlePublicDownloadAttachment(
} }
// Verify token // Verify token
const claims = await verifyFileDownloadToken(token, env.JWT_SECRET); const claims = await verifyFileDownloadToken(token, secret);
if (!claims) { if (!claims) {
return errorResponse('Invalid or expired token', 401); return errorResponse('Invalid or expired token', 401);
} }
@@ -449,7 +448,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
@@ -470,13 +469,13 @@ export async function handleDeleteAttachment(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
// Verify cipher exists and belongs to user // Verify cipher exists and belongs to user
const cipher = await storage.getCipher(cipherId); const cipher = await storage.getCipherForUser(cipherId, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
} }
// Verify attachment exists // Verify attachment exists
const attachment = await storage.getAttachment(attachmentId); const attachment = await storage.getAttachmentForUser(attachmentId, userId);
if (!attachment || attachment.cipherId !== cipherId) { if (!attachment || attachment.cipherId !== cipherId) {
return errorResponse('Attachment not found', 404); return errorResponse('Attachment not found', 404);
} }
@@ -485,7 +484,7 @@ export async function handleDeleteAttachment(
await deleteBlobObject(env, path); await deleteBlobObject(env, path);
// Delete attachment metadata // Delete attachment metadata
await storage.deleteAttachment(attachmentId); await storage.deleteAttachmentForUser(attachmentId, userId);
// Update cipher revision date // Update cipher revision date
const revisionInfo = await storage.updateCipherRevisionDate(cipherId); const revisionInfo = await storage.updateCipherRevisionDate(cipherId);
@@ -500,7 +499,7 @@ export async function handleDeleteAttachment(
} }
// Get updated cipher for response // Get updated cipher for response
const updatedCipher = await storage.getCipher(cipherId); const updatedCipher = await storage.getCipherForUser(cipherId, userId);
const attachments = await storage.getAttachmentsByCipher(cipherId); const attachments = await storage.getAttachmentsByCipher(cipherId);
const cipherResponse = cipherToResponse(updatedCipher!, attachments); const cipherResponse = cipherToResponse(updatedCipher!, attachments);
+19 -3
View File
@@ -14,6 +14,19 @@ function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '').trim().slice(0, maxLength); return String(value ?? '').trim().slice(0, maxLength);
} }
function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
function getClientIp(request: Request): string | null { function getClientIp(request: Request): string | null {
return ( return (
request.headers.get('CF-Connecting-IP') || request.headers.get('CF-Connecting-IP') ||
@@ -188,7 +201,7 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(id); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404); if (!authRequest || authRequest.userId !== userId) return errorResponse('Not found', 404);
return jsonResponse(toAuthRequestResponse(request, authRequest)); return jsonResponse(toAuthRequestResponse(request, authRequest));
} }
@@ -226,7 +239,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400); if (!body) return errorResponse('Invalid request payload', 400);
const authRequest = await storage.getAuthRequestById(id); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) { if (!authRequest || authRequest.userId !== userId || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404); return errorResponse('Not found', 404);
} }
@@ -251,6 +264,9 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
if (approved && !key) { if (approved && !key) {
return errorResponse('Encrypted key is required to approve the request.', 400); return errorResponse('Encrypted key is required to approve the request.', 400);
} }
if (approved && !isSerializedEncString(key)) {
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, { const updated = await storage.updateAuthRequestResponse(id, userId, {
approved, approved,
@@ -259,7 +275,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
masterPasswordHash, masterPasswordHash,
}); });
if (!updated) return errorResponse('Auth request has already been answered.', 409); if (!updated) return errorResponse('Auth request has already been answered.', 409);
const updatedRequest = await storage.getAuthRequestById(id); const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
// Match Bitwarden upstream behavior: only approval wakes the originating anonymous // Match Bitwarden upstream behavior: only approval wakes the originating anonymous
// client. Denials are not pushed to avoid leaking that a login attempt was rejected. // client. Denials are not pushed to avoid leaking that a login attempt was rejected.
if (approved) { if (approved) {
+141 -38
View File
@@ -21,6 +21,7 @@ import {
repairBackupSettings, repairBackupSettings,
requireBackupDestination, requireBackupDestination,
saveBackupSettings, saveBackupSettings,
updateBackupDestinationRuntime,
} from '../services/backup-config'; } from '../services/backup-config';
import { import {
type BackupImportExecutionResult, type BackupImportExecutionResult,
@@ -40,15 +41,51 @@ import {
uploadBackupArchive, uploadBackupArchive,
} from '../services/backup-uploader'; } from '../services/backup-uploader';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog( async function writeAuditLog(
storage: StorageService, storage: StorageService,
actorUserId: string | null, actorUserId: string | null,
@@ -224,6 +261,30 @@ async function uploadRemoteAttachmentChunk(
} }
} }
async function verifyUploadedBackupArchive(
session: RemoteBackupTransferSession,
archive: BackupArchiveBundle
): Promise<'metadata' | 'download'> {
try {
const stat = await session.stat(archive.fileName);
if (stat?.size === archive.bytes.byteLength) {
return 'metadata';
}
} catch {
// Fall through to a full read-back verification when lightweight metadata is unavailable.
}
const remoteFile = await session.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');
}
return 'download';
}
export async function executeConfiguredBackup( export async function executeConfiguredBackup(
env: Env, env: Env,
storage: StorageService, storage: StorageService,
@@ -251,12 +312,14 @@ export async function executeConfiguredBackup(
const destination = requireBackupDestination(currentSettings, destinationId); const destination = requireBackupDestination(currentSettings, destinationId);
const now = new Date(); 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 touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastAttemptAt: now.toISOString(),
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
lastErrorAt: null,
lastErrorMessage: null,
}));
try { try {
await touchLease(); await touchLease();
@@ -318,6 +381,7 @@ export async function executeConfiguredBackup(
} }
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease(); await touchLease();
await progress?.({ await progress?.({
@@ -337,14 +401,7 @@ export async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_verify_title', stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail', stageDetail: 'txt_backup_remote_run_progress_verify_detail',
}); });
const remoteFile = await remoteSession.download(archive.fileName); uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
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; break;
} catch (error) { } catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined); await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
@@ -373,14 +430,16 @@ export async function executeConfiguredBackup(
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed'; 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 touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastSuccessAt: new Date().toISOString(),
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: archive.fileName,
lastUploadedSizeBytes: archive.bytes.byteLength,
lastUploadedDestination: upload.remotePath,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
@@ -390,6 +449,7 @@ export async function executeConfiguredBackup(
fileName: archive.fileName, fileName: archive.fileName,
fileBytes: archive.bytes.byteLength, fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts, uploadVerificationAttempts: maxArchiveUploadAttempts,
uploadVerificationMethod,
prunedFileCount, prunedFileCount,
pruneError: pruneErrorMessage, pruneError: pruneErrorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
@@ -412,15 +472,18 @@ export async function executeConfiguredBackup(
provider: upload.provider, provider: upload.provider,
}; };
} catch (error) { } catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString(); const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastErrorAt: new Date().toISOString(),
lastErrorMessage: errorMessage,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage, error: errorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
}); });
await progress?.({ await progress?.({
@@ -431,7 +494,7 @@ export async function executeConfiguredBackup(
stageDetail: 'txt_backup_remote_run_progress_failed_detail', stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true, done: true,
ok: false, ok: false,
error: destination.runtime.lastErrorMessage, error: errorMessage,
}); });
throw error; throw error;
} }
@@ -619,12 +682,18 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting: boolean, replaceExisting: boolean,
checksumMismatchAccepted: boolean, checksumMismatchAccepted: boolean,
auditMetadata: Record<string, unknown> | null = null, auditMetadata: Record<string, unknown> | null = null,
targetDeviceIdentifier: string | null = null targetDeviceIdentifier: string | null = null,
keepAlive?: (() => Promise<void>) | null
): Promise<BackupImportExecutionResult> { ): Promise<BackupImportExecutionResult> {
const touchLease = async () => {
await keepAlive?.();
};
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath; const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
await touchLease();
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes); const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
const externalAttachmentCache = new Map<string, Uint8Array | null>(); const externalAttachmentCache = new Map<string, Uint8Array | null>();
const progress: BackupRestoreProgressReporter = async (event) => { const progress: BackupRestoreProgressReporter = async (event) => {
await touchLease();
await notifyUserBackupRestoreProgress( await notifyUserBackupRestoreProgress(
env, env,
actorUserId, actorUserId,
@@ -642,6 +711,7 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting, replaceExisting,
{ {
loadAttachment: async (blobName) => { loadAttachment: async (blobName) => {
await touchLease();
const normalized = String(blobName || '').trim(); const normalized = String(blobName || '').trim();
if (!normalized) return null; if (!normalized) return null;
if (externalAttachmentCache.has(normalized)) { if (externalAttachmentCache.has(normalized)) {
@@ -664,6 +734,7 @@ export async function importAndAuditRemoteBackupFile(
} catch { } catch {
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null)); externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
} }
await touchLease();
return externalAttachmentCache.get(normalized) || null; return externalAttachmentCache.get(normalized) || null;
}, },
}, },
@@ -787,13 +858,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch { } catch {
return errorResponse('Backup settings payload is invalid', 400); return errorResponse('Backup settings payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -837,13 +911,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch { } catch {
return errorResponse('Backup settings repair payload is invalid', 400); return errorResponse('Backup settings repair payload is invalid', 400);
} }
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -871,15 +948,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
let body: { destinationId?: string } | null = null; let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>(); body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup run payload is invalid', 400); return errorResponse('Backup run payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, { const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id, actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request), auditMetadata: auditRequestMetadata(request),
@@ -928,12 +1008,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path); const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, { return new Response(remoteFile.bytes, {
status: 200, status: 200,
@@ -994,13 +1083,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean }; let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try { try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch { } catch {
return errorResponse('Remote restore payload is invalid', 400); return errorResponse('Remote restore payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try { try {
const path = ensureRemoteRestoreCandidate(String(body.path || '')); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1126,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null; let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>(); body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup export payload is invalid', 400); return errorResponse('Backup export payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle; let archive: BackupArchiveBundle;
try { try {
const progress = async (event: { const progress = async (event: {
@@ -1140,6 +1240,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
return errorResponse('Backup file is required', 400); return errorResponse('Backup file is required', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array; let archiveBytes: Uint8Array;
+49 -13
View File
@@ -32,6 +32,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
// attachments, import/export, and current official clients. // attachments, import/export, and current official clients.
export interface CipherResponseOptions { export interface CipherResponseOptions {
preserveRepairableUris?: boolean; preserveRepairableUris?: boolean;
validFolderIds?: ReadonlySet<string>;
} }
export function shouldPreserveRepairableCipherUris(request: Request): boolean { export function shouldPreserveRepairableCipherUris(request: Request): boolean {
@@ -48,6 +49,12 @@ function normalizeOptionalId(value: unknown): string | null {
return normalized ? normalized : null; return normalized ? normalized : null;
} }
function normalizeResponseFolderId(folderId: unknown, validFolderIds?: ReadonlySet<string>): string | null {
const normalized = normalizeOptionalId(folderId);
if (!normalized) return null;
return validFolderIds && !validFolderIds.has(normalized) ? null : normalized;
}
function readBooleanOrFallback(value: unknown, fallback: boolean): boolean { function readBooleanOrFallback(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback; return typeof value === 'boolean' ? value : fallback;
} }
@@ -347,6 +354,34 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.'; if (uri.uriChecksum != null && !optionalEncStringWithin(uri.uriChecksum, 10000)) return 'Login URI checksum must be an encrypted string up to 10000 characters.';
} }
} }
// Validate FIDO2 credentials — all encrypted-string fields, both required and optional, must be valid.
if (Array.isArray(login.fido2Credentials)) {
const fido2EncryptedKeys = ['credentialId', 'keyType', 'keyAlgorithm', 'keyCurve', 'keyValue', 'rpId', 'counter', 'discoverable', 'userHandle', 'userName', 'rpName', 'userDisplayName'];
for (const cred of login.fido2Credentials) {
if (!cred || typeof cred !== 'object') continue;
for (const key of fido2EncryptedKeys) {
if (cred[key] != null && !isValidEncString(cred[key])) return `FIDO2 credential ${key} must be an encrypted string.`;
}
}
}
}
// Validate SSH key fields — all three must be encrypted strings.
const sshKey = cipher.sshKey as any;
if (sshKey && typeof sshKey === 'object') {
if (sshKey.privateKey != null && !isValidEncString(sshKey.privateKey)) return 'SSH key private key must be an encrypted string.';
if (sshKey.publicKey != null && !isValidEncString(sshKey.publicKey)) return 'SSH key public key must be an encrypted string.';
const fingerprint = sshKey.keyFingerprint ?? sshKey.fingerprint;
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
}
// Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) {
if (!entry || typeof entry !== 'object') continue;
if (entry.password != null && !isValidEncString(entry.password)) return 'Password history entry must be an encrypted string.';
}
} }
return null; return null;
@@ -727,7 +762,7 @@ export function cipherToResponse(
// 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), folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1, type: Number(cipher.type) || 1,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
@@ -785,9 +820,10 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
const attachmentsByCipher = await storage.getAttachmentsByCipherIds( const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
filteredCiphers.map((cipher) => cipher.id) filteredCiphers.map((cipher) => cipher.id)
); );
const validFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Build responses only for the current page to keep pagination cheap. // Build responses only for the current page to keep pagination cheap.
const responseOptions = cipherResponseOptionsForRequest(request); const responseOptions = { ...cipherResponseOptionsForRequest(request), validFolderIds };
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const attachments = attachmentsByCipher.get(cipher.id) || []; const attachments = attachmentsByCipher.get(cipher.id) || [];
@@ -804,7 +840,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
// GET /api/ciphers/:id // GET /api/ciphers/:id
export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -819,8 +855,8 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
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> {
if (!folderId) return true; if (!folderId) return true;
const folder = await storage.getFolder(folderId); const folder = await storage.getFolderForUser(folderId, userId);
return !!(folder && folder.userId === userId); return !!folder;
} }
// POST /api/ciphers // POST /api/ciphers
@@ -901,7 +937,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
// PUT /api/ciphers/:id // PUT /api/ciphers/:id
export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const existingCipher = await storage.getCipher(id); const existingCipher = await storage.getCipherForUser(id, userId);
if (!existingCipher || existingCipher.userId !== userId) { if (!existingCipher || existingCipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1012,7 +1048,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
// DELETE /api/ciphers/:id // DELETE /api/ciphers/:id
export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1044,7 +1080,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
// - If item is already soft-deleted -> hard delete. // - If item is already soft-deleted -> hard delete.
export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteCipherCompat(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1071,7 +1107,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI
// DELETE /api/ciphers/:id (permanent) // DELETE /api/ciphers/:id (permanent)
export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePermanentDeleteCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1096,7 +1132,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us
// PUT /api/ciphers/:id/restore // PUT /api/ciphers/:id/restore
export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleRestoreCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1118,7 +1154,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
// PUT /api/ciphers/:id/partial - Update only favorite/folderId // PUT /api/ciphers/:id/partial - Update only favorite/folderId
export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handlePartialUpdateCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1210,7 +1246,7 @@ function parseCipherIdList(body: { ids?: unknown }): string[] | null {
// PUT/POST /api/ciphers/:id/archive // PUT/POST /api/ciphers/:id/archive
export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleArchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
@@ -1236,7 +1272,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st
// PUT/POST /api/ciphers/:id/unarchive // PUT/POST /api/ciphers/:id/unarchive
export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUnarchiveCipher(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const cipher = await storage.getCipher(id); const cipher = await storage.getCipherForUser(id, userId);
if (!cipher || cipher.userId !== userId) { if (!cipher || cipher.userId !== userId) {
return errorResponse('Cipher not found', 404); return errorResponse('Cipher not found', 404);
+2
View File
@@ -48,6 +48,8 @@ function buildDeviceResponse(device: Device): DeviceResponse {
creationDate: device.createdAt, creationDate: device.createdAt,
RevisionDate: device.updatedAt, RevisionDate: device.updatedAt,
revisionDate: device.updatedAt, revisionDate: device.updatedAt,
LastActivityDate: device.lastSeenAt,
lastActivityDate: device.lastSeenAt,
LastSeenAt: device.lastSeenAt, LastSeenAt: device.lastSeenAt,
lastSeenAt: device.lastSeenAt, lastSeenAt: device.lastSeenAt,
HasStoredDevice: true, HasStoredDevice: true,
+36
View File
@@ -0,0 +1,36 @@
const EMPTY_FORMS_FILENAME = 'forms.v1.json';
const EMPTY_FORMS_BODY = JSON.stringify({
schemaVersion: '1.0.0',
hosts: {},
});
const EMPTY_MANIFEST_BODY = JSON.stringify({
maps: {
forms: {
v1: {
filename: EMPTY_FORMS_FILENAME,
cid: 'sha256:nodewarden-empty-fill-assist-v1',
},
},
},
});
function fillAssistJsonResponse(body: string): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
}
export function handleFillAssistManifest(): Response {
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
}
export function handleFillAssistForms(filename: string): Response {
if (String(filename || '').trim() !== EMPTY_FORMS_FILENAME) {
return new Response('Not found', { status: 404 });
}
return fillAssistJsonResponse(EMPTY_FORMS_BODY);
}
+5 -5
View File
@@ -80,7 +80,7 @@ export async function handleGetFolders(request: Request, env: Env, userId: strin
// GET /api/folders/:id // GET /api/folders/:id
export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -129,7 +129,7 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str
// PUT /api/folders/:id // PUT /api/folders/:id
export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleUpdateFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -163,7 +163,7 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str
// DELETE /api/folders/:id // DELETE /api/folders/:id
export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleDeleteFolder(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
if (!folder || folder.userId !== userId) { if (!folder || folder.userId !== userId) {
return errorResponse('Folder not found', 404); return errorResponse('Folder not found', 404);
@@ -204,8 +204,8 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId
const folders = ( const folders = (
await Promise.all(ids.map(async (id) => { await Promise.all(ids.map(async (id) => {
const folder = await storage.getFolder(id); const folder = await storage.getFolderForUser(id, userId);
return folder && folder.userId === userId ? folder : null; return folder;
})) }))
).filter((folder): folder is Folder => !!folder); ).filter((folder): folder is Folder => !!folder);
const revisionDate = await storage.bulkDeleteFolders(ids, userId); const revisionDate = await storage.bulkDeleteFolders(ids, userId);
+20 -18
View File
@@ -4,7 +4,7 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } 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';
@@ -21,6 +21,8 @@ import {
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { constantTimeEquals, verifyApiKey } from '../utils/api-key';
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;
@@ -105,18 +107,6 @@ function parseCookieValue(request: Request, name: string): string | null {
return null; return null;
} }
function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function readBodyValue(body: Record<string, string>, names: string[]): string | undefined { function readBodyValue(body: Record<string, string>, names: string[]): string | undefined {
for (const name of names) { for (const name of names) {
const value = body[name]; const value = body[name];
@@ -336,10 +326,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
let validatedAuthRequestId: string | null = null; let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
const authRequest = await storage.getAuthRequestById(normalizedAuthRequestId); const authRequest = await storage.getAuthRequestByIdForUser(normalizedAuthRequestId, user.id);
valid = !!( valid = !!(
authRequest && authRequest &&
authRequest.userId === user.id && authRequest.userId === user.id &&
@@ -348,10 +339,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
authRequest.responseDate && authRequest.responseDate &&
!authRequest.authenticationDate && !authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) && !isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash) constantTimeEquals(authRequest.accessCode, passwordHash)
); );
if (valid) { if (valid) {
validatedAuthRequestId = authRequest!.id; validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
} }
} else { } else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
@@ -408,8 +401,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return twoFactorRequiredResponse('Two factor required.'); return twoFactorRequiredResponse('Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if ( } else if (
@@ -422,9 +419,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
user.totpSecret = null; user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode(); user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
AuthService.invalidateUserCache(user.id);
rememberRequested = false; rememberRequested = false;
} else { } else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt. // Unsupported provider for this server profile behaves as an invalid 2FA attempt.
@@ -488,7 +487,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
accountKeys: accountKeys, accountKeys: accountKeys,
@@ -583,6 +582,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,6 +621,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
@@ -677,7 +679,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return identityErrorResponse('Account is disabled', 'invalid_grant', 400); return identityErrorResponse('Account is disabled', 'invalid_grant', 400);
} }
if (!user.apiKey || !constantTimeEquals(clientSecret, user.apiKey)) { if (!user.apiKey || !(await verifyApiKey(clientSecret, user.apiKey))) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
await safeWriteAuditEvent(env, { await safeWriteAuditEvent(env, {
actorUserId: user.id, actorUserId: user.id,
+8 -8
View File
@@ -134,7 +134,7 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
@@ -401,7 +401,7 @@ export async function handleGetSendFileUpload(
): Promise<Response> { ): Promise<Response> {
void request; void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -436,7 +436,7 @@ export async function handleUploadSendFile(
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -472,7 +472,7 @@ export async function handlePublicUploadSendFile(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, claims.userId);
if (!send || send.userId !== claims.userId) { if (!send || send.userId !== claims.userId) {
return errorResponse('Send not found. Unable to save the file.', 404); return errorResponse('Send not found. Unable to save the file.', 404);
} }
@@ -485,7 +485,7 @@ export async function handlePublicUploadSendFile(
export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -632,7 +632,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -698,7 +698,7 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId:
export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
@@ -719,7 +719,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI
export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> { export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId); const send = await storage.getSendForUser(sendId, userId);
if (!send || send.userId !== userId) { if (!send || send.userId !== userId) {
return errorResponse('Send not found', 404); return errorResponse('Send not found', 404);
} }
+5 -6
View File
@@ -2,7 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createSendAccessToken, createSendAccessToken,
createSendFileDownloadToken, createSendFileDownloadToken,
@@ -112,10 +112,9 @@ export async function handleAccessSendFile(
idOrAccessId: string, idOrAccessId: string,
fileId: string fileId: string
): Promise<Response> { ): Promise<Response> {
const secret = (env.JWT_SECRET || '').trim(); const safeSecret = getSafeJwtSecret(env);
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) { if (!safeSecret.ok) return safeSecret.response;
return errorResponse('Server configuration error', 500); const { secret } = safeSecret;
}
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId);
@@ -306,7 +305,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName), 'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+2 -2
View File
@@ -1,4 +1,4 @@
import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; import { Env, Send, SendAuthType, SendResponse, SendType } from '../types';
import { import {
notifyUserSendCreate, notifyUserSendCreate,
notifyUserSendDelete, notifyUserSendDelete,
@@ -371,7 +371,7 @@ export function hasEmailAuth(send: Send): boolean {
export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return { ok: false, response: errorResponse('Server configuration error', 500) }; return { ok: false, response: errorResponse('Server configuration error', 500) };
} }
return { ok: true, secret }; return { ok: true, secret };
+2 -1
View File
@@ -88,12 +88,13 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
.map(buildWebAuthnPrfOption) .map(buildWebAuthnPrfOption)
.filter((option): option is NonNullable<typeof option> => !!option); .filter((option): option is NonNullable<typeof option> => !!option);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOptions[0] || null);
const validFolderIds = new Set(folders.map((folder) => folder.id));
const profile: ProfileResponse = buildProfileResponse(user, env); const profile: ProfileResponse = buildProfileResponse(user, env);
const cipherResponses: CipherResponse[] = []; const cipherResponses: CipherResponse[] = [];
for (const cipher of ciphers) { for (const cipher of ciphers) {
const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris }); const response = cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || [], { preserveRepairableUris, validFolderIds });
if (isCipherResponseSyncCompatible(response)) { if (isCipherResponseSyncCompatible(response)) {
cipherResponses.push(response); cipherResponses.push(response);
} }
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser); return handleListAdminRemoteBackups(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/download' && method === 'GET') { if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import {
handleAdminCreateInvite, handleAdminCreateInvite,
handleAdminListInvites, handleAdminListInvites,
handleAdminDeleteAllInvites, handleAdminDeleteAllInvites,
handleAdminRevokeInvite, handleAdminDeleteInvite,
handleAdminSetUserStatus, handleAdminSetUserStatus,
handleAdminDeleteUser, handleAdminDeleteUser,
handleAdminListAuditLogs, handleAdminListAuditLogs,
@@ -52,7 +52,7 @@ export async function handleAdminRoute(
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i); const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
if (adminInviteMatch && method === 'DELETE') { if (adminInviteMatch && method === 'DELETE') {
const inviteCode = decodeURIComponent(adminInviteMatch[1]); const inviteCode = decodeURIComponent(adminInviteMatch[1]);
return handleAdminRevokeInvite(request, env, actorUser, inviteCode); return handleAdminDeleteInvite(request, env, actorUser, inviteCode);
} }
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i); const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
+24 -7
View File
@@ -1,5 +1,4 @@
import { LIMITS } from './config/limits'; import { LIMITS } from './config/limits';
import { DEFAULT_DEV_SECRET } from './types';
import { import {
handleAccessSend, handleAccessSend,
handleAccessSendFile, handleAccessSendFile,
@@ -8,6 +7,7 @@ import {
handleDownloadSendFile, handleDownloadSendFile,
} from './handlers/sends'; } from './handlers/sends';
import { handleKnownDevice } from './handlers/devices'; import { handleKnownDevice } from './handlers/devices';
import { handleFillAssistForms, handleFillAssistManifest } from './handlers/fill-assist';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys'; import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import { import {
@@ -27,12 +27,13 @@ import {
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response'; import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>; type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
type JwtUnsafeReason = 'missing' | 'default' | 'too_short' | null; type JwtUnsafeReason = 'missing' | 'too_short' | null;
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations: number; defaultKdfIterations: number;
@@ -96,6 +97,7 @@ function buildIconServiceCsp(origin: string): string {
} }
function buildConfigResponse(origin: string) { function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist`;
return { return {
version: LIMITS.compatibility.bitwardenServerVersion, version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden', gitHash: 'nodewarden',
@@ -108,7 +110,7 @@ function buildConfigResponse(origin: string) {
notifications: origin + '/notifications', notifications: origin + '/notifications',
icons: origin, icons: origin,
sso: '', sso: '',
fillAssistRules: null, fillAssistRules: fillAssistBase,
}, },
push: { push: {
pushTechnology: 0, pushTechnology: 0,
@@ -124,8 +126,11 @@ function buildConfigResponse(origin: string) {
'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled, 'cipher-key-encryption': LIMITS.compatibility.cipherKeyEncryptionFeatureEnabled,
'duo-redirect': true, 'duo-redirect': true,
'email-verification': true, 'email-verification': true,
'fill-assist-targeting-rules': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true, 'pm-19148-innovation-archive': true,
'pm-4516-devices-add-last-activity-date': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true, 'unauth-ui-refresh': true,
'web-push': false, 'web-push': false,
}, },
@@ -241,6 +246,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: { headers: {
'Content-Type': contentType || 'image/png', 'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
}, },
}); });
} }
@@ -272,7 +278,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers); const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue; if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
@@ -301,8 +307,6 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
const jwtUnsafeReason = const jwtUnsafeReason =
!secret !secret
? 'missing' ? 'missing'
: secret === DEFAULT_DEV_SECRET
? 'default'
: secret.length < LIMITS.auth.jwtSecretMinLength : secret.length < LIMITS.auth.jwtSecretMinLength
? 'too_short' ? 'too_short'
: null; : null;
@@ -340,6 +344,19 @@ export async function handlePublicRoute(
return jsonResponse(await buildWebBootstrapResponse(env)); return jsonResponse(await buildWebBootstrapResponse(env));
} }
if (path === '/fill-assist/manifest.json' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleFillAssistManifest();
}
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
if (fillAssistFormsMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleFillAssistForms(fillAssistFormsMatch[1]);
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
@@ -467,7 +484,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin)); return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
+17 -7
View File
@@ -1,4 +1,4 @@
import { DEFAULT_DEV_SECRET, Env } from './types'; import { Env } from './types';
import { AuthService } from './services/auth'; import { AuthService } from './services/auth';
import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { RateLimitService, getClientIdentifier } from './services/ratelimit';
import { handleCors, errorResponse } from './utils/response'; import { handleCors, errorResponse } from './utils/response';
@@ -6,14 +6,24 @@ import { LIMITS } from './config/limits';
import { handleAuthenticatedRoute } from './router-authenticated'; import { handleAuthenticatedRoute } from './router-authenticated';
import { handlePublicRoute } from './router-public'; import { handlePublicRoute } from './router-public';
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
if (secret === DEFAULT_DEV_SECRET) return 'default';
if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short';
return null; return null;
} }
function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'OPTIONS') return true;
if (method === 'GET' && (path === '/api/web-bootstrap' || path === '/web-bootstrap')) return true;
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false;
}
function isImportBypassRequest(request: Request, path: string, method: string): boolean { function isImportBypassRequest(request: Request, path: string, method: string): boolean {
if (request.headers.get('X-NodeWarden-Import') !== '1') return false; if (request.headers.get('X-NodeWarden-Import') !== '1') return false;
@@ -85,14 +95,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const secretIssue = jwtSecretUnsafeReason(env); const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue) { if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
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);
} }
const publicResponse = await handlePublicRoute(request, env, path, method, enforcePublicRateLimit);
if (publicResponse) return publicResponse;
const auth = new AuthService(env); const auth = new AuthService(env);
const authHeader = request.headers.get('Authorization'); const authHeader = request.headers.get('Authorization');
const verified = await auth.verifyAccessTokenWithUser(authHeader); const verified = await auth.verifyAccessTokenWithUser(authHeader);
+78 -12
View File
@@ -26,6 +26,7 @@ import {
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
const MAX_BACKUP_DESTINATIONS = 24; const MAX_BACKUP_DESTINATIONS = 24;
@@ -324,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
return new Map(destinations.map((destination) => [destination.id, destination])); return new Map(destinations.map((destination) => [destination.id, destination]));
} }
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: normalizeRuntime(null),
})),
};
}
function serializeRuntimeState(settings: BackupSettings): string {
return JSON.stringify({
version: 1,
destinations: Object.fromEntries(
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
),
});
}
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
const entries = Object.entries(parsed.destinations || {})
.filter(([id]) => !!asTrimmedString(id))
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
return new Map(entries);
} catch {
return new Map();
}
}
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
})),
};
}
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings { export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone)); return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
} }
@@ -387,27 +429,30 @@ export function normalizeBackupSettingsInput(
} }
export function serializeBackupSettings(settings: BackupSettings): string { export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings); return JSON.stringify(stripRuntimeFromSettings(settings));
} }
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> { export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
);
if (!raw) { if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone); const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
const envelope = parseBackupSettingsEnvelope(raw); const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) { if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone); return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
} catch { } catch {
throw new Error('Backup settings need administrator reactivation after restore'); throw new Error('Backup settings need administrator reactivation after restore');
} }
@@ -417,6 +462,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
await saveBackupRuntimeStates(storage, settings);
}
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
}
export async function updateBackupDestinationRuntime(
storage: StorageService,
destinationId: string,
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
): Promise<BackupRuntimeState> {
const runtimes = await loadBackupRuntimeStates(storage);
const current = runtimes.get(destinationId) || normalizeRuntime(null);
const next = normalizeRuntime(mutator(current));
runtimes.set(destinationId, next);
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
version: 1,
destinations: Object.fromEntries(runtimes.entries()),
}));
return next;
} }
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> { export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
@@ -596,9 +662,9 @@ export function hasBackupSlotBetween(
const endMs = endExclusive.getTime(); const endMs = endExclusive.getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false; if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs); const dayCursor = new Date(startMs);
@@ -620,7 +686,7 @@ export function hasBackupSlotBetween(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue; if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue; if (lastSuccessMs >= slotStartMs) continue;
return true; return true;
} }
} }
@@ -637,9 +703,9 @@ export function isBackupDueNow(
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000; const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone); const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay( const slotStarts = getBackupSlotStartsForLocalDay(
@@ -652,7 +718,7 @@ export function isBackupDueNow(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue; if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false; if (lastSuccessMs >= slotStartMs) return false;
return true; return true;
} }
return false; return false;
+45 -6
View File
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
bytes: Uint8Array; bytes: Uint8Array;
} }
export interface RemoteBackupFileStat {
provider: BackupDestinationType;
remotePath: string;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupFilePutOptions { export interface RemoteBackupFilePutOptions {
contentType?: string; contentType?: string;
} }
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
} }
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> { async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
return (await statWebDavFile(config, relativePath)) !== null;
}
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const authHeader = toBasicAuthHeader(config.username, config.password); const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath); const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
@@ -441,11 +452,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`); throw new Error(`WebDAV existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 'webdav',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean { function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
@@ -540,10 +557,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath); const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
let continuationToken = '';
do {
const url = s3BucketBaseUrl(config); const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2'); url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/'); url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix); if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
const response = await signedS3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
@@ -551,8 +574,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
} }
const xml = await response.text(); const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
@@ -596,6 +617,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
}); });
} }
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
} while (continuationToken);
const deduped = new Map<string, RemoteBackupItem>(); const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
@@ -637,14 +661,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
} }
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
return (await statS3File(config, relativePath)) !== null;
}
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = s3ObjectUrl(config, objectKey); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 existence check failed: ${response.status}`); throw new Error(`S3 existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 's3',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
interface ConfiguredDestinationAdapter { interface ConfiguredDestinationAdapter {
@@ -656,6 +690,7 @@ interface ConfiguredDestinationAdapter {
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
} }
export interface RemoteBackupTransferSession { export interface RemoteBackupTransferSession {
@@ -666,6 +701,7 @@ export interface RemoteBackupTransferSession {
download(relativePath: string): Promise<RemoteBackupFile>; download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>; deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>; exists(relativePath: string): Promise<boolean>;
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
} }
function resolveConfiguredDestinationAdapter( function resolveConfiguredDestinationAdapter(
@@ -683,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
}; };
} }
if (destination.type === 's3') { if (destination.type === 's3') {
@@ -695,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
}; };
} }
@@ -730,6 +768,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
download: async (relativePath: string) => adapter.download(adapter.config, relativePath), download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
}; };
} }
+36 -4
View File
@@ -117,25 +117,57 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
} }
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> { export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare( .prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" "UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
) )
.bind(userId, now, code, now) .bind(now, code, now)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
export async function revokeInvite(db: D1Database, code: string): Promise<boolean> { export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") .prepare(
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(userId, now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(now, code) .bind(now, code)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
export async function deleteInvite(db: D1Database, code: string): Promise<boolean> {
const result = await db
.prepare('DELETE FROM invites WHERE code = ?')
.bind(code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function deleteInvalidInvites(db: D1Database): Promise<number> {
const now = new Date().toISOString();
const result = await db
.prepare("DELETE FROM invites WHERE status != 'active' OR expires_at <= ?")
.bind(now)
.run();
return Number(result.meta.changes ?? 0);
}
export async function deleteAllInvites(db: D1Database): Promise<number> { export async function deleteAllInvites(db: D1Database): Promise<number> {
const result = await db.prepare('DELETE FROM invites').run(); const result = await db.prepare('DELETE FROM invites').run();
return Number(result.meta.changes ?? 0); return Number(result.meta.changes ?? 0);
+64 -1
View File
@@ -22,10 +22,35 @@ export async function getAttachment(db: D1Database, id: string): Promise<Attachm
}; };
} }
export async function getAttachmentForUser(db: D1Database, id: string, userId: string): Promise<Attachment | null> {
const row = 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 a.id = ? AND c.user_id = ?`
)
.bind(id, userId)
.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> { export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise<void> {
const stmt = db.prepare( const stmt = db.prepare(
'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + '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' '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 ' +
'WHERE EXISTS (' +
'SELECT 1 FROM ciphers current_cipher INNER JOIN ciphers next_cipher ON next_cipher.id = excluded.cipher_id ' +
'WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = next_cipher.user_id' +
')'
); );
await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run();
} }
@@ -34,6 +59,20 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
} }
export async function deleteAttachmentForUser(db: D1Database, id: string, userId: string): Promise<void> {
await db
.prepare(
`DELETE FROM attachments
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers c
WHERE c.id = attachments.cipher_id AND c.user_id = ?
)`
)
.bind(id, userId)
.run();
}
export async function bulkDeleteAttachmentsByIds( export async function bulkDeleteAttachmentsByIds(
db: D1Database, db: D1Database,
sqlChunkSize: SqlChunkSize, sqlChunkSize: SqlChunkSize,
@@ -135,6 +174,30 @@ export async function addAttachmentToCipher(db: D1Database, cipherId: string, at
await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run();
} }
export async function addAttachmentToCipherForUser(
db: D1Database,
cipherId: string,
attachmentId: string,
userId: string
): Promise<void> {
await db
.prepare(
`UPDATE attachments
SET cipher_id = ?
WHERE id = ?
AND EXISTS (
SELECT 1 FROM ciphers target_cipher
WHERE target_cipher.id = ? AND target_cipher.user_id = ?
)
AND EXISTS (
SELECT 1 FROM ciphers current_cipher
WHERE current_cipher.id = attachments.cipher_id AND current_cipher.user_id = ?
)`
)
.bind(cipherId, attachmentId, cipherId, userId, userId)
.run();
}
export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> { export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise<void> {
await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run();
} }
@@ -68,6 +68,11 @@ export async function getAuthRequestById(db: D1Database, id: string): Promise<Au
return row ? mapAuthRequestRow(row) : null; return row ? mapAuthRequestRow(row) : null;
} }
export async function getAuthRequestByIdForUser(db: D1Database, id: string, userId: string): Promise<AuthRequestRecord | null> {
const row = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE id = ? AND user_id = ? LIMIT 1`).bind(id, userId).first<any>();
return row ? mapAuthRequestRow(row) : null;
}
export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> { export async function listAuthRequestsByUserId(db: D1Database, userId: string): Promise<AuthRequestRecord[]> {
const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>(); const res = await db.prepare(`${AUTH_REQUEST_SELECT} WHERE user_id = ? ORDER BY creation_date DESC`).bind(userId).all<any>();
return (res.results || []).map(mapAuthRequestRow); return (res.results || []).map(mapAuthRequestRow);
+10 -1
View File
@@ -107,6 +107,14 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
return parseCipherRow(row); return parseCipherRow(row);
} }
export async function getCipherForUser(db: D1Database, id: string, userId: string): Promise<Cipher | null> {
const row = await db
.prepare(`SELECT ${selectCipherColumns()} FROM ciphers WHERE id = ? AND user_id = ?`)
.bind(id, userId)
.first<CipherRow>();
return parseCipherRow(row);
}
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> { export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
const folderId = normalizeOptionalId(cipher.folderId); const folderId = normalizeOptionalId(cipher.folderId);
const data = buildCipherData(cipher, folderId); const data = buildCipherData(cipher, folderId);
@@ -114,7 +122,8 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_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, archived_at=excluded.archived_at, deleted_at=excluded.deleted_at' '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 ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
stmt, stmt,
+24 -5
View File
@@ -19,11 +19,20 @@ export async function getFolder(db: D1Database, id: string): Promise<Folder | nu
return mapFolderRow(row); return mapFolderRow(row);
} }
export async function getFolderForUser(db: D1Database, id: string, userId: string): Promise<Folder | null> {
const row = await db
.prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ? AND user_id = ?')
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapFolderRow(row);
}
export async function saveFolder(db: D1Database, folder: Folder): Promise<void> { export async function saveFolder(db: D1Database, folder: Folder): Promise<void> {
await db await db
.prepare( .prepare(
'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + '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' 'ON CONFLICT(id) DO UPDATE SET name=excluded.name, updated_at=excluded.updated_at WHERE user_id=excluded.user_id'
) )
.bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt)
.run(); .run();
@@ -44,9 +53,14 @@ export async function clearFolderFromCiphers(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id = ?` WHERE user_id = ?
AND (
folder_id = ?
OR json_extract(data, '$.folderId') = ?
OR json_extract(data, '$.folder_id') = ?
)`
) )
.bind(now, userId, folderId) .bind(now, userId, folderId, folderId, folderId)
.run(); .run();
} }
@@ -71,9 +85,14 @@ export async function bulkDeleteFolders(
`UPDATE ciphers `UPDATE ciphers
SET folder_id = NULL, updated_at = ?, SET folder_id = NULL, updated_at = ?,
data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate') data = json_remove(data, '$.folderId', '$.folder_id', '$.updatedAt', '$.revisionDate')
WHERE user_id = ? AND folder_id IN (${placeholders})` WHERE user_id = ?
AND (
folder_id IN (${placeholders})
OR json_extract(data, '$.folderId') IN (${placeholders})
OR json_extract(data, '$.folder_id') IN (${placeholders})
)`
) )
.bind(now, userId, ...chunk) .bind(now, userId, ...chunk, ...chunk, ...chunk)
.run(); .run();
await db await db
+7
View File
@@ -78,6 +78,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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, ' + '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 (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE invites ADD COLUMN used_by TEXT',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)', '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 INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
@@ -126,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'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_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', '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 totp_login_replays (' +
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
'PRIMARY KEY (user_id, time_counter), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
+14 -2
View File
@@ -40,15 +40,27 @@ export async function getSend(db: D1Database, id: string): Promise<Send | null>
return mapSendRow(row); return mapSendRow(row);
} }
export async function getSendForUser(db: D1Database, id: string, userId: 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 = ? AND user_id = ?'
)
.bind(id, userId)
.first<any>();
if (!row) return null;
return mapSendRow(row);
}
export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> { export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise<void> {
const stmt = db.prepare( 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) ' + '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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + '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, ' + '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, ' + '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, ' + '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' 'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date ' +
'WHERE user_id=excluded.user_id'
); );
await safeBind( await safeBind(
+35
View File
@@ -0,0 +1,35 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function consumeTotpLoginCounter(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
userId: string,
timeCounter: number,
consumedAtMs: number,
markerTtlMs: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
.bind(consumedAtMs - markerTtlMs)
.run();
cleanedUpAt = consumedAtMs;
}
const result = await db
.prepare(
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(user_id, time_counter) DO NOTHING'
)
.bind(userId, timeCounter, consumedAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+79 -5
View File
@@ -22,7 +22,10 @@ import {
type AuditLogListOptions, type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs, clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteInvite as deleteStoredInvite,
deleteInvalidInvites as deleteStoredInvalidInvites,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
listAuditLogs as listStoredAuditLogs, listAuditLogs as listStoredAuditLogs,
@@ -30,7 +33,7 @@ import {
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs, pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax, pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revokeInvite as revokeStoredInvite, revertInviteUsed as revertStoredInviteUsed,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
bulkDeleteFolders as deleteStoredFolders, bulkDeleteFolders as deleteStoredFolders,
@@ -38,6 +41,7 @@ import {
deleteFolder as deleteStoredFolder, deleteFolder as deleteStoredFolder,
getAllFolders as listStoredFolders, getAllFolders as listStoredFolders,
getFolder as findStoredFolder, getFolder as findStoredFolder,
getFolderForUser as findStoredFolderForUser,
getFoldersPage as listStoredFoldersPage, getFoldersPage as listStoredFoldersPage,
saveFolder as saveStoredFolder, saveFolder as saveStoredFolder,
} from './storage-folder-repo'; } from './storage-folder-repo';
@@ -50,6 +54,7 @@ import {
bulkUnarchiveCiphers as unarchiveStoredCiphers, bulkUnarchiveCiphers as unarchiveStoredCiphers,
getAllCiphers as listStoredCiphers, getAllCiphers as listStoredCiphers,
getCipher as findStoredCipher, getCipher as findStoredCipher,
getCipherForUser as findStoredCipherForUser,
getCiphersByIds as listStoredCiphersByIds, getCiphersByIds as listStoredCiphersByIds,
getCiphersPage as listStoredCiphersPage, getCiphersPage as listStoredCiphersPage,
saveCipher as saveStoredCipher, saveCipher as saveStoredCipher,
@@ -57,10 +62,13 @@ import {
} from './storage-cipher-repo'; } from './storage-cipher-repo';
import { import {
addAttachmentToCipher as attachStoredAttachmentToCipher, addAttachmentToCipher as attachStoredAttachmentToCipher,
addAttachmentToCipherForUser as attachStoredAttachmentToCipherForUser,
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds, bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
deleteAttachment as deleteStoredAttachment, deleteAttachment as deleteStoredAttachment,
deleteAttachmentForUser as deleteStoredAttachmentForUser,
getAttachment as findStoredAttachment, getAttachment as findStoredAttachment,
getAttachmentForUser as findStoredAttachmentForUser,
getAttachmentsByCipher as listStoredAttachmentsByCipher, getAttachmentsByCipher as listStoredAttachmentsByCipher,
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
getAttachmentsByUserId as listStoredAttachmentsByUserId, getAttachmentsByUserId as listStoredAttachmentsByUserId,
@@ -72,6 +80,7 @@ import {
deleteSend as deleteStoredSend, deleteSend as deleteStoredSend,
getAllSends as listStoredSends, getAllSends as listStoredSends,
getSend as findStoredSend, getSend as findStoredSend,
getSendForUser as findStoredSendForUser,
getSendsByIds as listStoredSendsByIds, getSendsByIds as listStoredSendsByIds,
getSendsPage as listStoredSendsPage, getSendsPage as listStoredSendsPage,
incrementSendAccessCount as incrementStoredSendAccessCount, incrementSendAccessCount as incrementStoredSendAccessCount,
@@ -111,6 +120,7 @@ import {
import { import {
createAuthRequest as createStoredAuthRequest, createAuthRequest as createStoredAuthRequest,
getAuthRequestById as findStoredAuthRequestById, getAuthRequestById as findStoredAuthRequestById,
getAuthRequestByIdForUser as findStoredAuthRequestByIdForUser,
listAuthRequestsByUserId as listStoredAuthRequestsByUserId, listAuthRequestsByUserId as listStoredAuthRequestsByUserId,
listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId, listPendingAuthRequestsByUserId as listStoredPendingAuthRequestsByUserId,
markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated, markAuthRequestAuthenticated as markStoredAuthRequestAuthenticated,
@@ -121,6 +131,9 @@ import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo'; } from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import { import {
getRevisionDate as getStoredRevisionDate, getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate,
@@ -148,8 +161,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications'; const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -162,10 +175,13 @@ export class StorageService {
private static schemaVerified = false; private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0; private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -313,8 +329,20 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId); return markStoredInviteUsed(this.db, code, userId);
} }
async revokeInvite(code: string): Promise<boolean> { async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
return revokeStoredInvite(this.db, code); return assignStoredInviteUsedBy(this.db, code, userId);
}
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
return revertStoredInviteUsed(this.db, code, userId);
}
async deleteInvite(code: string): Promise<boolean> {
return deleteStoredInvite(this.db, code);
}
async deleteInvalidInvites(): Promise<number> {
return deleteStoredInvalidInvites(this.db);
} }
async deleteAllInvites(): Promise<number> { async deleteAllInvites(): Promise<number> {
@@ -437,6 +465,10 @@ export class StorageService {
return findStoredCipher(this.db, id); return findStoredCipher(this.db, id);
} }
async getCipherForUser(id: string, userId: string): Promise<Cipher | null> {
return findStoredCipherForUser(this.db, id, userId);
}
async saveCipher(cipher: Cipher): Promise<void> { async saveCipher(cipher: Cipher): Promise<void> {
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
} }
@@ -487,6 +519,10 @@ export class StorageService {
return findStoredFolder(this.db, id); return findStoredFolder(this.db, id);
} }
async getFolderForUser(id: string, userId: string): Promise<Folder | null> {
return findStoredFolderForUser(this.db, id, userId);
}
async saveFolder(folder: Folder): Promise<void> { async saveFolder(folder: Folder): Promise<void> {
await saveStoredFolder(this.db, folder); await saveStoredFolder(this.db, folder);
} }
@@ -525,6 +561,10 @@ export class StorageService {
return findStoredAttachment(this.db, id); return findStoredAttachment(this.db, id);
} }
async getAttachmentForUser(id: string, userId: string): Promise<Attachment | null> {
return findStoredAttachmentForUser(this.db, id, userId);
}
async saveAttachment(attachment: Attachment): Promise<void> { async saveAttachment(attachment: Attachment): Promise<void> {
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment); await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
} }
@@ -533,6 +573,10 @@ export class StorageService {
await deleteStoredAttachment(this.db, id); await deleteStoredAttachment(this.db, id);
} }
async deleteAttachmentForUser(id: string, userId: string): Promise<void> {
await deleteStoredAttachmentForUser(this.db, id, userId);
}
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> { async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids); await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
} }
@@ -553,6 +597,10 @@ export class StorageService {
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
} }
async addAttachmentToCipherForUser(cipherId: string, attachmentId: string, userId: string): Promise<void> {
await attachStoredAttachmentToCipherForUser(this.db, cipherId, attachmentId, userId);
}
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> { async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
await deleteStoredAttachmentsByCipher(this.db, cipherId); await deleteStoredAttachmentsByCipher(this.db, cipherId);
} }
@@ -613,6 +661,10 @@ export class StorageService {
return findStoredSend(this.db, id); return findStoredSend(this.db, id);
} }
async getSendForUser(id: string, userId: string): Promise<Send | null> {
return findStoredSendForUser(this.db, id, userId);
}
async saveSend(send: Send): Promise<void> { async saveSend(send: Send): Promise<void> {
await saveStoredSend(this.db, this.safeBind.bind(this), send); await saveStoredSend(this.db, this.safeBind.bind(this), send);
} }
@@ -762,6 +814,10 @@ export class StorageService {
return findStoredAuthRequestById(this.db, id); return findStoredAuthRequestById(this.db, id);
} }
async getAuthRequestByIdForUser(id: string, userId: string): Promise<AuthRequestRecord | null> {
return findStoredAuthRequestByIdForUser(this.db, id, userId);
}
async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> { async listAuthRequestsByUserId(userId: string): Promise<AuthRequestRecord[]> {
return listStoredAuthRequestsByUserId(this.db, userId); return listStoredAuthRequestsByUserId(this.db, userId);
} }
@@ -823,6 +879,24 @@ export class StorageService {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
} }
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
const result = await consumeStoredTotpLoginCounter(
this.db,
this.shouldRunPeriodicCleanup.bind(this),
StorageService.lastTotpReplayCleanupAt,
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
userId,
timeCounter,
consumedAtMs,
StorageService.TOTP_REPLAY_MARKER_TTL_MS
);
if (result.cleanedUpAt !== null) {
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
}
return result.consumed;
}
// --- Revision dates --- // --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> { async getRevisionDate(userId: string): Promise<string> {
+3 -4
View File
@@ -19,10 +19,6 @@ export interface Env {
export type UserRole = 'admin' | 'user'; export type UserRole = 'admin' | 'user';
export type UserStatus = 'active' | 'banned'; export type UserStatus = 'active' | 'banned';
// Sample JWT secret used by `.dev.vars.example`.
// If runtime JWT_SECRET equals this value, treat it as unsafe.
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
// Attachment model // Attachment model
export interface Attachment { export interface Attachment {
id: string; id: string;
@@ -307,6 +303,7 @@ export interface DeviceResponse {
type: number; type: number;
creationDate: string; creationDate: string;
revisionDate: string; revisionDate: string;
lastActivityDate?: string | null;
lastSeenAt?: string | null; lastSeenAt?: string | null;
hasStoredDevice?: boolean; hasStoredDevice?: boolean;
isTrusted: boolean; isTrusted: boolean;
@@ -466,6 +463,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean; ResetMasterPassword: boolean;
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number; minComplexity: number;
minLength: number; minLength: number;
+36
View File
@@ -0,0 +1,36 @@
const API_KEY_HASH_PREFIX = 'sha256:';
export function constantTimeEquals(a: string, b: string): boolean {
const encA = new TextEncoder().encode(a);
const encB = new TextEncoder().encode(b);
if (encA.length !== encB.length) return false;
let diff = 0;
for (let i = 0; i < encA.length; i++) {
diff |= encA[i] ^ encB[i];
}
return diff === 0;
}
function toHex(bytes: ArrayBuffer): string {
return [...new Uint8Array(bytes)]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
export function isStoredApiKeyHash(value: string | null | undefined): boolean {
return String(value || '').startsWith(API_KEY_HASH_PREFIX);
}
export async function hashApiKey(apiKey: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(apiKey));
return `${API_KEY_HASH_PREFIX}${toHex(digest)}`;
}
export async function verifyApiKey(apiKey: string, storedApiKey: string | null | undefined): Promise<boolean> {
const stored = String(storedApiKey || '').trim();
if (!isStoredApiKeyHash(stored)) return false;
const hashed = await hashApiKey(apiKey);
return constantTimeEquals(hashed, stored);
}
+38
View File
@@ -0,0 +1,38 @@
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]);
const SAFE_ICON_MEDIA_TYPES = new Set([
'image/avif',
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/vnd.microsoft.icon',
'image/webp',
'image/x-icon',
]);
function normalizeMediaType(contentType: string | null | undefined): string {
return String(contentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
}
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
}
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
const mediaType = normalizeMediaType(contentType);
if (!mediaType) return 'application/octet-stream';
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
return 'application/octet-stream';
}
return contentType || mediaType;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { DEFAULT_DEV_SECRET, Env } from '../types'; import { Env } from '../types';
import { errorResponse } from './response'; import { errorResponse } from './response';
export interface DirectUploadPayload { export interface DirectUploadPayload {
@@ -28,7 +28,7 @@ export function buildDirectUploadUrl(request: Request, path: string, token: stri
export function getSafeJwtSecret(env: Env): string | null { export function getSafeJwtSecret(env: Env): string | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) {
return null; return null;
} }
return secret; return secret;
+2
View File
@@ -100,7 +100,9 @@ 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');
if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); 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,
+16 -7
View File
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
return token.replace(/\s+/g, ''); return token.replace(/\s+/g, '');
} }
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> { export async function findMatchingTotpCounter(
secretRaw: string,
tokenRaw: string,
nowMs: number = Date.now()
): Promise<number | null> {
const token = normalizeToken(tokenRaw); const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false; if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw); const secret = base32Decode(secretRaw);
if (!secret) return false; if (!secret) return null;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false; let matchedCounter: number | null = null;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta); const candidateCounter = currentCounter + delta;
const expected = await hotp(secret, candidateCounter);
// Constant-time comparison: always check all windows, never short-circuit. // Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected); const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token); const b = new TextEncoder().encode(token);
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
for (let i = 0; i < a.length && i < b.length; i++) { for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i]; diff |= a[i] ^ b[i];
} }
if (diff === 0) matched = true; if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
} }
return matched; return matchedCounter;
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
} }
export function isTotpEnabled(secretRaw: string | undefined | null): boolean { export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+107 -23
View File
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage'; import JwtWarningPage from '@/components/JwtWarningPage';
import { import {
createAuthedFetch, createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices, getAuthorizedDevices,
clearProfileSnapshot, clearProfileSnapshot,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
@@ -237,6 +238,7 @@ export default function App() {
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null); const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null); const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
@@ -264,6 +266,11 @@ export default function App() {
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>(''); const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null); const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null); const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
@@ -506,6 +513,14 @@ export default function App() {
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current =
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
? {
accessToken: login.session.accessToken,
masterPasswordHash: login.freshMasterPasswordHash || null,
userVerificationToken: login.freshUserVerificationToken || null,
}
: null;
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
@@ -1085,18 +1100,38 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({ const pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey, queryKey: pendingAuthRequestsQueryKey,
queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''), queryFn: () => listPendingAuthRequests(authedFetch, profile?.email || session?.email || ''),
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email), enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && !!session?.symEncKey && !!session?.symMacKey && !!(profile?.email || session?.email),
staleTime: 5_000, staleTime: 5_000,
refetchInterval: 15_000,
refetchIntervalInBackground: true,
}); });
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest); const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null; const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId; const selectedPendingAuthRequest = authRequestDialogSelectedId
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
: null;
const authRequestDialogRequest = selectedPendingAuthRequest || (
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
? latestPendingAuthRequest
: null
);
const authRequestDialogOpen = !!authRequestDialogRequest;
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestDialogSelectedId(authRequest.id);
setAuthRequestDialogDismissedId(null);
}
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> { async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
@@ -1110,6 +1145,7 @@ export default function App() {
requestApproved: true, requestApproved: true,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved')); pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1125,6 +1161,7 @@ export default function App() {
requestApproved: false, requestApproved: false,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied')); pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1189,13 +1226,25 @@ export default function App() {
if (!isAdminProfile(profile)) return; if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return; if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
repairAttemptRef.current = session.accessToken; repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile); void (async () => {
try {
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
} finally {
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
loginScopedBackupRepairAuthRef.current = null;
}
}
})();
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
repairAttemptRef.current = ''; repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = ''; uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]); }, [session?.accessToken]);
@@ -1908,6 +1957,7 @@ export default function App() {
lockTimeoutMinutes, lockTimeoutMinutes,
sessionTimeoutAction, sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevices: authorizedDevicesQuery.data || [],
currentDeviceIdentifier: getCurrentDeviceIdentifier(),
authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '', authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '',
domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null, domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null,
@@ -1950,8 +2000,8 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent, sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword, onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await totpStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
@@ -1963,11 +2013,12 @@ export default function App() {
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock, onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey, onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
pendingAuthRequests, pendingAuthRequests,
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isFetching, pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
onRefreshPendingAuthRequests: async () => { onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
}, },
onApproveAuthRequest: approveAuthRequest, onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest, onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes, onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction, onSessionTimeoutActionChange: setSessionTimeoutAction,
@@ -1980,34 +2031,64 @@ export default function App() {
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently, onTrustDevicePermanently: accountSecurityActions.openTrustDevicePermanently,
onRemoveDevice: accountSecurityActions.openRemoveDevice, onRemoveDevice: accountSecurityActions.openRemoveDevice,
onRemoveSelectedDevices: accountSecurityActions.openRemoveSelectedDevices,
onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust,
onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices, onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices,
onRefreshAdmin: adminActions.refreshAdmin, onRefreshAdmin: adminActions.refreshAdmin,
onCreateInvite: adminActions.createInvite, onCreateInvite: adminActions.createInvite,
onDeleteInvalidInvites: adminActions.deleteInvalidInvites,
onDeleteAllInvites: adminActions.deleteAllInvites, onDeleteAllInvites: adminActions.deleteAllInvites,
onToggleUserStatus: adminActions.toggleUserStatus, onToggleUserStatus: adminActions.toggleUserStatus,
onDeleteUser: adminActions.deleteUser, onDeleteUser: adminActions.deleteUser,
onRevokeInvite: adminActions.revokeInvite, onDeleteInvite: adminActions.deleteInvite,
onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters), onLoadAuditLogs: (filters: AuditLogFilters) => listAuditLogs(authedFetch, filters),
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch), onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
onImportBackup: backupActions.importBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, return backupActions.exportBackup(hash, includeAttachments);
},
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackup(hash, file, replaceExisting);
},
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
},
onLoadBackupSettings: () => queryClient.ensureQueryData({ onLoadBackupSettings: () => queryClient.ensureQueryData({
queryKey: ['admin-backup-settings', vaultCacheKey], queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(), queryFn: () => backupActions.loadSettings(),
staleTime: 30_000, staleTime: 30_000,
}), }),
onSaveBackupSettings: backupActions.saveSettings, onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
onRunRemoteBackup: backupActions.runRemoteBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const saved = await backupActions.saveSettings(hash, settings);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
return saved;
},
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const result = await backupActions.runRemoteBackup(hash, destinationId);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
return result;
},
onListRemoteBackups: backupActions.listRemoteBackups, onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup, onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: backupActions.inspectRemoteBackup, onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup, onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
},
}; };
const effectiveMainRoutesProps = IS_DEMO_MODE const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, { ? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -2215,21 +2296,24 @@ export default function App() {
/> />
<AuthRequestApprovalDialog <AuthRequestApprovalDialog
open={authRequestDialogOpen} open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest} authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId} submitting={!!authRequestSubmittingId}
onApprove={() => { onApprove={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => { void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onDeny={() => { onDeny={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => { void denyAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)} onClose={() => {
setAuthRequestDialogSelectedId(null);
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
}}
/> />
</> </>
); );
+8 -6
View File
@@ -13,10 +13,11 @@ interface AdminPageProps {
error: string; error: string;
onRefresh: () => void; onRefresh: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>; onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, currentStatus: 'active' | 'banned') => 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>; onDeleteInvite: (code: string) => Promise<void>;
} }
export default function AdminPage(props: AdminPageProps) { export default function AdminPage(props: AdminPageProps) {
@@ -134,7 +135,10 @@ export default function AdminPage(props: AdminPageProps) {
<h3>{t('txt_invites')}</h3> <h3>{t('txt_invites')}</h3>
<div className="actions admin-invites-head-actions"> <div className="actions admin-invites-head-actions">
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}> <button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync')} <RefreshCw size={14} className="btn-icon" /> {t('txt_refresh')}
</button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteInvalidInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_invalid')}
</button> </button>
<button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}> <button type="button" className="btn btn-danger small" onClick={() => void props.onDeleteAllInvites()}>
<Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')} <Trash2 size={14} className="btn-icon" /> {t('txt_delete_all')}
@@ -184,11 +188,9 @@ export default function AdminPage(props: AdminPageProps) {
> >
<Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')} <Clipboard size={14} className="btn-icon" /> {t('txt_copy_link')}
</button> </button>
{invite.status === 'active' && ( <button type="button" className="btn btn-danger" onClick={() => void props.onDeleteInvite(invite.code)}>
<button type="button" className="btn btn-danger" onClick={() => void props.onRevokeInvite(invite.code)}> <Trash2 size={14} className="btn-icon" /> {t('txt_delete')}
<Trash2 size={14} className="btn-icon" /> {t('txt_revoke')}
</button> </button>
)}
</div> </div>
</td> </td>
</tr> </tr>
+19 -16
View File
@@ -57,6 +57,7 @@ export interface AppMainRoutesProps {
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
authorizedDevices: AuthorizedDevice[]; authorizedDevices: AuthorizedDevice[];
currentDeviceIdentifier: string;
authorizedDevicesLoading: boolean; authorizedDevicesLoading: boolean;
authorizedDevicesError: string; authorizedDevicesError: string;
domainRules: DomainRules | null; domainRules: DomainRules | null;
@@ -107,7 +108,7 @@ export interface AppMainRoutesProps {
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -118,6 +119,7 @@ export interface AppMainRoutesProps {
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[]; pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean; pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean;
onRefreshPendingAuthRequests: () => Promise<void>; onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>; onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>; onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
@@ -130,30 +132,32 @@ export interface AppMainRoutesProps {
onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onTrustDevicePermanently: (device: AuthorizedDevice) => void; onTrustDevicePermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAllDeviceTrust: () => void; onRevokeAllDeviceTrust: () => void;
onRemoveAllDevices: () => void; onRemoveAllDevices: () => void;
onCreateInvite: (hours: number) => Promise<void>; onCreateInvite: (hours: number) => Promise<void>;
onRefreshAdmin: () => void; onRefreshAdmin: () => void;
onDeleteInvalidInvites: () => Promise<void>;
onDeleteAllInvites: () => Promise<void>; onDeleteAllInvites: () => Promise<void>;
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>; onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>; onDeleteInvite: (code: string) => Promise<void>;
onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>; onLoadAuditLogs: (filters: AuditLogFilters) => Promise<AuditLogListResult>;
onLoadAuditLogSettings: () => Promise<AuditLogSettings>; onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>; onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, 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 } }>; 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>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
@@ -275,11 +279,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
@@ -352,10 +351,12 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Suspense fallback={<RouteContentFallback />}> <Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage <SecurityDevicesPage
devices={props.authorizedDevices} devices={props.authorizedDevices}
currentDeviceIdentifier={props.currentDeviceIdentifier}
loading={props.authorizedDevicesLoading} loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError} error={props.authorizedDevicesError}
pendingAuthRequests={props.pendingAuthRequests} pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading} pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
onRefresh={() => void props.onRefreshAuthorizedDevices()} onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests} onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest} onApproveAuthRequest={props.onApproveAuthRequest}
@@ -364,6 +365,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onRevokeTrust={props.onRevokeDeviceTrust} onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently} onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice} onRemoveDevice={props.onRemoveDevice}
onRemoveSelectedDevices={props.onRemoveSelectedDevices}
onRevokeAll={props.onRevokeAllDeviceTrust} onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices} onRemoveAll={props.onRemoveAllDevices}
/> />
@@ -412,10 +414,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
error={props.adminError} error={props.adminError}
onRefresh={props.onRefreshAdmin} onRefresh={props.onRefreshAdmin}
onCreateInvite={props.onCreateInvite} onCreateInvite={props.onCreateInvite}
onDeleteInvalidInvites={props.onDeleteInvalidInvites}
onDeleteAllInvites={props.onDeleteAllInvites} onDeleteAllInvites={props.onDeleteAllInvites}
onToggleUserStatus={props.onToggleUserStatus} onToggleUserStatus={props.onToggleUserStatus}
onDeleteUser={props.onDeleteUser} onDeleteUser={props.onDeleteUser}
onRevokeInvite={props.onRevokeInvite} onDeleteInvite={props.onDeleteInvite}
/> />
</Suspense> </Suspense>
</div> </div>
+172 -20
View File
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps { interface BackupCenterPageProps {
currentUserId: string | null; currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>; onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>; onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, 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: BackupFileIntegrityCheckResult }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
@@ -53,6 +53,15 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
type PendingBackupVerification =
| { action: 'export' }
| { action: 'saveSettings' }
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
titleKey: string; titleKey: string;
detailKey: string; detailKey: string;
@@ -184,7 +193,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null); const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
const [restoringRemotePath, setRestoringRemotePath] = useState(''); const [restoringRemotePath, setRestoringRemotePath] = useState('');
const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState('');
const [localError, setLocalError] = useState(''); const [, setLocalError] = useState('');
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null); const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0); const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
@@ -193,6 +202,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +221,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId); const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination; const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
@@ -226,6 +238,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination; const canBrowseSelectedDestination = !!savedSelectedDestination;
const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export'
? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -478,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
}; };
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(nextSettings); const saved = await props.onSaveSettings(masterPassword, payload);
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
setSavedSettings(saved); setSavedSettings(saved);
@@ -507,11 +537,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleExport() { async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
}
async function executeExport(masterPassword: string) {
setLocalError(''); setLocalError('');
setExporting(true); setExporting(true);
try { try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments); await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success')); props.onNotify('success', t('txt_backup_export_success'));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
@@ -527,6 +563,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
masterPassword: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (importing) return; if (importing) return;
if (!selectedFile) { if (!selectedFile) {
@@ -547,8 +605,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting); : await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
@@ -573,12 +631,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
}
async function executeSaveSettings(masterPassword: string) {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(payload); const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected = const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved) || getFirstVisibleDestinationId(saved)
@@ -613,6 +677,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return; if (!selectedDestination) return;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
@@ -621,7 +691,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote', source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments, includeAttachments: !!selectedDestination.includeAttachments,
}); });
const result = await props.onRunRemoteBackup(selectedDestination.id); const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings); setSavedSettings(result.settings);
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
@@ -638,12 +708,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message); setLocalError(message);
@@ -724,6 +799,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
masterPassword: string,
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
@@ -738,8 +832,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
@@ -762,6 +856,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
} }
async function submitBackupPasswordPrompt(): Promise<void> {
const request = pendingBackupVerification;
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
try {
if (request.action === 'export') {
await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
}
return ( return (
<div className="backup-grid"> <div className="backup-grid">
<input <input
@@ -848,7 +974,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}} }}
/> />
{localError ? <div className="local-error">{localError}</div> : null}
{restoreProgress && typeof document !== 'undefined' ? createPortal(( {restoreProgress && typeof document !== 'undefined' ? createPortal((
<div className="restore-progress-overlay" aria-live="polite"> <div className="restore-progress-overlay" aria-live="polite">
<section className="restore-progress-card restore-progress-modal"> <section className="restore-progress-card restore-progress-modal">
@@ -893,6 +1018,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div> </div>
), document.body) : null} ), document.body) : null}
<ConfirmDialog
open={pendingBackupVerification !== null}
title={backupPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
cancelDisabled={backupPasswordSubmitting}
onConfirm={() => void submitBackupPasswordPrompt()}
onCancel={() => {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={confirmLocalRestoreOpen} open={confirmLocalRestoreOpen}
title={t('txt_backup_import')} title={t('txt_backup_import')}
+1 -3
View File
@@ -5,7 +5,7 @@ import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
interface JwtWarningPageProps { interface JwtWarningPageProps {
reason: 'missing' | 'default' | 'too_short'; reason: 'missing' | 'too_short';
minLength: number; minLength: number;
} }
@@ -21,8 +21,6 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const title = const title =
props.reason === 'missing' props.reason === 'missing'
? t('txt_jwt_title_missing') ? t('txt_jwt_title_missing')
: props.reason === 'default'
? t('txt_jwt_title_default')
: t('txt_jwt_title_too_short'); : t('txt_jwt_title_too_short');
const isMissing = props.reason === 'missing'; const isMissing = props.reason === 'missing';
@@ -7,6 +7,7 @@ import { t } from '@/lib/i18n';
interface PendingAuthRequestsPanelProps { interface PendingAuthRequestsPanelProps {
pendingAuthRequests: AuthRequest[]; pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean; pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing?: boolean;
onRefreshPendingAuthRequests: () => Promise<void>; onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>; onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>; onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
@@ -22,6 +23,7 @@ function formatDateTime(value: string | null | undefined): string {
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) { export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null); const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const refreshing = props.pendingAuthRequestsLoading || !!props.pendingAuthRequestsRefreshing;
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> { async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return; if (authRequestSubmittingId) return;
@@ -50,10 +52,10 @@ export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanel
<button <button
type="button" type="button"
className="btn btn-secondary small" className="btn btn-secondary small"
disabled={props.pendingAuthRequestsLoading} disabled={refreshing}
onClick={() => void props.onRefreshPendingAuthRequests()} onClick={() => void props.onRefreshPendingAuthRequests()}
> >
<RefreshCw size={14} className="btn-icon" /> <RefreshCw size={14} className={`btn-icon${refreshing ? ' btn-icon-spin' : ''}`} />
{t('txt_refresh')} {t('txt_refresh')}
</button> </button>
</div> </div>
+62 -8
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { CheckSquare, Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState'; import LoadingState from '@/components/LoadingState';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel'; import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
@@ -8,10 +8,12 @@ import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps { interface SecurityDevicesPageProps {
devices: AuthorizedDevice[]; devices: AuthorizedDevice[];
currentDeviceIdentifier: string;
loading: boolean; loading: boolean;
error: string; error: string;
pendingAuthRequests: AuthRequest[]; pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean; pendingAuthRequestsLoading: boolean;
pendingAuthRequestsRefreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
onRefreshPendingAuthRequests: () => Promise<void>; onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>; onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
@@ -20,6 +22,7 @@ interface SecurityDevicesPageProps {
onRevokeTrust: (device: AuthorizedDevice) => void; onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void; onTrustPermanently: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void;
onRemoveSelectedDevices: (devices: AuthorizedDevice[]) => void;
onRevokeAll: () => void; onRevokeAll: () => void;
onRemoveAll: () => void; onRemoveAll: () => void;
} }
@@ -62,6 +65,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null); const [editingDevice, setEditingDevice] = useState<AuthorizedDevice | null>(null);
const [deviceNote, setDeviceNote] = useState(''); const [deviceNote, setDeviceNote] = useState('');
const [savingNote, setSavingNote] = useState(false); const [savingNote, setSavingNote] = useState(false);
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([]);
const currentDeviceIdentifier = props.currentDeviceIdentifier;
const selectableDevices = props.devices.filter((device) => (
device.identifier !== currentDeviceIdentifier
));
const selectedDeviceIdSet = new Set(selectedDeviceIds);
const selectedDevices = selectableDevices.filter((device) => selectedDeviceIdSet.has(device.identifier));
const allSelectableSelected = selectableDevices.length > 0 && selectedDevices.length === selectableDevices.length;
async function handleSaveDeviceNote(): Promise<void> { async function handleSaveDeviceNote(): Promise<void> {
if (!editingDevice || savingNote) return; if (!editingDevice || savingNote) return;
@@ -75,6 +86,19 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
} }
} }
function toggleSelectAllDevices(): void {
setSelectedDeviceIds(allSelectableSelected ? [] : selectableDevices.map((device) => device.identifier));
}
function toggleSelectedDevice(device: AuthorizedDevice): void {
if (device.identifier === currentDeviceIdentifier) return;
setSelectedDeviceIds((current) => (
current.includes(device.identifier)
? current.filter((id) => id !== device.identifier)
: [...current, device.identifier]
));
}
return ( return (
<> <>
<div className="stack"> <div className="stack">
@@ -83,6 +107,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
loadingVariant="compact" loadingVariant="compact"
pendingAuthRequests={props.pendingAuthRequests} pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading} pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
pendingAuthRequestsRefreshing={props.pendingAuthRequestsRefreshing}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests} onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest} onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest} onDenyAuthRequest={props.onDenyAuthRequest}
@@ -91,7 +116,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 className="flush-title">{t('txt_device_management')}</h3> <h3 className="flush-title">{t('txt_authorized_devices')}</h3>
<div className="muted-inline section-note"> <div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div> </div>
@@ -101,6 +126,27 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<RefreshCw size={14} className="btn-icon" /> <RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')} {t('txt_refresh')}
</button> </button>
<button
type="button"
className="btn btn-secondary small"
disabled={props.loading || selectableDevices.length === 0}
onClick={toggleSelectAllDevices}
>
<CheckSquare size={14} className="btn-icon" />
{allSelectableSelected ? t('txt_clear_selection') : t('txt_select_all')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={selectedDevices.length === 0}
onClick={() => {
props.onRemoveSelectedDevices(selectedDevices);
setSelectedDeviceIds([]);
}}
>
<Trash2 size={14} className="btn-icon" />
{t('txt_remove_selected_devices', { count: selectedDevices.length })}
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}> <button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
{t('txt_revoke_all_trusted')} {t('txt_revoke_all_trusted')}
@@ -111,10 +157,6 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
</div> </div>
</section>
<section className="card">
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
{!!props.error && ( {!!props.error && (
<div className="local-error"> <div className="local-error">
<span>{props.error}</span> <span>{props.error}</span>
@@ -126,6 +168,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
)} )}
<table className="table authorized-devices-table"> <table className="table authorized-devices-table">
<colgroup> <colgroup>
<col className="authorized-devices-col-select" />
<col className="authorized-devices-col-device" /> <col className="authorized-devices-col-device" />
<col className="authorized-devices-col-type" /> <col className="authorized-devices-col-type" />
<col className="authorized-devices-col-status" /> <col className="authorized-devices-col-status" />
@@ -136,6 +179,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>{t('txt_select')}</th>
<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_status')}</th>
@@ -148,6 +192,16 @@ 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 data-label={t('txt_select')}>
<input
type="checkbox"
className="authorized-device-checkbox"
checked={selectedDeviceIdSet.has(device.identifier)}
disabled={device.identifier === currentDeviceIdentifier}
aria-label={t('txt_select_device_name', { name: device.name || t('txt_unknown_device') })}
onChange={() => toggleSelectedDevice(device)}
/>
</td>
<td data-label={t('txt_device')}> <td data-label={t('txt_device')}>
<div>{device.name || t('txt_unknown_device')}</div> <div>{device.name || t('txt_unknown_device')}</div>
{!!device.deviceNote && !!device.systemName && device.systemName !== device.name && ( {!!device.deviceNote && !!device.systemName && device.systemName !== device.name && (
@@ -220,14 +274,14 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
))} ))}
{props.loading && props.devices.length === 0 && ( {props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<LoadingState lines={5} compact /> <LoadingState lines={5} compact />
</td> </td>
</tr> </tr>
)} )}
{!props.loading && props.devices.length === 0 && ( {!props.loading && props.devices.length === 0 && (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<div className="empty empty-comfortable">{t('txt_no_devices_found')}</div> <div className="empty empty-comfortable">{t('txt_no_devices_found')}</div>
</td> </td>
</tr> </tr>
+15 -24
View File
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types'; import type { AccountPasskeyCredential, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
@@ -14,7 +13,7 @@ interface SettingsPageProps {
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -23,17 +22,13 @@ interface SettingsPageProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
type MasterPasswordPromptAction = type MasterPasswordPromptAction =
| 'enableTotp'
| 'recovery' | 'recovery'
| 'apiKey' | 'apiKey'
| 'rotateApiKey' | 'rotateApiKey'
@@ -141,12 +136,12 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { if (totpLocked) return;
await props.onEnableTotp(secret, token); if (!secret.trim() || !token.trim()) {
setTotpLocked(true); props.onNotify?.('error', t('txt_secret_and_code_are_required'));
} catch { return;
// Keep inputs editable after a failed attempt.
} }
openMasterPasswordPrompt('enableTotp');
} }
async function refreshAccountPasskeys(): Promise<void> { async function refreshAccountPasskeys(): Promise<void> {
@@ -178,7 +173,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const masterPassword = masterPasswordPromptValue; const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true); setMasterPasswordPromptSubmitting(true);
try { try {
if (masterPasswordPrompt === 'recovery') { if (masterPasswordPrompt === 'enableTotp') {
await props.onEnableTotp(secret, token, masterPassword);
setTotpLocked(true);
} else if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword); const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
@@ -214,7 +212,9 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
const masterPasswordPromptTitle = const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery' masterPasswordPrompt === 'enableTotp'
? t('txt_enable_totp')
: masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code') ? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey' : masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key') ? t('txt_rotate_api_key')
@@ -509,15 +509,6 @@ export default function SettingsPage(props: SettingsPageProps) {
)} )}
</div> </div>
</section> </section>
<PendingAuthRequestsPanel
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="settings-module sensitive-actions-module"> <section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid"> <div className="sensitive-actions-grid">
<div className="sensitive-action"> <div className="sensitive-action">
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
<> <>
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')} <strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
</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> <a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
{t('txt_backup_recommend_koofr_step_2_suffix')} {t('txt_backup_recommend_koofr_step_2_suffix')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')} <strong>2.</strong> {t('txt_backup_recommend_koofr_step_3')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')} <strong>3.</strong> {t('txt_backup_recommend_koofr_step_4')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '} <strong>4.</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> <a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
{t('txt_backup_recommend_koofr_step_5_suffix')} {t('txt_backup_recommend_koofr_step_5_suffix')}
</div> </div>
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
return ( return (
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')} <strong>1.</strong> {t('txt_backup_recommend_pcloud_step_2')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')} <strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div> </div>
</div> </div>
); );
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
return ( return (
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')} <strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
</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> <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')} {t('txt_backup_recommend_infinicloud_step_2_suffix')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')} <strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')} <strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
</div>
</div>
);
case 'backblaze-b2':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '}
<a href={provider.bucketsUrl} target="_blank" rel="noreferrer">Buckets</a>
{t('txt_backup_recommend_backblaze_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_backblaze_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '}
<a href={provider.applicationKeysUrl} target="_blank" rel="noreferrer">Application Keys</a>
{t('txt_backup_recommend_backblaze_step_4_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_backblaze_step_5')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
</div>
</div>
);
case 'cloudflare-r2':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '}
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_bucket_link')}</a>
{t('txt_backup_recommend_cloudflare_r2_step_1_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '}
<a href={provider.apiTokenUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_api_link')}</a>
{t('txt_backup_recommend_cloudflare_r2_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_cloudflare_r2_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_cloudflare_r2_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_cloudflare_r2_step_5')}
</div>
</div>
);
case 'tigris':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_tigris_step_2_prefix')}{' '}
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">Create Bucket</a>
{t('txt_backup_recommend_tigris_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_tigris_step_3_prefix')}{' '}
<a href={provider.accessKeyUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_tigris_access_key_link')}</a>
{t('txt_backup_recommend_tigris_step_3_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_tigris_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_tigris_step_5')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
</div> </div>
</div> </div>
); );
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
<div className="backup-inline-note"> <div className="backup-inline-note">
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary') {props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary') : props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
: props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary')
: props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary')
: props.selectedRecommendedProvider.id === 'tigris' ? t('txt_backup_recommend_tigris_summary')
: t('txt_backup_recommend_pcloud_summary')} : t('txt_backup_recommend_pcloud_summary')}
</div> </div>
</div> </div>
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
className="input" className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath} value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder="nodewarden"
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
className="input" className="input"
value={(props.selectedDestination.destination as S3BackupDestination).rootPath} value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder=""
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
@@ -1,9 +1,12 @@
import { Download, FileUp } from 'lucide-preact'; import { Download, FileUp } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import type { RecommendedProvider } from '@/lib/backup-recommendations'; import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { hasLinkedStorages } from '@/lib/backup-recommendations'; import { hasLinkedStorages } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField'; import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
interface BackupOperationsSidebarProps { interface BackupOperationsSidebarProps {
disableWhileBusy: boolean; disableWhileBusy: boolean;
exporting: boolean; exporting: boolean;
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
onSelectProvider: (providerId: string) => void; onSelectProvider: (providerId: string) => void;
} }
function getDefaultRecommendationsOpen() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return true;
}
return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches;
}
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) { export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen);
const [recommendationsTouched, setRecommendationsTouched] = useState(false);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) {
return;
}
const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY);
const syncOpenState = () => setRecommendationsOpen(!media.matches);
syncOpenState();
media.addEventListener('change', syncOpenState);
return () => media.removeEventListener('change', syncOpenState);
}, [recommendationsTouched]);
return ( return (
<aside className="backup-operations-sidebar"> <aside className="backup-operations-sidebar">
<div className="section-head"> <div className="section-head">
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
</button> </button>
</div> </div>
<details className="backup-recommendations-disclosure"> <details
className="backup-recommendations-disclosure"
open={recommendationsOpen}
onToggle={(event) => {
setRecommendationsTouched(true);
setRecommendationsOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary className="backup-recommendations-summary"> <summary className="backup-recommendations-summary">
<span> <span>
<strong>{t('txt_backup_recommend_title')}</strong> <strong>{t('txt_backup_recommend_title')}</strong>
@@ -1,4 +1,4 @@
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; import { Download, FileArchive, FolderOpen, FolderUp, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center'; import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -32,26 +32,32 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
: t('txt_downloading_percent', { percent: props.downloadingRemotePercent }); : t('txt_downloading_percent', { percent: props.downloadingRemotePercent });
}; };
const renderRefreshPrompt = () => (
<div className="backup-browser-empty">
<span className="backup-browser-refresh-prompt">
<span>{t('txt_backup_remote_cached_empty_prefix')}</span>
<button type="button" className="btn btn-secondary small" disabled={!props.canBrowse || props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
{t('txt_backup_remote_refresh')}
</button>
<span>{t('txt_backup_remote_cached_empty_suffix')}</span>
</span>
</div>
);
return ( return (
<> <>
<div className="backup-divider" /> <div className="backup-divider" />
<div className="section-head"> <div className="section-head">
<h3>{t('txt_backup_remote_title')}</h3> <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> </div>
{!props.destinationIsSaved ? ( {!props.destinationIsSaved ? (
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div> <div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
) : props.loadingRemoteBrowser && !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : !props.remoteBrowser ? ( ) : !props.remoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div> renderRefreshPrompt()
) : ( ) : (
<> <>
<div className="backup-browser-path"> <div className="backup-browser-path">
@@ -59,7 +65,8 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span> <span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
</div> </div>
<div className="actions backup-browser-nav"> <div className="backup-browser-nav">
<div className="actions backup-browser-nav-left">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}> <button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
<FolderOpen size={14} className="btn-icon" /> <FolderOpen size={14} className="btn-icon" />
{t('txt_backup_remote_root')} {t('txt_backup_remote_root')}
@@ -70,16 +77,29 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null} disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')} onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
> >
<RotateCcw size={14} className="btn-icon" /> <FolderUp size={14} className="btn-icon" />
{t('txt_backup_remote_up')} {t('txt_backup_remote_up')}
</button> </button>
</div> </div>
{props.canBrowse ? (
<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>
) : null}
</div>
{props.loadingRemoteBrowser ? ( {props.loadingRemoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div> <div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : props.remoteBrowser.items.length ? ( ) : props.remoteBrowser.items.length ? (
<> <>
<div className="backup-browser-list"> <div className="backup-browser-list">
<div className="backup-browser-head" aria-hidden="true">
<span>{t('txt_name')}</span>
<span>{t('txt_backup_remote_modified')}</span>
<span>{t('txt_backup_remote_size')}</span>
<span>{t('txt_actions')}</span>
</div>
{props.visibleItems.map((item) => ( {props.visibleItems.map((item) => (
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row"> <div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
<button <button
@@ -92,10 +112,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />} {item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
<span className="backup-browser-name">{item.name}</span> <span className="backup-browser-name">{item.name}</span>
</button> </button>
<div className="backup-browser-meta"> <span className="backup-browser-meta backup-browser-modified">
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span> {item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span> </span>
</div> <span className="backup-browser-meta backup-browser-size">
{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}
</span>
<div className="actions backup-browser-actions"> <div className="actions backup-browser-actions">
{item.isDirectory ? ( {item.isDirectory ? (
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}> <button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
+49 -15
View File
@@ -1,6 +1,7 @@
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -171,16 +172,38 @@ export default function VaultEditor(props: VaultEditorProps) {
return new window.BarcodeDetector({ formats: ['qr_code'] }); return new window.BarcodeDetector({ formats: ['qr_code'] });
}; };
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => { const decodeTotpQrCanvas = (source: ImageBitmap | HTMLVideoElement): string => {
const width = 'videoWidth' in source ? source.videoWidth : source.width;
const height = 'videoHeight' in source ? source.videoHeight : source.height;
if (!width || !height) return '';
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) return '';
// jsQR ignores alpha and reads RGB directly, so transparent pixels would be
// treated as black. Composite over white first so transparent-background QR
// exports do not become black-on-black and fail to decode.
context.fillStyle = '#ffffff';
context.fillRect(0, 0, width, height);
context.drawImage(source, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
return String(jsQR(imageData.data, width, height)?.data || '').trim();
};
const decodeTotpQrImage = async (source: ImageBitmap): Promise<boolean> => {
const detector = createTotpQrDetector(); const detector = createTotpQrDetector();
if (!detector) { if (detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported')); try {
return false;
}
const results = await detector.detect(source); const results = await detector.detect(source);
const value = String(results[0]?.rawValue || '').trim(); const value = String(results[0]?.rawValue || '').trim();
if (!value) return false; if (value && applyTotpQrValue(value)) return true;
return applyTotpQrValue(value); } catch {
// Fall back to jsQR when the native detector is present but not usable.
}
}
const value = decodeTotpQrCanvas(source);
return value ? applyTotpQrValue(value) : false;
}; };
const handleTotpQrFile = async (file: File | null) => { const handleTotpQrFile = async (file: File | null) => {
@@ -206,14 +229,8 @@ export default function VaultEditor(props: VaultEditorProps) {
return; return;
} }
let stopped = false; let stopped = false;
let lastCanvasScan = 0;
const detector = createTotpQrDetector(); const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
return () => { return () => {
@@ -229,9 +246,26 @@ export default function VaultEditor(props: VaultEditorProps) {
totpQrFrameRef.current = window.requestAnimationFrame(scan); totpQrFrameRef.current = window.requestAnimationFrame(scan);
return; return;
} }
try {
let value = '';
if (detector) {
try { try {
const results = await detector.detect(video); const results = await detector.detect(video);
const value = String(results[0]?.rawValue || '').trim(); value = String(results[0]?.rawValue || '').trim();
} catch {
// Fall back to jsQR when the native detector is present but not usable.
}
}
// The jsQR fallback runs a synchronous full-frame decode, so throttle
// it to a few times per second instead of every animation frame to
// avoid pegging the CPU while a code is being aligned.
if (!value) {
const now = performance.now();
if (now - lastCanvasScan >= 250) {
lastCanvasScan = now;
value = decodeTotpQrCanvas(video);
}
}
if (value && applyTotpQrValue(value)) return; if (value && applyTotpQrValue(value)) return;
} catch { } catch {
// Keep the camera active; transient frame decode failures are common. // Keep the camera active; transient frame decode failures are common.
+61 -3
View File
@@ -3,6 +3,7 @@ import {
changeMasterPassword, changeMasterPassword,
deleteAllAuthorizedDevices, deleteAllAuthorizedDevices,
deleteAuthorizedDevice, deleteAuthorizedDevice,
deleteAuthorizedDevices,
deriveLoginHash, deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi, deleteAccountPasskey as deleteAccountPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi, enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
@@ -145,14 +146,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} }
}, },
async enableTotp(secret: string, token: string) { async enableTotp(secret: string, token: string, masterPassword: string) {
if (!profile) {
const error = new Error(t('txt_profile_unavailable'));
onNotify('error', error.message);
throw error;
}
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
const error = new Error(t('txt_secret_and_code_are_required')); const error = new Error(t('txt_secret_and_code_are_required'));
onNotify('error', error.message); onNotify('error', error.message);
throw error; throw error;
} }
if (!masterPassword) {
const error = new Error(t('txt_master_password_is_required'));
onNotify('error', error.message);
throw error;
}
try { try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations);
await setTotp(authedFetch, {
enabled: true,
secret: secret.trim(),
token: token.trim(),
masterPasswordHash: derived.hash,
});
onNotify('success', t('txt_totp_enabled')); onNotify('success', t('txt_totp_enabled'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
@@ -218,11 +235,19 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const normalizedName = String(name || '').trim() || t('txt_account_passkey'); const normalizedName = String(name || '').trim() || t('txt_account_passkey');
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash); const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
const pending = await createAccountPasskeyCredential(options); const pending = await createAccountPasskeyCredential(options, directUnlock);
let keySet = null; let keySet = null;
let savedWithoutDirectUnlock = false; let savedWithoutDirectUnlock = false;
if (directUnlock) { if (directUnlock) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
if (!pending.supportsPrf) {
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
if (!shouldSaveLoginOnly) {
onNotify('warning', t('txt_account_passkey_not_saved'));
return null;
}
savedWithoutDirectUnlock = true;
} else {
try { try {
keySet = await buildAccountPasskeyPrfKeySet(pending, { keySet = await buildAccountPasskeyPrfKeySet(pending, {
symEncKey: session.symEncKey, symEncKey: session.symEncKey,
@@ -238,6 +263,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
savedWithoutDirectUnlock = true; savedWithoutDirectUnlock = true;
} }
} }
}
const credential = await saveAccountPasskey(authedFetch, { const credential = await saveAccountPasskey(authedFetch, {
name: normalizedName, name: normalizedName,
token: pending.token, token: pending.token,
@@ -364,6 +390,38 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
}); });
}, },
openRemoveSelectedDevices(devices: AuthorizedDevice[]) {
const selectedDevices = devices.filter((device) => String(device.identifier || '').trim());
if (selectedDevices.length === 0) {
onNotify('warning', t('txt_no_devices_selected'));
return;
}
const includesCurrentDevice = selectedDevices.some((device) => device.identifier === getCurrentDeviceIdentifier());
onSetConfirm({
title: t('txt_remove_selected_devices', { count: selectedDevices.length }),
message: includesCurrentDevice
? t('txt_remove_selected_devices_and_sign_out_current', { count: selectedDevices.length })
: t('txt_remove_selected_devices_confirm', { count: selectedDevices.length }),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await deleteAuthorizedDevices(authedFetch, selectedDevices);
onNotify('success', t('txt_selected_devices_removed', { count: selectedDevices.length }));
if (includesCurrentDevice) {
onLogoutNow();
return;
}
await refetchAuthorizedDevices();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_remove_selected_devices_failed'));
}
})();
},
});
},
openRevokeAllDeviceTrust() { openRevokeAllDeviceTrust() {
onSetConfirm({ onSetConfirm({
title: t('txt_revoke_all_trusted_devices'), title: t('txt_revoke_all_trusted_devices'),
+35 -5
View File
@@ -1,5 +1,5 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteUser, revokeInvite, setUserStatus } from '@/lib/api/admin'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
@@ -45,14 +45,44 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
} }
}, },
async revokeInvite(code: string) { async deleteInvite(code: string) {
onSetConfirm({
title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try { try {
await revokeInvite(authedFetch, code); await deleteInvite(authedFetch, code);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_revoked')); onNotify('success', t('txt_invite_deleted'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_revoke_invite_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_delete_invite_failed'));
} }
})();
},
});
},
async deleteInvalidInvites() {
onSetConfirm({
title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'),
danger: true,
onConfirm: () => {
onSetConfirm(null);
void (async () => {
try {
await deleteInvalidInvites(authedFetch);
await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_delete_invalid_invites_failed'));
}
})();
},
});
}, },
async deleteAllInvites() { async deleteAllInvites() {
+16 -15
View File
@@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo( return useMemo(
() => ({ () => ({
async exportBackup(includeAttachments: boolean = false) { async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport( const payload = await buildCompleteAdminBackupExport(
authedFetch, authedFetch,
masterPasswordHash,
includeAttachments, includeAttachments,
async (event: BackupExportClientProgressEvent) => { async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event); dispatchBackupProgress(event);
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
}); });
}, },
async importBackup(file: File, replaceExisting: boolean = false) { async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
onImported?.(); onImported?.();
return result; return result;
}, },
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) { async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
onImported?.(); onImported?.();
return result; return result;
}, },
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return getAdminBackupSettings(authedFetch); return getAdminBackupSettings(authedFetch);
}, },
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) { async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
return saveAdminBackupSettings(authedFetch, settings); return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
}, },
async runRemoteBackup(destinationId?: string | null) { async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
return runAdminBackupNow(authedFetch, destinationId); return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
}, },
async listRemoteBackups(destinationId: string, path: string) { async listRemoteBackups(destinationId: string, path: string) {
return listRemoteBackups(authedFetch, destinationId, path); return listRemoteBackups(authedFetch, destinationId, path);
}, },
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) { async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress); const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
}, },
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
await deleteRemoteBackup(authedFetch, destinationId, path); await deleteRemoteBackup(authedFetch, destinationId, path);
}, },
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
onRestored?.(); onRestored?.();
return result; return result;
}, },
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
onRestored?.(); onRestored?.();
return result; return result;
}, },
+56 -5
View File
@@ -136,6 +136,19 @@ function withPrfExtension(
}; };
} }
function withoutCreatePrfExtension(options: PublicKeyCredentialCreationOptions): PublicKeyCredentialCreationOptions {
const extensions = { ...(((options as any).extensions || {}) as Record<string, unknown>) };
delete extensions.prf;
if (!Object.keys(extensions).length) {
const { extensions: _extensions, ...rest } = options as any;
return rest as PublicKeyCredentialCreationOptions;
}
return {
...options,
extensions: extensions as any,
};
}
function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined { function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined {
const result = (credential.getClientExtensionResults() as any).prf?.results?.first; const result = (credential.getClientExtensionResults() as any).prf?.results?.first;
return result instanceof ArrayBuffer ? result : undefined; return result instanceof ArrayBuffer ? result : undefined;
@@ -150,6 +163,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError'; return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
} }
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf( async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
salt: Uint8Array, salt: Uint8Array,
@@ -265,17 +294,39 @@ export async function assertAccountPasskey(
} }
export async function createAccountPasskeyCredential( export async function createAccountPasskeyCredential(
response: { options: unknown; token: string } response: { options: unknown; token: string },
requestPrf: boolean = false
): Promise<PendingAccountPasskeyCredential> { ): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) { if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported')); throw new Error(t('txt_passkey_browser_not_supported'));
} }
const nativeOptions = cloneCreationOptions(response.options); const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = { const noPrfOptions = withoutCreatePrfExtension(nativeOptions);
...((nativeOptions as any).extensions || {}), const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
prf: {}, const credential = await navigator.credentials.create({ publicKey: options });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
return credential;
}; };
const credential = await navigator.credentials.create({ publicKey: nativeOptions }); let credential: PublicKeyCredential;
if (requestPrf && await canRequestPrfExtension()) {
const prfOptions: PublicKeyCredentialCreationOptions = {
...noPrfOptions,
extensions: {
...((noPrfOptions as any).extensions || {}),
prf: {},
} as any,
};
try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(noPrfOptions);
}
} else {
credential = await createWithOptions(noPrfOptions);
}
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created')); throw new Error(t('txt_no_passkey_created'));
} }
+7 -2
View File
@@ -24,9 +24,14 @@ export async function createInvite(authedFetch: AuthedFetch, hours: number): Pro
if (!resp.ok) throw new Error('Create invite failed'); if (!resp.ok) throw new Error('Create invite failed');
} }
export async function revokeInvite(authedFetch: AuthedFetch, code: string): Promise<void> { export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Revoke invite failed'); if (!resp.ok) throw new Error('Delete invite failed');
}
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
if (!resp.ok) throw new Error('Delete invalid invites failed');
} }
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
+18 -1
View File
@@ -591,11 +591,14 @@ export async function changeMasterPassword(
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32); const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32); const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac); const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
if (userSym.length !== 64) {
throw new Error('Invalid profile key');
}
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32); const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32); const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac); const newKey = await encryptBw(userSym, nextEnc, nextMac);
const newMasterPasswordHash = bytesToBase64(nextHash); const newMasterPasswordHash = bytesToBase64(nextHash);
const resp = await authedFetch('/api/accounts/password', { const resp = await authedFetch('/api/accounts/password', {
@@ -885,6 +888,20 @@ export async function deleteAuthorizedDevice(
if (!resp.ok) throw new Error(t('txt_remove_device_failed')); if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
} }
export async function deleteAuthorizedDevices(
authedFetch: AuthedFetch,
devices: Array<Pick<AuthorizedDevice, 'identifier' | 'hasStoredDevice'>>
): Promise<void> {
const uniqueDevices = Array.from(
new Map(devices.map((device) => [String(device.identifier || '').trim(), device])).values()
).filter((device) => String(device.identifier || '').trim());
await Promise.all(uniqueDevices.map((device) => (
device.hasStoredDevice === false
? revokeAuthorizedDeviceTrust(authedFetch, device.identifier)
: deleteAuthorizedDevice(authedFetch, device.identifier)
)));
}
export async function updateAuthorizedDeviceName( export async function updateAuthorizedDeviceName(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
deviceIdentifier: string, deviceIdentifier: string,
+25 -10
View File
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
portable: BackupSettingsPortablePayload | null; portable: BackupSettingsPortablePayload | null;
} }
export interface BackupUserVerificationPayload {
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
}
export interface AdminBackupRunResponse { export interface AdminBackupRunResponse {
object: 'backup-run'; object: 'backup-run';
result: { result: {
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
export async function exportAdminBackup( export async function exportAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', { const resp = await authedFetch('/api/admin/backup/export', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeAttachments }), body: JSON.stringify({ includeAttachments, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport( export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false, includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void> onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments); const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
if (!includeAttachments) { if (!includeAttachments) {
await onProgress?.({ await onProgress?.({
operation: 'backup-export', operation: 'backup-export',
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
export async function saveAdminBackupSettings( export async function saveAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', { const resp = await authedFetch('/api/admin/backup/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
export async function repairAdminBackupSettings( export async function repairAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
verification: BackupUserVerificationPayload,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings/repair', { const resp = await authedFetch('/api/admin/backup/settings/repair', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, ...verification }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
export async function runAdminBackupNow( export async function runAdminBackupNow(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId?: string | null destinationId?: string | null
): Promise<AdminBackupRunResponse> { ): Promise<AdminBackupRunResponse> {
const resp = await authedFetch('/api/admin/backup/run', { const resp = await authedFetch('/api/admin/backup/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(destinationId ? { destinationId } : {}), body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
const body = await parseJson<AdminBackupRunResponse>(resp); const body = await parseJson<AdminBackupRunResponse>(resp);
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
export async function downloadRemoteBackup( export async function downloadRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
onProgress?: (percent: number | null) => void onProgress?: (percent: number | null) => void
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/remote/download', {
params.set('destinationId', destinationId); method: 'POST',
params.set('path', path); headers: { 'Content-Type': 'application/json' },
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' }); body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip'); const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
export async function restoreRemoteBackup( export async function restoreRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
replaceExisting: boolean = false, replaceExisting: boolean = false,
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
const resp = await authedFetch('/api/admin/backup/remote/restore', { const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }), body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp); const body = await parseJson<AdminBackupImportResponse>(resp);
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
export async function importAdminBackup( export async function importAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
file: File, file: File,
replaceExisting: boolean = false, replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> { ): Promise<AdminBackupImportResponse> {
const formData = new FormData(); const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_backup.zip'); formData.set('file', file, file.name || 'nodewarden_backup.zip');
formData.set('masterPasswordHash', masterPasswordHash);
if (replaceExisting) { if (replaceExisting) {
formData.set('replaceExisting', '1'); formData.set('replaceExisting', '1');
} }
+21 -8
View File
@@ -42,7 +42,7 @@ export interface PendingPasskeyPassword {
kdfIterations: number; kdfIterations: number;
} }
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; export type JwtUnsafeReason = 'missing' | 'too_short';
export interface BootstrapAppResult { export interface BootstrapAppResult {
defaultKdfIterations: number; defaultKdfIterations: number;
@@ -66,6 +66,12 @@ export interface CompletedLogin {
session: SessionState; session: SessionState;
profile: Profile; profile: Profile;
profilePromise: Promise<Profile>; profilePromise: Promise<Profile>;
freshMasterPasswordHash?: string | null;
freshUserVerificationToken?: string | null;
}
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
} }
export type PasswordLoginResult = export type PasswordLoginResult =
@@ -319,7 +325,8 @@ export async function completeLogin(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
masterKey: Uint8Array, masterKey: Uint8Array,
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -348,6 +355,8 @@ export async function completeLogin(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
keys: { symEncKey: string; symMacKey: string }, keys: { symEncKey: string; symMacKey: string },
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -400,7 +412,7 @@ export async function performPasswordLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
@@ -476,7 +488,7 @@ export async function completePasskeyPasswordLogin(
password: string password: string
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations); const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations); return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash);
} }
export async function performTotpLogin( export async function performTotpLogin(
@@ -489,7 +501,7 @@ export async function performTotpLogin(
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations); return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
} }
const tokenError = token as { error_description?: string; error?: string }; const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
@@ -508,7 +520,7 @@ export async function performRecoverTwoFactorLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
newRecoveryCode: recovered.newRecoveryCode || null, newRecoveryCode: recovered.newRecoveryCode || null,
}; };
} }
@@ -557,6 +569,7 @@ export async function performUnlock(
session: offline.session, session: offline.session,
profile: offline.profile, profile: offline.profile,
profilePromise: Promise.resolve(offline.profile), profilePromise: Promise.resolve(offline.profile),
freshMasterPasswordHash: null,
}, },
}; };
} catch { } catch {
@@ -589,7 +602,7 @@ export async function performUnlock(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
+47 -2
View File
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
} }
export interface RecommendedProviderBase { export interface RecommendedProviderBase {
id: 'infinicloud' | 'koofr' | 'pcloud'; id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
name: string; name: string;
capacity: string; capacity: string;
protocol: 'webdav' | 's3'; protocol: 'webdav' | 's3';
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
id: 'pcloud'; id: 'pcloud';
} }
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider; export interface BackblazeB2Provider extends RecommendedProviderBase {
id: 'backblaze-b2';
bucketsUrl: string;
applicationKeysUrl: string;
}
export interface CloudflareR2Provider extends RecommendedProviderBase {
id: 'cloudflare-r2';
bucketUrl: string;
apiTokenUrl: string;
}
export interface TigrisProvider extends RecommendedProviderBase {
id: 'tigris';
bucketUrl: string;
accessKeyUrl: string;
}
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider | CloudflareR2Provider | TigrisProvider;
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
{ {
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7', signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
hasAffiliateLink: true, hasAffiliateLink: true,
}, },
{
id: 'backblaze-b2',
name: 'Backblaze B2',
capacity: '10G',
protocol: 's3',
signupUrl: 'https://secure.backblaze.com/user_signin.htm',
bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm',
applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm',
},
{
id: 'cloudflare-r2',
name: 'Cloudflare R2',
capacity: '10G',
protocol: 's3',
signupUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
bucketUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
apiTokenUrl: 'https://dash.cloudflare.com/?to=/:account/r2/api-tokens/create?type=user',
},
{
id: 'tigris',
name: 'Tigris',
capacity: '5G',
protocol: 's3',
signupUrl: 'https://console.storage.dev/signup',
bucketUrl: 'https://console.storage.dev/createbucket',
accessKeyUrl: 'https://console.storage.dev/createaccesskey',
},
]; ];
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider { export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
+4 -2
View File
@@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types';
export async function silentlyRepairBackupSettingsIfNeeded( export async function silentlyRepairBackupSettingsIfNeeded(
activeSession: SessionState, activeSession: SessionState,
activeProfile: Profile activeProfile: Profile,
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
): Promise<void> { ): Promise<void> {
if (activeProfile.role !== 'admin') return; if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
try { try {
const state = await getAdminBackupSettingsRepairState(tempFetch); const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return; if (!state.needsRepair || !state.portable) return;
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings); await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
} catch (error) { } catch (error) {
console.error('Backup settings auto-repair failed:', error); console.error('Backup settings auto-repair failed:', error);
} }
+18 -13
View File
@@ -1127,6 +1127,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onRefreshAdmin: () => { onRefreshAdmin: () => {
notify('success', t('txt_demo_admin_refreshed')); notify('success', t('txt_demo_admin_refreshed'));
}, },
onDeleteInvalidInvites: async () => {
const now = Date.now();
state.setInvites((prev) => prev.filter((invite) => (
invite.status === 'active' && (!invite.expiresAt || new Date(invite.expiresAt).getTime() > now)
)));
notify('success', t('txt_invalid_invites_deleted'));
},
onDeleteAllInvites: async () => { onDeleteAllInvites: async () => {
state.setInvites([]); state.setInvites([]);
notify('success', t('txt_all_invites_deleted')); notify('success', t('txt_all_invites_deleted'));
@@ -1141,11 +1148,9 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
state.setUsers((prev) => prev.filter((user) => user.id !== userId)); state.setUsers((prev) => prev.filter((user) => user.id !== userId));
notify('success', t('txt_user_deleted')); notify('success', t('txt_user_deleted'));
}, },
onRevokeInvite: async (code) => { onDeleteInvite: async (code) => {
state.setInvites((prev) => prev.map((invite) => ( state.setInvites((prev) => prev.filter((invite) => invite.code !== code));
invite.code === code ? { ...invite, status: 'inactive' } : invite notify('success', t('txt_invite_deleted'));
)));
notify('success', t('txt_invite_revoked'));
}, },
onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }), onLoadAuditLogSettings: async () => ({ retentionDays: 90, maxEntries: null }),
onSaveAuditLogSettings: async (settings) => { onSaveAuditLogSettings: async (settings) => {
@@ -1156,32 +1161,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
notify('success', t('txt_logs_cleared')); notify('success', t('txt_logs_cleared'));
return 0; return 0;
}, },
onExportBackup: async () => { onExportBackup: async (_masterPassword: string) => {
notify('success', t('txt_backup_export_success')); notify('success', t('txt_backup_export_success'));
}, },
onImportBackup: async () => { onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onImportBackupAllowingChecksumMismatch: async () => { onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onLoadBackupSettings: async () => state.backupSettings, onLoadBackupSettings: async () => state.backupSettings,
onSaveBackupSettings: async (settings) => { onSaveBackupSettings: async (_masterPassword: string, settings) => {
const next = cloneJson(settings); const next = cloneJson(settings);
state.setBackupSettings(next); state.setBackupSettings(next);
notify('success', t('txt_backup_settings_saved')); notify('success', t('txt_backup_settings_saved'));
return next; return next;
}, },
onRunRemoteBackup: async (destinationId?: string | null) => { onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
notify('success', t('txt_backup_remote_run_success')); notify('success', t('txt_backup_remote_run_success'));
return createDemoBackupRun(state.backupSettings, destinationId); return createDemoBackupRun(state.backupSettings, destinationId);
}, },
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path), onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
onDownloadRemoteBackup: async () => { onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
notify('success', t('txt_demo_download_prepared')); notify('success', t('txt_demo_download_prepared'));
}, },
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({ onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
@@ -1199,13 +1204,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDeleteRemoteBackup: async () => { onDeleteRemoteBackup: async () => {
notify('success', t('txt_backup_remote_delete_success')); notify('success', t('txt_backup_remote_delete_success'));
}, },
onRestoreRemoteBackup: async (_destinationId, path) => { onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => { onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
+40 -2
View File
@@ -449,6 +449,43 @@ function appendRecordFieldLines(lines: string[], prefix: string, value: unknown)
} }
} }
const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
identity: [
'title',
'firstName',
'middleName',
'lastName',
'username',
'company',
'ssn',
'passportNumber',
'licenseNumber',
'email',
'phone',
'address1',
'address2',
'address3',
'city',
'state',
'postalCode',
'country',
],
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
};
function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
if (!isRecord(value)) return;
const keys = BITWARDEN_CSV_OBJECT_FIELDS[prefix];
if (!keys) {
appendRecordFieldLines(lines, prefix, value);
return;
}
for (const key of keys) {
appendFieldLine(lines, `${prefix}.${key}`, value[key]);
}
}
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string { function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
const lines: string[] = []; const lines: string[] = [];
const fields = Array.isArray(item.fields) ? item.fields : []; const fields = Array.isArray(item.fields) ? item.fields : [];
@@ -457,8 +494,9 @@ function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): s
appendFieldLine(lines, field.name, field.value); appendFieldLine(lines, field.name, field.value);
} }
if (type !== 1 && type !== 2) { if (type !== 1 && type !== 2) {
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type)); const sourceLabel = sourceTypeLabel(type);
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]); appendFieldLine(lines, 'nodewardenType', sourceLabel);
appendKnownRecordFieldLines(lines, sourceLabel, item[sourceLabel]);
} }
return lines.join('\n'); return lines.join('\n');
} }
+88
View File
@@ -113,27 +113,115 @@ export function translateServerError(message: string | null | undefined, fallbac
return t('txt_rate_limit_try_again_seconds', { seconds: rateLimitMatch[1] }); return t('txt_rate_limit_try_again_seconds', { seconds: rateLimitMatch[1] });
} }
const backupDestinationLimitMatch = normalized.match(/^You can save up to (\d+) backup destinations$/i);
if (backupDestinationLimitMatch) {
return t('txt_backup_error_destination_limit', { count: backupDestinationLimitMatch[1] });
}
const backupArchiveVerificationMatch = normalized.match(/^Backup archive upload verification failed after (\d+) attempts: (.+)$/i);
if (backupArchiveVerificationMatch) {
return t('txt_backup_error_archive_upload_verification_failed_attempts', {
count: backupArchiveVerificationMatch[1],
reason: translateServerError(backupArchiveVerificationMatch[2], backupArchiveVerificationMatch[2]),
});
}
const remoteAttachmentStatusMatch = normalized.match(/^Remote attachment (download|batch download) failed: (\d+)$/i);
if (remoteAttachmentStatusMatch) {
return t(
remoteAttachmentStatusMatch[1].toLowerCase() === 'batch download'
? 'txt_backup_error_remote_attachment_batch_download_failed_status'
: 'txt_backup_error_remote_attachment_download_failed_status',
{ status: remoteAttachmentStatusMatch[2] }
);
}
const providerStatusMatch = normalized.match(/^(WebDAV|S3) (directory creation|upload|listing|download|delete|existence check) failed: (\d+)$/i);
if (providerStatusMatch) {
const provider = providerStatusMatch[1].toLowerCase() === 'webdav' ? 'webdav' : 's3';
const actionKey = providerStatusMatch[2].toLowerCase().replace(/\s+/g, '_');
return t(`txt_backup_error_${provider}_${actionKey}_failed_status`, { status: providerStatusMatch[3] });
}
const key = { const key = {
'Account is disabled': 'txt_server_error_account_disabled', 'Account is disabled': 'txt_server_error_account_disabled',
'Another backup or restore run is already in progress': 'txt_backup_error_another_backup_or_restore_running',
'Another backup run is already in progress': 'txt_backup_error_another_backup_running',
'Backup archive upload failed': 'txt_backup_error_archive_upload_failed',
'Backup attachment blob is invalid': 'txt_backup_error_attachment_blob_invalid',
'Backup attachment blob is required': 'txt_backup_error_attachment_blob_required',
'Backup attachment blob not found': 'txt_backup_error_attachment_blob_not_found',
'Backup attachment download failed': 'txt_backup_error_attachment_download_failed',
'Backup destination is invalid': 'txt_backup_error_destination_invalid',
'Backup destination not found': 'txt_backup_error_destination_not_found',
'Backup destination ids must be unique': 'txt_backup_error_destination_ids_unique',
'Backup destination type is invalid': 'txt_backup_error_destination_type_invalid',
'Backup destinations are invalid': 'txt_backup_error_destinations_invalid',
'Backup export payload is invalid': 'txt_backup_error_export_payload_invalid',
'Backup file checksum does not match its filename': 'txt_backup_error_file_checksum_mismatch',
'Backup file is required': 'txt_backup_error_file_required',
'Backup interval hours must be between 1 and 99': 'txt_backup_error_interval_hours_range',
'Backup retention count must be between 1 and 1000': 'txt_backup_error_retention_count_range',
'Backup run failed': 'txt_backup_error_run_failed',
'Backup run payload is invalid': 'txt_backup_error_run_payload_invalid',
'Backup run response is invalid': 'txt_backup_error_run_response_invalid',
'Backup settings are invalid': 'txt_backup_error_settings_invalid',
'Backup settings could not be loaded': 'txt_backup_error_settings_load_failed',
'Backup settings envelope is invalid': 'txt_backup_error_settings_envelope_invalid',
'Backup settings need administrator reactivation after restore': 'txt_backup_error_settings_need_reactivation',
'Backup settings payload is invalid': 'txt_backup_error_settings_payload_invalid',
'Backup settings repair payload is invalid': 'txt_backup_error_settings_repair_payload_invalid',
'Backup settings repair state could not be loaded': 'txt_backup_error_settings_repair_state_load_failed',
'Backup start time must be in HH:mm format': 'txt_backup_error_start_time_format',
'Client IP is required': 'txt_server_error_client_ip_required', 'Client IP is required': 'txt_server_error_client_ip_required',
'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect', 'ClientId or clientSecret is incorrect. Try again': 'txt_server_error_client_credentials_incorrect',
'Content-Type must be multipart/form-data': 'txt_backup_error_multipart_required',
'Email already registered': 'txt_server_error_email_already_registered', 'Email already registered': 'txt_server_error_email_already_registered',
'Email and password are required': 'txt_server_error_email_password_required', 'Email and password are required': 'txt_server_error_email_password_required',
'Email is required': 'txt_server_error_email_required', 'Email is required': 'txt_server_error_email_required',
'Forbidden': 'txt_server_error_forbidden',
'Invite code is invalid or expired': 'txt_server_error_invite_invalid_or_expired', 'Invite code is invalid or expired': 'txt_server_error_invite_invalid_or_expired',
'Invite code is required': 'txt_server_error_invite_required', 'Invite code is required': 'txt_server_error_invite_required',
'Invalid backup timezone': 'txt_backup_error_timezone_invalid',
'Invalid password': 'txt_server_error_invalid_password',
'Invalid refresh token': 'txt_server_error_invalid_refresh_token', 'Invalid refresh token': 'txt_server_error_invalid_refresh_token',
'Invalid remote backup path': 'txt_backup_error_remote_path_invalid',
'Invalid request payload': 'txt_server_error_invalid_request_payload', 'Invalid request payload': 'txt_server_error_invalid_request_payload',
'Invalid user verification token': 'txt_server_error_invalid_user_verification_token',
'JWT_SECRET is not set': 'txt_server_error_jwt_secret_missing', 'JWT_SECRET is not set': 'txt_server_error_jwt_secret_missing',
'JWT_SECRET is using the default/sample value. Please change it.': 'txt_server_error_jwt_secret_default', 'JWT_SECRET is using the default/sample value. Please change it.': 'txt_server_error_jwt_secret_default',
'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short', 'JWT_SECRET must be at least 32 characters': 'txt_server_error_jwt_secret_too_short',
'Parameter error': 'txt_server_error_parameter_error', 'Parameter error': 'txt_server_error_parameter_error',
'Please select a backup file': 'txt_backup_error_select_backup_file',
'Please select a backup ZIP file': 'txt_backup_error_select_backup_zip_file',
'Refresh token is required': 'txt_server_error_refresh_token_required', 'Refresh token is required': 'txt_server_error_refresh_token_required',
'Remote backup ZIP checksum verification failed': 'txt_backup_error_remote_zip_checksum_failed',
'Remote backup ZIP size verification failed': 'txt_backup_error_remote_zip_size_failed',
'Remote backup delete failed': 'txt_backup_error_remote_delete_failed',
'Remote backup download failed': 'txt_backup_error_remote_download_failed',
'Remote backup download payload is invalid': 'txt_backup_error_remote_download_payload_invalid',
'Remote backup integrity inspection failed': 'txt_backup_error_remote_integrity_failed',
'Remote backup listing failed': 'txt_backup_error_remote_listing_failed',
'Remote restore payload is invalid': 'txt_backup_error_remote_restore_payload_invalid',
'Registration is temporarily unavailable, retry once': 'txt_server_error_registration_retry', 'Registration is temporarily unavailable, retry once': 'txt_server_error_registration_retry',
'S3 access key is required': 'txt_backup_error_s3_access_key_required',
'S3 bucket is required': 'txt_backup_error_s3_bucket_required',
'S3 endpoint is required': 'txt_backup_error_s3_endpoint_required',
'S3 endpoint must start with http:// or https://': 'txt_backup_error_s3_endpoint_protocol',
'S3 secret key is required': 'txt_backup_error_s3_secret_key_required',
'TOTP token is required': 'txt_server_error_totp_token_required', 'TOTP token is required': 'txt_server_error_totp_token_required',
'Two factor required.': 'txt_server_error_two_factor_required', 'Two factor required.': 'txt_server_error_two_factor_required',
'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid', 'Two-step token is invalid. Try again.': 'txt_server_error_two_factor_invalid',
'Unable to read backup file': 'txt_backup_error_read_backup_file_failed',
'Unsupported backup destination type': 'txt_backup_error_destination_type_unsupported',
'Username or password is incorrect. Try again': 'txt_server_error_username_password_incorrect', 'Username or password is incorrect. Try again': 'txt_server_error_username_password_incorrect',
'WebDAV password is required': 'txt_backup_error_webdav_password_required',
'WebDAV remote backup path is too deep for safe attachment batching': 'txt_backup_error_webdav_path_too_deep',
'WebDAV server URL is required': 'txt_backup_error_webdav_url_required',
'WebDAV server URL must start with http:// or https://': 'txt_backup_error_webdav_url_protocol',
'WebDAV username is required': 'txt_backup_error_webdav_username_required',
'masterPasswordHash is required': 'txt_server_error_master_password_hash_required',
'masterPasswordHash or userVerificationToken is required': 'txt_server_error_master_password_or_verification_required',
}[normalized]; }[normalized];
return key ? t(key) : normalized; return key ? t(key) : normalized;
+128 -1
View File
@@ -85,6 +85,37 @@ const en: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.", "txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
"txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.", "txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
"txt_backup_recommend_backblaze_summary": "S3-compatible object storage with 10 GB free and no credit card required.",
"txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.",
"txt_backup_recommend_backblaze_step_2_prefix": "Open",
"txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.",
"txt_backup_recommend_backblaze_step_3": "After creation, put the displayed Endpoint into S3 Endpoint URL, use the bucket name for Bucket Name, and use the middle segment of the endpoint, such as us-west-004, for Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Open",
"txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.",
"txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.",
"txt_backup_recommend_cloudflare_r2_summary": "S3-compatible object storage with 10 GB free, but it requires credit card verification.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "Create bucket page",
"txt_backup_recommend_cloudflare_r2_api_link": "API token page",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Open the",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", enter only the bucket name, and create it directly.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Open the",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", select Object Read & Write for permissions, and create it directly.",
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore the token value after creation. Fill Access Key ID into Access ID, and Secret Access Key into Access Password.",
"txt_backup_recommend_cloudflare_r2_step_4": "Copy the address shown below into S3 Endpoint URL, fill Bucket Name exactly as shown, and leave Region as auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
"txt_backup_recommend_s3_path_prefix_step": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
"txt_backup_recommend_tigris_summary": "S3-compatible object storage with 5 GB free and no credit card required.",
"txt_backup_recommend_tigris_signup_link": "signup page",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket page",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key page",
"txt_backup_recommend_tigris_step_1_prefix": "Open the",
"txt_backup_recommend_tigris_step_1_suffix": ", sign up, and log in to Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Open",
"txt_backup_recommend_tigris_step_2_suffix": ", enter only the bucket name, leave everything else unchanged, and create it.",
"txt_backup_recommend_tigris_step_3_prefix": "Then open the",
"txt_backup_recommend_tigris_step_3_suffix": ", use any name you like, and create it.",
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM after creation. Fill the other displayed values into the backup page using the matching field names.",
"txt_backup_recommend_tigris_step_5": "Finally, click Manage Key Permissions and turn on Admin Access, otherwise writing backups will fail.",
"txt_backup_add_destination": "Add Destination", "txt_backup_add_destination": "Add Destination",
"txt_backup_schedule_panel_title": "Automatic Schedule", "txt_backup_schedule_panel_title": "Automatic Schedule",
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.", "txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
@@ -193,10 +224,14 @@ const en: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "The server is performing final validation and then switching the verified restore data into the live tables.", "txt_backup_restore_progress_remote_finalize_detail": "The server is performing final validation and then switching the verified restore data into the live tables.",
"txt_backup_remote_loading": "Loading remote backups...", "txt_backup_remote_loading": "Loading remote backups...",
"txt_backup_remote_cached_empty": "Click Refresh to load this destination.", "txt_backup_remote_cached_empty": "Click Refresh to load this destination.",
"txt_backup_remote_cached_empty_prefix": "Click",
"txt_backup_remote_cached_empty_suffix": "to load this destination.",
"txt_backup_remote_empty": "No backup files found in this folder.", "txt_backup_remote_empty": "No backup files found in this folder.",
"txt_backup_remote_folder": "Folder", "txt_backup_remote_folder": "Folder",
"txt_backup_remote_unknown_time": "Unknown time", "txt_backup_remote_unknown_time": "Unknown time",
"txt_backup_remote_current_path": "Current Folder", "txt_backup_remote_current_path": "Current Folder",
"txt_backup_remote_modified": "Modified",
"txt_backup_remote_size": "Size",
"txt_backup_remote_load_failed": "Loading remote backups failed", "txt_backup_remote_load_failed": "Loading remote backups failed",
"txt_backup_remote_invalid_response": "Invalid remote backup response", "txt_backup_remote_invalid_response": "Invalid remote backup response",
"txt_backup_remote_download_failed": "Downloading remote backup failed", "txt_backup_remote_download_failed": "Downloading remote backup failed",
@@ -214,6 +249,74 @@ const en: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Invalid remote backup run response", "txt_backup_remote_run_invalid_response": "Invalid remote backup run response",
"txt_backup_settings_invalid_response": "Invalid backup settings response", "txt_backup_settings_invalid_response": "Invalid backup settings response",
"txt_backup_import_invalid_response": "Invalid backup import response", "txt_backup_import_invalid_response": "Invalid backup import response",
"txt_backup_error_another_backup_or_restore_running": "Another backup or restore task is already running.",
"txt_backup_error_another_backup_running": "Another backup task is already running.",
"txt_backup_error_archive_upload_failed": "Backup archive upload failed.",
"txt_backup_error_archive_upload_verification_failed_attempts": "Backup upload verification failed after {count} attempt(s): {reason}",
"txt_backup_error_attachment_blob_invalid": "Backup attachment blob is invalid.",
"txt_backup_error_attachment_blob_required": "Backup attachment blob is required.",
"txt_backup_error_attachment_blob_not_found": "Backup attachment blob not found.",
"txt_backup_error_attachment_download_failed": "Backup attachment download failed.",
"txt_backup_error_destination_invalid": "Backup destination is invalid.",
"txt_backup_error_destination_limit": "You can save up to {count} backup destinations.",
"txt_backup_error_destination_not_found": "Backup destination not found.",
"txt_backup_error_destination_ids_unique": "Backup destination IDs must be unique.",
"txt_backup_error_destination_type_invalid": "Backup destination type is invalid.",
"txt_backup_error_destination_type_unsupported": "Unsupported backup destination type.",
"txt_backup_error_destinations_invalid": "Backup destinations are invalid.",
"txt_backup_error_export_payload_invalid": "Backup export payload is invalid.",
"txt_backup_error_file_checksum_mismatch": "Backup file checksum does not match its filename.",
"txt_backup_error_file_required": "Backup file is required.",
"txt_backup_error_interval_hours_range": "Backup interval must be between 1 and 99 hours.",
"txt_backup_error_multipart_required": "The upload request must use multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "Unable to read backup file.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Remote attachment batch download failed: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Remote attachment download failed: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "Remote backup delete failed.",
"txt_backup_error_remote_download_failed": "Remote backup download failed.",
"txt_backup_error_remote_download_payload_invalid": "Remote backup download request is invalid.",
"txt_backup_error_remote_integrity_failed": "Remote backup integrity inspection failed.",
"txt_backup_error_remote_listing_failed": "Remote backup listing failed.",
"txt_backup_error_remote_path_invalid": "Remote backup path is invalid.",
"txt_backup_error_remote_restore_payload_invalid": "Remote restore request is invalid.",
"txt_backup_error_remote_zip_checksum_failed": "Remote backup ZIP checksum verification failed.",
"txt_backup_error_remote_zip_size_failed": "Remote backup ZIP size verification failed.",
"txt_backup_error_retention_count_range": "Backup retention count must be between 1 and 1000.",
"txt_backup_error_run_failed": "Backup run failed.",
"txt_backup_error_run_payload_invalid": "Backup run request is invalid.",
"txt_backup_error_run_response_invalid": "Backup run response is invalid.",
"txt_backup_error_s3_access_key_required": "S3 access key is required.",
"txt_backup_error_s3_bucket_required": "S3 bucket is required.",
"txt_backup_error_s3_delete_failed_status": "S3 delete failed: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "S3 download failed: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "S3 endpoint is required.",
"txt_backup_error_s3_endpoint_protocol": "S3 endpoint must start with http:// or https://.",
"txt_backup_error_s3_existence_check_failed_status": "S3 existence check failed: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "S3 listing failed: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "S3 secret key is required.",
"txt_backup_error_s3_upload_failed_status": "S3 upload failed: HTTP {status}.",
"txt_backup_error_select_backup_file": "Please select a backup file.",
"txt_backup_error_select_backup_zip_file": "Please select a backup ZIP file.",
"txt_backup_error_settings_envelope_invalid": "Backup settings envelope is invalid.",
"txt_backup_error_settings_invalid": "Backup settings are invalid.",
"txt_backup_error_settings_load_failed": "Backup settings could not be loaded.",
"txt_backup_error_settings_need_reactivation": "Backup settings need administrator reactivation after restore.",
"txt_backup_error_settings_payload_invalid": "Backup settings request is invalid.",
"txt_backup_error_settings_repair_payload_invalid": "Backup settings repair request is invalid.",
"txt_backup_error_settings_repair_state_load_failed": "Backup settings repair state could not be loaded.",
"txt_backup_error_start_time_format": "Backup start time must be in HH:mm format.",
"txt_backup_error_timezone_invalid": "Backup timezone is invalid.",
"txt_backup_error_webdav_delete_failed_status": "WebDAV delete failed: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV directory creation failed: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "WebDAV download failed: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV existence check failed: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "WebDAV listing failed: HTTP {status}.",
"txt_backup_error_webdav_password_required": "WebDAV password is required.",
"txt_backup_error_webdav_path_too_deep": "WebDAV remote backup path is too deep for safe attachment batching.",
"txt_backup_error_webdav_upload_failed_status": "WebDAV upload failed: HTTP {status}.",
"txt_backup_error_webdav_url_required": "WebDAV server URL is required.",
"txt_backup_error_webdav_url_protocol": "WebDAV server URL must start with http:// or https://.",
"txt_backup_error_webdav_username_required": "WebDAV username is required.",
"txt_backup_destination": "Backup Destination", "txt_backup_destination": "Backup Destination",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -479,8 +582,17 @@ const en: Record<string, string> = {
"txt_identity_details": "Identity Details", "txt_identity_details": "Identity Details",
"txt_ie_browser": "IE Browser", "txt_ie_browser": "IE Browser",
"txt_create_invite_failed": "Failed to create invite", "txt_create_invite_failed": "Failed to create invite",
"txt_delete_invalid": "Delete Invalid",
"txt_delete_invalid_invites": "Delete invalid invites",
"txt_delete_invalid_invites_confirm_message": "Delete all invalid invite codes? Active, unexpired invite codes will be kept.",
"txt_delete_invalid_invites_failed": "Failed to delete invalid invites",
"txt_delete_invite": "Delete invite",
"txt_delete_invite_confirm_message": "Delete this invite code? This cannot be undone.",
"txt_delete_invite_failed": "Failed to delete invite",
"txt_invite_code_required": "Invite Code (Required)", "txt_invite_code_required": "Invite Code (Required)",
"txt_invite_created": "Invite created", "txt_invite_created": "Invite created",
"txt_invite_deleted": "Invite deleted",
"txt_invalid_invites_deleted": "Invalid invites deleted",
"txt_invite_revoked": "Invite revoked", "txt_invite_revoked": "Invite revoked",
"txt_revoke_invite_failed": "Failed to revoke invite", "txt_revoke_invite_failed": "Failed to revoke invite",
"txt_invite_validity_hours": "Invite validity (hours)", "txt_invite_validity_hours": "Invite validity (hours)",
@@ -489,16 +601,21 @@ const en: Record<string, string> = {
"txt_server_error_account_disabled": "Account is disabled", "txt_server_error_account_disabled": "Account is disabled",
"txt_server_error_client_credentials_incorrect": "Client ID or client secret is incorrect. Try again.", "txt_server_error_client_credentials_incorrect": "Client ID or client secret is incorrect. Try again.",
"txt_server_error_client_ip_required": "Client IP is required", "txt_server_error_client_ip_required": "Client IP is required",
"txt_server_error_forbidden": "You do not have permission to perform this action.",
"txt_server_error_email_already_registered": "Email already registered", "txt_server_error_email_already_registered": "Email already registered",
"txt_server_error_email_password_required": "Email and password are required", "txt_server_error_email_password_required": "Email and password are required",
"txt_server_error_email_required": "Email is required", "txt_server_error_email_required": "Email is required",
"txt_server_error_invalid_password": "Invalid password.",
"txt_server_error_invalid_refresh_token": "Session expired. Please sign in again.", "txt_server_error_invalid_refresh_token": "Session expired. Please sign in again.",
"txt_server_error_invalid_user_verification_token": "Invalid user verification token.",
"txt_server_error_invalid_request_payload": "Invalid request payload", "txt_server_error_invalid_request_payload": "Invalid request payload",
"txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired", "txt_server_error_invite_invalid_or_expired": "Invite code is invalid or expired",
"txt_server_error_invite_required": "Invite code is required", "txt_server_error_invite_required": "Invite code is required",
"txt_server_error_jwt_secret_default": "JWT_SECRET is using the default/sample value. Please change it.", "txt_server_error_jwt_secret_default": "JWT_SECRET is using the default/sample value. Please change it.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET is not set", "txt_server_error_jwt_secret_missing": "JWT_SECRET is not set",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters", "txt_server_error_jwt_secret_too_short": "JWT_SECRET must be at least 32 characters",
"txt_server_error_master_password_hash_required": "Master password verification is required.",
"txt_server_error_master_password_or_verification_required": "Master password or user verification token is required.",
"txt_server_error_parameter_error": "Parameter error", "txt_server_error_parameter_error": "Parameter error",
"txt_server_error_refresh_token_required": "Session is missing. Please sign in again.", "txt_server_error_refresh_token_required": "Session is missing. Please sign in again.",
"txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.", "txt_server_error_registration_retry": "Registration is temporarily unavailable. Please retry once.",
@@ -552,7 +669,7 @@ const en: Record<string, string> = {
"txt_jwt_secret_value_label": "Value:", "txt_jwt_secret_value_label": "Value:",
"txt_jwt_secret_value_requirement": "Random string with at least {min} characters", "txt_jwt_secret_value_requirement": "Random string with at least {min} characters",
"txt_jwt_what_is": "What is JWT?", "txt_jwt_what_is": "What is JWT?",
"txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing, too short, or still using the sample value, the instance is not safe to use normally.", "txt_jwt_what_is_body": "JWT_SECRET is the server-side signing key used to issue and verify login tokens. If it is missing or too short, the instance is not safe to use normally.",
"txt_how_to_fix": "How to fix", "txt_how_to_fix": "How to fix",
"txt_jwt_fix_step_1": "Open your deployment environment variables.", "txt_jwt_fix_step_1": "Open your deployment environment variables.",
"txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.", "txt_jwt_fix_step_2": "If your current key is not random enough, use the 32-character generator below.",
@@ -730,6 +847,11 @@ const en: Record<string, string> = {
"txt_remove_all_devices": "Remove all devices", "txt_remove_all_devices": "Remove all devices",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?", "txt_remove_all_devices_and_clear_all_2fa_trust": "Remove all devices and clear all 2FA trust?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?", "txt_remove_all_devices_and_sign_out_all_sessions": "Remove all devices, clear all trust, and sign out every device?",
"txt_remove_selected_devices": "Remove selected ({count})",
"txt_remove_selected_devices_confirm": "Remove {count} selected devices, clear their trust, and sign them out?",
"txt_remove_selected_devices_and_sign_out_current": "Remove {count} selected devices, clear their trust, and sign out this device too?",
"txt_selected_devices_removed": "Selected devices removed",
"txt_remove_selected_devices_failed": "Failed to remove selected devices",
"txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?", "txt_remove_device_name_and_clear_its_2fa_trust": "Remove device \"{name}\" and clear its 2FA trust?",
"txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?", "txt_remove_device_and_sign_out_name": "Remove device \"{name}\", clear its trust, and sign it out?",
"txt_reveal": "Reveal", "txt_reveal": "Reveal",
@@ -771,6 +893,9 @@ const en: Record<string, string> = {
"txt_security_code": "Security Code", "txt_security_code": "Security Code",
"txt_security_code_cvv": "Security Code (CVV)", "txt_security_code_cvv": "Security Code (CVV)",
"txt_select_all": "Select All", "txt_select_all": "Select All",
"txt_clear_selection": "Clear selection",
"txt_select_device_name": "Select {name}",
"txt_no_devices_selected": "No devices selected",
"txt_select": "Select", "txt_select": "Select",
"txt_select_duplicate_items": "Select Duplicates", "txt_select_duplicate_items": "Select Duplicates",
"txt_select_an_item": "Select an item", "txt_select_an_item": "Select an item",
@@ -1040,7 +1165,9 @@ const en: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "Repair backup settings", "txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings", "txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite", "txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites", "txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite", "txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user", "txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status", "txt_log_action_admin_user_status": "Change user status",
+128 -1
View File
@@ -85,6 +85,37 @@ const es: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.", "txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.", "txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
"txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis y sin tarjeta de crédito.",
"txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.",
"txt_backup_recommend_backblaze_step_2_prefix": "Abra",
"txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.",
"txt_backup_recommend_backblaze_step_3": "Después de crearlo, ponga el Endpoint mostrado en S3 Endpoint URL, use el nombre del bucket en Bucket Name y la parte central del endpoint, como us-west-004, en Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Abra",
"txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.",
"txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.",
"txt_backup_recommend_cloudflare_r2_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis, pero requiere verificación con tarjeta de crédito.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "página para crear bucket",
"txt_backup_recommend_cloudflare_r2_api_link": "página de token API",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Abra la",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", introduzca solo el nombre del bucket y créelo directamente.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Abra la",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", seleccione Object Read & Write en permisos y créelo directamente.",
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore el valor del token después de crearlo. Use Access Key ID como ID de acceso y Secret Access Key como contraseña de acceso.",
"txt_backup_recommend_cloudflare_r2_step_4": "Copie la dirección mostrada abajo en S3 Endpoint URL, rellene Bucket Name tal como aparece y deje Region en auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
"txt_backup_recommend_s3_path_prefix_step": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
"txt_backup_recommend_tigris_summary": "Almacenamiento de objetos compatible con S3 con 5 GB gratis y sin tarjeta de crédito.",
"txt_backup_recommend_tigris_signup_link": "página de registro",
"txt_backup_recommend_tigris_bucket_link": "página Create Bucket",
"txt_backup_recommend_tigris_access_key_link": "página Create Access Key",
"txt_backup_recommend_tigris_step_1_prefix": "Abra la",
"txt_backup_recommend_tigris_step_1_suffix": ", regístrese e inicie sesión en Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Abra",
"txt_backup_recommend_tigris_step_2_suffix": ", introduzca solo el nombre del bucket, deje todo lo demás sin cambios y créelo.",
"txt_backup_recommend_tigris_step_3_prefix": "Luego abra la",
"txt_backup_recommend_tigris_step_3_suffix": ", use cualquier nombre y créela.",
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM después de crearla. Rellene los demás valores mostrados en la página de copia de seguridad usando los nombres correspondientes.",
"txt_backup_recommend_tigris_step_5": "Por último, haga clic en Manage Key Permissions y active Admin Access; de lo contrario, no podrá escribir copias de seguridad.",
"txt_backup_add_destination": "Añadir destino", "txt_backup_add_destination": "Añadir destino",
"txt_backup_schedule_panel_title": "Programación automática", "txt_backup_schedule_panel_title": "Programación automática",
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.", "txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
@@ -193,10 +224,14 @@ const es: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "El servidor está realizando la validación final y luego cambiando los datos de restauración verificados a las tablas activas.", "txt_backup_restore_progress_remote_finalize_detail": "El servidor está realizando la validación final y luego cambiando los datos de restauración verificados a las tablas activas.",
"txt_backup_remote_loading": "Cargando copias remotas...", "txt_backup_remote_loading": "Cargando copias remotas...",
"txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.", "txt_backup_remote_cached_empty": "Haga clic en Actualizar para cargar este destino.",
"txt_backup_remote_cached_empty_prefix": "Haga clic en",
"txt_backup_remote_cached_empty_suffix": "para cargar este destino.",
"txt_backup_remote_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.", "txt_backup_remote_empty": "No se encontraron archivos de copia de seguridad en esta carpeta.",
"txt_backup_remote_folder": "Carpeta", "txt_backup_remote_folder": "Carpeta",
"txt_backup_remote_unknown_time": "Hora desconocida", "txt_backup_remote_unknown_time": "Hora desconocida",
"txt_backup_remote_current_path": "Carpeta actual", "txt_backup_remote_current_path": "Carpeta actual",
"txt_backup_remote_modified": "Modificado",
"txt_backup_remote_size": "Tamaño",
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas", "txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida", "txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota", "txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
@@ -214,6 +249,74 @@ const es: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida", "txt_backup_remote_run_invalid_response": "Respuesta de ejecución de copia de seguridad remota no válida",
"txt_backup_settings_invalid_response": "Respuesta de configuración de copia de seguridad no válida", "txt_backup_settings_invalid_response": "Respuesta de configuración de copia de seguridad no válida",
"txt_backup_import_invalid_response": "Respuesta de importación de copia de seguridad no válida", "txt_backup_import_invalid_response": "Respuesta de importación de copia de seguridad no válida",
"txt_backup_error_another_backup_or_restore_running": "Ya hay una tarea de copia o restauración en curso.",
"txt_backup_error_another_backup_running": "Ya hay una tarea de copia en curso.",
"txt_backup_error_archive_upload_failed": "No se pudo subir el archivo de copia.",
"txt_backup_error_archive_upload_verification_failed_attempts": "La verificación de subida falló tras {count} intento(s): {reason}",
"txt_backup_error_attachment_blob_invalid": "El objeto de adjunto de copia no es válido.",
"txt_backup_error_attachment_blob_required": "Falta el objeto de adjunto de copia.",
"txt_backup_error_attachment_blob_not_found": "No se encontró el objeto de adjunto de copia.",
"txt_backup_error_attachment_download_failed": "No se pudo descargar el adjunto de copia.",
"txt_backup_error_destination_invalid": "El destino de copia no es válido.",
"txt_backup_error_destination_limit": "Puede guardar hasta {count} destinos de copia.",
"txt_backup_error_destination_not_found": "No se encontró el destino de copia.",
"txt_backup_error_destination_ids_unique": "Los ID de destino de copia no pueden repetirse.",
"txt_backup_error_destination_type_invalid": "El tipo de destino de copia no es válido.",
"txt_backup_error_destination_type_unsupported": "Tipo de destino de copia no compatible.",
"txt_backup_error_destinations_invalid": "La lista de destinos de copia no es válida.",
"txt_backup_error_export_payload_invalid": "La solicitud de exportación de copia no es válida.",
"txt_backup_error_file_checksum_mismatch": "La suma de verificación de la copia no coincide con el nombre del archivo.",
"txt_backup_error_file_required": "Seleccione un archivo de copia.",
"txt_backup_error_interval_hours_range": "El intervalo de copia debe estar entre 1 y 99 horas.",
"txt_backup_error_multipart_required": "La solicitud de subida debe usar multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "No se pudo leer el archivo de copia.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Error al descargar adjuntos remotos por lotes: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Error al descargar adjunto remoto: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "No se pudo eliminar la copia remota.",
"txt_backup_error_remote_download_failed": "No se pudo descargar la copia remota.",
"txt_backup_error_remote_download_payload_invalid": "La solicitud de descarga remota no es válida.",
"txt_backup_error_remote_integrity_failed": "No se pudo inspeccionar la integridad de la copia remota.",
"txt_backup_error_remote_listing_failed": "No se pudo leer la lista de copias remotas.",
"txt_backup_error_remote_path_invalid": "La ruta de copia remota no es válida.",
"txt_backup_error_remote_restore_payload_invalid": "La solicitud de restauración remota no es válida.",
"txt_backup_error_remote_zip_checksum_failed": "Falló la verificación de suma del ZIP remoto.",
"txt_backup_error_remote_zip_size_failed": "Falló la verificación de tamaño del ZIP remoto.",
"txt_backup_error_retention_count_range": "La retención debe estar entre 1 y 1000.",
"txt_backup_error_run_failed": "La ejecución de copia falló.",
"txt_backup_error_run_payload_invalid": "La solicitud de ejecución de copia no es válida.",
"txt_backup_error_run_response_invalid": "La respuesta de ejecución de copia no es válida.",
"txt_backup_error_s3_access_key_required": "La clave de acceso S3 es obligatoria.",
"txt_backup_error_s3_bucket_required": "El bucket S3 es obligatorio.",
"txt_backup_error_s3_delete_failed_status": "Eliminación S3 fallida: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "Descarga S3 fallida: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "El endpoint S3 es obligatorio.",
"txt_backup_error_s3_endpoint_protocol": "El endpoint S3 debe empezar por http:// o https://.",
"txt_backup_error_s3_existence_check_failed_status": "Comprobación de existencia S3 fallida: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "Listado S3 fallido: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "La clave secreta S3 es obligatoria.",
"txt_backup_error_s3_upload_failed_status": "Subida S3 fallida: HTTP {status}.",
"txt_backup_error_select_backup_file": "Seleccione un archivo de copia.",
"txt_backup_error_select_backup_zip_file": "Seleccione un archivo ZIP de copia.",
"txt_backup_error_settings_envelope_invalid": "El contenedor cifrado de configuración de copia no es válido.",
"txt_backup_error_settings_invalid": "La configuración de copia no es válida.",
"txt_backup_error_settings_load_failed": "No se pudo cargar la configuración de copia.",
"txt_backup_error_settings_need_reactivation": "La configuración de copia requiere reactivación de administrador tras la restauración.",
"txt_backup_error_settings_payload_invalid": "La solicitud de configuración de copia no es válida.",
"txt_backup_error_settings_repair_payload_invalid": "La solicitud de reparación de configuración no es válida.",
"txt_backup_error_settings_repair_state_load_failed": "No se pudo cargar el estado de reparación de configuración.",
"txt_backup_error_start_time_format": "La hora de inicio debe tener formato HH:mm.",
"txt_backup_error_timezone_invalid": "La zona horaria de copia no es válida.",
"txt_backup_error_webdav_delete_failed_status": "Eliminación WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "Creación de directorio WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "Descarga WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "Comprobación de existencia WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "Listado WebDAV fallido: HTTP {status}.",
"txt_backup_error_webdav_password_required": "La contraseña WebDAV es obligatoria.",
"txt_backup_error_webdav_path_too_deep": "La ruta remota WebDAV es demasiado profunda para procesar adjuntos por lotes de forma segura.",
"txt_backup_error_webdav_upload_failed_status": "Subida WebDAV fallida: HTTP {status}.",
"txt_backup_error_webdav_url_required": "La URL del servidor WebDAV es obligatoria.",
"txt_backup_error_webdav_url_protocol": "La URL WebDAV debe empezar por http:// o https://.",
"txt_backup_error_webdav_username_required": "El usuario WebDAV es obligatorio.",
"txt_backup_destination": "Destino de copia", "txt_backup_destination": "Destino de copia",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -479,8 +582,17 @@ const es: Record<string, string> = {
"txt_identity_details": "Detalles de identidad", "txt_identity_details": "Detalles de identidad",
"txt_ie_browser": "Navegador Internet Explorer", "txt_ie_browser": "Navegador Internet Explorer",
"txt_create_invite_failed": "Error al crear invitación", "txt_create_invite_failed": "Error al crear invitación",
"txt_delete_invalid": "Eliminar inválidas",
"txt_delete_invalid_invites": "Eliminar invitaciones inválidas",
"txt_delete_invalid_invites_confirm_message": "¿Eliminar todos los códigos de invitación inválidos? Se conservarán los códigos activos y no vencidos.",
"txt_delete_invalid_invites_failed": "Error al eliminar invitaciones inválidas",
"txt_delete_invite": "Eliminar invitación",
"txt_delete_invite_confirm_message": "¿Eliminar este código de invitación? Esta acción no se puede deshacer.",
"txt_delete_invite_failed": "Error al eliminar invitación",
"txt_invite_code_required": "Código de invitación (obligatorio)", "txt_invite_code_required": "Código de invitación (obligatorio)",
"txt_invite_created": "Invitación creada", "txt_invite_created": "Invitación creada",
"txt_invite_deleted": "Invitación eliminada",
"txt_invalid_invites_deleted": "Invitaciones inválidas eliminadas",
"txt_invite_revoked": "Invitación revocada", "txt_invite_revoked": "Invitación revocada",
"txt_revoke_invite_failed": "Error al revocar invitación", "txt_revoke_invite_failed": "Error al revocar invitación",
"txt_invite_validity_hours": "Validez de la invitación en horas", "txt_invite_validity_hours": "Validez de la invitación en horas",
@@ -489,16 +601,21 @@ const es: Record<string, string> = {
"txt_server_error_account_disabled": "La cuenta está deshabilitada", "txt_server_error_account_disabled": "La cuenta está deshabilitada",
"txt_server_error_client_credentials_incorrect": "El ID de cliente o el secreto de cliente no son correctos. Inténtalo de nuevo.", "txt_server_error_client_credentials_incorrect": "El ID de cliente o el secreto de cliente no son correctos. Inténtalo de nuevo.",
"txt_server_error_client_ip_required": "Se requiere la IP del cliente", "txt_server_error_client_ip_required": "Se requiere la IP del cliente",
"txt_server_error_forbidden": "No tiene permiso para realizar esta acción.",
"txt_server_error_email_already_registered": "Este correo ya está registrado", "txt_server_error_email_already_registered": "Este correo ya está registrado",
"txt_server_error_email_password_required": "Correo y contraseña son obligatorios", "txt_server_error_email_password_required": "Correo y contraseña son obligatorios",
"txt_server_error_email_required": "El correo es obligatorio", "txt_server_error_email_required": "El correo es obligatorio",
"txt_server_error_invalid_password": "Contraseña no válida.",
"txt_server_error_invalid_refresh_token": "La sesión caducó. Inicia sesión de nuevo.", "txt_server_error_invalid_refresh_token": "La sesión caducó. Inicia sesión de nuevo.",
"txt_server_error_invalid_user_verification_token": "Token de verificación de usuario no válido.",
"txt_server_error_invalid_request_payload": "Solicitud no válida", "txt_server_error_invalid_request_payload": "Solicitud no válida",
"txt_server_error_invite_invalid_or_expired": "El código de invitación no es válido o ha caducado", "txt_server_error_invite_invalid_or_expired": "El código de invitación no es válido o ha caducado",
"txt_server_error_invite_required": "El código de invitación es obligatorio", "txt_server_error_invite_required": "El código de invitación es obligatorio",
"txt_server_error_jwt_secret_default": "JWT_SECRET usa el valor predeterminado/de ejemplo. Cámbialo.", "txt_server_error_jwt_secret_default": "JWT_SECRET usa el valor predeterminado/de ejemplo. Cámbialo.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET no está configurado", "txt_server_error_jwt_secret_missing": "JWT_SECRET no está configurado",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres", "txt_server_error_jwt_secret_too_short": "JWT_SECRET debe tener al menos 32 caracteres",
"txt_server_error_master_password_hash_required": "Se requiere verificación de la contraseña maestra.",
"txt_server_error_master_password_or_verification_required": "Se requiere contraseña maestra o token de verificación de usuario.",
"txt_server_error_parameter_error": "Error de parámetros", "txt_server_error_parameter_error": "Error de parámetros",
"txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.", "txt_server_error_refresh_token_required": "Falta la sesión. Inicia sesión de nuevo.",
"txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.", "txt_server_error_registration_retry": "El registro no está disponible temporalmente. Inténtalo una vez más.",
@@ -552,7 +669,7 @@ const es: Record<string, string> = {
"txt_jwt_secret_value_label": "Valor:", "txt_jwt_secret_value_label": "Valor:",
"txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres", "txt_jwt_secret_value_requirement": "Cadena aleatoria de al menos {min} caracteres",
"txt_jwt_what_is": "Qué es JWT", "txt_jwt_what_is": "Qué es JWT",
"txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente, es demasiado corta o todavía usa el valor de ejemplo, la instancia no es segura para uso normal.", "txt_jwt_what_is_body": "JWT_SECRET es la clave de firma del lado del servidor utilizada para emitir y verificar tokens de inicio de sesión. Si no está presente o es demasiado corta, la instancia no es segura para uso normal.",
"txt_how_to_fix": "Cómo corregirlo", "txt_how_to_fix": "Cómo corregirlo",
"txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.", "txt_jwt_fix_step_1": "Abra las variables de entorno de su despliegue.",
"txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.", "txt_jwt_fix_step_2": "Si su clave actual no es lo suficientemente aleatoria, use el generador de 32 caracteres a continuación.",
@@ -730,6 +847,11 @@ const es: Record<string, string> = {
"txt_remove_all_devices": "Quitar todos los dispositivos", "txt_remove_all_devices": "Quitar todos los dispositivos",
"txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?", "txt_remove_all_devices_and_clear_all_2fa_trust": "¿Quitar todos los dispositivos y limpiar toda la confianza 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?", "txt_remove_all_devices_and_sign_out_all_sessions": "¿Quitar todos los dispositivos, limpiar toda la confianza y cerrar sesión en todos los dispositivos?",
"txt_remove_selected_devices": "Quitar seleccionados ({count})",
"txt_remove_selected_devices_confirm": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar sesión?",
"txt_remove_selected_devices_and_sign_out_current": "¿Quitar {count} dispositivos seleccionados, limpiar su confianza y cerrar también esta sesión?",
"txt_selected_devices_removed": "Dispositivos seleccionados quitados",
"txt_remove_selected_devices_failed": "Error al quitar los dispositivos seleccionados",
"txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?", "txt_remove_device_name_and_clear_its_2fa_trust": "¿Quitar dispositivo \"{name}\" y limpiar su confianza 2FA?",
"txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?", "txt_remove_device_and_sign_out_name": "¿Quitar dispositivo \"{name}\", limpiar su confianza y cerrar sesión?",
"txt_reveal": "Mostrar", "txt_reveal": "Mostrar",
@@ -771,6 +893,9 @@ const es: Record<string, string> = {
"txt_security_code": "Código de seguridad", "txt_security_code": "Código de seguridad",
"txt_security_code_cvv": "Código de seguridad (CVV)", "txt_security_code_cvv": "Código de seguridad (CVV)",
"txt_select_all": "Seleccionar todo", "txt_select_all": "Seleccionar todo",
"txt_clear_selection": "Borrar selección",
"txt_select_device_name": "Seleccionar {name}",
"txt_no_devices_selected": "No hay dispositivos seleccionados",
"txt_select": "Seleccionar", "txt_select": "Seleccionar",
"txt_select_duplicate_items": "Seleccionar duplicados", "txt_select_duplicate_items": "Seleccionar duplicados",
"txt_select_an_item": "Seleccione un elemento", "txt_select_an_item": "Seleccione un elemento",
@@ -1040,7 +1165,9 @@ const es: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "Repair backup settings", "txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings", "txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite", "txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites", "txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite", "txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user", "txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status", "txt_log_action_admin_user_status": "Change user status",
+128 -1
View File
@@ -86,6 +86,37 @@ const ru: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.", "txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.", "txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.", "txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
"txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ и без кредитной карты.",
"txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.",
"txt_backup_recommend_backblaze_step_2_prefix": "Откройте",
"txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.",
"txt_backup_recommend_backblaze_step_3": "После создания вставьте показанный Endpoint в S3 Endpoint URL, имя bucket укажите в Bucket Name, а среднюю часть endpoint, например us-west-004, используйте как Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Откройте",
"txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.",
"txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.",
"txt_backup_recommend_cloudflare_r2_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ, но с обязательной проверкой кредитной карты.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "страницу создания bucket",
"txt_backup_recommend_cloudflare_r2_api_link": "страницу API token",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Откройте",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", введите только имя bucket и сразу создайте его.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Откройте",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", выберите Object Read & Write в разрешениях и сразу создайте токен.",
"txt_backup_recommend_cloudflare_r2_step_3": "После создания игнорируйте token value. Введите Access Key ID как ID доступа, а Secret Access Key как пароль доступа.",
"txt_backup_recommend_cloudflare_r2_step_4": "Вставьте показанный ниже адрес в S3 Endpoint URL, заполните Bucket Name как показано и оставьте Region в значении auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
"txt_backup_recommend_s3_path_prefix_step": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
"txt_backup_recommend_tigris_summary": "S3-совместимое объектное хранилище с бесплатными 5 ГБ и без кредитной карты.",
"txt_backup_recommend_tigris_signup_link": "страницу регистрации",
"txt_backup_recommend_tigris_bucket_link": "страницу Create Bucket",
"txt_backup_recommend_tigris_access_key_link": "страницу Create Access Key",
"txt_backup_recommend_tigris_step_1_prefix": "Откройте",
"txt_backup_recommend_tigris_step_1_suffix": ", зарегистрируйтесь и войдите в Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Откройте",
"txt_backup_recommend_tigris_step_2_suffix": ", введите только имя bucket, ничего больше не меняйте и создайте его.",
"txt_backup_recommend_tigris_step_3_prefix": "Затем откройте",
"txt_backup_recommend_tigris_step_3_suffix": ", введите любое имя и создайте ключ.",
"txt_backup_recommend_tigris_step_4": "После создания игнорируйте Endpoint URL IAM. Остальные показанные значения заполните на странице резервного копирования по совпадающим названиям полей.",
"txt_backup_recommend_tigris_step_5": "В конце нажмите Manage Key Permissions и включите Admin Access, иначе запись резервных копий не будет работать.",
"txt_backup_add_destination": "Добавить пункт назначения", "txt_backup_add_destination": "Добавить пункт назначения",
"txt_backup_schedule_panel_title": "Автоматическое расписание", "txt_backup_schedule_panel_title": "Автоматическое расписание",
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.", "txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
@@ -193,10 +224,14 @@ const ru: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.", "txt_backup_restore_progress_remote_finalize_detail": "Сервер выполняет окончательную проверку, а затем переключает проверенные данные восстановления в живые таблицы.",
"txt_backup_remote_loading": "Загрузка удаленных резервных копий...", "txt_backup_remote_loading": "Загрузка удаленных резервных копий...",
"txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.", "txt_backup_remote_cached_empty": "Нажмите «Обновить», чтобы загрузить это место назначения.",
"txt_backup_remote_cached_empty_prefix": "Нажмите",
"txt_backup_remote_cached_empty_suffix": "чтобы загрузить это место назначения.",
"txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.", "txt_backup_remote_empty": "В этой папке не найдено файлов резервных копий.",
"txt_backup_remote_folder": "Папка", "txt_backup_remote_folder": "Папка",
"txt_backup_remote_unknown_time": "Неизвестное время", "txt_backup_remote_unknown_time": "Неизвестное время",
"txt_backup_remote_current_path": "Текущая папка", "txt_backup_remote_current_path": "Текущая папка",
"txt_backup_remote_modified": "Изменено",
"txt_backup_remote_size": "Размер",
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.", "txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования", "txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.", "txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
@@ -214,6 +249,74 @@ const ru: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.", "txt_backup_remote_run_invalid_response": "Неверный ответ на удаленное резервное копирование.",
"txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования", "txt_backup_settings_invalid_response": "Неверный ответ на настройки резервного копирования",
"txt_backup_import_invalid_response": "Неверный ответ на импорт резервной копии", "txt_backup_import_invalid_response": "Неверный ответ на импорт резервной копии",
"txt_backup_error_another_backup_or_restore_running": "Уже выполняется задача резервного копирования или восстановления.",
"txt_backup_error_another_backup_running": "Уже выполняется задача резервного копирования.",
"txt_backup_error_archive_upload_failed": "Не удалось загрузить архив резервной копии.",
"txt_backup_error_archive_upload_verification_failed_attempts": "Проверка загрузки не прошла после {count} попыток: {reason}",
"txt_backup_error_attachment_blob_invalid": "Объект вложения резервной копии недействителен.",
"txt_backup_error_attachment_blob_required": "Требуется объект вложения резервной копии.",
"txt_backup_error_attachment_blob_not_found": "Объект вложения резервной копии не найден.",
"txt_backup_error_attachment_download_failed": "Не удалось скачать вложение резервной копии.",
"txt_backup_error_destination_invalid": "Место назначения резервной копии недействительно.",
"txt_backup_error_destination_limit": "Можно сохранить не более {count} мест назначения резервной копии.",
"txt_backup_error_destination_not_found": "Место назначения резервной копии не найдено.",
"txt_backup_error_destination_ids_unique": "ID мест назначения резервной копии должны быть уникальными.",
"txt_backup_error_destination_type_invalid": "Тип места назначения резервной копии недействителен.",
"txt_backup_error_destination_type_unsupported": "Неподдерживаемый тип места назначения резервной копии.",
"txt_backup_error_destinations_invalid": "Список мест назначения резервной копии недействителен.",
"txt_backup_error_export_payload_invalid": "Запрос экспорта резервной копии недействителен.",
"txt_backup_error_file_checksum_mismatch": "Контрольная сумма файла резервной копии не совпадает с именем файла.",
"txt_backup_error_file_required": "Выберите файл резервной копии.",
"txt_backup_error_interval_hours_range": "Интервал резервного копирования должен быть от 1 до 99 часов.",
"txt_backup_error_multipart_required": "Запрос загрузки должен использовать multipart/form-data.",
"txt_backup_error_read_backup_file_failed": "Не удалось прочитать файл резервной копии.",
"txt_backup_error_remote_attachment_batch_download_failed_status": "Пакетное скачивание удаленных вложений не удалось: HTTP {status}.",
"txt_backup_error_remote_attachment_download_failed_status": "Скачивание удаленного вложения не удалось: HTTP {status}.",
"txt_backup_error_remote_delete_failed": "Не удалось удалить удаленную резервную копию.",
"txt_backup_error_remote_download_failed": "Не удалось скачать удаленную резервную копию.",
"txt_backup_error_remote_download_payload_invalid": "Запрос скачивания удаленной резервной копии недействителен.",
"txt_backup_error_remote_integrity_failed": "Не удалось проверить целостность удаленной резервной копии.",
"txt_backup_error_remote_listing_failed": "Не удалось получить список удаленных резервных копий.",
"txt_backup_error_remote_path_invalid": "Путь удаленной резервной копии недействителен.",
"txt_backup_error_remote_restore_payload_invalid": "Запрос удаленного восстановления недействителен.",
"txt_backup_error_remote_zip_checksum_failed": "Проверка контрольной суммы удаленного ZIP не прошла.",
"txt_backup_error_remote_zip_size_failed": "Проверка размера удаленного ZIP не прошла.",
"txt_backup_error_retention_count_range": "Количество сохраняемых копий должно быть от 1 до 1000.",
"txt_backup_error_run_failed": "Запуск резервного копирования не удался.",
"txt_backup_error_run_payload_invalid": "Запрос запуска резервного копирования недействителен.",
"txt_backup_error_run_response_invalid": "Ответ запуска резервного копирования недействителен.",
"txt_backup_error_s3_access_key_required": "Требуется ключ доступа S3.",
"txt_backup_error_s3_bucket_required": "Требуется bucket S3.",
"txt_backup_error_s3_delete_failed_status": "Удаление S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_download_failed_status": "Скачивание S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_endpoint_required": "Требуется endpoint S3.",
"txt_backup_error_s3_endpoint_protocol": "Endpoint S3 должен начинаться с http:// или https://.",
"txt_backup_error_s3_existence_check_failed_status": "Проверка существования S3 не удалась: HTTP {status}.",
"txt_backup_error_s3_listing_failed_status": "Получение списка S3 не удалось: HTTP {status}.",
"txt_backup_error_s3_secret_key_required": "Требуется секретный ключ S3.",
"txt_backup_error_s3_upload_failed_status": "Загрузка S3 не удалась: HTTP {status}.",
"txt_backup_error_select_backup_file": "Выберите файл резервной копии.",
"txt_backup_error_select_backup_zip_file": "Выберите ZIP-файл резервной копии.",
"txt_backup_error_settings_envelope_invalid": "Зашифрованный контейнер настроек резервного копирования недействителен.",
"txt_backup_error_settings_invalid": "Настройки резервного копирования недействительны.",
"txt_backup_error_settings_load_failed": "Не удалось загрузить настройки резервного копирования.",
"txt_backup_error_settings_need_reactivation": "После восстановления настройки резервного копирования нужно повторно активировать администратором.",
"txt_backup_error_settings_payload_invalid": "Запрос настроек резервного копирования недействителен.",
"txt_backup_error_settings_repair_payload_invalid": "Запрос восстановления настроек резервного копирования недействителен.",
"txt_backup_error_settings_repair_state_load_failed": "Не удалось загрузить состояние восстановления настроек резервного копирования.",
"txt_backup_error_start_time_format": "Время начала резервного копирования должно быть в формате HH:mm.",
"txt_backup_error_timezone_invalid": "Часовой пояс резервного копирования недействителен.",
"txt_backup_error_webdav_delete_failed_status": "Удаление WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_directory_creation_failed_status": "Создание каталога WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_download_failed_status": "Скачивание WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_existence_check_failed_status": "Проверка существования WebDAV не удалась: HTTP {status}.",
"txt_backup_error_webdav_listing_failed_status": "Получение списка WebDAV не удалось: HTTP {status}.",
"txt_backup_error_webdav_password_required": "Требуется пароль WebDAV.",
"txt_backup_error_webdav_path_too_deep": "Удаленный путь WebDAV слишком глубокий для безопасной пакетной обработки вложений.",
"txt_backup_error_webdav_upload_failed_status": "Загрузка WebDAV не удалась: HTTP {status}.",
"txt_backup_error_webdav_url_required": "Требуется URL сервера WebDAV.",
"txt_backup_error_webdav_url_protocol": "URL WebDAV должен начинаться с http:// или https://.",
"txt_backup_error_webdav_username_required": "Требуется имя пользователя WebDAV.",
"txt_backup_destination": "Место назначения резервного копирования", "txt_backup_destination": "Место назначения резервного копирования",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -479,8 +582,17 @@ const ru: Record<string, string> = {
"txt_identity_details": "Данные личности", "txt_identity_details": "Данные личности",
"txt_ie_browser": "IE-браузер", "txt_ie_browser": "IE-браузер",
"txt_create_invite_failed": "Не удалось создать приглашение", "txt_create_invite_failed": "Не удалось создать приглашение",
"txt_delete_invalid": "Удалить недействительные",
"txt_delete_invalid_invites": "Удалить недействительные приглашения",
"txt_delete_invalid_invites_confirm_message": "Удалить все недействительные пригласительные коды? Активные и не истекшие коды будут сохранены.",
"txt_delete_invalid_invites_failed": "Не удалось удалить недействительные приглашения",
"txt_delete_invite": "Удалить приглашение",
"txt_delete_invite_confirm_message": "Удалить этот пригласительный код? Это действие нельзя отменить.",
"txt_delete_invite_failed": "Не удалось удалить приглашение",
"txt_invite_code_required": "Пригласительный код (обязательно)", "txt_invite_code_required": "Пригласительный код (обязательно)",
"txt_invite_created": "Приглашение создано", "txt_invite_created": "Приглашение создано",
"txt_invite_deleted": "Приглашение удалено",
"txt_invalid_invites_deleted": "Недействительные приглашения удалены",
"txt_invite_revoked": "Приглашение отозвано", "txt_invite_revoked": "Приглашение отозвано",
"txt_revoke_invite_failed": "Не удалось отозвать приглашение", "txt_revoke_invite_failed": "Не удалось отозвать приглашение",
"txt_invite_validity_hours": "Срок действия приглашения (часы)", "txt_invite_validity_hours": "Срок действия приглашения (часы)",
@@ -489,16 +601,21 @@ const ru: Record<string, string> = {
"txt_server_error_account_disabled": "Учетная запись отключена", "txt_server_error_account_disabled": "Учетная запись отключена",
"txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.", "txt_server_error_client_credentials_incorrect": "ID клиента или секрет клиента неверны. Повторите попытку.",
"txt_server_error_client_ip_required": "Требуется IP клиента", "txt_server_error_client_ip_required": "Требуется IP клиента",
"txt_server_error_forbidden": "У вас нет прав для выполнения этого действия.",
"txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован", "txt_server_error_email_already_registered": "Этот адрес электронной почты уже зарегистрирован",
"txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль", "txt_server_error_email_password_required": "Требуются адрес электронной почты и пароль",
"txt_server_error_email_required": "Требуется адрес электронной почты", "txt_server_error_email_required": "Требуется адрес электронной почты",
"txt_server_error_invalid_password": "Неверный пароль.",
"txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.", "txt_server_error_invalid_refresh_token": "Сеанс истек. Войдите снова.",
"txt_server_error_invalid_user_verification_token": "Недействительный токен проверки пользователя.",
"txt_server_error_invalid_request_payload": "Недопустимый запрос", "txt_server_error_invalid_request_payload": "Недопустимый запрос",
"txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек", "txt_server_error_invite_invalid_or_expired": "Код приглашения недействителен или истек",
"txt_server_error_invite_required": "Требуется код приглашения", "txt_server_error_invite_required": "Требуется код приглашения",
"txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.", "txt_server_error_jwt_secret_default": "JWT_SECRET использует значение по умолчанию/пример. Измените его.",
"txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен", "txt_server_error_jwt_secret_missing": "JWT_SECRET не настроен",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов", "txt_server_error_jwt_secret_too_short": "JWT_SECRET должен содержать не менее 32 символов",
"txt_server_error_master_password_hash_required": "Требуется проверка мастер-пароля.",
"txt_server_error_master_password_or_verification_required": "Требуется мастер-пароль или токен проверки пользователя.",
"txt_server_error_parameter_error": "Ошибка параметров", "txt_server_error_parameter_error": "Ошибка параметров",
"txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.", "txt_server_error_refresh_token_required": "Сеанс отсутствует. Войдите снова.",
"txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.", "txt_server_error_registration_retry": "Регистрация временно недоступна. Повторите попытку один раз.",
@@ -552,7 +669,7 @@ const ru: Record<string, string> = {
"txt_jwt_secret_value_label": "Значение:", "txt_jwt_secret_value_label": "Значение:",
"txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.", "txt_jwt_secret_value_requirement": "Случайная строка, содержащая не менее {min} символов.",
"txt_jwt_what_is": "Что такое JWT?", "txt_jwt_what_is": "Что такое JWT?",
"txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует, слишком короткий или все еще использует образец значения, обычное использование экземпляра небезопасно.", "txt_jwt_what_is_body": "JWT_SECRET — это ключ подписи на стороне сервера, используемый для выдачи и проверки токенов входа. Если он отсутствует или слишком короткий, обычное использование экземпляра небезопасно.",
"txt_how_to_fix": "Как исправить", "txt_how_to_fix": "Как исправить",
"txt_jwt_fix_step_1": "Откройте переменные среды развертывания.", "txt_jwt_fix_step_1": "Откройте переменные среды развертывания.",
"txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.", "txt_jwt_fix_step_2": "Если ваш текущий ключ недостаточно случайный, используйте 32-значный генератор ниже.",
@@ -730,6 +847,11 @@ const ru: Record<string, string> = {
"txt_remove_all_devices": "Удалить все устройства", "txt_remove_all_devices": "Удалить все устройства",
"txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?", "txt_remove_all_devices_and_clear_all_2fa_trust": "Удалить все устройства и очистить все доверие 2FA?",
"txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?", "txt_remove_all_devices_and_sign_out_all_sessions": "Удалить все устройства, отменить все доверительные отношения и выйти из системы на каждом устройстве?",
"txt_remove_selected_devices": "Удалить выбранные ({count})",
"txt_remove_selected_devices_confirm": "Удалить {count} выбранных устройств, очистить их доверие и выйти из системы на них?",
"txt_remove_selected_devices_and_sign_out_current": "Удалить {count} выбранных устройств, очистить их доверие и также выйти из системы на этом устройстве?",
"txt_selected_devices_removed": "Выбранные устройства удалены",
"txt_remove_selected_devices_failed": "Не удалось удалить выбранные устройства",
"txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?", "txt_remove_device_name_and_clear_its_2fa_trust": "Удалить устройство «{name}» и очистить его доверие 2FA?",
"txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?", "txt_remove_device_and_sign_out_name": "Удалить устройство «{name}», очистить его доверие и выйти из системы?",
"txt_reveal": "Раскрыть", "txt_reveal": "Раскрыть",
@@ -771,6 +893,9 @@ const ru: Record<string, string> = {
"txt_security_code": "Код безопасности", "txt_security_code": "Код безопасности",
"txt_security_code_cvv": "Код безопасности (CVV)", "txt_security_code_cvv": "Код безопасности (CVV)",
"txt_select_all": "Выбрать все", "txt_select_all": "Выбрать все",
"txt_clear_selection": "Очистить выбор",
"txt_select_device_name": "Выбрать {name}",
"txt_no_devices_selected": "Устройства не выбраны",
"txt_select": "Выбрать", "txt_select": "Выбрать",
"txt_select_duplicate_items": "Выберите дубликаты", "txt_select_duplicate_items": "Выберите дубликаты",
"txt_select_an_item": "Выберите элемент", "txt_select_an_item": "Выберите элемент",
@@ -1040,7 +1165,9 @@ const ru: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "Repair backup settings", "txt_log_action_admin_backup_settings_repair": "Repair backup settings",
"txt_log_action_admin_backup_settings_update": "Update backup settings", "txt_log_action_admin_backup_settings_update": "Update backup settings",
"txt_log_action_admin_invite_create": "Create invite", "txt_log_action_admin_invite_create": "Create invite",
"txt_log_action_admin_invite_delete": "Delete invite",
"txt_log_action_admin_invite_delete_all": "Clear invites", "txt_log_action_admin_invite_delete_all": "Clear invites",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites",
"txt_log_action_admin_invite_revoke": "Revoke invite", "txt_log_action_admin_invite_revoke": "Revoke invite",
"txt_log_action_admin_user_delete": "Delete user", "txt_log_action_admin_user_delete": "Delete user",
"txt_log_action_admin_user_status": "Change user status", "txt_log_action_admin_user_status": "Change user status",
+134 -7
View File
@@ -85,6 +85,37 @@ const zhCN: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。", "txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。", "txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
"txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。",
"txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。",
"txt_backup_recommend_backblaze_step_2_prefix": "打开",
"txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。",
"txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3 端点 URL;桶名字填到存储桶名称;区域填 Endpoint 中间那段,例如 us-west-004。",
"txt_backup_recommend_backblaze_step_4_prefix": "打开",
"txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。",
"txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 IDapplicationKey 填到 访问密码。",
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的对象存储,免费容量 10 GB,需要信用卡认证。",
"txt_backup_recommend_cloudflare_r2_bucket_link": "创建储存桶页面",
"txt_backup_recommend_cloudflare_r2_api_link": "API 创建页面",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打开",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只输入存储桶名称,直接创建。",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打开",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",权限全选“对象读和写”,直接创建。",
"txt_backup_recommend_cloudflare_r2_step_3": "创建后令牌值不用管;Access Key ID 填到 访问 IDSecret Access Key 填到 访问密码。",
"txt_backup_recommend_cloudflare_r2_step_4": "把下面显示的地址填到 S3 端点 URL;存储桶名称如实填写;区域保持 auto 不改。",
"txt_backup_recommend_cloudflare_r2_step_5": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
"txt_backup_recommend_s3_path_prefix_step": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
"txt_backup_recommend_tigris_summary": "兼容 S3 的对象存储。免费容量 5 GB,无需信用卡。",
"txt_backup_recommend_tigris_signup_link": "注册页面",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 页面",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 页面",
"txt_backup_recommend_tigris_step_1_prefix": "打开",
"txt_backup_recommend_tigris_step_1_suffix": ",注册并登录 Tigris。",
"txt_backup_recommend_tigris_step_2_prefix": "打开",
"txt_backup_recommend_tigris_step_2_suffix": ",只输入桶的名字,其他地方不动,直接创建。",
"txt_backup_recommend_tigris_step_3_prefix": "然后打开",
"txt_backup_recommend_tigris_step_3_suffix": ",名字随意,直接创建。",
"txt_backup_recommend_tigris_step_4": "创建后显示的 Endpoint URL IAM 不用管;其余显示出来的内容按名称填写到备份页面里。",
"txt_backup_recommend_tigris_step_5": "最后点击 Manage Key Permissions,把 Admin Access 打开,否则无法写入。",
"txt_backup_add_destination": "新增地点", "txt_backup_add_destination": "新增地点",
"txt_backup_schedule_panel_title": "自动备份计划", "txt_backup_schedule_panel_title": "自动备份计划",
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。", "txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
@@ -193,10 +224,14 @@ const zhCN: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。", "txt_backup_restore_progress_remote_finalize_detail": "服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。",
"txt_backup_remote_loading": "正在读取远端备份...", "txt_backup_remote_loading": "正在读取远端备份...",
"txt_backup_remote_cached_empty": "点击“刷新”后读取", "txt_backup_remote_cached_empty": "点击“刷新”后读取",
"txt_backup_remote_cached_empty_prefix": "点击",
"txt_backup_remote_cached_empty_suffix": "后读取",
"txt_backup_remote_empty": "这个目录下还没有备份文件", "txt_backup_remote_empty": "这个目录下还没有备份文件",
"txt_backup_remote_folder": "文件夹", "txt_backup_remote_folder": "文件夹",
"txt_backup_remote_unknown_time": "未知时间", "txt_backup_remote_unknown_time": "未知时间",
"txt_backup_remote_current_path": "当前目录", "txt_backup_remote_current_path": "当前目录",
"txt_backup_remote_modified": "修改时间",
"txt_backup_remote_size": "大小",
"txt_backup_remote_load_failed": "读取远端备份失败", "txt_backup_remote_load_failed": "读取远端备份失败",
"txt_backup_remote_invalid_response": "远端备份响应无效", "txt_backup_remote_invalid_response": "远端备份响应无效",
"txt_backup_remote_download_failed": "下载远端备份失败", "txt_backup_remote_download_failed": "下载远端备份失败",
@@ -214,6 +249,74 @@ const zhCN: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "远端备份执行响应无效", "txt_backup_remote_run_invalid_response": "远端备份执行响应无效",
"txt_backup_settings_invalid_response": "备份设置响应无效", "txt_backup_settings_invalid_response": "备份设置响应无效",
"txt_backup_import_invalid_response": "备份还原响应无效", "txt_backup_import_invalid_response": "备份还原响应无效",
"txt_backup_error_another_backup_or_restore_running": "已有备份或还原任务正在执行。",
"txt_backup_error_another_backup_running": "已有备份任务正在执行。",
"txt_backup_error_archive_upload_failed": "备份压缩包上传失败。",
"txt_backup_error_archive_upload_verification_failed_attempts": "备份上传校验在 {count} 次尝试后仍失败:{reason}",
"txt_backup_error_attachment_blob_invalid": "备份附件对象无效。",
"txt_backup_error_attachment_blob_required": "缺少备份附件对象。",
"txt_backup_error_attachment_blob_not_found": "未找到备份附件对象。",
"txt_backup_error_attachment_download_failed": "备份附件下载失败。",
"txt_backup_error_destination_invalid": "备份地点无效。",
"txt_backup_error_destination_limit": "最多只能保存 {count} 个备份地点。",
"txt_backup_error_destination_not_found": "未找到备份地点。",
"txt_backup_error_destination_ids_unique": "备份地点 ID 不能重复。",
"txt_backup_error_destination_type_invalid": "备份地点类型无效。",
"txt_backup_error_destination_type_unsupported": "不支持的备份地点类型。",
"txt_backup_error_destinations_invalid": "备份地点列表无效。",
"txt_backup_error_export_payload_invalid": "备份导出请求无效。",
"txt_backup_error_file_checksum_mismatch": "备份文件校验值与文件名不一致。",
"txt_backup_error_file_required": "请选择备份文件。",
"txt_backup_error_interval_hours_range": "备份间隔必须在 1 到 99 小时之间。",
"txt_backup_error_multipart_required": "上传请求必须使用 multipart/form-data。",
"txt_backup_error_read_backup_file_failed": "无法读取备份文件。",
"txt_backup_error_remote_attachment_batch_download_failed_status": "远端附件批量下载失败:HTTP {status}。",
"txt_backup_error_remote_attachment_download_failed_status": "远端附件下载失败:HTTP {status}。",
"txt_backup_error_remote_delete_failed": "远端备份删除失败。",
"txt_backup_error_remote_download_failed": "远端备份下载失败。",
"txt_backup_error_remote_download_payload_invalid": "远端备份下载请求无效。",
"txt_backup_error_remote_integrity_failed": "远端备份完整性检查失败。",
"txt_backup_error_remote_listing_failed": "远端备份列表读取失败。",
"txt_backup_error_remote_path_invalid": "远端备份路径无效。",
"txt_backup_error_remote_restore_payload_invalid": "远端还原请求无效。",
"txt_backup_error_remote_zip_checksum_failed": "远端备份 ZIP 校验失败。",
"txt_backup_error_remote_zip_size_failed": "远端备份 ZIP 大小校验失败。",
"txt_backup_error_retention_count_range": "备份保留数量必须在 1 到 1000 之间。",
"txt_backup_error_run_failed": "备份执行失败。",
"txt_backup_error_run_payload_invalid": "备份执行请求无效。",
"txt_backup_error_run_response_invalid": "备份执行响应无效。",
"txt_backup_error_s3_access_key_required": "请填写 S3 访问 ID。",
"txt_backup_error_s3_bucket_required": "请填写 S3 存储桶名称。",
"txt_backup_error_s3_delete_failed_status": "S3 删除失败:HTTP {status}。",
"txt_backup_error_s3_download_failed_status": "S3 下载失败:HTTP {status}。",
"txt_backup_error_s3_endpoint_required": "请填写 S3 端点 URL。",
"txt_backup_error_s3_endpoint_protocol": "S3 端点 URL 必须以 http:// 或 https:// 开头。",
"txt_backup_error_s3_existence_check_failed_status": "S3 文件存在性检查失败:HTTP {status}。",
"txt_backup_error_s3_listing_failed_status": "S3 列表读取失败:HTTP {status}。",
"txt_backup_error_s3_secret_key_required": "请填写 S3 访问密码。",
"txt_backup_error_s3_upload_failed_status": "S3 上传失败:HTTP {status}。",
"txt_backup_error_select_backup_file": "请选择备份文件。",
"txt_backup_error_select_backup_zip_file": "请选择备份 ZIP 文件。",
"txt_backup_error_settings_envelope_invalid": "备份设置加密封装无效。",
"txt_backup_error_settings_invalid": "备份设置无效。",
"txt_backup_error_settings_load_failed": "无法加载备份设置。",
"txt_backup_error_settings_need_reactivation": "还原后需要管理员重新激活备份设置。",
"txt_backup_error_settings_payload_invalid": "备份设置请求无效。",
"txt_backup_error_settings_repair_payload_invalid": "备份设置修复请求无效。",
"txt_backup_error_settings_repair_state_load_failed": "无法加载备份设置修复状态。",
"txt_backup_error_start_time_format": "备份开始时间必须是 HH:mm 格式。",
"txt_backup_error_timezone_invalid": "备份时区无效。",
"txt_backup_error_webdav_delete_failed_status": "WebDAV 删除失败:HTTP {status}。",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV 目录创建失败:HTTP {status}。",
"txt_backup_error_webdav_download_failed_status": "WebDAV 下载失败:HTTP {status}。",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV 文件存在性检查失败:HTTP {status}。",
"txt_backup_error_webdav_listing_failed_status": "WebDAV 列表读取失败:HTTP {status}。",
"txt_backup_error_webdav_password_required": "请填写 WebDAV 密码。",
"txt_backup_error_webdav_path_too_deep": "WebDAV 远端备份路径过深,无法安全分批处理附件。",
"txt_backup_error_webdav_upload_failed_status": "WebDAV 上传失败:HTTP {status}。",
"txt_backup_error_webdav_url_required": "请填写 WebDAV 服务地址。",
"txt_backup_error_webdav_url_protocol": "WebDAV 服务地址必须以 http:// 或 https:// 开头。",
"txt_backup_error_webdav_username_required": "请填写 WebDAV 用户名。",
"txt_backup_destination": "备份地点", "txt_backup_destination": "备份地点",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -266,15 +369,15 @@ const zhCN: Record<string, string> = {
"txt_backup_webdav_username": "WebDAV 用户名", "txt_backup_webdav_username": "WebDAV 用户名",
"txt_backup_webdav_password": "WebDAV 密码", "txt_backup_webdav_password": "WebDAV 密码",
"txt_backup_webdav_path": "远程目录", "txt_backup_webdav_path": "远程目录",
"txt_backup_s3_endpoint": "S3 端点", "txt_backup_s3_endpoint": "S3 端点 URL",
"txt_backup_s3_addressing_style": "S3 寻址方式", "txt_backup_s3_addressing_style": "寻址方式",
"txt_backup_s3_addressing_path_style": "path-style(默认)", "txt_backup_s3_addressing_path_style": "path-style(默认)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style", "txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "存储桶", "txt_backup_s3_bucket": "存储桶名称",
"txt_backup_s3_region": "区域", "txt_backup_s3_region": "区域",
"txt_backup_s3_access_key": "访问密钥", "txt_backup_s3_access_key": "访问 ID",
"txt_backup_s3_secret_key": "秘密密钥", "txt_backup_s3_secret_key": "访问密码",
"txt_backup_s3_path": "远程路径", "txt_backup_s3_path": "路径前缀",
"txt_backup_reserved_name": "预留类型名称", "txt_backup_reserved_name": "预留类型名称",
"txt_backup_reserved_notes": "预留备注", "txt_backup_reserved_notes": "预留备注",
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明", "txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
@@ -479,8 +582,17 @@ const zhCN: Record<string, string> = {
"txt_identity_details": "身份详情", "txt_identity_details": "身份详情",
"txt_ie_browser": "IE 浏览器", "txt_ie_browser": "IE 浏览器",
"txt_create_invite_failed": "创建邀请码失败", "txt_create_invite_failed": "创建邀请码失败",
"txt_delete_invalid": "删除无效",
"txt_delete_invalid_invites": "删除无效邀请码",
"txt_delete_invalid_invites_confirm_message": "确定删除所有无效邀请码吗?仍有效且未过期的邀请码会保留。",
"txt_delete_invalid_invites_failed": "删除无效邀请码失败",
"txt_delete_invite": "删除邀请码",
"txt_delete_invite_confirm_message": "确定删除该邀请码吗?删除后无法恢复。",
"txt_delete_invite_failed": "删除邀请码失败",
"txt_invite_code_required": "邀请码(必填)", "txt_invite_code_required": "邀请码(必填)",
"txt_invite_created": "邀请码已创建", "txt_invite_created": "邀请码已创建",
"txt_invite_deleted": "邀请码已删除",
"txt_invalid_invites_deleted": "无效邀请码已删除",
"txt_invite_revoked": "邀请码已撤销", "txt_invite_revoked": "邀请码已撤销",
"txt_revoke_invite_failed": "撤销邀请码失败", "txt_revoke_invite_failed": "撤销邀请码失败",
"txt_invite_validity_hours": "邀请码有效期(小时)", "txt_invite_validity_hours": "邀请码有效期(小时)",
@@ -489,16 +601,21 @@ const zhCN: Record<string, string> = {
"txt_server_error_account_disabled": "账号已被禁用", "txt_server_error_account_disabled": "账号已被禁用",
"txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试", "txt_server_error_client_credentials_incorrect": "客户端 ID 或客户端密钥不正确,请重试",
"txt_server_error_client_ip_required": "无法获取客户端 IP", "txt_server_error_client_ip_required": "无法获取客户端 IP",
"txt_server_error_forbidden": "你没有权限执行此操作。",
"txt_server_error_email_already_registered": "该邮箱已注册", "txt_server_error_email_already_registered": "该邮箱已注册",
"txt_server_error_email_password_required": "邮箱和密码不能为空", "txt_server_error_email_password_required": "邮箱和密码不能为空",
"txt_server_error_email_required": "邮箱不能为空", "txt_server_error_email_required": "邮箱不能为空",
"txt_server_error_invalid_password": "密码无效。",
"txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录", "txt_server_error_invalid_refresh_token": "登录状态已失效,请重新登录",
"txt_server_error_invalid_user_verification_token": "用户验证令牌无效。",
"txt_server_error_invalid_request_payload": "请求内容无效", "txt_server_error_invalid_request_payload": "请求内容无效",
"txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期", "txt_server_error_invite_invalid_or_expired": "邀请码无效或已过期",
"txt_server_error_invite_required": "邀请码不能为空", "txt_server_error_invite_required": "邀请码不能为空",
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续", "txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默认示例值,请修改后再继续",
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置", "txt_server_error_jwt_secret_missing": "JWT_SECRET 未设置",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符", "txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 个字符",
"txt_server_error_master_password_hash_required": "需要验证主密码。",
"txt_server_error_master_password_or_verification_required": "需要主密码或用户验证令牌。",
"txt_server_error_parameter_error": "请求参数错误", "txt_server_error_parameter_error": "请求参数错误",
"txt_server_error_refresh_token_required": "登录状态缺失,请重新登录", "txt_server_error_refresh_token_required": "登录状态缺失,请重新登录",
"txt_server_error_registration_retry": "注册暂时不可用,请重试一次", "txt_server_error_registration_retry": "注册暂时不可用,请重试一次",
@@ -552,7 +669,7 @@ const zhCN: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位随机字符", "txt_jwt_secret_value_requirement": "最低 {min} 位随机字符",
"txt_jwt_what_is": "JWT 是什么", "txt_jwt_what_is": "JWT 是什么",
"txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,或者仍然使用示例值,实例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服务端用来签发和校验登录令牌的密钥。如果它缺失过短,实例就不能安全地正常使用。",
"txt_how_to_fix": "处理步骤(添加 / 更换)", "txt_how_to_fix": "处理步骤(添加 / 更换)",
"txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。", "txt_jwt_fix_step_1": "你可以继续下一步,不影响使用。",
"txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果当前密钥不是强随机值,建议使用下方 32 位生成器。",
@@ -730,6 +847,11 @@ const zhCN: Record<string, string> = {
"txt_remove_all_devices": "移除所有设备", "txt_remove_all_devices": "移除所有设备",
"txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?", "txt_remove_all_devices_and_clear_all_2fa_trust": "确认移除所有设备并清除全部 2FA 信任吗?",
"txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?", "txt_remove_all_devices_and_sign_out_all_sessions": "确认移除所有设备、清除全部信任,并让所有设备重新登录吗?",
"txt_remove_selected_devices": "移除已选({count}",
"txt_remove_selected_devices_confirm": "确认移除选中的 {count} 台设备、清除其信任,并让它们重新登录吗?",
"txt_remove_selected_devices_and_sign_out_current": "确认移除选中的 {count} 台设备、清除其信任,并同时退出本设备吗?",
"txt_selected_devices_removed": "已移除选中设备",
"txt_remove_selected_devices_failed": "移除选中设备失败",
"txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?", "txt_remove_device_name_and_clear_its_2fa_trust": "确认移除设备“{name}”并清除其 2FA 信任吗?",
"txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?", "txt_remove_device_and_sign_out_name": "确认移除设备“{name}”,清除其信任,并让它重新登录吗?",
"txt_reveal": "显示", "txt_reveal": "显示",
@@ -771,6 +893,9 @@ const zhCN: Record<string, string> = {
"txt_security_code": "安全码", "txt_security_code": "安全码",
"txt_security_code_cvv": "安全码 (CVV)", "txt_security_code_cvv": "安全码 (CVV)",
"txt_select_all": "全选", "txt_select_all": "全选",
"txt_clear_selection": "取消选择",
"txt_select_device_name": "选择 {name}",
"txt_no_devices_selected": "未选择设备",
"txt_select": "请选择", "txt_select": "请选择",
"txt_select_duplicate_items": "选择重复项", "txt_select_duplicate_items": "选择重复项",
"txt_select_an_item": "请选择一个项目", "txt_select_an_item": "请选择一个项目",
@@ -1040,7 +1165,9 @@ const zhCN: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "修复备份设置", "txt_log_action_admin_backup_settings_repair": "修复备份设置",
"txt_log_action_admin_backup_settings_update": "更新备份设置", "txt_log_action_admin_backup_settings_update": "更新备份设置",
"txt_log_action_admin_invite_create": "创建邀请", "txt_log_action_admin_invite_create": "创建邀请",
"txt_log_action_admin_invite_delete": "删除邀请",
"txt_log_action_admin_invite_delete_all": "清空邀请", "txt_log_action_admin_invite_delete_all": "清空邀请",
"txt_log_action_admin_invite_delete_invalid": "删除无效邀请",
"txt_log_action_admin_invite_revoke": "撤销邀请", "txt_log_action_admin_invite_revoke": "撤销邀请",
"txt_log_action_admin_user_delete": "删除用户", "txt_log_action_admin_user_delete": "删除用户",
"txt_log_action_admin_user_status": "修改用户状态", "txt_log_action_admin_user_status": "修改用户状态",
+128 -1
View File
@@ -85,6 +85,37 @@ const zhTW: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。", "txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。", "txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
"txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB,無需信用卡。",
"txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。",
"txt_backup_recommend_backblaze_step_2_prefix": "打開",
"txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。",
"txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 填到 S3 端點 URL;桶名字填到儲存桶名稱;區域填 Endpoint 中間那段,例如 us-west-004。",
"txt_backup_recommend_backblaze_step_4_prefix": "打開",
"txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。",
"txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。",
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的對象儲存,免費容量 10 GB,需要信用卡驗證。",
"txt_backup_recommend_cloudflare_r2_bucket_link": "創建儲存桶頁面",
"txt_backup_recommend_cloudflare_r2_api_link": "API 創建頁面",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打開",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只輸入儲存桶名稱,直接創建。",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打開",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",權限全選「對象讀和寫」,直接創建。",
"txt_backup_recommend_cloudflare_r2_step_3": "創建後令牌值不用管;Access Key ID 填到存取 IDSecret Access Key 填到存取密碼。",
"txt_backup_recommend_cloudflare_r2_step_4": "把下面顯示的地址填到 S3 端點 URL;儲存桶名稱如實填寫;區域保持 auto 不改。",
"txt_backup_recommend_cloudflare_r2_step_5": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
"txt_backup_recommend_s3_path_prefix_step": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
"txt_backup_recommend_tigris_summary": "兼容 S3 的對象儲存。免費容量 5 GB,無需信用卡。",
"txt_backup_recommend_tigris_signup_link": "註冊頁面",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 頁面",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 頁面",
"txt_backup_recommend_tigris_step_1_prefix": "打開",
"txt_backup_recommend_tigris_step_1_suffix": ",註冊並登入 Tigris。",
"txt_backup_recommend_tigris_step_2_prefix": "打開",
"txt_backup_recommend_tigris_step_2_suffix": ",只輸入桶的名字,其他地方不動,直接創建。",
"txt_backup_recommend_tigris_step_3_prefix": "然後打開",
"txt_backup_recommend_tigris_step_3_suffix": ",名字隨意,直接創建。",
"txt_backup_recommend_tigris_step_4": "創建後顯示的 Endpoint URL IAM 不用管;其餘顯示出來的內容按名稱填寫到備份頁面裡。",
"txt_backup_recommend_tigris_step_5": "最後點擊 Manage Key Permissions,把 Admin Access 打開,否則無法寫入。",
"txt_backup_add_destination": "新增地點", "txt_backup_add_destination": "新增地點",
"txt_backup_schedule_panel_title": "自動備份計劃", "txt_backup_schedule_panel_title": "自動備份計劃",
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。", "txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
@@ -193,10 +224,14 @@ const zhTW: Record<string, string> = {
"txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。", "txt_backup_restore_progress_remote_finalize_detail": "服務器正在執行最終校驗,校驗通過後會把已驗證的數據切換為正式數據。",
"txt_backup_remote_loading": "正在讀取遠端備份...", "txt_backup_remote_loading": "正在讀取遠端備份...",
"txt_backup_remote_cached_empty": "點擊“刷新”後讀取", "txt_backup_remote_cached_empty": "點擊“刷新”後讀取",
"txt_backup_remote_cached_empty_prefix": "點擊",
"txt_backup_remote_cached_empty_suffix": "後讀取",
"txt_backup_remote_empty": "這個目錄下還沒有備份文件", "txt_backup_remote_empty": "這個目錄下還沒有備份文件",
"txt_backup_remote_folder": "文件夾", "txt_backup_remote_folder": "文件夾",
"txt_backup_remote_unknown_time": "未知時間", "txt_backup_remote_unknown_time": "未知時間",
"txt_backup_remote_current_path": "當前目錄", "txt_backup_remote_current_path": "當前目錄",
"txt_backup_remote_modified": "修改時間",
"txt_backup_remote_size": "大小",
"txt_backup_remote_load_failed": "讀取遠端備份失敗", "txt_backup_remote_load_failed": "讀取遠端備份失敗",
"txt_backup_remote_invalid_response": "遠端備份響應無效", "txt_backup_remote_invalid_response": "遠端備份響應無效",
"txt_backup_remote_download_failed": "下載遠端備份失敗", "txt_backup_remote_download_failed": "下載遠端備份失敗",
@@ -214,6 +249,74 @@ const zhTW: Record<string, string> = {
"txt_backup_remote_run_invalid_response": "遠端備份執行響應無效", "txt_backup_remote_run_invalid_response": "遠端備份執行響應無效",
"txt_backup_settings_invalid_response": "備份設置響應無效", "txt_backup_settings_invalid_response": "備份設置響應無效",
"txt_backup_import_invalid_response": "備份還原響應無效", "txt_backup_import_invalid_response": "備份還原響應無效",
"txt_backup_error_another_backup_or_restore_running": "已有備份或還原任務正在執行。",
"txt_backup_error_another_backup_running": "已有備份任務正在執行。",
"txt_backup_error_archive_upload_failed": "備份壓縮包上傳失敗。",
"txt_backup_error_archive_upload_verification_failed_attempts": "備份上傳校驗在 {count} 次嘗試後仍失敗:{reason}",
"txt_backup_error_attachment_blob_invalid": "備份附件對象無效。",
"txt_backup_error_attachment_blob_required": "缺少備份附件對象。",
"txt_backup_error_attachment_blob_not_found": "未找到備份附件對象。",
"txt_backup_error_attachment_download_failed": "備份附件下載失敗。",
"txt_backup_error_destination_invalid": "備份地點無效。",
"txt_backup_error_destination_limit": "最多只能保存 {count} 個備份地點。",
"txt_backup_error_destination_not_found": "未找到備份地點。",
"txt_backup_error_destination_ids_unique": "備份地點 ID 不能重複。",
"txt_backup_error_destination_type_invalid": "備份地點類型無效。",
"txt_backup_error_destination_type_unsupported": "不支持的備份地點類型。",
"txt_backup_error_destinations_invalid": "備份地點列表無效。",
"txt_backup_error_export_payload_invalid": "備份導出請求無效。",
"txt_backup_error_file_checksum_mismatch": "備份文件校驗值與文件名不一致。",
"txt_backup_error_file_required": "請選擇備份文件。",
"txt_backup_error_interval_hours_range": "備份間隔必須在 1 到 99 小時之間。",
"txt_backup_error_multipart_required": "上傳請求必須使用 multipart/form-data。",
"txt_backup_error_read_backup_file_failed": "無法讀取備份文件。",
"txt_backup_error_remote_attachment_batch_download_failed_status": "遠端附件批量下載失敗:HTTP {status}。",
"txt_backup_error_remote_attachment_download_failed_status": "遠端附件下載失敗:HTTP {status}。",
"txt_backup_error_remote_delete_failed": "遠端備份刪除失敗。",
"txt_backup_error_remote_download_failed": "遠端備份下載失敗。",
"txt_backup_error_remote_download_payload_invalid": "遠端備份下載請求無效。",
"txt_backup_error_remote_integrity_failed": "遠端備份完整性檢查失敗。",
"txt_backup_error_remote_listing_failed": "遠端備份列表讀取失敗。",
"txt_backup_error_remote_path_invalid": "遠端備份路徑無效。",
"txt_backup_error_remote_restore_payload_invalid": "遠端還原請求無效。",
"txt_backup_error_remote_zip_checksum_failed": "遠端備份 ZIP 校驗失敗。",
"txt_backup_error_remote_zip_size_failed": "遠端備份 ZIP 大小校驗失敗。",
"txt_backup_error_retention_count_range": "備份保留數量必須在 1 到 1000 之間。",
"txt_backup_error_run_failed": "備份執行失敗。",
"txt_backup_error_run_payload_invalid": "備份執行請求無效。",
"txt_backup_error_run_response_invalid": "備份執行響應無效。",
"txt_backup_error_s3_access_key_required": "請填寫 S3 存取 ID。",
"txt_backup_error_s3_bucket_required": "請填寫 S3 儲存桶名稱。",
"txt_backup_error_s3_delete_failed_status": "S3 刪除失敗:HTTP {status}。",
"txt_backup_error_s3_download_failed_status": "S3 下載失敗:HTTP {status}。",
"txt_backup_error_s3_endpoint_required": "請填寫 S3 端點 URL。",
"txt_backup_error_s3_endpoint_protocol": "S3 端點 URL 必須以 http:// 或 https:// 開頭。",
"txt_backup_error_s3_existence_check_failed_status": "S3 文件存在性檢查失敗:HTTP {status}。",
"txt_backup_error_s3_listing_failed_status": "S3 列表讀取失敗:HTTP {status}。",
"txt_backup_error_s3_secret_key_required": "請填寫 S3 存取密碼。",
"txt_backup_error_s3_upload_failed_status": "S3 上傳失敗:HTTP {status}。",
"txt_backup_error_select_backup_file": "請選擇備份文件。",
"txt_backup_error_select_backup_zip_file": "請選擇備份 ZIP 文件。",
"txt_backup_error_settings_envelope_invalid": "備份設置加密封裝無效。",
"txt_backup_error_settings_invalid": "備份設置無效。",
"txt_backup_error_settings_load_failed": "無法加載備份設置。",
"txt_backup_error_settings_need_reactivation": "還原後需要管理員重新激活備份設置。",
"txt_backup_error_settings_payload_invalid": "備份設置請求無效。",
"txt_backup_error_settings_repair_payload_invalid": "備份設置修復請求無效。",
"txt_backup_error_settings_repair_state_load_failed": "無法加載備份設置修復狀態。",
"txt_backup_error_start_time_format": "備份開始時間必須是 HH:mm 格式。",
"txt_backup_error_timezone_invalid": "備份時區無效。",
"txt_backup_error_webdav_delete_failed_status": "WebDAV 刪除失敗:HTTP {status}。",
"txt_backup_error_webdav_directory_creation_failed_status": "WebDAV 目錄創建失敗:HTTP {status}。",
"txt_backup_error_webdav_download_failed_status": "WebDAV 下載失敗:HTTP {status}。",
"txt_backup_error_webdav_existence_check_failed_status": "WebDAV 文件存在性檢查失敗:HTTP {status}。",
"txt_backup_error_webdav_listing_failed_status": "WebDAV 列表讀取失敗:HTTP {status}。",
"txt_backup_error_webdav_password_required": "請填寫 WebDAV 密碼。",
"txt_backup_error_webdav_path_too_deep": "WebDAV 遠端備份路徑過深,無法安全分批處理附件。",
"txt_backup_error_webdav_upload_failed_status": "WebDAV 上傳失敗:HTTP {status}。",
"txt_backup_error_webdav_url_required": "請填寫 WebDAV 服務地址。",
"txt_backup_error_webdav_url_protocol": "WebDAV 服務地址必須以 http:// 或 https:// 開頭。",
"txt_backup_error_webdav_username_required": "請填寫 WebDAV 用戶名。",
"txt_backup_destination": "備份地點", "txt_backup_destination": "備份地點",
"txt_backup_protocol_webdav": "WebDAV", "txt_backup_protocol_webdav": "WebDAV",
"txt_backup_protocol_s3": "S3", "txt_backup_protocol_s3": "S3",
@@ -479,8 +582,17 @@ const zhTW: Record<string, string> = {
"txt_identity_details": "身份詳情", "txt_identity_details": "身份詳情",
"txt_ie_browser": "IE 瀏覽器", "txt_ie_browser": "IE 瀏覽器",
"txt_create_invite_failed": "創建邀請碼失敗", "txt_create_invite_failed": "創建邀請碼失敗",
"txt_delete_invalid": "刪除無效",
"txt_delete_invalid_invites": "刪除無效邀請碼",
"txt_delete_invalid_invites_confirm_message": "確定刪除所有無效邀請碼嗎?仍有效且未過期的邀請碼會保留。",
"txt_delete_invalid_invites_failed": "刪除無效邀請碼失敗",
"txt_delete_invite": "刪除邀請碼",
"txt_delete_invite_confirm_message": "確定刪除此邀請碼嗎?刪除後無法復原。",
"txt_delete_invite_failed": "刪除邀請碼失敗",
"txt_invite_code_required": "邀請碼(必填)", "txt_invite_code_required": "邀請碼(必填)",
"txt_invite_created": "邀請碼已創建", "txt_invite_created": "邀請碼已創建",
"txt_invite_deleted": "邀請碼已刪除",
"txt_invalid_invites_deleted": "無效邀請碼已刪除",
"txt_invite_revoked": "邀請碼已撤銷", "txt_invite_revoked": "邀請碼已撤銷",
"txt_revoke_invite_failed": "撤銷邀請碼失敗", "txt_revoke_invite_failed": "撤銷邀請碼失敗",
"txt_invite_validity_hours": "邀請碼有效期(小時)", "txt_invite_validity_hours": "邀請碼有效期(小時)",
@@ -489,16 +601,21 @@ const zhTW: Record<string, string> = {
"txt_server_error_account_disabled": "帳號已被禁用", "txt_server_error_account_disabled": "帳號已被禁用",
"txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試", "txt_server_error_client_credentials_incorrect": "客戶端 ID 或客戶端密鑰不正確,請重試",
"txt_server_error_client_ip_required": "無法獲取客戶端 IP", "txt_server_error_client_ip_required": "無法獲取客戶端 IP",
"txt_server_error_forbidden": "你沒有權限執行此操作。",
"txt_server_error_email_already_registered": "該郵箱已註冊", "txt_server_error_email_already_registered": "該郵箱已註冊",
"txt_server_error_email_password_required": "郵箱和密碼不能為空", "txt_server_error_email_password_required": "郵箱和密碼不能為空",
"txt_server_error_email_required": "郵箱不能為空", "txt_server_error_email_required": "郵箱不能為空",
"txt_server_error_invalid_password": "密碼無效。",
"txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入", "txt_server_error_invalid_refresh_token": "登入狀態已失效,請重新登入",
"txt_server_error_invalid_user_verification_token": "用戶驗證令牌無效。",
"txt_server_error_invalid_request_payload": "請求內容無效", "txt_server_error_invalid_request_payload": "請求內容無效",
"txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期", "txt_server_error_invite_invalid_or_expired": "邀請碼無效或已過期",
"txt_server_error_invite_required": "邀請碼不能為空", "txt_server_error_invite_required": "邀請碼不能為空",
"txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續", "txt_server_error_jwt_secret_default": "JWT_SECRET 正在使用默認示例值,請修改後再繼續",
"txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置", "txt_server_error_jwt_secret_missing": "JWT_SECRET 未設置",
"txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符", "txt_server_error_jwt_secret_too_short": "JWT_SECRET 至少需要 32 個字符",
"txt_server_error_master_password_hash_required": "需要驗證主密碼。",
"txt_server_error_master_password_or_verification_required": "需要主密碼或用戶驗證令牌。",
"txt_server_error_parameter_error": "請求參數錯誤", "txt_server_error_parameter_error": "請求參數錯誤",
"txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入", "txt_server_error_refresh_token_required": "登入狀態缺失,請重新登入",
"txt_server_error_registration_retry": "註冊暫時不可用,請重試一次", "txt_server_error_registration_retry": "註冊暫時不可用,請重試一次",
@@ -552,7 +669,7 @@ const zhTW: Record<string, string> = {
"txt_jwt_secret_value_label": "值:", "txt_jwt_secret_value_label": "值:",
"txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符", "txt_jwt_secret_value_requirement": "最低 {min} 位隨機字符",
"txt_jwt_what_is": "JWT 是什麼", "txt_jwt_what_is": "JWT 是什麼",
"txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,或者仍然使用示例值,實例就不能安全地正常使用。", "txt_jwt_what_is_body": "JWT_SECRET 是服務端用來簽發和校驗登錄令牌的密鑰。如果它缺失過短,實例就不能安全地正常使用。",
"txt_how_to_fix": "處理步驟(添加 / 更換)", "txt_how_to_fix": "處理步驟(添加 / 更換)",
"txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。", "txt_jwt_fix_step_1": "你可以繼續下一步,不影響使用。",
"txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。", "txt_jwt_fix_step_2": "如果當前密鑰不是強隨機值,建議使用下方 32 位生成器。",
@@ -730,6 +847,11 @@ const zhTW: Record<string, string> = {
"txt_remove_all_devices": "移除所有設備", "txt_remove_all_devices": "移除所有設備",
"txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?", "txt_remove_all_devices_and_clear_all_2fa_trust": "確認移除所有設備並清除全部 2FA 信任嗎?",
"txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?", "txt_remove_all_devices_and_sign_out_all_sessions": "確認移除所有設備、清除全部信任,並讓所有設備重新登錄嗎?",
"txt_remove_selected_devices": "移除已選({count}",
"txt_remove_selected_devices_confirm": "確認移除選中的 {count} 臺設備、清除其信任,並讓它們重新登錄嗎?",
"txt_remove_selected_devices_and_sign_out_current": "確認移除選中的 {count} 臺設備、清除其信任,並同時退出本設備嗎?",
"txt_selected_devices_removed": "已移除選中設備",
"txt_remove_selected_devices_failed": "移除選中設備失敗",
"txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?", "txt_remove_device_name_and_clear_its_2fa_trust": "確認移除設備“{name}”並清除其 2FA 信任嗎?",
"txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?", "txt_remove_device_and_sign_out_name": "確認移除設備“{name}”,清除其信任,並讓它重新登錄嗎?",
"txt_reveal": "顯示", "txt_reveal": "顯示",
@@ -771,6 +893,9 @@ const zhTW: Record<string, string> = {
"txt_security_code": "安全碼", "txt_security_code": "安全碼",
"txt_security_code_cvv": "安全碼 (CVV)", "txt_security_code_cvv": "安全碼 (CVV)",
"txt_select_all": "全選", "txt_select_all": "全選",
"txt_clear_selection": "取消選擇",
"txt_select_device_name": "選擇 {name}",
"txt_no_devices_selected": "未選擇設備",
"txt_select": "請選擇", "txt_select": "請選擇",
"txt_select_duplicate_items": "選擇重複項", "txt_select_duplicate_items": "選擇重複項",
"txt_select_an_item": "請選擇一個項目", "txt_select_an_item": "請選擇一個項目",
@@ -1040,7 +1165,9 @@ const zhTW: Record<string, string> = {
"txt_log_action_admin_backup_settings_repair": "修復備份設定", "txt_log_action_admin_backup_settings_repair": "修復備份設定",
"txt_log_action_admin_backup_settings_update": "更新備份設定", "txt_log_action_admin_backup_settings_update": "更新備份設定",
"txt_log_action_admin_invite_create": "建立邀請", "txt_log_action_admin_invite_create": "建立邀請",
"txt_log_action_admin_invite_delete": "刪除邀請",
"txt_log_action_admin_invite_delete_all": "清空邀請", "txt_log_action_admin_invite_delete_all": "清空邀請",
"txt_log_action_admin_invite_delete_invalid": "刪除無效邀請",
"txt_log_action_admin_invite_revoke": "撤銷邀請", "txt_log_action_admin_invite_revoke": "撤銷邀請",
"txt_log_action_admin_user_delete": "刪除使用者", "txt_log_action_admin_user_delete": "刪除使用者",
"txt_log_action_admin_user_status": "修改使用者狀態", "txt_log_action_admin_user_status": "修改使用者狀態",
+135 -13
View File
@@ -1,6 +1,114 @@
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, processKvp, txt, val } from '@/lib/import-format-shared'; import { addFolder, cardBrand, makeLoginCipher, nameFromUrl, normalizeUri, parseCsv, parseSerializedUris, processKvp, txt, val } from '@/lib/import-format-shared';
type BitwardenCsvFieldLine = {
key: string;
value: string;
};
const NODEWARDEN_CSV_TYPE_FIELD = 'nodewardenType';
const NODEWARDEN_CSV_PREFIX_TYPES: Record<string, number> = {
card: 3,
identity: 4,
sshkey: 5,
};
const NODEWARDEN_CSV_TYPE_PREFIXES: Record<number, 'card' | 'identity' | 'sshKey'> = {
3: 'card',
4: 'identity',
5: 'sshKey',
};
const NODEWARDEN_CSV_OBJECT_FIELDS: Record<'card' | 'identity' | 'sshKey', readonly string[]> = {
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
identity: [
'title',
'firstName',
'middleName',
'lastName',
'username',
'company',
'ssn',
'passportNumber',
'licenseNumber',
'email',
'phone',
'address1',
'address2',
'address3',
'city',
'state',
'postalCode',
'country',
],
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
};
// Parse the `fields` CSV column into key-value pairs.
// Lines without a `: ` delimiter are treated as continuations of the previous
// line's value, preserving multiline content such as SSH private keys.
function parseBitwardenCsvFieldLines(rawFields: unknown): BitwardenCsvFieldLine[] {
return String(rawFields || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reduce<BitwardenCsvFieldLine[]>((acc, line) => {
const delim = line.lastIndexOf(': ');
if (delim < 0) {
// Continuation line — append to the previous entry's value.
if (acc.length > 0) {
acc[acc.length - 1].value += '\n' + line;
}
return acc;
}
// New key-value line.
const key = txt(line.slice(0, delim));
const value = txt(line.slice(delim + 2));
if (key && value) {
acc.push({ key, value });
}
return acc;
}, []);
}
function getNodeWardenCsvType(lines: BitwardenCsvFieldLine[]): number | null {
const typeLine = lines.find((line) => line.key === NODEWARDEN_CSV_TYPE_FIELD);
const normalized = txt(typeLine?.value).toLowerCase().replace(/[\s_-]+/g, '');
const type = NODEWARDEN_CSV_PREFIX_TYPES[normalized] ?? null;
if (!type) return null;
const prefix = NODEWARDEN_CSV_TYPE_PREFIXES[type];
return lines.some((line) => line.key.startsWith(`${prefix}.`)) ? type : null;
}
function applyBitwardenCustomFields(cipher: Record<string, unknown>, lines: BitwardenCsvFieldLine[]): void {
for (const line of lines) {
processKvp(cipher, line.key, line.value, false);
}
}
function restoreNodeWardenObject(lines: BitwardenCsvFieldLine[], prefix: 'card' | 'identity' | 'sshKey'): Record<string, unknown> {
const out: Record<string, unknown> = {};
const fieldPrefix = `${prefix}.`;
const allowedKeys = new Set(NODEWARDEN_CSV_OBJECT_FIELDS[prefix]);
for (const line of lines) {
if (!line.key.startsWith(fieldPrefix)) continue;
const key = line.key.slice(fieldPrefix.length);
if (!allowedKeys.has(key)) continue;
out[key] = line.value;
}
return out;
}
function nodeWardenMetadataLines(lines: BitwardenCsvFieldLine[]): Set<BitwardenCsvFieldLine> {
return new Set(
lines.filter(
(line) =>
line.key === NODEWARDEN_CSV_TYPE_FIELD ||
line.key.startsWith('card.') ||
line.key.startsWith('identity.') ||
line.key.startsWith('sshKey.')
)
);
}
export function parseChromeCsv(textRaw: string): CiphersImportPayload { export function parseChromeCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw); const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
@@ -62,19 +170,33 @@ export function parseSafariCsv(textRaw: string): CiphersImportPayload {
export function parseBitwardenCsv(textRaw: string): CiphersImportPayload { export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
const rows = parseCsv(textRaw); const rows = parseCsv(textRaw);
const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] }; const result: CiphersImportPayload = { ciphers: [], folders: [], folderRelationships: [] };
const applyBitwardenCustomFields = (cipher: Record<string, unknown>, rawFields: unknown) => {
const lines = String(rawFields || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const delim = line.lastIndexOf(': ');
if (delim < 0) continue;
processKvp(cipher, line.slice(0, delim), line.slice(delim + 2), false);
}
};
for (const row of rows) { for (const row of rows) {
const type = txt(row.type).toLowerCase() || 'login'; const type = txt(row.type).toLowerCase() || 'login';
const fieldLines = parseBitwardenCsvFieldLines(row.fields);
const restoredNodeWardenType = type === 'note' ? getNodeWardenCsvType(fieldLines) : null;
if (restoredNodeWardenType === 3 || restoredNodeWardenType === 4 || restoredNodeWardenType === 5) {
const metadataLines = nodeWardenMetadataLines(fieldLines);
const customLines = fieldLines.filter((line) => !metadataLines.has(line));
const cipher: Record<string, unknown> = {
type: restoredNodeWardenType,
name: val(row.name, '--'),
notes: val(row.notes),
favorite: txt(row.favorite) === '1',
reprompt: Number(row.reprompt ?? 0) || 0,
key: null,
login: null,
card: restoredNodeWardenType === 3 ? restoreNodeWardenObject(fieldLines, 'card') : null,
identity: restoredNodeWardenType === 4 ? restoreNodeWardenObject(fieldLines, 'identity') : null,
secureNote: null,
fields: [],
passwordHistory: null,
sshKey: restoredNodeWardenType === 5 ? restoreNodeWardenObject(fieldLines, 'sshKey') : null,
};
applyBitwardenCustomFields(cipher, customLines);
const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx);
continue;
}
if (type === 'note' || type === 'secure note' || type === 'securenote') { if (type === 'note' || type === 'secure note' || type === 'securenote') {
const cipher = { const cipher = {
type: 2, type: 2,
@@ -91,7 +213,7 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
passwordHistory: null, passwordHistory: null,
sshKey: null, sshKey: null,
}; };
applyBitwardenCustomFields(cipher, row.fields); applyBitwardenCustomFields(cipher, fieldLines);
const idx = result.ciphers.push(cipher) - 1; const idx = result.ciphers.push(cipher) - 1;
addFolder(result, row.folder, idx); addFolder(result, row.folder, idx);
continue; continue;
@@ -101,7 +223,7 @@ export function parseBitwardenCsv(textRaw: string): CiphersImportPayload {
cipher.notes = val(row.notes); cipher.notes = val(row.notes);
cipher.favorite = txt(row.favorite) === '1'; cipher.favorite = txt(row.favorite) === '1';
cipher.reprompt = Number(row.reprompt ?? 0) || 0; cipher.reprompt = Number(row.reprompt ?? 0) || 0;
applyBitwardenCustomFields(cipher, row.fields); applyBitwardenCustomFields(cipher, fieldLines);
const login = cipher.login as Record<string, unknown>; const login = cipher.login as Record<string, unknown>;
login.username = val(row.login_username, val(row.username)); login.username = val(row.login_username, val(row.username));
login.password = val(row.login_password, val(row.password)); login.password = val(row.login_password, val(row.password));
+3 -1
View File
@@ -290,7 +290,7 @@ export interface ListResponse<T> {
export interface WebBootstrapResponse { export interface WebBootstrapResponse {
defaultKdfIterations?: number; defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
registrationInviteRequired?: boolean; registrationInviteRequired?: boolean;
} }
@@ -314,6 +314,8 @@ export interface TokenSuccess {
ResetMasterPassword?: boolean; ResetMasterPassword?: boolean;
scope?: string; scope?: string;
unofficialServer?: boolean; unofficialServer?: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
UserDecryptionOptions?: unknown; UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown; userDecryptionOptions?: unknown;
VaultKeys?: { VaultKeys?: {
-20
View File
@@ -14,7 +14,6 @@
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */ /* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
/* ── surface consistency ── */ /* ── surface consistency ── */
.app-shell,
.auth-card, .auth-card,
.dialog-card, .dialog-card,
.card, .card,
@@ -36,12 +35,6 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.app-shell {
background: var(--panel-soft);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.topbar, .topbar,
.mobile-tabbar, .mobile-tabbar,
.app-side { .app-side {
@@ -104,7 +97,6 @@
} }
/* ── dark mode surface resets ── */ /* ── dark mode surface resets ── */
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card, :root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog-card, :root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .card, :root[data-theme='dark'] .card,
@@ -259,17 +251,6 @@ h4 {
letter-spacing: 0; letter-spacing: 0;
} }
.app-page {
padding: 18px;
}
.app-shell {
border-radius: var(--radius-xl);
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow-lg);
}
.topbar { .topbar {
height: 56px; height: 56px;
padding-inline: 16px; padding-inline: 16px;
@@ -916,7 +897,6 @@ textarea {
background: var(--bg-accent); background: var(--bg-accent);
} }
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .topbar, :root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .app-side, :root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .mobile-tabbar, :root[data-theme='dark'] .mobile-tabbar,
+14 -4
View File
@@ -200,10 +200,6 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
} }
:root[data-theme='dark'] .app-shell {
box-shadow: 0 4px 40px rgba(0, 0, 0, 0.30);
}
:root[data-theme='dark'] .list-item:hover { :root[data-theme='dark'] .list-item:hover {
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
} }
@@ -340,6 +336,7 @@
:root[data-theme='dark'] .backup-recommendation-step, :root[data-theme='dark'] .backup-recommendation-step,
:root[data-theme='dark'] .backup-recommendation-inline-note, :root[data-theme='dark'] .backup-recommendation-inline-note,
:root[data-theme='dark'] .backup-recommendation-linked-item, :root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-browser-head,
:root[data-theme='dark'] .backup-browser-meta, :root[data-theme='dark'] .backup-browser-meta,
:root[data-theme='dark'] .backup-browser-empty, :root[data-theme='dark'] .backup-browser-empty,
:root[data-theme='dark'] .backup-inline-note, :root[data-theme='dark'] .backup-inline-note,
@@ -351,6 +348,19 @@
color: var(--muted); color: var(--muted);
} }
:root[data-theme='dark'] .backup-recommendation-step a {
color: var(--primary);
}
:root[data-theme='dark'] .backup-recommendation-step a:hover,
:root[data-theme='dark'] .backup-recommendation-step a:focus-visible {
color: var(--primary-strong);
}
:root[data-theme='dark'] .backup-browser-head {
background: var(--panel-subtle);
}
:root[data-theme='dark'] .restore-progress-overlay { :root[data-theme='dark'] .restore-progress-overlay {
background: var(--overlay-strong); background: var(--overlay-strong);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
+4
View File
@@ -172,6 +172,10 @@ input[type='file'].input::file-selector-button:hover {
@apply shrink-0; @apply shrink-0;
} }
.btn-icon-spin {
animation: spin 0.9s linear infinite;
}
.btn.full { .btn.full {
@apply my-2.5 h-12 w-full; @apply my-2.5 h-12 w-full;
font-size: var(--font-md); font-size: var(--font-md);
+58 -7
View File
@@ -193,6 +193,18 @@
line-height: 1.5; line-height: 1.5;
} }
.backup-recommendation-step a {
color: #1d4ed8;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.backup-recommendation-step a:hover,
.backup-recommendation-step a:focus-visible {
color: #1742b0;
}
.backup-recommendation-inline-note { .backup-recommendation-inline-note {
color: #475467; color: #475467;
line-height: 1.5; line-height: 1.5;
@@ -351,16 +363,34 @@
} }
.backup-browser-nav { .backup-browser-nav {
@apply mb-2.5; @apply mb-2.5 flex items-center justify-between gap-2;
}
.backup-browser-nav-left {
@apply min-w-0;
} }
.backup-browser-list { .backup-browser-list {
@apply overflow-hidden rounded-xl border bg-white; @apply overflow-hidden rounded-lg border bg-white;
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.backup-browser-head {
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
border-bottom: 1px solid var(--line);
color: #64748b;
background: #f8fafc;
}
.backup-browser-head span:nth-child(2),
.backup-browser-head span:nth-child(3),
.backup-browser-head span:nth-child(4) {
text-align: right;
}
.backup-browser-pagination { .backup-browser-pagination {
@apply mt-2.5 flex items-center justify-end gap-2.5; @apply mt-2.5 flex items-center justify-center gap-2.5;
} }
.backup-browser-page-indicator { .backup-browser-page-indicator {
@@ -373,13 +403,15 @@
} }
.backup-browser-row { .backup-browser-row {
@apply grid items-center gap-2.5 px-3 py-2.5; @apply grid items-center gap-3 px-3 py-2;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
min-height: 48px;
} }
.backup-browser-entry { .backup-browser-entry {
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left; @apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
color: #0f172a; color: #0f172a;
min-width: 0;
} }
.backup-browser-entry.file { .backup-browser-entry.file {
@@ -392,12 +424,17 @@
} }
.backup-browser-meta { .backup-browser-meta {
@apply grid justify-items-end gap-1 text-right text-[13px]; @apply block text-right text-[13px];
color: #64748b; color: #64748b;
} }
.backup-browser-size {
font-variant-numeric: tabular-nums;
}
.backup-browser-actions { .backup-browser-actions {
justify-content: flex-end; justify-content: flex-end;
flex-wrap: nowrap;
} }
.backup-browser-empty { .backup-browser-empty {
@@ -406,6 +443,10 @@
color: #64748b; color: #64748b;
} }
.backup-browser-refresh-prompt {
@apply inline-flex flex-wrap items-center justify-center gap-2;
}
.backup-inline-note { .backup-inline-note {
@apply m-0 mb-3 leading-[1.5]; @apply m-0 mb-3 leading-[1.5];
color: #64748b; color: #64748b;
@@ -1479,8 +1520,12 @@
table-layout: fixed; table-layout: fixed;
} }
.authorized-devices-col-select {
width: 4%;
}
.authorized-devices-col-device { .authorized-devices-col-device {
width: 28%; width: 26%;
} }
.authorized-devices-col-type { .authorized-devices-col-type {
@@ -1503,6 +1548,12 @@
width: 26%; width: 26%;
} }
.authorized-device-checkbox {
width: 16px;
height: 16px;
accent-color: #2563eb;
}
.authorized-devices-table td:first-child { .authorized-devices-table td:first-child {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
+36
View File
@@ -46,6 +46,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-browser-head {
display: none;
}
.backup-browser-meta {
text-align: left;
}
.backup-browser-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.backup-browser-nav {
align-items: flex-start;
flex-direction: column;
}
.settings-twofactor-grid { .settings-twofactor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1110,6 +1128,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-browser-head {
display: none;
}
.backup-browser-meta {
text-align: left;
}
.backup-browser-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.backup-browser-nav {
align-items: flex-start;
flex-direction: column;
}
.backup-grid { .backup-grid {
gap: 8px; gap: 8px;
padding: 0; padding: 0;
+4 -10
View File
@@ -1,17 +1,11 @@
.app-page { .app-page {
@apply relative min-h-full bg-transparent p-5; @apply relative min-h-full bg-transparent;
} }
.app-shell { .app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft; @apply relative flex flex-col;
height: calc(100vh - 40px); height: 100vh;
border-color: var(--line); background: var(--bg-accent);
@apply rounded-3xl;
box-shadow:
0 20px 60px rgba(15, 23, 42, 0.12),
0 8px 24px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(15, 23, 42, 0.04);
transition: box-shadow var(--dur-medium) var(--ease-smooth);
} }
.topbar { .topbar {