feat: refactor setup handling and enhance asset serving with bootstrap integration

This commit is contained in:
shuaiplus
2026-03-16 23:48:08 +08:00
parent b5f8ef28cc
commit 0ba85229a9
14 changed files with 217 additions and 107 deletions
-11
View File
@@ -1,11 +0,0 @@
import { Env } from '../types';
import { StorageService } from '../services/storage';
import { jsonResponse } from '../utils/response';
// GET /setup/status
export async function handleSetupStatus(request: Request, env: Env): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const registered = (await storage.isRegistered()) || (await storage.getUserCount()) > 0;
return jsonResponse({ registered });
}
+48
View File
@@ -4,11 +4,54 @@ import { handleRequest } from './router';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response'; import { applyCors, jsonResponse } from './utils/response';
import { runScheduledBackupIfDue, seedDefaultBackupSettings } from './handlers/backup'; import { runScheduledBackupIfDue, seedDefaultBackupSettings } from './handlers/backup';
import { buildWebBootstrapResponse } from './router-public';
let dbInitialized = false; let dbInitialized = false;
let dbInitError: string | null = null; let dbInitError: string | null = null;
let dbInitPromise: Promise<void> | null = null; let dbInitPromise: Promise<void> | null = null;
function isWorkerHandledPath(path: string): boolean {
return (
path.startsWith('/api/') ||
path.startsWith('/identity/') ||
path.startsWith('/icons/') ||
path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') ||
path === '/config' ||
path === '/api/config' ||
path === '/api/version'
);
}
function injectBootstrapIntoHtml(html: string, env: Env): string {
const payload = JSON.stringify(buildWebBootstrapResponse(env)).replace(/</g, '\\u003c');
const script = `<script>window.__NW_BOOT__=${payload};</script>`;
if (html.includes('</head>')) {
return html.replace('</head>', `${script}</head>`);
}
return `${script}${html}`;
}
async function maybeServeAsset(request: Request, env: Env): Promise<Response | null> {
if (!env.ASSETS) return null;
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
const url = new URL(request.url);
if (isWorkerHandledPath(url.pathname)) return null;
const assetResponse = await env.ASSETS.fetch(request);
const contentType = String(assetResponse.headers.get('Content-Type') || '').toLowerCase();
if (request.method === 'GET' && contentType.includes('text/html')) {
const html = await assetResponse.text();
const injected = injectBootstrapIntoHtml(html, env);
return new Response(injected, {
status: assetResponse.status,
statusText: assetResponse.statusText,
headers: assetResponse.headers,
});
}
return assetResponse;
}
async function ensureDatabaseInitialized(env: Env): Promise<void> { async function ensureDatabaseInitialized(env: Env): Promise<void> {
if (dbInitialized) return; if (dbInitialized) return;
@@ -35,6 +78,11 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
export default { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
void ctx; void ctx;
const assetResponse = await maybeServeAsset(request, env);
if (assetResponse) {
return applyCors(request, assetResponse);
}
await ensureDatabaseInitialized(env); await ensureDatabaseInitialized(env);
if (dbInitError) { if (dbInitError) {
// Log full error server-side, return generic message to client. // Log full error server-side, return generic message to client.
+8 -21
View File
@@ -7,7 +7,6 @@ import {
handleAccessSendFileV2, handleAccessSendFileV2,
handleDownloadSendFile, handleDownloadSendFile,
} from './handlers/sends'; } from './handlers/sends';
import { handleSetupStatus } from './handlers/setup';
import { handleKnownDevice } from './handlers/devices'; import { handleKnownDevice } from './handlers/devices';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { import {
@@ -23,6 +22,13 @@ import { jsonResponse } from './utils/response';
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;
export interface WebBootstrapResponse {
defaultKdfIterations: number;
jwtUnsafeReason: JwtUnsafeReason;
jwtSecretMinLength: number;
}
function isSameOriginWriteRequest(request: Request): boolean { function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin; const targetOrigin = new URL(request.url).origin;
@@ -111,7 +117,7 @@ async function handleWebsiteIcon(host: string): Promise<Response> {
} }
} }
export function buildWebConfigResponse(env: Env, origin: string) { export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
const jwtUnsafeReason = const jwtUnsafeReason =
!secret !secret
@@ -126,9 +132,6 @@ export function buildWebConfigResponse(env: Env, origin: string) {
defaultKdfIterations: LIMITS.auth.defaultKdfIterations, defaultKdfIterations: LIMITS.auth.defaultKdfIterations,
jwtUnsafeReason, jwtUnsafeReason,
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
_icon_service_url: buildIconServiceTemplate(origin),
_icon_service_csp: buildIconServiceCsp(origin),
iconServiceUrl: buildIconServiceTemplate(origin),
}; };
} }
@@ -139,18 +142,6 @@ export async function handlePublicRoute(
method: string, method: string,
enforcePublicRateLimit: PublicRateLimiter enforcePublicRateLimit: PublicRateLimiter
): Promise<Response | null> { ): Promise<Response | null> {
if (path === '/setup/status' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleSetupStatus(request, env);
}
if (path === '/api/web/config' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return jsonResponse(buildWebConfigResponse(env, new URL(request.url).origin));
}
if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') { if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') {
return new Response('{}', { return new Response('{}', {
status: 200, status: 200,
@@ -161,10 +152,6 @@ export async function handlePublicRoute(
}); });
} }
if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') {
return handleNwFavicon();
}
const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i);
if (iconMatch && method === 'GET') { if (iconMatch && method === 'GET') {
return handleWebsiteIcon(iconMatch[1]); return handleWebsiteIcon(iconMatch[1]);
+3
View File
@@ -2,6 +2,9 @@
export interface Env { export interface Env {
DB: D1Database; DB: D1Database;
NOTIFICATIONS_HUB: DurableObjectNamespace; NOTIFICATIONS_HUB: DurableObjectNamespace;
ASSETS?: {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
};
// Prefer R2 when available. Optional to support KV-only deployments. // Prefer R2 when available. Optional to support KV-only deployments.
ATTACHMENTS?: R2Bucket; ATTACHMENTS?: R2Bucket;
// Optional fallback for attachment/send file storage (no credit card required). // Optional fallback for attachment/send file storage (no credit card required).
+15 -22
View File
@@ -32,6 +32,7 @@ import {
} from '@/lib/app-support'; } from '@/lib/app-support';
import { import {
bootstrapAppSession, bootstrapAppSession,
readInitialAppBootstrapState,
performPasswordLogin, performPasswordLogin,
performRecoverTwoFactorLogin, performRecoverTwoFactorLogin,
performRegistration, performRegistration,
@@ -60,14 +61,15 @@ const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
export default function App() { export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>('loading'); const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
const [session, setSessionState] = useState<SessionState | null>(null); const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(600000); const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
const [setupRegistered, setSetupRegistered] = useState(true); const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(null);
const [loginValues, setLoginValues] = useState({ email: '', password: '' }); const [loginValues, setLoginValues] = useState({ email: '', password: '' });
const [registerValues, setRegisterValues] = useState({ const [registerValues, setRegisterValues] = useState({
@@ -75,9 +77,9 @@ export default function App() {
email: '', email: '',
password: '', password: '',
password2: '', password2: '',
inviteCode: '', inviteCode: initialInviteCode,
}); });
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(''); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
@@ -128,7 +130,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
if (!inviteCodeFromUrl) return; if (!inviteCodeFromUrl) return;
if (phase === 'loading' || phase === 'locked' || phase === 'app') return; if (phase === 'locked' || phase === 'app') return;
setPhase('register'); setPhase('register');
if (location !== '/register') navigate('/register'); if (location !== '/register') navigate('/register');
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') { if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
@@ -163,11 +165,11 @@ export default function App() {
setSession(next); setSession(next);
if (!next) { if (!next) {
setProfile(null); setProfile(null);
setPhase(setupRegistered ? 'login' : 'register'); setPhase('login');
} }
} }
), ),
[session, setupRegistered] [session]
); );
const importAuthedFetch = useMemo( const importAuthedFetch = useMemo(
() => async (input: string, init?: RequestInit) => { () => async (input: string, init?: RequestInit) => {
@@ -196,7 +198,6 @@ export default function App() {
(async () => { (async () => {
const boot = await bootstrapAppSession(); const boot = await bootstrapAppSession();
if (!mounted) return; if (!mounted) return;
setSetupRegistered(boot.setupRegistered);
setDefaultKdfIterations(boot.defaultKdfIterations); setDefaultKdfIterations(boot.defaultKdfIterations);
setJwtWarning(boot.jwtWarning); setJwtWarning(boot.jwtWarning);
setSession(boot.session); setSession(boot.session);
@@ -363,8 +364,8 @@ export default function App() {
setSession(null); setSession(null);
setProfile(null); setProfile(null);
setPendingTotp(null); setPendingTotp(null);
setPhase(setupRegistered ? 'login' : 'register'); setPhase('login');
navigate(setupRegistered ? '/login' : '/register'); navigate('/login');
} }
function handleLogout() { function handleLogout() {
@@ -951,21 +952,13 @@ export default function App() {
); );
} }
if (phase === 'loading') {
return (
<>
<div className="loading-screen">{t('txt_loading_nodewarden')}</div>
{renderPassiveOverlays()}
</>
);
}
if (phase === 'register' || phase === 'login' || phase === 'locked') { if (phase === 'register' || phase === 'login' || phase === 'locked') {
return ( return (
<> <>
<AuthViews <AuthViews
mode={phase} mode={phase}
pendingAction={pendingAuthAction} pendingAction={pendingAuthAction}
unlockReady={!!profile}
loginValues={loginValues} loginValues={loginValues}
registerValues={registerValues} registerValues={registerValues}
unlockPassword={unlockPassword} unlockPassword={unlockPassword}
+2 -1
View File
@@ -19,6 +19,7 @@ interface RegisterValues {
interface AuthViewsProps { interface AuthViewsProps {
mode: 'login' | 'register' | 'locked'; mode: 'login' | 'register' | 'locked';
pendingAction: 'login' | 'register' | 'unlock' | null; pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean;
loginValues: LoginValues; loginValues: LoginValues;
registerValues: RegisterValues; registerValues: RegisterValues;
unlockPassword: string; unlockPassword: string;
@@ -86,7 +87,7 @@ export default function AuthViews(props: AuthViewsProps) {
autoComplete="current-password" autoComplete="current-password"
onInput={props.onChangeUnlock} onInput={props.onChangeUnlock}
/> />
<button type="submit" className="btn btn-primary full" disabled={unlockBusy}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
</button> </button>
+34 -2
View File
@@ -9,6 +9,9 @@ interface JwtWarningPageProps {
minLength: number; minLength: number;
} }
const CLOUDFLARE_SETTINGS_URL =
'https://dash.cloudflare.com/?to=/:account/workers/services/view/nodewarden/production/settings';
export default function JwtWarningPage(props: JwtWarningPageProps) { export default function JwtWarningPage(props: JwtWarningPageProps) {
const [seed, setSeed] = useState(0); const [seed, setSeed] = useState(0);
const [copyHint, setCopyHint] = useState(''); const [copyHint, setCopyHint] = useState('');
@@ -25,7 +28,8 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
const isMissing = props.reason === 'missing'; const isMissing = props.reason === 'missing';
const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace'); const fixTitle = isMissing ? t('txt_jwt_how_to_fix_add') : t('txt_jwt_how_to_fix_replace');
const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength }); const fixStep1 = isMissing ? t('txt_jwt_add_step_1') : t('txt_jwt_replace_step_1', { min: props.minLength });
const fixStep2 = isMissing ? t('txt_jwt_add_step_2') : t('txt_jwt_replace_step_2'); const fixStep2Prefix = isMissing ? t('txt_jwt_add_step_2_prefix') : t('txt_jwt_replace_step_2_prefix');
const fixStep2Suffix = isMissing ? t('txt_jwt_add_step_2_suffix') : t('txt_jwt_replace_step_2_suffix');
const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3'); const fixStep3 = isMissing ? t('txt_jwt_add_step_3') : t('txt_jwt_replace_step_3');
return ( return (
@@ -37,10 +41,38 @@ export default function JwtWarningPage(props: JwtWarningPageProps) {
</div> </div>
<div className="jwt-warning-box"> <div className="jwt-warning-box">
<div className="jwt-warning-label">{t('txt_jwt_what_is')}</div>
<p className="jwt-warning-copy">{t('txt_jwt_what_is_body')}</p>
<div className="jwt-warning-label">{fixTitle}</div> <div className="jwt-warning-label">{fixTitle}</div>
<ol className="jwt-warning-list"> <ol className="jwt-warning-list">
<li>{fixStep1}</li> <li>{fixStep1}</li>
<li>{fixStep2}</li> <li>
{fixStep2Prefix}
<a
href={CLOUDFLARE_SETTINGS_URL}
className="jwt-inline-link"
target="_blank"
rel="noreferrer"
>
{t('txt_settings')}
</a>
{fixStep2Suffix}
<div className="jwt-secret-fields">
<div className="jwt-secret-row">
<span>{t('txt_jwt_secret_type_label')}</span>
<strong>{t('txt_jwt_secret_type_value')}</strong>
</div>
<div className="jwt-secret-row">
<span>{t('txt_jwt_secret_name_label')}</span>
<strong>JWT_SECRET</strong>
</div>
<div className="jwt-secret-row">
<span>{t('txt_jwt_secret_value_label')}</span>
<strong>{t('txt_jwt_secret_value_requirement', { min: props.minLength })}</strong>
</div>
</div>
</li>
<li>{fixStep3}</li> <li>{fixStep3}</li>
</ol> </ol>
-13
View File
@@ -4,10 +4,8 @@ import type { AuthorizedDevice } from '../types';
import type { import type {
Profile, Profile,
SessionState, SessionState,
SetupStatusResponse,
TokenError, TokenError,
TokenSuccess, TokenSuccess,
WebConfigResponse,
} from '../types'; } from '../types';
import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
@@ -93,17 +91,6 @@ export function saveSession(session: SessionState | null): void {
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted)); localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
} }
export async function getSetupStatus(): Promise<SetupStatusResponse> {
const resp = await fetch('/setup/status');
const body = await parseJson<SetupStatusResponse>(resp);
return { registered: !!body?.registered };
}
export async function getWebConfig(): Promise<WebConfigResponse> {
const resp = await fetch('/api/web/config');
return (await parseJson<WebConfigResponse>(resp)) || {};
}
export function getCurrentDeviceIdentifier(): string { export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
} }
+46 -22
View File
@@ -2,8 +2,6 @@ import {
createAuthedFetch, createAuthedFetch,
deriveLoginHash, deriveLoginHash,
getProfile, getProfile,
getSetupStatus,
getWebConfig,
loadSession, loadSession,
loginWithPassword, loginWithPassword,
refreshAccessToken, refreshAccessToken,
@@ -11,7 +9,8 @@ import {
registerAccount, registerAccount,
unlockVaultKey, unlockVaultKey,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import type { AppPhase, Profile, SessionState } from '@/lib/types'; import { readInviteCodeFromUrl } from '@/lib/app-support';
import type { AppPhase, Profile, SessionState, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp { export interface PendingTotp {
email: string; email: string;
@@ -22,7 +21,6 @@ export interface PendingTotp {
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
export interface BootstrapAppResult { export interface BootstrapAppResult {
setupRegistered: boolean;
defaultKdfIterations: number; defaultKdfIterations: number;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null; jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null; session: SessionState | null;
@@ -30,6 +28,13 @@ export interface BootstrapAppResult {
phase: AppPhase; phase: AppPhase;
} }
export interface InitialAppBootstrapState {
defaultKdfIterations: number;
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
session: SessionState | null;
phase: AppPhase;
}
export interface CompletedLogin { export interface CompletedLogin {
session: SessionState; session: SessionState;
profile: Profile; profile: Profile;
@@ -80,35 +85,56 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
}; };
} }
export async function bootstrapAppSession(): Promise<BootstrapAppResult> { function readWindowBootstrap(): WebBootstrapResponse {
const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]); if (typeof window === 'undefined') return {};
const setupRegistered = setup.registered; const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
const defaultKdfIterations = Number(config.defaultKdfIterations || 600000); return raw && typeof raw === 'object' ? raw : {};
const jwtUnsafeReason = config.jwtUnsafeReason || null; }
if (jwtUnsafeReason) { export function readInitialAppBootstrapState(): InitialAppBootstrapState {
return { const boot = readWindowBootstrap();
setupRegistered, const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
defaultKdfIterations, const jwtUnsafeReason = boot.jwtUnsafeReason || null;
jwtWarning: { const jwtWarning = jwtUnsafeReason
? {
reason: jwtUnsafeReason, reason: jwtUnsafeReason,
minLength: Number(config.jwtSecretMinLength || 32), minLength: Number(boot.jwtSecretMinLength || 32),
}, }
: null;
const session = loadSession();
const hasInviteCode = !!readInviteCodeFromUrl();
return {
defaultKdfIterations,
jwtWarning,
session,
phase: jwtWarning ? 'login' : session ? 'locked' : hasInviteCode ? 'register' : 'login',
};
}
export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
const initial = readInitialAppBootstrapState();
const defaultKdfIterations = initial.defaultKdfIterations;
const jwtWarning = initial.jwtWarning;
if (jwtWarning) {
return {
defaultKdfIterations,
jwtWarning,
session: null, session: null,
profile: null, profile: null,
phase: 'login', phase: 'login',
}; };
} }
const loaded = loadSession(); const loaded = initial.session;
if (!loaded) { if (!loaded) {
return { return {
setupRegistered,
defaultKdfIterations, defaultKdfIterations,
jwtWarning: null, jwtWarning: null,
session: null, session: null,
profile: null, profile: null,
phase: setupRegistered ? 'login' : 'register', phase: initial.phase,
}; };
} }
@@ -124,7 +150,6 @@ export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
) )
); );
return { return {
setupRegistered,
defaultKdfIterations, defaultKdfIterations,
jwtWarning: null, jwtWarning: null,
session, session,
@@ -133,12 +158,11 @@ export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
}; };
} catch { } catch {
return { return {
setupRegistered,
defaultKdfIterations, defaultKdfIterations,
jwtWarning: null, jwtWarning: null,
session: null, session: null,
profile: null, profile: null,
phase: setupRegistered ? 'login' : 'register', phase: initial.phase === 'register' ? 'register' : 'login',
}; };
} }
} }
+22 -4
View File
@@ -374,11 +374,20 @@ const messages: Record<Locale, Record<string, string>> = {
txt_jwt_how_to_fix_add: "How to add JWT_SECRET", txt_jwt_how_to_fix_add: "How to add JWT_SECRET",
txt_jwt_how_to_fix_replace: "How to replace JWT_SECRET", txt_jwt_how_to_fix_replace: "How to replace JWT_SECRET",
txt_jwt_add_step_1: "Use the 32-character generator below and copy a new key.", txt_jwt_add_step_1: "Use the 32-character generator below and copy a new key.",
txt_jwt_add_step_2: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, add JWT_SECRET.", txt_jwt_add_step_2_prefix: "Go to Cloudflare Dashboard -> Workers & Pages -> Your Service -> ",
txt_jwt_add_step_2_suffix: " -> Variables and Secrets -> Add",
txt_jwt_add_step_3: "Save and wait for redeploy, then refresh this page.", txt_jwt_add_step_3: "Save and wait for redeploy, then refresh this page.",
txt_jwt_replace_step_1: "Use the 32-character generator below and create a stronger key (minimum {min} characters).", txt_jwt_replace_step_1: "Use the 32-character generator below and create a stronger key (minimum {min} characters).",
txt_jwt_replace_step_2: "Cloudflare Dashboard -> Workers & Pages -> Your Service -> Settings -> Variables and Secrets, replace JWT_SECRET.", txt_jwt_replace_step_2_prefix: "Go to Cloudflare Dashboard -> Workers & Pages -> Your Service -> ",
txt_jwt_replace_step_2_suffix: " -> Variables and Secrets -> Update JWT_SECRET",
txt_jwt_replace_step_3: "Save and wait for redeploy, then refresh this page.", txt_jwt_replace_step_3: "Save and wait for redeploy, then refresh this page.",
txt_jwt_secret_type_label: "Type:",
txt_jwt_secret_type_value: "Secret",
txt_jwt_secret_name_label: "Variable name:",
txt_jwt_secret_value_label: "Value:",
txt_jwt_secret_value_requirement: "Random string with at least {min} characters",
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_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.",
@@ -1147,11 +1156,20 @@ const zhCNOverrides: Record<string, string> = {
txt_jwt_how_to_fix_add: '处理步骤(添加 JWT_SECRET', txt_jwt_how_to_fix_add: '处理步骤(添加 JWT_SECRET',
txt_jwt_how_to_fix_replace: '处理步骤(更换 JWT_SECRET', txt_jwt_how_to_fix_replace: '处理步骤(更换 JWT_SECRET',
txt_jwt_add_step_1: '使用下方 32 位随机生成器,复制一个新密钥。', txt_jwt_add_step_1: '使用下方 32 位随机生成器,复制一个新密钥。',
txt_jwt_add_step_2: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,新增 JWT_SECRET。', txt_jwt_add_step_2_prefix: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> ',
txt_jwt_add_step_2_suffix: ' -> 变量和机密 -> 新增',
txt_jwt_add_step_3: '保存并等待重新部署完成,然后刷新本页确认。', txt_jwt_add_step_3: '保存并等待重新部署完成,然后刷新本页确认。',
txt_jwt_replace_step_1: '使用下方 32 位随机生成器,生成更强的密钥(至少 {min} 位)。', txt_jwt_replace_step_1: '使用下方 32 位随机生成器,生成更强的密钥(至少 {min} 位)。',
txt_jwt_replace_step_2: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> 设置 -> 变量和机密,替换 JWT_SECRET。', txt_jwt_replace_step_2_prefix: '到 Cloudflare 控制台 -> Workers 和 Pages -> 你的服务 -> ',
txt_jwt_replace_step_2_suffix: ' -> 变量和机密 -> 更新 JWT_SECRET',
txt_jwt_replace_step_3: '保存并等待重新部署完成,然后刷新本页确认。', txt_jwt_replace_step_3: '保存并等待重新部署完成,然后刷新本页确认。',
txt_jwt_secret_type_label: '类型:',
txt_jwt_secret_type_value: '密钥',
txt_jwt_secret_name_label: '变量名称:',
txt_jwt_secret_value_label: '值:',
txt_jwt_secret_value_requirement: '最低 {min} 位随机字符',
txt_jwt_what_is: 'JWT 是什么',
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 位生成器。',
+2 -9
View File
@@ -1,4 +1,4 @@
export type AppPhase = 'loading' | 'register' | 'login' | 'locked' | 'app'; export type AppPhase = 'register' | 'login' | 'locked' | 'app';
export interface SessionState { export interface SessionState {
accessToken: string; accessToken: string;
@@ -256,17 +256,10 @@ export interface ListResponse<T> {
data: T[]; data: T[];
} }
export interface SetupStatusResponse { export interface WebBootstrapResponse {
registered: boolean;
}
export interface WebConfigResponse {
defaultKdfIterations?: number; defaultKdfIterations?: number;
jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null; jwtUnsafeReason?: 'missing' | 'default' | 'too_short' | null;
jwtSecretMinLength?: number; jwtSecretMinLength?: number;
_icon_service_url?: string;
_icon_service_csp?: string;
iconServiceUrl?: string;
} }
export interface TokenSuccess { export interface TokenSuccess {
+33
View File
@@ -131,6 +131,12 @@ body,
margin-bottom: 6px; margin-bottom: 6px;
} }
.jwt-warning-copy {
margin: 0 0 14px;
color: #475569;
line-height: 1.6;
}
.jwt-warning-list { .jwt-warning-list {
margin: 0; margin: 0;
padding-left: 18px; padding-left: 18px;
@@ -138,6 +144,33 @@ body,
line-height: 1.55; line-height: 1.55;
} }
.jwt-inline-link {
color: #1d4ed8;
font-weight: 700;
text-decoration: none;
}
.jwt-inline-link:hover {
text-decoration: underline;
}
.jwt-secret-fields {
margin-top: 8px;
display: grid;
gap: 6px;
}
.jwt-secret-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.jwt-secret-row > span {
color: #64748b;
}
.jwt-generator { .jwt-generator {
margin-top: 14px; margin-top: 14px;
} }
+2 -1
View File
@@ -3,9 +3,10 @@ main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
[assets] [assets]
binding = "ASSETS"
directory = "./dist" directory = "./dist"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = [ "/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*" ] run_worker_first = true
[build] [build]
command = "npm run build" command = "npm run build"
+2 -1
View File
@@ -3,9 +3,10 @@ main = "src/index.ts"
compatibility_date = "2024-01-01" compatibility_date = "2024-01-01"
[assets] [assets]
binding = "ASSETS"
directory = "./dist" directory = "./dist"
not_found_handling = "single-page-application" not_found_handling = "single-page-application"
run_worker_first = [ "/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", "/notifications/*", "/.well-known/*" ] run_worker_first = true
[build] [build]
command = "npm run build" command = "npm run build"