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 +21 -0
- package/README.md +170 -0
- package/bin/ework-web.js +2 -0
- package/package.json +61 -0
- package/src/attachments.ts +54 -0
- package/src/auth.ts +218 -0
- package/src/build.ts +17 -0
- package/src/config.ts +178 -0
- package/src/db.ts +106 -0
- package/src/fileview.ts +617 -0
- package/src/giteaApi.ts +333 -0
- package/src/index.ts +1310 -0
- package/src/logger.ts +44 -0
- package/src/opencode.ts +340 -0
- package/src/ratelimit.ts +35 -0
- package/src/reactions.ts +31 -0
- package/src/render/components.ts +66 -0
- package/src/render/layout.ts +240 -0
- package/src/render/markdown.ts +106 -0
- package/src/schema.sql +178 -0
- package/src/static/app.js +571 -0
- package/src/static/favicon.svg +6 -0
- package/src/static/file.js +103 -0
- package/src/static/session.js +380 -0
- package/src/static/tts.js +101 -0
- package/src/store.ts +1020 -0
- package/src/translate.ts +166 -0
- package/src/views/adminTokens.ts +122 -0
- package/src/views/home.ts +109 -0
- package/src/views/issueList.ts +89 -0
- package/src/views/issueNew.ts +35 -0
- package/src/views/issueThread.ts +179 -0
- package/src/views/issues.ts +66 -0
- package/src/views/projectMembers.ts +146 -0
- package/src/views/projectUpstreams.ts +145 -0
- package/src/views/sessionLog.ts +709 -0
- package/src/views/settings.ts +67 -0
- package/src/views/tokens.ts +146 -0
- package/src/views/ttsBackends.ts +98 -0
- package/src/views/users.ts +163 -0
- package/src/views/webhooks.ts +166 -0
- package/src/webhooks.ts +634 -0
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).
|
package/bin/ework-web.js
ADDED
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, "&")
|
|
176
|
+
.replace(/</g, "<")
|
|
177
|
+
.replace(/>/g, ">")
|
|
178
|
+
.replace(/"/g, """);
|
|
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());
|