@fonlan/dsh-web-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,372 @@
1
+ import { COOKIE_NAME, MIN_PASSWORD_LENGTH, SESSION_TTL_SECONDS, hashPassword, needsSessionRefresh, sanitizeNext, signSession, verifyPassword, verifySession } from "./auth-core.js";
2
+ import { renderLoginPage } from "./login-page.js";
3
+ import { ALLOWED_LISTEN_HOSTS, isAllowedListenHost } from "./profile-patch.js";
4
+ /** Maximum accepted request body for the login form / API. */
5
+ export const MAX_BODY_BYTES = 16 * 1024;
6
+ // ── small request helpers ───────────────────────────────────────────────────
7
+ export function pathnameOf(req) {
8
+ return new URL(req.url ?? '/', 'http://x').pathname;
9
+ }
10
+ /** Read one cookie by name from the Cookie header. */
11
+ export function cookieValue(header, name) {
12
+ if (header === undefined)
13
+ return undefined;
14
+ for (const part of header.split(';')) {
15
+ const eq = part.indexOf('=');
16
+ if (eq <= 0)
17
+ continue;
18
+ if (part.slice(0, eq).trim() === name)
19
+ return part.slice(eq + 1).trim();
20
+ }
21
+ return undefined;
22
+ }
23
+ /**
24
+ * The client address for rate limiting. X-Forwarded-For is honored ONLY when
25
+ * the direct peer is loopback (the reverse proxy sits on the same host), so
26
+ * a remote attacker hitting dsh directly cannot forge a fresh identity.
27
+ */
28
+ export function clientIp(req) {
29
+ const direct = req.socket.remoteAddress ?? 'unknown';
30
+ const loopback = direct === '127.0.0.1' || direct === '::1' || direct === '::ffff:127.0.0.1';
31
+ if (loopback) {
32
+ const xff = req.headers['x-forwarded-for'];
33
+ if (typeof xff === 'string') {
34
+ const first = xff.split(',')[0]?.trim();
35
+ if (first !== undefined && first.length > 0)
36
+ return first;
37
+ }
38
+ }
39
+ return direct;
40
+ }
41
+ /** Whether the browser's connection is HTTPS (reverse-proxied or direct). */
42
+ export function isSecureRequest(req) {
43
+ return String(req.headers['x-forwarded-proto']).split(',')[0]?.trim() === 'https';
44
+ }
45
+ /** Collect the request body; undefined when it exceeds the cap (413). */
46
+ export async function readBody(req, maxBytes = MAX_BODY_BYTES) {
47
+ const chunks = [];
48
+ let total = 0;
49
+ for await (const chunk of req) {
50
+ total += chunk.length;
51
+ if (total > maxBytes)
52
+ return undefined;
53
+ chunks.push(chunk);
54
+ }
55
+ return Buffer.concat(chunks).toString('utf8');
56
+ }
57
+ /** Parse application/x-www-form-urlencoded. */
58
+ export function parseUrlEncoded(body) {
59
+ const out = {};
60
+ for (const pair of body.split('&')) {
61
+ if (pair.length === 0)
62
+ continue;
63
+ const eq = pair.indexOf('=');
64
+ const key = eq === -1 ? pair : pair.slice(0, eq);
65
+ const value = eq === -1 ? '' : pair.slice(eq + 1);
66
+ try {
67
+ out[decodeURIComponent(key.replace(/\+/g, ' '))] = decodeURIComponent(value.replace(/\+/g, ' '));
68
+ }
69
+ catch {
70
+ /* malformed pair — ignore */
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ function writeJson(res, status, body) {
76
+ const text = JSON.stringify(body);
77
+ res.writeHead(status, {
78
+ 'content-type': 'application/json; charset=utf-8',
79
+ 'cache-control': 'no-store'
80
+ });
81
+ res.end(text);
82
+ }
83
+ function redirect(res, location) {
84
+ res.writeHead(302, { location, 'cache-control': 'no-store' });
85
+ res.end();
86
+ }
87
+ /** Set (or refresh) the session cookie on a response. */
88
+ export function setSessionCookie(res, secret, now, secure) {
89
+ const exp = Math.floor(now / 1000) + SESSION_TTL_SECONDS;
90
+ const value = signSession(secret, exp);
91
+ const parts = [
92
+ COOKIE_NAME + '=' + value,
93
+ 'Path=/',
94
+ 'HttpOnly',
95
+ 'SameSite=Lax',
96
+ 'Max-Age=' + String(SESSION_TTL_SECONDS)
97
+ ];
98
+ if (secure)
99
+ parts.push('Secure');
100
+ res.setHeader('set-cookie', parts.join('; '));
101
+ }
102
+ function clearCookie(res, secure) {
103
+ const parts = [COOKIE_NAME + '=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'];
104
+ if (secure)
105
+ parts.push('Secure');
106
+ res.setHeader('set-cookie', parts.join('; '));
107
+ }
108
+ /**
109
+ * Build the gate used to wrap the HTTP server listeners. Whitelisted paths
110
+ * (login page/form, logout) pass through; everything else requires a valid
111
+ * session cookie. Page requests (GET/HEAD) are redirected to /login with the
112
+ * original URL as `next`; other methods get a plain 401 JSON. WebSocket
113
+ * upgrades without a session are rejected at the handshake.
114
+ */
115
+ export function createGate(env) {
116
+ const now = env.now ?? Date.now;
117
+ const whitelisted = (method, pathname) => {
118
+ if (pathname === '/login')
119
+ return method === 'GET' || method === 'HEAD' || method === 'POST';
120
+ if (pathname === '/logout')
121
+ return method === 'GET' || method === 'POST';
122
+ return false;
123
+ };
124
+ const sessionOf = (req) => verifySession(cookieValue(req.headers.cookie, COOKIE_NAME), env.state.secret, now());
125
+ /**
126
+ * Present AUTHENTICATED traffic as loopback to every downstream
127
+ * browser-trust fence, without tracking plugin path prefixes. The /api
128
+ * gateway (dsh-client-connection) and any plugin surface that mirrors its
129
+ * fence (dsh-better-sidebar's /sidebar/* today, whatever prefix the next
130
+ * plugin picks) decide admission from the Host/Origin pair: loopback or
131
+ * the deployment's --trusted-host list. Rather than enumerating prefixes
132
+ * here, ANY request that passed the session check (whitelisted /login,
133
+ * /logout never reach this point) is presented as loopback: SameSite=Lax
134
+ * makes a valid session cookie same-origin by construction, so this is
135
+ * exactly the trust decision the /api rewrite already made, and the gate
136
+ * still refuses unauthenticated traffic before any fence is consulted
137
+ * (401/302). No dsh surface generates content or URLs from the request
138
+ * Host, and the browser builds its own URLs from window.location, so the
139
+ * rewrite is invisible to real behavior. The sec-fetch-site marker is
140
+ * deliberately NOT rewritten: cross-site requests stay refused by the
141
+ * fences even with a session.
142
+ */
143
+ const rewriteAsLoopback = (req) => {
144
+ const authority = env.loopbackAuthority;
145
+ if (authority === undefined)
146
+ return;
147
+ req.headers.host = authority;
148
+ if (req.headers.origin !== undefined) {
149
+ req.headers.origin = 'http://' + authority;
150
+ }
151
+ };
152
+ return {
153
+ allow(req, res) {
154
+ const pathname = pathnameOf(req);
155
+ const method = req.method ?? 'GET';
156
+ if (whitelisted(method, pathname))
157
+ return true;
158
+ const session = sessionOf(req);
159
+ if (session !== undefined) {
160
+ if (needsSessionRefresh(session, now())) {
161
+ setSessionCookie(res, env.state.secret, now(), isSecureRequest(req));
162
+ }
163
+ rewriteAsLoopback(req);
164
+ return true;
165
+ }
166
+ if (method === 'GET' || method === 'HEAD') {
167
+ const next = sanitizeNext(req.url) ?? '/';
168
+ redirect(res, '/login?next=' + encodeURIComponent(next));
169
+ }
170
+ else {
171
+ writeJson(res, 401, { error: 'unauthorized' });
172
+ }
173
+ return false;
174
+ },
175
+ allowUpgrade(req) {
176
+ if (sessionOf(req) === undefined)
177
+ return false;
178
+ rewriteAsLoopback(req);
179
+ return true;
180
+ }
181
+ };
182
+ }
183
+ // ── /login GET — the page ───────────────────────────────────────────────────
184
+ export function handleLoginPage(req, res, env) {
185
+ const now = env.now ?? Date.now;
186
+ const params = new URL(req.url ?? '/login', 'http://x').searchParams;
187
+ const next = sanitizeNext(params.get('next')) ?? undefined;
188
+ const error = params.get('error');
189
+ // Already authenticated? Send them where they were going.
190
+ if (verifySession(cookieValue(req.headers.cookie, COOKIE_NAME), env.state.secret, now()) !== undefined) {
191
+ redirect(res, next ?? '/');
192
+ return;
193
+ }
194
+ const setup = env.state.readHash() === undefined;
195
+ const errorText = error === 'rate-limited'
196
+ ? '尝试过于频繁,请稍后再试。'
197
+ : error === 'setup-failed'
198
+ ? '设置失败:请确认两次输入一致且不少于 8 位。'
199
+ : error === 'wrong-password'
200
+ ? '密码错误。'
201
+ : undefined;
202
+ const html = renderLoginPage({ setup, next, error: errorText, loggedOut: params.get('loggedOut') === '1' });
203
+ res.writeHead(200, {
204
+ 'content-type': 'text/html; charset=utf-8',
205
+ 'cache-control': 'no-store',
206
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'",
207
+ 'x-content-type-options': 'nosniff',
208
+ 'referrer-policy': 'no-referrer'
209
+ });
210
+ res.end(html);
211
+ }
212
+ // ── /login POST — form processing ───────────────────────────────────────────
213
+ export async function handleLoginPost(req, res, env) {
214
+ const now = env.now ?? Date.now;
215
+ const secure = isSecureRequest(req);
216
+ const ip = clientIp(req);
217
+ const key = 'login:' + ip;
218
+ const blocked = env.limiter.check(key, now());
219
+ if (!blocked.allowed) {
220
+ redirect(res, '/login?error=rate-limited&next=' + encodeURIComponent(sanitizeNext(queryNext(req)) ?? ''));
221
+ return;
222
+ }
223
+ const body = await readBody(req);
224
+ if (body === undefined) {
225
+ writeJson(res, 413, { error: 'payload-too-large' });
226
+ return;
227
+ }
228
+ const fields = parseUrlEncoded(body);
229
+ const password = fields.password ?? '';
230
+ const next = sanitizeNext(fields.next) ?? '/';
231
+ const hash = env.state.readHash();
232
+ if (hash === undefined) {
233
+ // First-password setup (bootstrap): anyone may claim it until set —
234
+ // the boot log warns about this window.
235
+ const confirm = fields.confirm ?? '';
236
+ if (password.length < MIN_PASSWORD_LENGTH || password !== confirm) {
237
+ env.limiter.recordFailure(key, now());
238
+ redirect(res, '/login?error=setup-failed&next=' + encodeURIComponent(next));
239
+ return;
240
+ }
241
+ env.state.writeHash(hashPassword(password));
242
+ const secret = env.state.rotateSecret();
243
+ env.limiter.reset(key);
244
+ setSessionCookie(res, secret, now(), secure);
245
+ redirect(res, next);
246
+ return;
247
+ }
248
+ if (!verifyPassword(password, hash)) {
249
+ env.limiter.recordFailure(key, now());
250
+ redirect(res, '/login?error=wrong-password&next=' + encodeURIComponent(next));
251
+ return;
252
+ }
253
+ env.limiter.reset(key);
254
+ setSessionCookie(res, env.state.secret, now(), secure);
255
+ redirect(res, next);
256
+ }
257
+ function queryNext(req) {
258
+ try {
259
+ return new URL(req.url ?? '/login', 'http://x').searchParams.get('next') ?? undefined;
260
+ }
261
+ catch {
262
+ return undefined;
263
+ }
264
+ }
265
+ // ── /logout ─────────────────────────────────────────────────────────────────
266
+ export function handleLogout(req, res, env) {
267
+ clearCookie(res, isSecureRequest(req));
268
+ redirect(res, '/login?loggedOut=1');
269
+ }
270
+ // ── GET /api/web-auth/status — settings-card state ─────────────────────────
271
+ export function handleStatus(req, res, env) {
272
+ const body = { configured: env.state.readHash() !== undefined };
273
+ const current = env.listen?.current();
274
+ if (current !== undefined)
275
+ body.host = current;
276
+ writeJson(res, 200, body);
277
+ }
278
+ export async function handleChangePassword(req, res, env) {
279
+ const now = env.now ?? Date.now;
280
+ const ip = clientIp(req);
281
+ const key = 'change:' + ip;
282
+ const blocked = env.limiter.check(key, now());
283
+ if (!blocked.allowed) {
284
+ writeJson(res, 429, { error: 'rate-limited', retryAfterSeconds: blocked.retryAfterSeconds });
285
+ return;
286
+ }
287
+ const body = await readBody(req);
288
+ if (body === undefined) {
289
+ writeJson(res, 413, { error: 'payload-too-large' });
290
+ return;
291
+ }
292
+ let parsed;
293
+ try {
294
+ parsed = JSON.parse(body);
295
+ }
296
+ catch {
297
+ writeJson(res, 400, { error: 'invalid-json' });
298
+ return;
299
+ }
300
+ const oldPassword = parsed.oldPassword ?? '';
301
+ const newPassword = parsed.newPassword ?? '';
302
+ const hash = env.state.readHash();
303
+ if (hash === undefined) {
304
+ writeJson(res, 409, { error: 'not-configured' });
305
+ return;
306
+ }
307
+ if (!verifyPassword(oldPassword, hash)) {
308
+ env.limiter.recordFailure(key, now());
309
+ writeJson(res, 401, { error: 'wrong-password' });
310
+ return;
311
+ }
312
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
313
+ writeJson(res, 400, { error: 'password-too-short', minLength: MIN_PASSWORD_LENGTH });
314
+ return;
315
+ }
316
+ env.state.writeHash(hashPassword(newPassword));
317
+ env.state.rotateSecret();
318
+ env.limiter.reset(key);
319
+ writeJson(res, 200, { ok: true, sessionsInvalidated: true });
320
+ }
321
+ /**
322
+ * Switch the web server's bind host between '127.0.0.1' and '0.0.0.0'.
323
+ *
324
+ * The change is applied to the owning patch layer (profile or home
325
+ * cordis.patch.yml); HMR then reloads the webserver row, which closes and
326
+ * rebinds the listener. The response is written BEFORE the patch file is
327
+ * committed — the reload tears down every connection, including this one —
328
+ * so the client gets its confirmation first and the write lands a beat
329
+ * later (see {@link ListenController}).
330
+ */
331
+ export async function handleListenChange(req, res, env) {
332
+ const body = await readBody(req);
333
+ if (body === undefined) {
334
+ writeJson(res, 413, { error: 'payload-too-large' });
335
+ return;
336
+ }
337
+ let parsed;
338
+ try {
339
+ parsed = JSON.parse(body);
340
+ }
341
+ catch {
342
+ writeJson(res, 400, { error: 'invalid-json' });
343
+ return;
344
+ }
345
+ if (!isAllowedListenHost(parsed.host)) {
346
+ writeJson(res, 400, { error: 'invalid-host', allowed: [...ALLOWED_LISTEN_HOSTS] });
347
+ return;
348
+ }
349
+ const listen = env.listen;
350
+ if (listen === undefined) {
351
+ writeJson(res, 501, { error: 'unsupported' });
352
+ return;
353
+ }
354
+ const host = parsed.host;
355
+ if (listen.current() === host) {
356
+ writeJson(res, 200, { ok: true, host, unchanged: true });
357
+ return;
358
+ }
359
+ let next;
360
+ try {
361
+ next = listen.prepare(host);
362
+ }
363
+ catch (error) {
364
+ writeJson(res, 500, {
365
+ error: 'patch-prepare-failed',
366
+ message: error instanceof Error ? error.message : String(error)
367
+ });
368
+ return;
369
+ }
370
+ writeJson(res, 200, { ok: true, host, applying: true });
371
+ listen.commit(next);
372
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { PluginContext } from './context-types.ts';
2
+ /** Stable Cordis plugin name. */
3
+ export declare const name = "web-auth";
4
+ /** Services required before the gate can be installed. */
5
+ export declare const inject: string[];
6
+ /**
7
+ * Mount the auth gate and its routes.
8
+ * @param ctx - plugin context carrying the webServer service (listening).
9
+ */
10
+ export declare function apply(ctx: PluginContext): void;
package/lib/index.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * dsh-web-auth — password gate for the dsh web surface.
3
+ *
4
+ * Host half: wraps the webserver's HTTP/upgrade listeners so every request
5
+ * (routes, static fallback, WebSocket upgrades) must carry a valid session
6
+ * cookie, serves the /login page (including first-password setup), /logout,
7
+ * and the JSON change-password API used by the settings card (client half).
8
+ *
9
+ * State lives under $DSH_HOME/web-auth/ (password.hash, secret) — see
10
+ * state.ts. Until a password is configured the gate is OPEN and a boot
11
+ * warning is logged; the login page then offers first-password setup.
12
+ *
13
+ * Install (bundle layer): dsh plugin --profile web add ./dsh-web-auth
14
+ */
15
+ import { installGate } from "./gate.js";
16
+ import { RateLimiter } from "./auth-core.js";
17
+ import { readSecret, readPasswordHash, resolveDshHome, rotateSecret, writePasswordHash } from "./state.js";
18
+ import { createGate, handleChangePassword, handleListenChange, handleLoginPage, handleLoginPost, handleLogout, handleStatus } from "./handlers.js";
19
+ import { LISTEN_APPLY_DELAY_MS, prepareWebserverHostPatch, resolveListenPatchPath, writeWebserverHostPatch } from "./profile-patch.js";
20
+ import { fileURLToPath } from 'node:url';
21
+ import { join } from 'node:path';
22
+ /** Stable Cordis plugin name. */
23
+ export const name = 'web-auth';
24
+ /** Services required before the gate can be installed. */
25
+ export const inject = ['webServer'];
26
+ /**
27
+ * Mount the auth gate and its routes.
28
+ * @param ctx - plugin context carrying the webServer service (listening).
29
+ */
30
+ export function apply(ctx) {
31
+ const dshHome = resolveDshHome();
32
+ const state = {
33
+ secret: readSecret(dshHome),
34
+ readHash: () => readPasswordHash(dshHome),
35
+ writeHash: (hash) => writePasswordHash(dshHome, hash),
36
+ rotateSecret: () => {
37
+ const next = rotateSecret(dshHome);
38
+ state.secret = next;
39
+ return next;
40
+ }
41
+ };
42
+ const env = {
43
+ state,
44
+ limiter: new RateLimiter(),
45
+ // Authenticated /api requests are presented to the gateway's trust fence
46
+ // as loopback, so the privileged-method pinning (settings.*, credentials.*,
47
+ // discoverModels, …) passes through the reverse proxy. See handlers.ts.
48
+ loopbackAuthority: '127.0.0.1:' + String(ctx.webServer.port),
49
+ // Live listen-host switching: the webserver's bind host is composition
50
+ // config owned by the profile patch layer (or the home patch when it
51
+ // spells the webserver row), so the change is written there and the HMR
52
+ // watcher tails it — the webserver row reloads and rebinds. ctx.baseUrl
53
+ // is the profile directory the boot anchored on. commit() is deferred
54
+ // after the HTTP response so the reload (which destroys every
55
+ // connection, including the confirmation's) cannot tear the response
56
+ // away; failures are logged, never thrown — the client was confirmed.
57
+ listen: buildListenController(ctx)
58
+ };
59
+ if (state.readHash() === undefined) {
60
+ // console.error, not ctx.logger.warn: the cordis default logger level
61
+ // suppresses warn (level 2) unless the deployment raises it, and this
62
+ // warning matters — the first-password window is open to anyone.
63
+ console.error('[dsh-web-auth] WARNING: no password configured — web access is currently OPEN. ' +
64
+ 'Anyone reaching this server can claim a password from the /login page. ' +
65
+ 'Set one before exposing the service publicly.');
66
+ }
67
+ // ── the gate: wrap the server listeners once the socket is bound ──────────
68
+ const server = ctx.webServer.server;
69
+ const gate = createGate(env);
70
+ let disposeGate;
71
+ const install = () => {
72
+ disposeGate = installGate(server, gate);
73
+ };
74
+ if (server.listening)
75
+ install();
76
+ else
77
+ server.once('listening', install);
78
+ ctx.effect(() => {
79
+ return () => disposeGate?.();
80
+ }, 'web-auth: auth gate');
81
+ // ── routes (exact table; /api/web-auth/password wins over the /api prefix) ─
82
+ const routes = [
83
+ {
84
+ path: '/login',
85
+ handler: (req, res) => {
86
+ const method = req.method ?? 'GET';
87
+ if (method === 'POST') {
88
+ void handleLoginPost(req, res, env);
89
+ return;
90
+ }
91
+ if (method === 'GET' || method === 'HEAD') {
92
+ handleLoginPage(req, res, env);
93
+ return;
94
+ }
95
+ res.writeHead(405, { allow: 'GET, HEAD, POST' });
96
+ res.end();
97
+ }
98
+ },
99
+ {
100
+ path: '/logout',
101
+ handler: (req, res) => {
102
+ void handleLogout(req, res, env);
103
+ }
104
+ },
105
+ {
106
+ path: '/api/web-auth/password',
107
+ handler: (req, res) => {
108
+ void handleChangePassword(req, res, env);
109
+ }
110
+ },
111
+ {
112
+ path: '/api/web-auth/status',
113
+ handler: (req, res) => {
114
+ handleStatus(req, res, env);
115
+ }
116
+ },
117
+ {
118
+ path: '/api/web-auth/listen',
119
+ handler: (req, res) => {
120
+ void handleListenChange(req, res, env);
121
+ }
122
+ }
123
+ ];
124
+ for (const route of routes) {
125
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }), `web-auth: ${route.path} route`);
126
+ }
127
+ }
128
+ /**
129
+ * Bind the live listen-host controller to this deployment's patch layer.
130
+ * Resolves which layer owns the webserver row (home outranks the profile),
131
+ * then prepares/commits the host override there. Undefined baseUrl (an
132
+ * unusual embedding) yields a controller whose prepare refuses loudly —
133
+ * callers surface that as 501/500.
134
+ */
135
+ function buildListenController(ctx) {
136
+ const resolvePatch = () => {
137
+ const profilePatch = join(fileURLToPath(ctx.baseUrl), 'cordis.patch.yml');
138
+ const homePatch = join(resolveDshHome(), 'cordis.patch.yml');
139
+ return resolveListenPatchPath(profilePatch, homePatch);
140
+ };
141
+ return {
142
+ current: () => ctx.webServer.host,
143
+ prepare: (host) => prepareWebserverHostPatch(resolvePatch(), host),
144
+ commit: (content) => {
145
+ // Everything below runs after the HTTP response was written; failures
146
+ // are logged, never thrown (the client was already confirmed).
147
+ let patchPath;
148
+ try {
149
+ patchPath = resolvePatch();
150
+ }
151
+ catch (error) {
152
+ console.error('[dsh-web-auth] listen patch path unresolvable:', error);
153
+ return;
154
+ }
155
+ setTimeout(() => {
156
+ try {
157
+ writeWebserverHostPatch(patchPath, content);
158
+ }
159
+ catch (error) {
160
+ console.error('[dsh-web-auth] failed to apply listen address:', error);
161
+ }
162
+ }, LISTEN_APPLY_DELAY_MS);
163
+ }
164
+ };
165
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * dsh-web-auth — server-rendered login / first-setup page.
3
+ *
4
+ * A single self-contained HTML document (inline CSS only, no external
5
+ * assets, dark theme matching the dsh shell). Two modes:
6
+ * setup — no password has been configured yet: the form sets the first
7
+ * password (this is the bootstrap path — see README for the
8
+ * window this opens on public deployments).
9
+ * login — a password exists: the form signs a session cookie.
10
+ */
11
+ export interface LoginPageState {
12
+ setup: boolean;
13
+ error?: string;
14
+ next?: string;
15
+ /** True right after a successful logout. */
16
+ loggedOut?: boolean;
17
+ }
18
+ /**
19
+ * Render the login/setup page. `next` is the hidden form target; `error`
20
+ * renders the failure banner (kept generic for the login case).
21
+ */
22
+ export declare function renderLoginPage(state: LoginPageState): string;
@@ -0,0 +1,80 @@
1
+ const PAGE_STYLE = `
2
+ :root { color-scheme: dark; }
3
+ * { box-sizing: border-box; }
4
+ body {
5
+ margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
6
+ background: #0b0e14; color: #e6e6e6;
7
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
8
+ }
9
+ .card {
10
+ width: min(380px, calc(100vw - 40px)); background: #131722;
11
+ border: 1px solid #232a38; border-radius: 14px; padding: 28px 26px;
12
+ box-shadow: 0 12px 40px rgba(0,0,0,.45);
13
+ }
14
+ h1 { margin: 0 0 6px; font-size: 17px; font-weight: 650; }
15
+ .sub { margin: 0 0 20px; color: #8b93a7; font-size: 12.5px; line-height: 1.6; }
16
+ label { display: block; margin: 12px 0 6px; color: #aab2c5; font-size: 12px; font-weight: 600; }
17
+ input {
18
+ width: 100%; height: 38px; padding: 0 12px; border-radius: 9px; outline: none;
19
+ border: 1px solid #2a3346; background: #0d1017; color: #e6e6e6; font: inherit; font-size: 13.5px;
20
+ }
21
+ input:focus { border-color: #4f8cff; }
22
+ button {
23
+ width: 100%; height: 40px; margin-top: 20px; border: none; border-radius: 9px; cursor: pointer;
24
+ background: #4f8cff; color: #fff; font: inherit; font-size: 14px; font-weight: 600;
25
+ }
26
+ button:hover { background: #6b9dff; }
27
+ .error {
28
+ margin: 14px 0 0; padding: 9px 12px; border-radius: 8px; font-size: 12.5px;
29
+ background: rgba(255, 82, 82, .12); border: 1px solid rgba(255, 82, 82, .35); color: #ff8f8f;
30
+ }
31
+ .info {
32
+ margin: 14px 0 0; padding: 9px 12px; border-radius: 8px; font-size: 12.5px;
33
+ background: rgba(79, 140, 255, .1); border: 1px solid rgba(79, 140, 255, .3); color: #9dbdff;
34
+ }
35
+ .foot { margin: 18px 0 0; color: #5c657a; font-size: 11.5px; line-height: 1.6; }
36
+ `;
37
+ function esc(value) {
38
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
39
+ }
40
+ /**
41
+ * Render the login/setup page. `next` is the hidden form target; `error`
42
+ * renders the failure banner (kept generic for the login case).
43
+ */
44
+ export function renderLoginPage(state) {
45
+ const title = state.setup ? '设置访问密码' : '访问认证';
46
+ const sub = state.setup
47
+ ? '首次使用 dsh web:设置一个访问密码(至少 8 位)。此后访问本服务需要输入该密码。'
48
+ : '此服务需要密码访问。请输入访问密码。';
49
+ const error = state.error === undefined ? '' : `<p class="error">${esc(state.error)}</p>`;
50
+ const loggedOut = state.loggedOut === true ? '<p class="info">已退出登录。</p>' : '';
51
+ const nextField = state.next === undefined ? '' : `<input type="hidden" name="next" value="${esc(state.next)}">`;
52
+ const confirmField = state.setup
53
+ ? `<label for="confirm">确认密码</label><input id="confirm" name="confirm" type="password" required minlength="8" autocomplete="new-password">`
54
+ : '';
55
+ return `<!doctype html>
56
+ <html lang="zh-CN">
57
+ <head>
58
+ <meta charset="utf-8">
59
+ <meta name="viewport" content="width=device-width, initial-scale=1">
60
+ <title>${title} — DSH Web</title>
61
+ <style>${PAGE_STYLE}</style>
62
+ </head>
63
+ <body>
64
+ <main class="card">
65
+ <h1>${title}</h1>
66
+ <p class="sub">${sub}</p>
67
+ ${loggedOut}${error}
68
+ <form method="post" action="/login">
69
+ ${nextField}
70
+ <label for="password">${state.setup ? '新密码' : '密码'}</label>
71
+ <input id="password" name="password" type="password" required minlength="8"
72
+ autocomplete="${state.setup ? 'new-password' : 'current-password'}" autofocus>
73
+ ${confirmField}
74
+ <button type="submit">${state.setup ? '设置密码并进入' : '登录'}</button>
75
+ </form>
76
+ <p class="foot">DSH Web 访问认证 · 会话有效期 7 天(活动自动续期)</p>
77
+ </main>
78
+ </body>
79
+ </html>`;
80
+ }