mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: refactor setup handling and enhance asset serving with bootstrap integration
This commit is contained in:
@@ -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 });
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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]);
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 位生成器。',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user