ework-web 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dog and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # ework-web
2
+
3
+ Standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML + vanilla JS.
4
+
5
+ Issues, comments, labels, reactions, and attachments live in a local SQLite database, served under `/<owner>/<repo>/issues/<n>` URLs. The same process also hosts an OpenCode session viewer, a file viewer, inline translate, and TTS sidecars.
6
+
7
+ ## Features
8
+
9
+ - **Multi-project issues** — `/<owner>/<repo>/issues/<n>` URLs, one DB holds all projects
10
+ - **Comments** with markdown (marked + highlight.js + DOMPurify), code highlighting, linkify
11
+ - **Attachments** — image/pdf/etc upload, stored on local filesystem, streamed via `Bun.file`
12
+ - **Labels & reactions** — per-project labels, 9-emoji reactions on comments
13
+ - **OpenCode session viewer** — read-only `/sessions`, hot bars, translate, TTS
14
+ - **File viewer** — `/file?path=…` with security gate (path validation + denylist + realpath)
15
+ - **Inline translation + TTS** — OpenAI-compatible `/v1/chat/completions` + `/v1/audio/speech`
16
+ - **Token-cookie auth** — single shared token + HMAC-signed cookie (30d)
17
+ - **Rate limiting** — token bucket on `/api/*`, `/login`, translate, TTS, settings
18
+
19
+ ## Installation
20
+
21
+ ### Prerequisites
22
+
23
+ - **Bun** runtime (v1.x) — required for all install paths
24
+ - **Gitea instance** — optional; only needed if you want an upstream mirror per project. Standalone mode (local SQLite) needs no external service.
25
+ - **LLM endpoint** (OpenAI-compatible `/v1/chat/completions`, optionally `/v1/audio/speech`) — optional; only needed for inline translation and TTS.
26
+
27
+ ### Option 1 — AIO Docker (recommended)
28
+
29
+ The all-in-one image bundles ework-web, the daemon, and opencode. Requires Docker.
30
+
31
+ ```bash
32
+ cp docker/.env.docker.example docker/.env.docker # fill WORK_TOKEN + WORK_COOKIE_SECRET
33
+ ./docker/build.sh # builds ework-aio:latest
34
+ HOST_PORT_WEB=13002 HOST_PORT_DAEMON=13101 ./docker/run.sh ework-aio:latest
35
+ ```
36
+
37
+ Default published ports are `3002` (web) and `3101` (daemon). Override `HOST_PORT_WEB` / `HOST_PORT_DAEMON` if those are taken on the host. See [`docker/run.sh`](./docker/run.sh) and [`docker/README.md`](./docker/) (if present) for full options (`HOST_BIND_ADDR`, volume names, env passthrough).
38
+
39
+ ### Option 2 — From source (development)
40
+
41
+ ```bash
42
+ git clone <this-repo> ework-web && cd ework-web
43
+ cp .env.example .env # fill WORK_TOKEN (≥8 chars) + WORK_COOKIE_SECRET (≥8 chars)
44
+ bun install
45
+ bun run check # tsc --noEmit — must pass
46
+ bun run dev # :3002 watch (or bun run start for one-shot)
47
+ ```
48
+
49
+ ### Option 3 — Systemd (production)
50
+
51
+ For a persistent single-host deploy, use the deploy script which rsyncs to `~/.local/share/ework/` and installs a systemd unit:
52
+
53
+ ```bash
54
+ ./scripts/deploy.sh --restart # type-check → rsync src/ → bun install → install unit → restart
55
+ ```
56
+
57
+ The unit runs under `User=ework` and reads `/var/lib/ework/.env`. Inspect or customize [`scripts/ework.service`](./scripts/ework.service) before first deploy; subsequent deploys leave the installed unit alone. See the **Deploy** section below for the full flow.
58
+
59
+ ### First-run setup
60
+
61
+ 1. Visit `http://127.0.0.1:3002/login` (or whichever `WORK_PORT` you set).
62
+ 2. Log in with the `WORK_TOKEN` you configured.
63
+ 3. Land on `/projects` — create a project inline, or just POST an issue to `/<owner>/<repo>/issues` and the project will be auto-created.
64
+
65
+ ## Configuration
66
+
67
+ All env vars prefixed `WORK_*`. Zod-validated in `src/config.ts`.
68
+
69
+ | Var | Default | Purpose |
70
+ |---|---|---|
71
+ | `WORK_PORT` | `3002` | Listen port |
72
+ | `WORK_HOST` | `127.0.0.1` | Bind host (use `::` for IPv6 remote) |
73
+ | `WORK_TOKEN` | — | Required access token (≥8 chars) |
74
+ | `WORK_COOKIE_SECRET` | — | HMAC secret for auth cookie (≥8 chars) |
75
+ | `WORK_OPERATOR_LOGIN` | `op` | Author attribution for writes |
76
+ | `WORK_WRITES_ENABLED` | `true` | Gate all write ops |
77
+ | `WORK_DB_PATH` | `$XDG_DATA_HOME/ework/ework.db` | SQLite DB location |
78
+ | `WORK_ATTACHMENT_ROOT` | `$XDG_DATA_HOME/ework/attachments` | Attachment files root |
79
+
80
+ Plus OpenCode / translate / TTS / file-viewer vars — see `.env.example` and `src/config.ts`.
81
+
82
+ ### Defaults worth knowing
83
+
84
+ - **Port `3002`** — chosen to avoid the 119x range used by VPN/tunnels.
85
+ - **Writes default `true`** — ework-web is intended for direct human use; flip `WORK_WRITES_ENABLED=false` for a read-only mirror.
86
+ - **Auth scope** — single operator (`WORK_OPERATOR_LOGIN`) writing as one user. The schema reserves a `users` table for multi-user; not yet surfaced in the UI.
87
+
88
+ ## Project layout
89
+
90
+ ```
91
+ src/
92
+ ├── index.ts # Bun.serve :3002 + route dispatch + security headers
93
+ ├── config.ts # Zod env validation (no gitea block)
94
+ ├── auth.ts # token + HMAC cookie
95
+ ├── store.ts # SQLite data layer (projects/issues/comments/labels/reactions/attachments)
96
+ ├── schema.sql # DDL applied on boot
97
+ ├── db.ts # rawDB handle + config table CRUD
98
+ ├── attachments.ts # filesystem blob storage + Bun.file streaming
99
+ ├── ratelimit.ts # token bucket
100
+ ├── opencode.ts # OpenCode CLI client (read-only)
101
+ ├── translate.ts # OpenAI-compatible translation
102
+ ├── fileview.ts # file/dir viewer + security gate
103
+ ├── reactions.ts # hydrate reactions onto CommentView[]
104
+ ├── render/
105
+ │ ├── layout.ts # SSR layout + THEME_CSS + utils
106
+ │ ├── markdown.ts # marked + DOMPurify + linkify (no Gitea rewrite)
107
+ │ └── components.ts # CommentView + renderCommentCard
108
+ ├── views/
109
+ │ ├── issueThread.ts # SSR issue thread (SQLite-backed)
110
+ │ ├── issueList.ts # per-project list (with search + state tabs)
111
+ │ ├── issueNew.ts # create-issue form
112
+ │ ├── issues.ts # global feed (cross-project)
113
+ │ ├── home.ts # projects list + new-project form
114
+ │ ├── sessionLog.ts # OpenCode session viewer
115
+ │ └── settings.ts # /settings form
116
+ └── static/
117
+ ├── app.js # issue-thread client (virtual scroll, poll, composer, reactions UI)
118
+ ├── session.js # session-viewer client
119
+ ├── file.js # file tail -f
120
+ ├── tts.js # TTS playback
121
+ └── favicon.svg
122
+
123
+ scripts/deploy.sh # tsc → rsync → bun install → systemctl restart ework
124
+ ```
125
+
126
+ ## URL / API surface
127
+
128
+ | Method | Path | Notes |
129
+ |---|---|---|
130
+ | `GET` | `/` | redirect → `/projects` |
131
+ | `GET/POST` | `/projects` | projects list / create new |
132
+ | `GET` | `/issues` | global feed (cross-project), `?state=&q=` |
133
+ | `GET` | `/<o>/<r>/issues` | per-project list, `?state=&q=` |
134
+ | `GET` | `/<o>/<r>/issues/new` | create form |
135
+ | `POST` | `/<o>/<r>/issues` | create (auto-creates project if missing) |
136
+ | `GET` | `/<o>/<r>/issues/<n>` | issue thread SSR |
137
+ | `GET` | `/api/<o>/<r>/issues/<n>/page?page=K` | fetch older comments page |
138
+ | `GET` | `/api/<o>/<r>/issues/<n>/since?since=ISO` | poll for new comments |
139
+ | `POST` | `/api/<o>/<r>/issues/<n>/comment` | `{body, close?, reopen?}` |
140
+ | `POST` | `/api/<o>/<r>/issues/<n>/upload` | multipart attachment upload |
141
+ | `GET` | `/attachments/<uuid>` | stream attachment (image inline, else download) |
142
+ | `GET` | `/sessions` `/sessions/:id` | OpenCode session browser |
143
+ | `GET` | `/file` `/file/raw` `/file/dl` | file viewer (path-gated) |
144
+ | `GET` | `/api/file/since` | tail -f polling |
145
+ | `POST` | `/api/translate` `/api/translate/stream` | inline translation |
146
+ | `POST` | `/api/tts` `GET /api/tts/stream/:id` | TTS staging + streaming |
147
+ | `GET/POST` | `/settings` | runtime overrides |
148
+ | `POST` | `/login` | token → cookie |
149
+
150
+ ## Deploy
151
+
152
+ ```bash
153
+ git add -A && git commit -m "..." # commit first (deploy.sh rsyncs working tree)
154
+ ./scripts/deploy.sh
155
+ ```
156
+
157
+ `deploy.sh` type-checks → rsyncs `src/` → `bun install` → installs the systemd unit from `scripts/ework.service` on first run → restarts `ework.service`. To customize the unit (different user, port, env path), edit `scripts/ework.service` before first deploy; subsequent deploys leave the installed unit alone.
158
+
159
+ Logs: `sudo journalctl -u ework -f`.
160
+
161
+ ## Scope
162
+
163
+ - ❌ No issue edit/delete.
164
+ - ❌ No milestones / assignees / PR / multi-user login (schema-compatible; not implemented).
165
+ - ❌ No migration tool from Gitea (fresh start; can be added later).
166
+ - ✅ **Never writes** to `opencode.db` (read-only SELECT).
167
+
168
+ ## License
169
+
170
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.ts";
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "ework-web",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
+ "license": "MIT",
7
+ "author": "contributors",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/ranxianglei/ework-web.git"
11
+ },
12
+ "homepage": "https://github.com/ranxianglei/ework-web",
13
+ "bugs": {
14
+ "url": "https://github.com/ranxianglei/ework-web/issues"
15
+ },
16
+ "keywords": [
17
+ "issue-tracker",
18
+ "gitea-compatible",
19
+ "opencode",
20
+ "sqlite",
21
+ "ssr",
22
+ "bun",
23
+ "self-hosted"
24
+ ],
25
+ "bin": {
26
+ "ework-web": "./bin/ework-web.js"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "bin",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "bun": ">=1.1.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "dev": "bun --watch src/index.ts",
42
+ "start": "bun src/index.ts",
43
+ "check": "tsc --noEmit",
44
+ "typecheck": "tsc --noEmit",
45
+ "test": "bun test"
46
+ },
47
+ "dependencies": {
48
+ "@types/dompurify": "^3.2.0",
49
+ "dompurify": "^3.4.11",
50
+ "highlight.js": "^11.10.0",
51
+ "jsdom": "^29.1.1",
52
+ "marked": "^15.0.0",
53
+ "marked-highlight": "^2.2.0",
54
+ "zod": "^3.23.8"
55
+ },
56
+ "devDependencies": {
57
+ "@types/bun": "latest",
58
+ "@types/jsdom": "^28.0.3",
59
+ "typescript": "^5.6.0"
60
+ }
61
+ }
@@ -0,0 +1,54 @@
1
+ // Filesystem-backed attachment storage. One file per uuid under WORK_ATTACHMENT_ROOT
2
+ // (defaults to $XDG_DATA_HOME/ework/attachments). Streamed back via Bun.file so
3
+ // large media never sits in memory.
4
+
5
+ import { mkdirSync, writeFileSync, existsSync, statSync } from "fs";
6
+ import { join } from "path";
7
+ import { randomUUID } from "crypto";
8
+
9
+ const ATTACHMENT_ROOT =
10
+ process.env.WORK_ATTACHMENT_ROOT ||
11
+ join(process.env.XDG_DATA_HOME || `${process.env.HOME}/.local/share`, "ework", "attachments");
12
+
13
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
14
+
15
+ export function attachmentPath(uuid: string): string {
16
+ return join(ATTACHMENT_ROOT, uuid);
17
+ }
18
+
19
+ export function saveAttachmentBlob(uuid: string, data: Uint8Array): string {
20
+ mkdirSync(ATTACHMENT_ROOT, { recursive: true });
21
+ const p = attachmentPath(uuid);
22
+ writeFileSync(p, data);
23
+ return p;
24
+ }
25
+
26
+ export function newAttachmentUUID(): string {
27
+ return randomUUID();
28
+ }
29
+
30
+ type BunFile = ReturnType<typeof Bun.file>;
31
+
32
+ export function readAttachmentStream(uuid: string): { file: BunFile; size: number } | null {
33
+ const p = attachmentPath(uuid);
34
+ if (!existsSync(p)) return null;
35
+ const stat = statSync(p);
36
+ return { file: Bun.file(p) as BunFile, size: stat.size };
37
+ }
38
+
39
+ export function sniffImageContentType(filename: string): string {
40
+ const lower = filename.toLowerCase();
41
+ if (lower.endsWith(".png")) return "image/png";
42
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
43
+ if (lower.endsWith(".gif")) return "image/gif";
44
+ if (lower.endsWith(".webp")) return "image/webp";
45
+ if (lower.endsWith(".svg")) return "image/svg+xml";
46
+ if (lower.endsWith(".pdf")) return "application/pdf";
47
+ if (lower.endsWith(".txt")) return "text/plain; charset=utf-8";
48
+ if (lower.endsWith(".md")) return "text/markdown; charset=utf-8";
49
+ return "application/octet-stream";
50
+ }
51
+
52
+ export function isImageContentType(ct: string): boolean {
53
+ return ct.startsWith("image/");
54
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,218 @@
1
+ import type { Config } from "./config";
2
+ import { getUserByLogin, ensureUser, verifyPat, type UserRow } from "./store";
3
+
4
+ // Per-user token-cookie auth. Cookie value is HMAC-signed and carries login +
5
+ // issued-at, so the server is stateless (no session table). Two cookie formats
6
+ // coexist for one release to avoid breaking existing sessions on upgrade:
7
+ // v2 (new): "v2.<login>.<issued_unix>.<sig>" — resolves to that user
8
+ // legacy: "<token>.<sig>" — resolves to cfg.operatorLogin (shared-token era)
9
+
10
+ export const AUTH_COOKIE_NAME = "ework_auth";
11
+ export const AUTH_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
12
+ const COOKIE_VERSION = "v2";
13
+
14
+ async function hmac(secret: string, msg: string): Promise<string> {
15
+ const key = await crypto.subtle.importKey(
16
+ "raw",
17
+ new TextEncoder().encode(secret),
18
+ { name: "HMAC", hash: "SHA-256" },
19
+ false,
20
+ ["sign"]
21
+ );
22
+ const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(msg));
23
+ return Buffer.from(new Uint8Array(sig)).toString("base64url");
24
+ }
25
+
26
+ function parseCookies(header: string | null): Record<string, string> {
27
+ const out: Record<string, string> = {};
28
+ if (!header) return out;
29
+ for (const part of header.split(";")) {
30
+ const idx = part.indexOf("=");
31
+ if (idx < 0) continue;
32
+ const k = part.slice(0, idx).trim();
33
+ const v = part.slice(idx + 1).trim();
34
+ if (k) out[k] = v;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export interface AuthResult {
40
+ ok: boolean;
41
+ user: UserRow | null;
42
+ }
43
+
44
+ export function authCookieName(cfg: Config): string {
45
+ // __Host- prefix (C2 hardening) requires Secure + Path=/ + no Domain and is only
46
+ // honored by browsers over TLS; cfg.secureCookie is flipped on only after Caddy/TLS.
47
+ return cfg.secureCookie ? `__Host-${AUTH_COOKIE_NAME}` : AUTH_COOKIE_NAME;
48
+ }
49
+
50
+ export async function makeAuthCookieHeader(cfg: Config, login: string): Promise<string> {
51
+ const issued = Math.floor(Date.now() / 1000);
52
+ const payload = `${COOKIE_VERSION}.${login}.${issued}`;
53
+ const sig = await hmac(cfg.cookieSecret, payload);
54
+ const value = `${payload}.${sig}`;
55
+ const flags = ["Path=/", "HttpOnly", `Max-Age=${AUTH_COOKIE_MAX_AGE_SECONDS}`, "SameSite=Lax"];
56
+ if (cfg.secureCookie) flags.push("Secure");
57
+ return `${authCookieName(cfg)}=${value}; ${flags.join("; ")}`;
58
+ }
59
+
60
+ // Logout: set Max-Age=0 so the browser drops the cookie immediately.
61
+ export function clearAuthCookieHeader(cfg: Config): string {
62
+ const flags = ["Path=/", "HttpOnly", "Max-Age=0", "SameSite=Lax"];
63
+ if (cfg.secureCookie) flags.push("Secure");
64
+ return `${authCookieName(cfg)}=; ${flags.join("; ")}`;
65
+ }
66
+
67
+ function ctEqual(a: string, b: string): boolean {
68
+ const ab = new TextEncoder().encode(a);
69
+ const bb = new TextEncoder().encode(b);
70
+ if (ab.length !== bb.length) return false;
71
+ let diff = 0;
72
+ for (let i = 0; i < ab.length; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
73
+ return diff === 0;
74
+ }
75
+
76
+ // v2 cookie parse: value is "v2.<login>.<issued>.<sig>" where login is
77
+ // guaranteed not to contain "." (enforced by LOGIN_RE in store.ts).
78
+ function parseV2Cookie(value: string): { login: string; issued: string; sig: string } | null {
79
+ const parts = value.split(".");
80
+ if (parts.length !== 4 || parts[0] !== COOKIE_VERSION) return null;
81
+ const [, login, issued, sig] = parts;
82
+ if (!login || !issued || !sig) return null;
83
+ return { login, issued, sig };
84
+ }
85
+
86
+ export async function checkAuth(req: Request, cfg: Config, ip?: string | null): Promise<AuthResult> {
87
+ const cookies = parseCookies(req.headers.get("cookie"));
88
+ const cookieVal = cookies[authCookieName(cfg)];
89
+
90
+ if (cookieVal) {
91
+ if (cookieVal.startsWith(`${COOKIE_VERSION}.`)) {
92
+ const parsed = parseV2Cookie(cookieVal);
93
+ if (!parsed) return { ok: false, user: null };
94
+ const payload = `${COOKIE_VERSION}.${parsed.login}.${parsed.issued}`;
95
+ const expected = await hmac(cfg.cookieSecret, payload);
96
+ if (!ctEqual(parsed.sig, expected)) return { ok: false, user: null };
97
+ const user = getUserByLogin(parsed.login);
98
+ if (!user || !user.is_active) return { ok: false, user: null };
99
+ return { ok: true, user };
100
+ }
101
+
102
+ // Legacy format: "<token>.<sig>". Accept only if token == cfg.authToken,
103
+ // then resolve to the configured operator user (auto-created on boot by
104
+ // ensureBootstrapAdmin in index.ts).
105
+ const dot = cookieVal.lastIndexOf(".");
106
+ if (dot <= 0) return { ok: false, user: null };
107
+ const token = cookieVal.slice(0, dot);
108
+ const sig = cookieVal.slice(dot + 1);
109
+ const expected = await hmac(cfg.cookieSecret, token);
110
+ if (!ctEqual(sig, expected) || !ctEqual(token, cfg.authToken)) {
111
+ return { ok: false, user: null };
112
+ }
113
+ const user = getUserByLogin(cfg.operatorLogin);
114
+ if (!user || !user.is_active) return { ok: false, user: null };
115
+ return { ok: true, user };
116
+ }
117
+
118
+ // PAT bearer (API clients / agents). Same auth surface as cookies, so any
119
+ // route that takes a logged-in cookie also takes a Bearer PAT. Two header
120
+ // shapes are accepted:
121
+ // "Bearer <token>" — RFC 6750 / OAuth standard (also GitHub-compat)
122
+ // "token <token>" — Gitea legacy form (Gitea's own client uses this)
123
+ const authHeader = req.headers.get("authorization");
124
+ if (authHeader) {
125
+ const lower = authHeader.toLowerCase();
126
+ let token: string | null = null;
127
+ if (lower.startsWith("bearer ")) {
128
+ token = authHeader.slice(7).trim();
129
+ } else if (lower.startsWith("token ")) {
130
+ token = authHeader.slice(6).trim();
131
+ }
132
+ if (token) {
133
+ const user = await verifyPat(token, ip);
134
+ if (user) return { ok: true, user };
135
+ }
136
+ }
137
+
138
+ return { ok: false, user: null };
139
+ }
140
+
141
+ export function ensureBootstrapAdmin(login: string): UserRow {
142
+ const existing = getUserByLogin(login);
143
+ if (existing) return existing;
144
+ return ensureUser(login, "human");
145
+ }
146
+
147
+ // Reserved system user for automated actions (cron, import jobs, future CI
148
+ // integration). kind=system, no password (cannot login via UI). Created on
149
+ // boot if missing. UI guards prevent disabling/deleting it.
150
+ export function ensureBootstrapSystem(login: string): UserRow {
151
+ const existing = getUserByLogin(login);
152
+ if (existing) return existing;
153
+ return ensureUser(login, "system");
154
+ }
155
+
156
+ export function isReservedSystemLogin(login: string, cfg: Config): boolean {
157
+ return login === cfg.systemLogin;
158
+ }
159
+
160
+ // Same-origin relative targets only. Rejects "//" and "/\" — browsers collapse a
161
+ // leading "/\" to "//" (protocol-relative), an open-redirect bypass (M1).
162
+ export function sanitizeNext(next: string): string {
163
+ if (!next.startsWith("/") || next.startsWith("//") || next.startsWith("/\\")) return "/";
164
+ try {
165
+ const u = new URL(next, "http://x.invalid");
166
+ if (u.origin !== "http://x.invalid") return "/";
167
+ } catch {
168
+ return "/";
169
+ }
170
+ return next;
171
+ }
172
+
173
+ function esc(s: string): string {
174
+ return (s ?? "")
175
+ .replace(/&/g, "&amp;")
176
+ .replace(/</g, "&lt;")
177
+ .replace(/>/g, "&gt;")
178
+ .replace(/"/g, "&quot;");
179
+ }
180
+
181
+ export function loginHTML(next: string, error?: string): string {
182
+ const err = error ? `<div class="err">${esc(error)}</div>` : "";
183
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
184
+ <meta name="viewport" content="width=device-width,initial-scale=1">
185
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
186
+ <title>登录 · ework</title>
187
+ <style>
188
+ body{font-family:system-ui,-apple-system,sans-serif;background:#1b1b1b;color:#e6e6e6;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
189
+ .box{background:#262626;border:1px solid #373737;border-radius:12px;padding:1.8rem;max-width:360px;width:90%}
190
+ h1{font-size:18px;margin:0 0 1.2rem;font-weight:600}
191
+ label{display:block;font-size:12px;color:#9a9a9a;margin:0 0 .25rem}
192
+ input{width:100%;box-sizing:border-box;padding:.75rem;border:1px solid #373737;border-radius:8px;background:#1b1b1b;color:#e6e6e6;font:inherit;margin-bottom:.8rem}
193
+ input:focus{outline:none;border-color:#2da44e}
194
+ button{width:100%;padding:.75rem;border:none;border-radius:8px;background:#2da44e;color:#fff;font:600 14px system-ui,sans-serif;cursor:pointer}
195
+ button:hover{background:#218742}
196
+ .err{color:#f85149;font-size:13px;margin-bottom:.8rem}
197
+ .hint{color:#9a9a9a;font-size:12px;line-height:1.5;margin-top:.6rem}
198
+ .divider{display:flex;align-items:center;gap:.6rem;color:#666;font-size:12px;margin:1rem 0 .6rem}
199
+ .divider::before,.divider::after{content:"";flex:1;height:1px;background:#373737}
200
+ .section-tag{display:inline-block;background:#373737;color:#d6d6d6;font-size:11px;padding:.15rem .55rem;border-radius:4px;margin-bottom:.4rem}
201
+ </style></head><body>
202
+ <form class="box" method="POST" action="/login" autocomplete="on">
203
+ <h1>🔒 ework 登录</h1>
204
+ ${err}
205
+ <span class="section-tag">首次登录 / 管理员</span>
206
+ <label for="f-token">共享 token</label>
207
+ <input id="f-token" name="token" type="password" autocomplete="off" autofocus placeholder="WORK_TOKEN(新部署时填这个)">
208
+ <div class="hint">新部署的管理员 token 在 <code>.env</code> 文件的 <code>WORK_TOKEN</code> 里。</div>
209
+ <div class="divider">或已注册用户</div>
210
+ <label for="f-login">用户名</label>
211
+ <input id="f-login" name="login" type="text" autocomplete="username">
212
+ <label for="f-pw">密码</label>
213
+ <input id="f-pw" name="password" type="password" autocomplete="current-password">
214
+ <input type="hidden" name="next" value="${esc(next)}">
215
+ <button type="submit">登录</button>
216
+ <div class="hint">登录后 cookie 30 天有效;token 登录后可在「我的」页面给自己设密码,之后即可用户名密码登录。</div>
217
+ </form></body></html>`;
218
+ }
package/src/build.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { statSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ const STATIC = join(__dirname, "static");
5
+ let m = 0;
6
+ for (const f of ["app.js", "session.js", "file.js", "tts.js", "highlight.css"]) {
7
+ try {
8
+ const s = statSync(join(STATIC, f));
9
+ if (s.mtimeMs > m) m = s.mtimeMs;
10
+ } catch (e) {
11
+ /* file missing at boot — fall through to Date.now() */
12
+ }
13
+ }
14
+
15
+ // Cache-bust query for /static/*.js — changes on every deploy (newest mtime),
16
+ // forcing browsers to fetch fresh instead of serving a stale heuristic-cached copy.
17
+ export const BUILD_ID = String(m || Date.now());