mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Refactor JWT_SECRET handling and add setup warning page
This commit is contained in:
+3
-1
@@ -1,3 +1,5 @@
|
|||||||
# JWT Secret for signing tokens (required)
|
# JWT Secret for signing tokens (required)
|
||||||
|
# IMPORTANT: change this value before any real deployment.
|
||||||
# Generate one with: openssl rand -hex 32
|
# Generate one with: openssl rand -hex 32
|
||||||
JWT_SECRET=your-secret-key-herexxs22fd2ds
|
# (Example only, 64 hex chars = 32 bytes)
|
||||||
|
JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# NodeWarden
|
# NodeWarden
|
||||||
中文文档:[`README_ZH.md`](./README_ZH.md)
|
中文文档:[`README_ZH.md`](./README_ZH.md)
|
||||||
|
|
||||||
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use.
|
A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
||||||
|
|
||||||
- Simple deploy (no VPS)
|
- Simple deploy (no VPS)
|
||||||
- Focused feature set
|
- Focused feature set
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# NodeWarden
|
# NodeWarden
|
||||||
English:[`README.md`](./README.md)
|
English:[`README.md`](./README.md)
|
||||||
|
|
||||||
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景。
|
一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。
|
||||||
|
|
||||||
- 部署简单(不需要 VPS)
|
- 部署简单(不需要 VPS)
|
||||||
- 功能聚焦
|
- 功能聚焦
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "wrangler dev -c wrangler.dev.toml",
|
"dev": "wrangler dev -c wrangler.toml",
|
||||||
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
"deploymy": "wrangler deploy -c wrangler.my.toml",
|
||||||
"deploy": "wrangler deploy "
|
"deploy": "wrangler deploy "
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,32 @@
|
|||||||
import { Env, User, ProfileResponse } from '../types';
|
import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { AuthService } from '../services/auth';
|
import { AuthService } from '../services/auth';
|
||||||
import { jsonResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, errorResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
|
|
||||||
|
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret) return 'missing';
|
||||||
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||||
|
if (secret.length < 32) return 'too_short';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/accounts/register (only used from setup page, not client)
|
// POST /api/accounts/register (only used from setup page, not client)
|
||||||
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
export async function handleRegister(request: Request, env: Env): Promise<Response> {
|
||||||
const storage = new StorageService(env.VAULT);
|
const storage = new StorageService(env.VAULT);
|
||||||
|
|
||||||
|
// Enforce safe JWT_SECRET before allowing first registration.
|
||||||
|
const unsafe = jwtSecretUnsafeReason(env);
|
||||||
|
if (unsafe) {
|
||||||
|
const message = unsafe === 'missing'
|
||||||
|
? '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';
|
||||||
|
return errorResponse(message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already registered
|
// Check if already registered
|
||||||
const isRegistered = await storage.isRegistered();
|
const isRegistered = await storage.isRegistered();
|
||||||
if (isRegistered) {
|
if (isRegistered) {
|
||||||
|
|||||||
+19
-763
@@ -1,769 +1,17 @@
|
|||||||
import { Env } from '../types';
|
import { Env, DEFAULT_DEV_SECRET } from '../types';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
import { jsonResponse, htmlResponse, errorResponse } from '../utils/response';
|
||||||
|
import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages';
|
||||||
|
import { handleRegisterPage } from './setupRegisterPage';
|
||||||
|
|
||||||
// Setup page HTML (single-file, no external assets)
|
function getJwtSecretState(env: Env): JwtSecretState | null {
|
||||||
const setupPageHTML = `<!DOCTYPE html>
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
<html lang="en">
|
if (!secret) return 'missing';
|
||||||
<head>
|
// Block common "forgot to change" sample value (matches .dev.vars.example)
|
||||||
<meta charset="UTF-8">
|
if (secret === DEFAULT_DEV_SECRET) return 'default';
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
if (secret.length < 32) return 'too_short';
|
||||||
<title>NodeWarden</title>
|
return null;
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--bg0: #0b0b0f;
|
|
||||||
--bg1: #0f1020;
|
|
||||||
--card: rgba(255, 255, 255, 0.08);
|
|
||||||
--card2: rgba(255, 255, 255, 0.06);
|
|
||||||
--border: rgba(255, 255, 255, 0.14);
|
|
||||||
--text: rgba(255, 255, 255, 0.92);
|
|
||||||
--muted: rgba(255, 255, 255, 0.62);
|
|
||||||
--muted2: rgba(255, 255, 255, 0.52);
|
|
||||||
--accent: #0a84ff;
|
|
||||||
--accent2: #64d2ff;
|
|
||||||
--danger: #ff453a;
|
|
||||||
--ok: #32d74b;
|
|
||||||
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
|
||||||
--radius: 18px;
|
|
||||||
--radius2: 14px;
|
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
||||||
background:
|
|
||||||
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
|
||||||
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
|
||||||
linear-gradient(180deg, var(--bg0), var(--bg1));
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
.shell {
|
|
||||||
|
|
||||||
width: max(500px);
|
|
||||||
}
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.shell { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.hero {
|
|
||||||
padding: 26px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.06));
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.hero::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: -2px;
|
|
||||||
background: radial-gradient(500px 240px at 20% 0%, rgba(100, 210, 255, 0.18), transparent 60%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.top {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.mark {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
|
||||||
border: 1px solid rgba(255,255,255,0.20);
|
|
||||||
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
text-transform: uppercase;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.title h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
margin: 0;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
.title p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
padding: 22px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
-webkit-backdrop-filter: blur(16px);
|
|
||||||
}
|
|
||||||
.panel h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
margin: 0 0 14px 0;
|
|
||||||
letter-spacing: -0.2px;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
display: none;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px 12px;
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.45;
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
.message.error {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(255, 69, 58, 0.40);
|
|
||||||
background: rgba(255, 69, 58, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.message.success {
|
|
||||||
display: block;
|
|
||||||
border-color: rgba(50, 215, 75, 0.35);
|
|
||||||
background: rgba(50, 215, 75, 0.10);
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
@media (max-width: 540px) {
|
|
||||||
.grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
height: 42px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: rgba(0,0,0,0.18);
|
|
||||||
color: rgba(255,255,255,0.92);
|
|
||||||
outline: none;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
|
||||||
}
|
|
||||||
input::placeholder { color: rgba(255,255,255,0.35); }
|
|
||||||
input:focus {
|
|
||||||
border-color: rgba(10, 132, 255, 0.55);
|
|
||||||
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted2);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.actions {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.primary {
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(255,255,255,0.18);
|
|
||||||
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
|
||||||
color: rgba(255,255,255,0.96);
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 120ms ease, filter 120ms ease;
|
|
||||||
}
|
|
||||||
.primary:hover { filter: brightness(1.03); }
|
|
||||||
.primary:active { transform: translateY(1px) scale(0.99); }
|
|
||||||
.primary:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
.sideCard {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.kv {
|
|
||||||
border-radius: var(--radius2);
|
|
||||||
border: 1px solid rgba(255,255,255,0.14);
|
|
||||||
background: rgba(255,255,255,0.05);
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.kv h3 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: rgba(255,255,255,0.86);
|
|
||||||
}
|
|
||||||
.kv p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.server {
|
|
||||||
margin-top: 10px;
|
|
||||||
font-family: var(--mono);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0,0,0,0.25);
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
|
||||||
word-break: break-all;
|
|
||||||
color: rgba(255,255,255,0.90);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: rgba(100, 210, 255, 0.92);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
.footer {
|
|
||||||
margin-top: 18px;
|
|
||||||
padding-top: 14px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.10);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255,255,255,0.55);
|
|
||||||
}
|
|
||||||
.muted { color: var(--muted); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="shell">
|
|
||||||
<aside class="panel">
|
|
||||||
<div class="top">
|
|
||||||
<div class="mark" aria-label="NodeWarden">NW</div>
|
|
||||||
<div class="title">
|
|
||||||
<h1 id="t_app">NodeWarden</h1>
|
|
||||||
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 12px"></div>
|
|
||||||
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
|
||||||
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 14px"></div>
|
|
||||||
<h2 id="t_setup">初始化</h2>
|
|
||||||
|
|
||||||
<div id="message" class="message"></div>
|
|
||||||
|
|
||||||
<div id="setup-form">
|
|
||||||
<form id="form" onsubmit="handleSubmit(event)">
|
|
||||||
<div class="grid">
|
|
||||||
<div class="field">
|
|
||||||
<label for="name" id="t_name_label">Name</label>
|
|
||||||
<input type="text" id="name" name="name" required placeholder="Your name">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="email" id="t_email_label">Email</label>
|
|
||||||
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="password" id="t_pw_label">Master password</label>
|
|
||||||
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
|
||||||
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="height: 10px"></div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
|
||||||
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="registered-view" class="sideCard" style="display: none;">
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_done_title">Setup complete</h3>
|
|
||||||
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
|
||||||
<div class="server" id="serverUrl"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_important">Important</h3>
|
|
||||||
<p id="t_limitations">
|
|
||||||
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
|
||||||
If you forget it, you must redeploy and register again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kv">
|
|
||||||
<h3 id="t_hide_title">Hide setup page</h3>
|
|
||||||
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="footer">
|
|
||||||
<div>
|
|
||||||
<span class="muted" id="t_by">By</span>
|
|
||||||
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const AUTHOR = { name: 'shuaiplus', website: 'https://shuai.plus', github: 'https://github.com/shuaiplus/nodewarden' };
|
|
||||||
let isRegistered = false;
|
|
||||||
|
|
||||||
function isChinese() {
|
|
||||||
const lang = (navigator.language || '').toLowerCase();
|
|
||||||
return lang.startsWith('zh');
|
|
||||||
}
|
|
||||||
|
|
||||||
function t(key) {
|
|
||||||
const zh = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端(个人使用)。',
|
|
||||||
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
|
|
||||||
by: '作者',
|
|
||||||
setup: '初始化',
|
|
||||||
nameLabel: '昵称',
|
|
||||||
emailLabel: '邮箱',
|
|
||||||
pwLabel: '主密码',
|
|
||||||
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
|
||||||
pw2Label: '确认主密码',
|
|
||||||
create: '创建账号',
|
|
||||||
creating: '正在创建…',
|
|
||||||
doneTitle: '初始化完成',
|
|
||||||
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
|
||||||
important: '重要提示',
|
|
||||||
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
|
||||||
hideTitle: '隐藏初始化页',
|
|
||||||
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
|
|
||||||
hideBtn: '隐藏初始化页',
|
|
||||||
hideWorking: '正在隐藏…',
|
|
||||||
hideDone: '已隐藏,此页面将返回 404。',
|
|
||||||
hideFailed: '隐藏失败',
|
|
||||||
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
|
|
||||||
errPwNotMatch: '两次输入的密码不一致',
|
|
||||||
errPwTooShort: '密码长度至少 12 位',
|
|
||||||
errGeneric: '发生错误:',
|
|
||||||
errRegisterFailed: '注册失败',
|
|
||||||
};
|
|
||||||
const en = {
|
|
||||||
app: 'NodeWarden',
|
|
||||||
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers (personal use).',
|
|
||||||
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
|
|
||||||
by: 'By',
|
|
||||||
setup: 'Setup',
|
|
||||||
nameLabel: 'Name',
|
|
||||||
emailLabel: 'Email',
|
|
||||||
pwLabel: 'Master password',
|
|
||||||
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
|
||||||
pw2Label: 'Confirm password',
|
|
||||||
create: 'Create account',
|
|
||||||
creating: 'Creating…',
|
|
||||||
doneTitle: 'Setup complete',
|
|
||||||
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
|
|
||||||
important: 'Important',
|
|
||||||
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
|
|
||||||
hideTitle: 'Hide setup page',
|
|
||||||
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
|
|
||||||
hideBtn: 'Hide setup page',
|
|
||||||
hideWorking: 'Hiding…',
|
|
||||||
hideDone: 'Hidden. This page will now return 404.',
|
|
||||||
hideFailed: 'Failed to hide setup page',
|
|
||||||
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
|
|
||||||
errPwNotMatch: 'Passwords do not match',
|
|
||||||
errPwTooShort: 'Password must be at least 12 characters',
|
|
||||||
errGeneric: 'An error occurred: ',
|
|
||||||
errRegisterFailed: 'Registration failed',
|
|
||||||
};
|
|
||||||
return (isChinese() ? zh : en)[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyI18n() {
|
|
||||||
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
|
||||||
|
|
||||||
document.getElementById('t_app').textContent = t('app');
|
|
||||||
document.getElementById('t_tag').textContent = t('tag');
|
|
||||||
document.getElementById('t_intro').textContent = t('intro');
|
|
||||||
document.getElementById('t_by').textContent = t('by');
|
|
||||||
document.getElementById('t_setup').textContent = t('setup');
|
|
||||||
|
|
||||||
document.getElementById('t_name_label').textContent = t('nameLabel');
|
|
||||||
document.getElementById('t_email_label').textContent = t('emailLabel');
|
|
||||||
document.getElementById('t_pw_label').textContent = t('pwLabel');
|
|
||||||
document.getElementById('t_pw_hint').textContent = t('pwHint');
|
|
||||||
document.getElementById('t_pw2_label').textContent = t('pw2Label');
|
|
||||||
document.getElementById('submitBtn').textContent = t('create');
|
|
||||||
|
|
||||||
document.getElementById('t_done_title').textContent = t('doneTitle');
|
|
||||||
document.getElementById('t_done_desc').textContent = t('doneDesc');
|
|
||||||
document.getElementById('t_important').textContent = t('important');
|
|
||||||
document.getElementById('t_limitations').textContent = t('limitations');
|
|
||||||
document.getElementById('t_hide_title').textContent = t('hideTitle');
|
|
||||||
document.getElementById('t_hide_desc').textContent = t('hideDesc');
|
|
||||||
document.getElementById('hideBtn').textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if already registered
|
|
||||||
async function checkStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/status');
|
|
||||||
const data = await res.json();
|
|
||||||
isRegistered = !!data.registered;
|
|
||||||
if (data.registered) {
|
|
||||||
showRegisteredView();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to check status:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRegisteredView() {
|
|
||||||
isRegistered = true;
|
|
||||||
document.getElementById('setup-form').style.display = 'none';
|
|
||||||
document.getElementById('registered-view').style.display = 'block';
|
|
||||||
document.getElementById('serverUrl').textContent = window.location.origin;
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
const form = document.getElementById('form');
|
|
||||||
if (form) {
|
|
||||||
const fields = form.querySelectorAll('input, button');
|
|
||||||
fields.forEach((el) => {
|
|
||||||
el.disabled = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableSetupPage() {
|
|
||||||
if (!isRegistered) return;
|
|
||||||
if (!confirm(t('hideConfirm'))) return;
|
|
||||||
|
|
||||||
const btn = document.getElementById('hideBtn');
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('hideWorking');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/setup/disable', { method: 'POST' });
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
showMessage(t('hideDone'), 'success');
|
|
||||||
setTimeout(() => window.location.reload(), 600);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showMessage(data.error || t('hideFailed'), 'error');
|
|
||||||
} catch (e) {
|
|
||||||
showMessage(t('hideFailed'), 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (btn) {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('hideBtn');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showMessage(text, type) {
|
|
||||||
const msg = document.getElementById('message');
|
|
||||||
msg.textContent = text;
|
|
||||||
msg.className = 'message ' + type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PBKDF2-SHA256 key derivation (compatible with Bitwarden)
|
|
||||||
// password can be string or Uint8Array
|
|
||||||
async function pbkdf2(password, salt, iterations, keyLen) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
|
|
||||||
// Handle password as string or Uint8Array
|
|
||||||
const passwordBytes = (password instanceof Uint8Array)
|
|
||||||
? password
|
|
||||||
: encoder.encode(password);
|
|
||||||
|
|
||||||
// Handle salt as string or Uint8Array
|
|
||||||
const saltBytes = (salt instanceof Uint8Array)
|
|
||||||
? salt
|
|
||||||
: encoder.encode(salt);
|
|
||||||
|
|
||||||
const keyMaterial = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
passwordBytes,
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveBits']
|
|
||||||
);
|
|
||||||
|
|
||||||
const derivedBits = await crypto.subtle.deriveBits(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: saltBytes,
|
|
||||||
iterations: iterations,
|
|
||||||
hash: 'SHA-256'
|
|
||||||
},
|
|
||||||
keyMaterial,
|
|
||||||
keyLen * 8
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(derivedBits);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HKDF expand
|
|
||||||
async function hkdfExpand(prk, info, length) {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const key = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
prk,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const infoBytes = encoder.encode(info);
|
|
||||||
const result = new Uint8Array(length);
|
|
||||||
let prev = new Uint8Array(0);
|
|
||||||
let offset = 0;
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (offset < length) {
|
|
||||||
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
|
||||||
input.set(prev);
|
|
||||||
input.set(infoBytes, prev.length);
|
|
||||||
input[input.length - 1] = counter;
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', key, input);
|
|
||||||
prev = new Uint8Array(signature);
|
|
||||||
|
|
||||||
const toCopy = Math.min(prev.length, length - offset);
|
|
||||||
result.set(prev.slice(0, toCopy), offset);
|
|
||||||
offset += toCopy;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate symmetric key
|
|
||||||
function generateSymmetricKey() {
|
|
||||||
return crypto.getRandomValues(new Uint8Array(64));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt with AES-256-CBC
|
|
||||||
async function encryptAesCbc(data, key, iv) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'AES-CBC' },
|
|
||||||
false,
|
|
||||||
['encrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
const encrypted = await crypto.subtle.encrypt(
|
|
||||||
{ name: 'AES-CBC', iv: iv },
|
|
||||||
cryptoKey,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Uint8Array(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// HMAC-SHA256
|
|
||||||
async function hmacSha256(key, data) {
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
key,
|
|
||||||
{ name: 'HMAC', hash: 'SHA-256' },
|
|
||||||
false,
|
|
||||||
['sign']
|
|
||||||
);
|
|
||||||
|
|
||||||
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
|
||||||
return new Uint8Array(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base64 encode
|
|
||||||
function base64Encode(bytes) {
|
|
||||||
return btoa(String.fromCharCode.apply(null, bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create encrypted string in Bitwarden format
|
|
||||||
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
|
||||||
const encrypted = await encryptAesCbc(data, encKey, iv);
|
|
||||||
|
|
||||||
// Calculate MAC over IV + encrypted data
|
|
||||||
const macData = new Uint8Array(iv.length + encrypted.length);
|
|
||||||
macData.set(iv);
|
|
||||||
macData.set(encrypted, iv.length);
|
|
||||||
const mac = await hmacSha256(macKey, macData);
|
|
||||||
|
|
||||||
// Format: 2.{base64(iv)}|{base64(encrypted)}|{base64(mac)}
|
|
||||||
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate RSA key pair
|
|
||||||
async function generateRsaKeyPair() {
|
|
||||||
const keyPair = await crypto.subtle.generateKey(
|
|
||||||
{
|
|
||||||
name: 'RSA-OAEP',
|
|
||||||
modulusLength: 2048,
|
|
||||||
publicExponent: new Uint8Array([1, 0, 1]),
|
|
||||||
hash: 'SHA-1'
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export public key
|
|
||||||
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
|
||||||
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
|
||||||
|
|
||||||
// Export private key
|
|
||||||
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
|
||||||
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
|
||||||
|
|
||||||
return {
|
|
||||||
publicKey: publicKeyB64,
|
|
||||||
privateKey: privateKeyBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (isRegistered) {
|
|
||||||
showMessage(t('doneTitle'), 'success');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = document.getElementById('name').value;
|
|
||||||
const email = document.getElementById('email').value.toLowerCase();
|
|
||||||
const password = document.getElementById('password').value;
|
|
||||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
showMessage(t('errPwNotMatch'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password.length < 12) {
|
|
||||||
showMessage(t('errPwTooShort'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = t('creating');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Generate master key using PBKDF2 (Bitwarden default: 600000 iterations)
|
|
||||||
const iterations = 600000;
|
|
||||||
const masterKey = await pbkdf2(password, email, iterations, 32);
|
|
||||||
|
|
||||||
// Generate master password hash (for authentication)
|
|
||||||
// Bitwarden: PBKDF2(masterKey as raw bytes, password, 1 iteration)
|
|
||||||
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
|
||||||
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
|
||||||
|
|
||||||
// Stretch master key using HKDF
|
|
||||||
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
|
||||||
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
|
||||||
|
|
||||||
// Generate symmetric key (will be encrypted with stretched master key)
|
|
||||||
const symmetricKey = generateSymmetricKey();
|
|
||||||
|
|
||||||
// Encrypt symmetric key with stretched master key
|
|
||||||
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
|
||||||
|
|
||||||
// Generate RSA key pair
|
|
||||||
const rsaKeys = await generateRsaKeyPair();
|
|
||||||
|
|
||||||
// Encrypt private key with symmetric key
|
|
||||||
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
|
||||||
|
|
||||||
// Register with server
|
|
||||||
const response = await fetch('/api/accounts/register', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: email,
|
|
||||||
name: name,
|
|
||||||
masterPasswordHash: masterPasswordHashB64,
|
|
||||||
key: encryptedKey,
|
|
||||||
kdf: 0,
|
|
||||||
kdfIterations: iterations,
|
|
||||||
keys: {
|
|
||||||
publicKey: rsaKeys.publicKey,
|
|
||||||
encryptedPrivateKey: encryptedPrivateKey
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
showRegisteredView();
|
|
||||||
} else {
|
|
||||||
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Registration error:', error);
|
|
||||||
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('create');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status on page load
|
|
||||||
applyI18n();
|
|
||||||
checkStatus();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
// GET / - Setup page
|
// GET / - Setup page
|
||||||
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
export async function handleSetupPage(request: Request, env: Env): Promise<Response> {
|
||||||
@@ -772,7 +20,15 @@ export async function handleSetupPage(request: Request, env: Env): Promise<Respo
|
|||||||
if (disabled) {
|
if (disabled) {
|
||||||
return new Response(null, { status: 404 });
|
return new Response(null, { status: 404 });
|
||||||
}
|
}
|
||||||
return htmlResponse(setupPageHTML);
|
|
||||||
|
// Guard: require a strong JWT_SECRET before allowing setup/registration.
|
||||||
|
const jwtState = getJwtSecretState(env);
|
||||||
|
if (jwtState) {
|
||||||
|
return htmlResponse(renderJwtSecretWarningPage(request, jwtState), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve the registration/setup UI (split into a dedicated module).
|
||||||
|
return handleRegisterPage(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /setup/status
|
// GET /setup/status
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
|
||||||
|
// NOTE: Kept as a single file with inline HTML/CSS to avoid external assets.
|
||||||
|
// This file splits the old monolithic setup page into reusable page generators.
|
||||||
|
|
||||||
|
type Lang = 'zh' | 'en';
|
||||||
|
|
||||||
|
function isChineseFromRequest(request: Request): boolean {
|
||||||
|
const acceptLang = (request.headers.get('accept-language') || '').toLowerCase();
|
||||||
|
return acceptLang.includes('zh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(lang: Lang, key: string): string {
|
||||||
|
const zh: Record<string, string> = {
|
||||||
|
app: 'NodeWarden',
|
||||||
|
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
|
||||||
|
|
||||||
|
// Config warning page
|
||||||
|
cfgTitle: '需要配置 JWT_SECRET',
|
||||||
|
cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。',
|
||||||
|
cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。',
|
||||||
|
cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。',
|
||||||
|
cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET',
|
||||||
|
cfgSteps: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。',
|
||||||
|
cfgGenTitle: '随机密钥生成器',
|
||||||
|
cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。',
|
||||||
|
cfgCopy: '复制',
|
||||||
|
cfgRefresh: '刷新',
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
by: '作者',
|
||||||
|
github: 'GitHub',
|
||||||
|
};
|
||||||
|
|
||||||
|
const en: Record<string, string> = {
|
||||||
|
app: 'NodeWarden',
|
||||||
|
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
|
||||||
|
|
||||||
|
// Config warning page
|
||||||
|
cfgTitle: 'JWT_SECRET is required',
|
||||||
|
cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.',
|
||||||
|
cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.',
|
||||||
|
cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.',
|
||||||
|
cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare',
|
||||||
|
cfgSteps: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.',
|
||||||
|
cfgGenTitle: 'Random secret generator',
|
||||||
|
cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.',
|
||||||
|
cfgCopy: 'Copy',
|
||||||
|
cfgRefresh: 'Refresh',
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
by: 'By',
|
||||||
|
github: 'GitHub',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (lang === 'zh' ? zh : en)[key] ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseStyles(): string {
|
||||||
|
// Keep consistent with existing setup page look & feel.
|
||||||
|
return `
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg0: #0b0b0f;
|
||||||
|
--bg1: #0f1020;
|
||||||
|
--card: rgba(255, 255, 255, 0.08);
|
||||||
|
--card2: rgba(255, 255, 255, 0.06);
|
||||||
|
--border: rgba(255, 255, 255, 0.14);
|
||||||
|
--text: rgba(255, 255, 255, 0.92);
|
||||||
|
--muted: rgba(255, 255, 255, 0.62);
|
||||||
|
--muted2: rgba(255, 255, 255, 0.52);
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent2: #64d2ff;
|
||||||
|
--danger: #ff453a;
|
||||||
|
--ok: #32d74b;
|
||||||
|
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
||||||
|
--radius: 18px;
|
||||||
|
--radius2: 14px;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.shell { width: max(500px); }
|
||||||
|
.panel {
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
||||||
|
border: 1px solid rgba(255,255,255,0.20);
|
||||||
|
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgba(255,255,255,0.96);
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.title { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
|
||||||
|
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||||
|
|
||||||
|
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
|
||||||
|
.lead { font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.86); }
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
border-radius: var(--radius2);
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
|
||||||
|
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); white-space: pre-line; }
|
||||||
|
|
||||||
|
.server {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
word-break: break-all;
|
||||||
|
color: rgba(255,255,255,0.90);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.btn {
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
||||||
|
}
|
||||||
|
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
|
||||||
|
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.10);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JwtSecretState = 'missing' | 'default' | 'too_short';
|
||||||
|
|
||||||
|
export function renderJwtSecretWarningPage(request: Request, state: JwtSecretState): string {
|
||||||
|
const lang: Lang = isChineseFromRequest(request) ? 'zh' : 'en';
|
||||||
|
|
||||||
|
const descKey = state === 'missing' ? 'cfgDescMissing' : state === 'default' ? 'cfgDescDefault' : 'cfgDescTooShort';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NodeWarden</title>
|
||||||
|
<style>${baseStyles()}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="panel">
|
||||||
|
<div class="top">
|
||||||
|
<div class="mark" aria-label="NodeWarden">NW</div>
|
||||||
|
<div class="title">
|
||||||
|
<h1>${t(lang, 'app')}</h1>
|
||||||
|
<p>${t(lang, 'tag')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>${t(lang, 'cfgTitle')}</h2>
|
||||||
|
<div class="lead">${t(lang, descKey)}</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<h3>${t(lang, 'cfgStepsTitle')}</h3>
|
||||||
|
<p>${t(lang, 'cfgSteps')
|
||||||
|
.replace(/^类型:密钥/m, '<b>类型:密钥</b>')
|
||||||
|
.replace(/^名称:JWT_SECRET/m, '<b>名称:JWT_SECRET</b>')
|
||||||
|
.replace(/^Type: Secret/m, '<b>Type: Secret</b>')
|
||||||
|
.replace(/^Name: JWT_SECRET/m, '<b>Name: JWT_SECRET</b>')
|
||||||
|
}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<h3>${t(lang, 'cfgGenTitle')}</h3>
|
||||||
|
<p>${t(lang, 'cfgGenHint')}</p>
|
||||||
|
<div class="server" id="secret"></div>
|
||||||
|
<div style="height: 10px"></div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn primary" type="button" onclick="refreshSecret()">${t(lang, 'cfgRefresh')}</button>
|
||||||
|
<button class="btn" type="button" onclick="copySecret()">${t(lang, 'cfgCopy')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div>
|
||||||
|
<span>${t(lang, 'by')} </span>
|
||||||
|
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">${t(lang, 'github')}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Generate a URL-safe random secret (default length: 64)
|
||||||
|
function genSecret(len) {
|
||||||
|
len = len || 50;
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
out += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSecret() {
|
||||||
|
const s = genSecret(50);
|
||||||
|
document.getElementById('secret').textContent = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copySecret() {
|
||||||
|
const s = document.getElementById('secret').textContent || '';
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(s);
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = s;
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSecret();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,668 @@
|
|||||||
|
import { Env } from '../types';
|
||||||
|
import { StorageService } from '../services/storage';
|
||||||
|
import { htmlResponse } from '../utils/response';
|
||||||
|
|
||||||
|
// Registration/setup page HTML (single-file, no external assets)
|
||||||
|
// Split out from the old monolithic `setup.ts` as requested.
|
||||||
|
const registerPageHTML = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NodeWarden</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg0: #0b0b0f;
|
||||||
|
--bg1: #0f1020;
|
||||||
|
--card: rgba(255, 255, 255, 0.08);
|
||||||
|
--card2: rgba(255, 255, 255, 0.06);
|
||||||
|
--border: rgba(255, 255, 255, 0.14);
|
||||||
|
--text: rgba(255, 255, 255, 0.92);
|
||||||
|
--muted: rgba(255, 255, 255, 0.62);
|
||||||
|
--muted2: rgba(255, 255, 255, 0.52);
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent2: #64d2ff;
|
||||||
|
--danger: #ff453a;
|
||||||
|
--ok: #32d74b;
|
||||||
|
--shadow: 0 16px 60px rgba(0, 0, 0, 0.50);
|
||||||
|
--radius: 18px;
|
||||||
|
--radius2: 14px;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%),
|
||||||
|
radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg0), var(--bg1));
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.shell { width: max(500px); }
|
||||||
|
.panel {
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55));
|
||||||
|
border: 1px solid rgba(255,255,255,0.20);
|
||||||
|
box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: rgba(255,255,255,0.96);
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.title { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; }
|
||||||
|
.title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||||
|
|
||||||
|
h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; }
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 12px;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
.message.error {
|
||||||
|
display: block;
|
||||||
|
border-color: rgba(255, 69, 58, 0.40);
|
||||||
|
background: rgba(255, 69, 58, 0.10);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
.message.success {
|
||||||
|
display: block;
|
||||||
|
border-color: rgba(50, 215, 75, 0.35);
|
||||||
|
background: rgba(50, 215, 75, 0.10);
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
@media (max-width: 540px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.field { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
label { font-size: 12px; color: var(--muted); letter-spacing: 0.2px; }
|
||||||
|
input {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
outline: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
input::placeholder { color: rgba(255,255,255,0.35); }
|
||||||
|
input:focus {
|
||||||
|
border-color: rgba(10, 132, 255, 0.55);
|
||||||
|
box-shadow: 0 0 0 6px rgba(10, 132, 255, 0.12);
|
||||||
|
}
|
||||||
|
.hint { margin: 0; color: var(--muted2); font-size: 12px; line-height: 1.55; }
|
||||||
|
|
||||||
|
.actions { margin-top: 12px; display: flex; gap: 10px; align-items: center; }
|
||||||
|
.primary {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.18);
|
||||||
|
background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60));
|
||||||
|
color: rgba(255,255,255,0.96);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, filter 120ms ease;
|
||||||
|
}
|
||||||
|
.primary:hover { filter: brightness(1.03); }
|
||||||
|
.primary:active { transform: translateY(1px) scale(0.99); }
|
||||||
|
.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
|
||||||
|
|
||||||
|
.sideCard { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.kv {
|
||||||
|
border-radius: var(--radius2);
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); }
|
||||||
|
.kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); }
|
||||||
|
|
||||||
|
.server {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0,0,0,0.25);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
word-break: break-all;
|
||||||
|
color: rgba(255,255,255,0.90);
|
||||||
|
}
|
||||||
|
a { color: rgba(100, 210, 255, 0.92); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.footer {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.10);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="panel">
|
||||||
|
<div class="top">
|
||||||
|
<div class="mark" aria-label="NodeWarden">NW</div>
|
||||||
|
<div class="title">
|
||||||
|
<h1 id="t_app">NodeWarden</h1>
|
||||||
|
<p id="t_tag">部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="muted" id="t_intro" style="font-size: 13px; line-height: 1.7;">
|
||||||
|
创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 14px"></div>
|
||||||
|
<h2 id="t_setup">初始化</h2>
|
||||||
|
|
||||||
|
<div id="message" class="message"></div>
|
||||||
|
|
||||||
|
<div id="setup-form">
|
||||||
|
<form id="form" onsubmit="handleSubmit(event)">
|
||||||
|
<div class="grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="name" id="t_name_label">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required placeholder="Your name">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email" id="t_email_label">Email</label>
|
||||||
|
<input type="email" id="email" name="email" required placeholder="you@example.com" autocomplete="email">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 10px"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password" id="t_pw_label">Master password</label>
|
||||||
|
<input type="password" id="password" name="password" required minlength="12" placeholder="At least 12 characters" autocomplete="new-password">
|
||||||
|
<p class="hint" id="t_pw_hint">Choose a strong password you can remember. The server cannot recover it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height: 10px"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="confirmPassword" id="t_pw2_label">Confirm password</label>
|
||||||
|
<input type="password" id="confirmPassword" name="confirmPassword" required placeholder="Confirm password" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" id="submitBtn" class="primary">Create account</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="registered-view" class="sideCard" style="display: none;">
|
||||||
|
<div class="kv">
|
||||||
|
<h3 id="t_done_title">Setup complete</h3>
|
||||||
|
<p id="t_done_desc">Your server is ready. Configure your Bitwarden client with this server URL:</p>
|
||||||
|
<div class="server" id="serverUrl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<h3 id="t_important">Important</h3>
|
||||||
|
<p id="t_limitations">
|
||||||
|
This project is designed for a single user. You cannot add new users. Changing the master password is not supported.
|
||||||
|
If you forget it, you must redeploy and register again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kv">
|
||||||
|
<h3 id="t_hide_title">Hide setup page</h3>
|
||||||
|
<p id="t_hide_desc">After hiding, this setup page will return 404 for everyone. Your vault will keep working.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="hideBtn" class="primary" onclick="disableSetupPage()">Hide setup page</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div>
|
||||||
|
<span class="muted" id="t_by">By</span>
|
||||||
|
<a href="https://shuai.plus" target="_blank" rel="noreferrer">shuaiplus</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="https://github.com/shuaiplus/nodewarden" target="_blank" rel="noreferrer">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let isRegistered = false;
|
||||||
|
|
||||||
|
function isChinese() {
|
||||||
|
const lang = (navigator.language || '').toLowerCase();
|
||||||
|
return lang.startsWith('zh');
|
||||||
|
}
|
||||||
|
|
||||||
|
function t(key) {
|
||||||
|
const zh = {
|
||||||
|
app: 'NodeWarden',
|
||||||
|
tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。',
|
||||||
|
intro: '创建第一个账号完成初始化,然后用任意 Bitwarden 官方客户端登录。',
|
||||||
|
by: '作者',
|
||||||
|
setup: '初始化',
|
||||||
|
nameLabel: '昵称',
|
||||||
|
emailLabel: '邮箱',
|
||||||
|
pwLabel: '主密码',
|
||||||
|
pwHint: '请选择你能记住的强密码。服务器无法找回主密码。',
|
||||||
|
pw2Label: '确认主密码',
|
||||||
|
create: '创建账号',
|
||||||
|
creating: '正在创建…',
|
||||||
|
doneTitle: '初始化完成',
|
||||||
|
doneDesc: '服务已就绪。在 Bitwarden 客户端中填入以下服务器地址:',
|
||||||
|
important: '重要提示',
|
||||||
|
limitations: '本项目仅支持单用户:不能添加新用户;不支持修改主密码;如果忘记主密码,只能重新部署并重新注册。',
|
||||||
|
hideTitle: '隐藏初始化页',
|
||||||
|
hideDesc: '隐藏后,初始化页对任何人都会直接返回 404。你的密码库仍可正常使用。',
|
||||||
|
hideBtn: '隐藏初始化页',
|
||||||
|
hideWorking: '正在隐藏…',
|
||||||
|
hideDone: '已隐藏,此页面将返回 404。',
|
||||||
|
hideFailed: '隐藏失败',
|
||||||
|
hideConfirm: '确认隐藏初始化页?隐藏后页面将不可访问,但你的密码库不会受影响。',
|
||||||
|
errPwNotMatch: '两次输入的密码不一致',
|
||||||
|
errPwTooShort: '密码长度至少 12 位',
|
||||||
|
errGeneric: '发生错误:',
|
||||||
|
errRegisterFailed: '注册失败',
|
||||||
|
};
|
||||||
|
const en = {
|
||||||
|
app: 'NodeWarden',
|
||||||
|
tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.',
|
||||||
|
intro: 'Create your first account to finish setup. Then use any official Bitwarden client to sign in.',
|
||||||
|
by: 'By',
|
||||||
|
setup: 'Setup',
|
||||||
|
nameLabel: 'Name',
|
||||||
|
emailLabel: 'Email',
|
||||||
|
pwLabel: 'Master password',
|
||||||
|
pwHint: 'Choose a strong password you can remember. The server cannot recover it.',
|
||||||
|
pw2Label: 'Confirm password',
|
||||||
|
create: 'Create account',
|
||||||
|
creating: 'Creating…',
|
||||||
|
doneTitle: 'Setup complete',
|
||||||
|
doneDesc: 'Your server is ready. Configure your Bitwarden client with this server URL:',
|
||||||
|
important: 'Important',
|
||||||
|
limitations: 'Single user only: you cannot add new users. Changing the master password is not supported. If you forget it, redeploy and register again.',
|
||||||
|
hideTitle: 'Hide setup page',
|
||||||
|
hideDesc: 'After hiding, this setup page will return 404 for everyone. Your vault will keep working.',
|
||||||
|
hideBtn: 'Hide setup page',
|
||||||
|
hideWorking: 'Hiding…',
|
||||||
|
hideDone: 'Hidden. This page will now return 404.',
|
||||||
|
hideFailed: 'Failed to hide setup page',
|
||||||
|
hideConfirm: 'Hide the setup page? It will no longer be accessible, but your vault will keep working.',
|
||||||
|
errPwNotMatch: 'Passwords do not match',
|
||||||
|
errPwTooShort: 'Password must be at least 12 characters',
|
||||||
|
errGeneric: 'An error occurred: ',
|
||||||
|
errRegisterFailed: 'Registration failed',
|
||||||
|
};
|
||||||
|
return (isChinese() ? zh : en)[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyI18n() {
|
||||||
|
document.documentElement.lang = isChinese() ? 'zh-CN' : 'en';
|
||||||
|
|
||||||
|
document.getElementById('t_app').textContent = t('app');
|
||||||
|
document.getElementById('t_tag').textContent = t('tag');
|
||||||
|
document.getElementById('t_intro').textContent = t('intro');
|
||||||
|
document.getElementById('t_by').textContent = t('by');
|
||||||
|
document.getElementById('t_setup').textContent = t('setup');
|
||||||
|
|
||||||
|
document.getElementById('t_name_label').textContent = t('nameLabel');
|
||||||
|
document.getElementById('t_email_label').textContent = t('emailLabel');
|
||||||
|
document.getElementById('t_pw_label').textContent = t('pwLabel');
|
||||||
|
document.getElementById('t_pw_hint').textContent = t('pwHint');
|
||||||
|
document.getElementById('t_pw2_label').textContent = t('pw2Label');
|
||||||
|
document.getElementById('submitBtn').textContent = t('create');
|
||||||
|
|
||||||
|
document.getElementById('t_done_title').textContent = t('doneTitle');
|
||||||
|
document.getElementById('t_done_desc').textContent = t('doneDesc');
|
||||||
|
document.getElementById('t_important').textContent = t('important');
|
||||||
|
document.getElementById('t_limitations').textContent = t('limitations');
|
||||||
|
document.getElementById('t_hide_title').textContent = t('hideTitle');
|
||||||
|
document.getElementById('t_hide_desc').textContent = t('hideDesc');
|
||||||
|
document.getElementById('hideBtn').textContent = t('hideBtn');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/status');
|
||||||
|
const data = await res.json();
|
||||||
|
isRegistered = !!data.registered;
|
||||||
|
if (data.registered) {
|
||||||
|
showRegisteredView();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check status:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRegisteredView() {
|
||||||
|
isRegistered = true;
|
||||||
|
document.getElementById('setup-form').style.display = 'none';
|
||||||
|
document.getElementById('registered-view').style.display = 'block';
|
||||||
|
document.getElementById('serverUrl').textContent = window.location.origin;
|
||||||
|
showMessage(t('doneTitle'), 'success');
|
||||||
|
const form = document.getElementById('form');
|
||||||
|
if (form) {
|
||||||
|
const fields = form.querySelectorAll('input, button');
|
||||||
|
fields.forEach((el) => {
|
||||||
|
el.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableSetupPage() {
|
||||||
|
if (!isRegistered) return;
|
||||||
|
if (!confirm(t('hideConfirm'))) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('hideBtn');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = t('hideWorking');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/disable', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
showMessage(t('hideDone'), 'success');
|
||||||
|
setTimeout(() => window.location.reload(), 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMessage(data.error || t('hideFailed'), 'error');
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(t('hideFailed'), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = t('hideBtn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, type) {
|
||||||
|
const msg = document.getElementById('message');
|
||||||
|
msg.textContent = text;
|
||||||
|
msg.className = 'message ' + type;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pbkdf2(password, salt, iterations, keyLen) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const passwordBytes = (password instanceof Uint8Array)
|
||||||
|
? password
|
||||||
|
: encoder.encode(password);
|
||||||
|
const saltBytes = (salt instanceof Uint8Array)
|
||||||
|
? salt
|
||||||
|
: encoder.encode(salt);
|
||||||
|
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
passwordBytes,
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
|
||||||
|
const derivedBits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: saltBytes,
|
||||||
|
iterations: iterations,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
keyLen * 8
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Uint8Array(derivedBits);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hkdfExpand(prk, info, length) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
prk,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const infoBytes = encoder.encode(info);
|
||||||
|
const result = new Uint8Array(length);
|
||||||
|
let prev = new Uint8Array(0);
|
||||||
|
let offset = 0;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (offset < length) {
|
||||||
|
const input = new Uint8Array(prev.length + infoBytes.length + 1);
|
||||||
|
input.set(prev);
|
||||||
|
input.set(infoBytes, prev.length);
|
||||||
|
input[input.length - 1] = counter;
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, input);
|
||||||
|
prev = new Uint8Array(signature);
|
||||||
|
|
||||||
|
const toCopy = Math.min(prev.length, length - offset);
|
||||||
|
result.set(prev.slice(0, toCopy), offset);
|
||||||
|
offset += toCopy;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateSymmetricKey() {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptAesCbc(data, key, iv) {
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
key,
|
||||||
|
{ name: 'AES-CBC' },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-CBC', iv: iv },
|
||||||
|
cryptoKey,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Uint8Array(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256(key, data) {
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
key,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
|
||||||
|
return new Uint8Array(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Encode(bytes) {
|
||||||
|
return btoa(String.fromCharCode.apply(null, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptToBitwardenFormat(data, encKey, macKey) {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const encrypted = await encryptAesCbc(data, encKey, iv);
|
||||||
|
|
||||||
|
const macData = new Uint8Array(iv.length + encrypted.length);
|
||||||
|
macData.set(iv);
|
||||||
|
macData.set(encrypted, iv.length);
|
||||||
|
const mac = await hmacSha256(macKey, macData);
|
||||||
|
|
||||||
|
return '2.' + base64Encode(iv) + '|' + base64Encode(encrypted) + '|' + base64Encode(mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateRsaKeyPair() {
|
||||||
|
const keyPair = await crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([1, 0, 1]),
|
||||||
|
hash: 'SHA-1'
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
const publicKeySpki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||||
|
const publicKeyB64 = base64Encode(new Uint8Array(publicKeySpki));
|
||||||
|
|
||||||
|
const privateKeyPkcs8 = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||||
|
const privateKeyBytes = new Uint8Array(privateKeyPkcs8);
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: publicKeyB64,
|
||||||
|
privateKey: privateKeyBytes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (isRegistered) {
|
||||||
|
showMessage(t('doneTitle'), 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = document.getElementById('name').value;
|
||||||
|
const email = document.getElementById('email').value.toLowerCase();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
showMessage(t('errPwNotMatch'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 12) {
|
||||||
|
showMessage(t('errPwTooShort'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = t('creating');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iterations = 600000;
|
||||||
|
const masterKey = await pbkdf2(password, email, iterations, 32);
|
||||||
|
|
||||||
|
const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32);
|
||||||
|
const masterPasswordHashB64 = base64Encode(masterPasswordHash);
|
||||||
|
|
||||||
|
const stretchedKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||||
|
const stretchedMacKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||||
|
|
||||||
|
const symmetricKey = generateSymmetricKey();
|
||||||
|
|
||||||
|
const encryptedKey = await encryptToBitwardenFormat(symmetricKey, stretchedKey, stretchedMacKey);
|
||||||
|
|
||||||
|
const rsaKeys = await generateRsaKeyPair();
|
||||||
|
|
||||||
|
const encryptedPrivateKey = await encryptToBitwardenFormat(rsaKeys.privateKey, symmetricKey.slice(0, 32), symmetricKey.slice(32, 64));
|
||||||
|
|
||||||
|
const response = await fetch('/api/accounts/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
name: name,
|
||||||
|
masterPasswordHash: masterPasswordHashB64,
|
||||||
|
key: encryptedKey,
|
||||||
|
kdf: 0,
|
||||||
|
kdfIterations: iterations,
|
||||||
|
keys: {
|
||||||
|
publicKey: rsaKeys.publicKey,
|
||||||
|
encryptedPrivateKey: encryptedPrivateKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
showRegisteredView();
|
||||||
|
} else {
|
||||||
|
showMessage(result.error || result.ErrorModel?.Message || t('errRegisterFailed'), 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = t('create');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
showMessage(t('errGeneric') + (error && error.message ? error.message : String(error)), 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = t('create');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyI18n();
|
||||||
|
checkStatus();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
export async function handleRegisterPage(request: Request, env: Env): Promise<Response> {
|
||||||
|
const storage = new StorageService(env.VAULT);
|
||||||
|
const disabled = await storage.isSetupDisabled();
|
||||||
|
if (disabled) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
return htmlResponse(registerPageHTML);
|
||||||
|
}
|
||||||
@@ -3,15 +3,6 @@ import { handleRequest } from './router';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
// Security check: JWT_SECRET must be set
|
|
||||||
if (!env.JWT_SECRET) {
|
|
||||||
return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Security check: warn if JWT_SECRET is too weak
|
|
||||||
if (env.JWT_SECRET.length < 32) {
|
|
||||||
console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security');
|
|
||||||
}
|
|
||||||
|
|
||||||
return handleRequest(request, env);
|
return handleRequest(request, env);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
|
|
||||||
// Route matching
|
// Route matching
|
||||||
try {
|
try {
|
||||||
|
|
||||||
// Setup page (root)
|
// Setup page (root)
|
||||||
if (path === '/' && method === 'GET') {
|
if (path === '/' && method === 'GET') {
|
||||||
return handleSetupPage(request, env);
|
return handleSetupPage(request, env);
|
||||||
@@ -181,6 +182,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
return handleRegister(request, env);
|
return handleRegister(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If JWT_SECRET is not safely configured, block any other endpoints.
|
||||||
|
const secret = (env.JWT_SECRET || '').trim();
|
||||||
|
if (!secret || secret.length < 32) {
|
||||||
|
return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500);
|
||||||
|
}
|
||||||
|
|
||||||
// All other API endpoints require authentication
|
// All other API endpoints require authentication
|
||||||
const auth = new AuthService(env);
|
const auth = new AuthService(env);
|
||||||
const authHeader = request.headers.get('Authorization');
|
const authHeader = request.headers.get('Authorization');
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export interface Env {
|
|||||||
JWT_SECRET: string;
|
JWT_SECRET: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Shared config related to JWT secret bootstrapping / safety checks.
|
||||||
|
// Keep this in one place so handlers don't duplicate the sample value.
|
||||||
|
|
||||||
|
// IMPORTANT:
|
||||||
|
// This is a *sample* secret value used in `.dev.vars.example`.
|
||||||
|
// If the runtime JWT_SECRET equals this value, we treat it as unsafe.
|
||||||
|
export const DEFAULT_DEV_SECRET = 'Enter-your-JWT-key-here-at-least-32-characters';
|
||||||
Reference in New Issue
Block a user