@lelouchhe/webagent 0.7.0 → 0.9.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/README.md +4 -1
- package/dist/index.html +31 -6
- package/dist/js/app.QC7IRDTP.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{chunk.3CCHZCG7.js → chunk.S5LRNRJI.js} +42 -41
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.GP5VXAUY.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.01ky3cex.css → styles.00nlhhf3.css} +261 -6
- package/dist/sw.js +19 -9
- package/lib/agent-key.js +6 -0
- package/lib/attachments.js +44 -7
- package/lib/auth-middleware.js +9 -2
- package/lib/bridge.js +164 -33
- package/lib/event-handler.js +91 -18
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/message-cleanup.js +5 -4
- package/lib/routes.js +316 -72
- package/lib/server.js +26 -23
- package/lib/session-manager.js +217 -44
- package/lib/session-state.js +99 -5
- package/lib/share/routes.js +12 -0
- package/lib/sse-manager.js +31 -7
- package/lib/store.js +124 -41
- package/lib/title-service.js +3 -3
- package/package.json +2 -1
- package/dist/js/app.KFQAFHA3.js +0 -4
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.T32M5AZZ.js +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path resolution and directory listing helpers for the file viewer.
|
|
3
|
+
*
|
|
4
|
+
* Contract (confirmed design): the viewer accepts *absolute* paths or `~`
|
|
5
|
+
* prefixes only — relative paths are rejected outright so there is no base
|
|
6
|
+
* ambiguity to exploit. `~` expands to HOME, then `realpath` canonicalizes
|
|
7
|
+
* symlinks and `..` segments once; every path on the system is legal by
|
|
8
|
+
* design (single-owner personal tool), so there is deliberately no escape
|
|
9
|
+
* rejection logic — the guards here prevent blocking on special files,
|
|
10
|
+
* unbounded directory scans, and whole-file buffering.
|
|
11
|
+
*/
|
|
12
|
+
import { constants } from "node:fs";
|
|
13
|
+
import { open, opendir, realpath, stat, } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { isAbsolute, join, basename } from "node:path";
|
|
16
|
+
import { expandHomePath } from "../home-path.js";
|
|
17
|
+
import { MAX_LIST_ITEMS } from "./limits.js";
|
|
18
|
+
/** HTTP status-backed error; routes map it to a JSON error response. */
|
|
19
|
+
export class FilePathError extends Error {
|
|
20
|
+
status;
|
|
21
|
+
constructor(status, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.status = status;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Expand a user-supplied path string into an absolute path.
|
|
28
|
+
* Accepts `/absolute/path` and `~/...` / `~`. Everything else (including
|
|
29
|
+
* `~user` and bare relative paths) is rejected with 400.
|
|
30
|
+
*/
|
|
31
|
+
export function expandPath(raw, home = homedir()) {
|
|
32
|
+
if (raw.length === 0)
|
|
33
|
+
throw new FilePathError(400, "Missing path");
|
|
34
|
+
if (raw.includes("\0"))
|
|
35
|
+
throw new FilePathError(400, "Invalid path");
|
|
36
|
+
const expanded = expandHomePath(raw, home);
|
|
37
|
+
if (raw.startsWith("~") && expanded === raw) {
|
|
38
|
+
throw new FilePathError(400, "Unsupported ~user expansion");
|
|
39
|
+
}
|
|
40
|
+
if (!isAbsolute(expanded)) {
|
|
41
|
+
throw new FilePathError(400, "Path must be absolute or start with ~");
|
|
42
|
+
}
|
|
43
|
+
return expanded;
|
|
44
|
+
}
|
|
45
|
+
/** realpath canonicalization; missing paths map to 404. */
|
|
46
|
+
export async function canonicalize(target) {
|
|
47
|
+
try {
|
|
48
|
+
return await realpath(target);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
if (err.code === "ENOENT") {
|
|
52
|
+
throw new FilePathError(404, "Path does not exist");
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** stat a canonical path; non-regular files (fifo/socket/device) → 400. */
|
|
58
|
+
export async function statMeta(path) {
|
|
59
|
+
const s = await stat(path);
|
|
60
|
+
let kind;
|
|
61
|
+
if (s.isFile())
|
|
62
|
+
kind = "file";
|
|
63
|
+
else if (s.isDirectory())
|
|
64
|
+
kind = "dir";
|
|
65
|
+
else
|
|
66
|
+
throw new FilePathError(400, "Not a regular file or directory");
|
|
67
|
+
return {
|
|
68
|
+
path,
|
|
69
|
+
name: basename(path),
|
|
70
|
+
kind,
|
|
71
|
+
size: s.size,
|
|
72
|
+
mtime: s.mtimeMs,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* List one directory: scan at most MAX_LIST_ITEMS plus one sentinel raw entry,
|
|
77
|
+
* omit dotfiles/special nodes, then sort the bounded result dirs-first and
|
|
78
|
+
* lexicographically. Entries that vanish mid-list are skipped.
|
|
79
|
+
*/
|
|
80
|
+
export async function listDirectory(target) {
|
|
81
|
+
const dir = await opendir(target);
|
|
82
|
+
const entries = [];
|
|
83
|
+
let scanned = 0;
|
|
84
|
+
let truncated = false;
|
|
85
|
+
// Dir's async iterator closes the descriptor both at EOF and on break.
|
|
86
|
+
for await (const entry of dir) {
|
|
87
|
+
scanned++;
|
|
88
|
+
// Read one sentinel beyond the cap so `truncated` is authoritative,
|
|
89
|
+
// but never materialize/sort an unbounded directory.
|
|
90
|
+
if (scanned > MAX_LIST_ITEMS) {
|
|
91
|
+
truncated = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
if (entry.name.startsWith("."))
|
|
95
|
+
continue;
|
|
96
|
+
try {
|
|
97
|
+
const s = await stat(join(target, entry.name));
|
|
98
|
+
// Only expose targets the viewer can actually open. Symlinks to
|
|
99
|
+
// regular files/dirs pass because stat follows them; fifos, sockets,
|
|
100
|
+
// and devices are omitted rather than mislabelled as files.
|
|
101
|
+
if (!s.isDirectory() && !s.isFile())
|
|
102
|
+
continue;
|
|
103
|
+
entries.push({
|
|
104
|
+
name: entry.name,
|
|
105
|
+
kind: s.isDirectory() ? "dir" : "file",
|
|
106
|
+
size: s.isFile() ? s.size : null,
|
|
107
|
+
mtime: s.mtimeMs,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Raced deletion — skip rather than fail the whole listing.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
entries.sort(compareEntries);
|
|
115
|
+
return { entries, truncated };
|
|
116
|
+
}
|
|
117
|
+
function compareEntries(a, b) {
|
|
118
|
+
if (a.kind !== b.kind)
|
|
119
|
+
return a.kind === "dir" ? -1 : 1;
|
|
120
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
121
|
+
}
|
|
122
|
+
/** Read up to `n` bytes from the head of a regular file (mime sniffing). */
|
|
123
|
+
export async function readHead(file, n = 4096) {
|
|
124
|
+
const h = await open(file, "r");
|
|
125
|
+
try {
|
|
126
|
+
const st = await h.stat();
|
|
127
|
+
const len = Math.min(st.size, n);
|
|
128
|
+
const buf = Buffer.alloc(len);
|
|
129
|
+
if (len > 0)
|
|
130
|
+
await h.read(buf, 0, len, 0);
|
|
131
|
+
return buf;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
await h.close();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Open without following a final symlink and without blocking on a FIFO.
|
|
139
|
+
* Windows lacks equivalent POSIX flags; fstat below remains authoritative.
|
|
140
|
+
*/
|
|
141
|
+
export function openForStreaming(file) {
|
|
142
|
+
const safeFlags = process.platform === "win32"
|
|
143
|
+
? constants.O_RDONLY
|
|
144
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
145
|
+
return open(file, safeFlags);
|
|
146
|
+
}
|
|
147
|
+
/** Read mime-sniff bytes from an already validated descriptor. */
|
|
148
|
+
export async function readHandleHead(handle, size, n = 4096) {
|
|
149
|
+
const len = Math.min(size, n);
|
|
150
|
+
const buf = Buffer.alloc(len);
|
|
151
|
+
if (len === 0)
|
|
152
|
+
return buf;
|
|
153
|
+
const { bytesRead } = await handle.read(buf, 0, len, 0);
|
|
154
|
+
return buf.subarray(0, bytesRead);
|
|
155
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File viewer HTTP routes — read-only access to arbitrary local files.
|
|
3
|
+
*
|
|
4
|
+
* URL space claimed: `/api/v1/files/{info,list,content}`.
|
|
5
|
+
* Sessionless by design (confirmed): the caller passes an absolute or
|
|
6
|
+
* `~`-prefixed path; the server own `~` expansion + realpath canonicalization.
|
|
7
|
+
* Relative paths are rejected. Bearer auth is enforced by the shared
|
|
8
|
+
* `/api/**` gate in routes.ts — these paths are deliberately NOT in the
|
|
9
|
+
* public whitelist, with one exception: `content` IS whitelisted so
|
|
10
|
+
* `<img>` / `<a download>` can fetch without an Authorization header, and
|
|
11
|
+
* it instead requires an HMAC sig+exp signed URL (issued by `info`),
|
|
12
|
+
* mirroring the attachment scheme.
|
|
13
|
+
*
|
|
14
|
+
* Guards (see paths.ts + limits.ts): only regular files/dirs are served
|
|
15
|
+
* (no fifos/sockets/devices), directory scans and inline previews are bounded,
|
|
16
|
+
* downloads stream with backpressure, and responses carry nosniff + a
|
|
17
|
+
* restrictive CSP. The viewer renders text/markdown/image only — nothing here
|
|
18
|
+
* executes content.
|
|
19
|
+
*/
|
|
20
|
+
import { basename, dirname } from "node:path";
|
|
21
|
+
import { pipeline } from "node:stream/promises";
|
|
22
|
+
import { signAttachmentUrl, verifyAttachmentSig } from "../auth.js";
|
|
23
|
+
import { buildContentDisposition, sniffMime } from "../attachments.js";
|
|
24
|
+
import { HTTP_STATUS } from "../http-status.js";
|
|
25
|
+
import { abbreviateHomePath } from "../home-path.js";
|
|
26
|
+
import { log } from "../log.js";
|
|
27
|
+
import { MAX_IMAGE_BYTES, MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
|
|
28
|
+
import { canonicalize, expandPath, FilePathError, listDirectory, openForStreaming, readHandleHead, readHead, statMeta, } from "./paths.js";
|
|
29
|
+
const flog = log.scope("files");
|
|
30
|
+
const CONTENT_TTL_SECONDS = 3600; // signed URLs live 1h, like attachments
|
|
31
|
+
const SNIFF_BYTES = 4096;
|
|
32
|
+
function fileSystemError(err) {
|
|
33
|
+
const code = err?.code;
|
|
34
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
35
|
+
return new FilePathError(HTTP_STATUS.FORBIDDEN, "Permission denied");
|
|
36
|
+
}
|
|
37
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
38
|
+
return new FilePathError(HTTP_STATUS.NOT_FOUND, "Path does not exist");
|
|
39
|
+
}
|
|
40
|
+
if (code === "ELOOP" ||
|
|
41
|
+
code === "EINVAL" ||
|
|
42
|
+
code === "ENAMETOOLONG" ||
|
|
43
|
+
code === "ERR_INVALID_ARG_VALUE") {
|
|
44
|
+
return new FilePathError(HTTP_STATUS.BAD_REQUEST, "Invalid path");
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function json(res, status, body) {
|
|
49
|
+
res.writeHead(status, {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
"Cache-Control": "no-store",
|
|
52
|
+
});
|
|
53
|
+
res.end(JSON.stringify(body));
|
|
54
|
+
}
|
|
55
|
+
function previewLimitFor(mime) {
|
|
56
|
+
const m = mime.toLowerCase();
|
|
57
|
+
if (m.startsWith("image/"))
|
|
58
|
+
return MAX_IMAGE_BYTES;
|
|
59
|
+
if (m.startsWith("text/"))
|
|
60
|
+
return MAX_TEXT_PREVIEW_BYTES;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
/** Expand + canonicalize a caller-supplied path; 400 on empty/relative. */
|
|
64
|
+
async function resolvePath(raw) {
|
|
65
|
+
if (!raw)
|
|
66
|
+
throw new FilePathError(400, "Missing path");
|
|
67
|
+
return canonicalize(expandPath(raw));
|
|
68
|
+
}
|
|
69
|
+
function contentBasePath(pathRaw) {
|
|
70
|
+
return `/api/v1/files/content?path=${encodeURIComponent(pathRaw)}`;
|
|
71
|
+
}
|
|
72
|
+
async function streamHandle(res, handle, size) {
|
|
73
|
+
const stream = handle.createReadStream({
|
|
74
|
+
autoClose: false,
|
|
75
|
+
start: 0,
|
|
76
|
+
...(size > 0 ? { end: size - 1 } : {}),
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
await pipeline(stream, res);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Headers are already committed. A client disconnect or disk read error
|
|
83
|
+
// must terminate the stream, never fall through to a second JSON response.
|
|
84
|
+
if (!res.destroyed) {
|
|
85
|
+
res.destroy(err instanceof Error ? err : undefined);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export async function handleFileRoutes(req, res, deps) {
|
|
90
|
+
const url = req.url ?? "/";
|
|
91
|
+
if (!url.startsWith("/api/v1/files"))
|
|
92
|
+
return false;
|
|
93
|
+
const m = url.match(/^\/api\/v1\/files\/(info|list|content)(?:\?(.*))?$/);
|
|
94
|
+
if (!m)
|
|
95
|
+
return false;
|
|
96
|
+
const method = req.method ?? "GET";
|
|
97
|
+
if (method !== "GET") {
|
|
98
|
+
res.setHeader("Allow", "GET");
|
|
99
|
+
json(res, HTTP_STATUS.METHOD_NOT_ALLOWED, {
|
|
100
|
+
error: "Read-only: GET only",
|
|
101
|
+
});
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const params = new URLSearchParams(m[2]);
|
|
106
|
+
const pathRaw = params.get("path") ?? "";
|
|
107
|
+
switch (m[1]) {
|
|
108
|
+
case "info":
|
|
109
|
+
await handleInfo(res, deps, pathRaw);
|
|
110
|
+
return true;
|
|
111
|
+
case "list":
|
|
112
|
+
await handleList(res, pathRaw);
|
|
113
|
+
return true;
|
|
114
|
+
case "content":
|
|
115
|
+
await handleContent(res, deps, pathRaw, params);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err instanceof FilePathError) {
|
|
121
|
+
json(res, err.status, { error: err.message });
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
const fsError = fileSystemError(err);
|
|
125
|
+
if (fsError) {
|
|
126
|
+
json(res, fsError.status, { error: fsError.message });
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
flog.error("file route failed", { url, error: String(err) });
|
|
130
|
+
json(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, { error: "internal_error" });
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
async function handleInfo(res, deps, pathRaw) {
|
|
136
|
+
const canonical = await resolvePath(pathRaw);
|
|
137
|
+
const meta = await statMeta(canonical);
|
|
138
|
+
const out = {
|
|
139
|
+
path: meta.path,
|
|
140
|
+
pathDisplay: abbreviateHomePath(meta.path),
|
|
141
|
+
name: meta.name,
|
|
142
|
+
kind: meta.kind,
|
|
143
|
+
size: meta.size,
|
|
144
|
+
mtime: meta.mtime,
|
|
145
|
+
};
|
|
146
|
+
if (meta.kind === "file") {
|
|
147
|
+
const mime = await sniffMime(await readHead(canonical, SNIFF_BYTES));
|
|
148
|
+
const previewLimit = previewLimitFor(mime);
|
|
149
|
+
out.mime = mime;
|
|
150
|
+
if (previewLimit !== null)
|
|
151
|
+
out.maxBytes = previewLimit;
|
|
152
|
+
if (deps.secret) {
|
|
153
|
+
const basePath = contentBasePath(canonical);
|
|
154
|
+
out.contentUrl = `${basePath}&${signAttachmentUrl(basePath, deps.secret, CONTENT_TTL_SECONDS)}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
json(res, HTTP_STATUS.OK, out);
|
|
158
|
+
}
|
|
159
|
+
async function handleList(res, pathRaw) {
|
|
160
|
+
const canonical = await resolvePath(pathRaw);
|
|
161
|
+
const meta = await statMeta(canonical);
|
|
162
|
+
if (meta.kind !== "dir") {
|
|
163
|
+
throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a directory");
|
|
164
|
+
}
|
|
165
|
+
const { entries, truncated } = await listDirectory(canonical);
|
|
166
|
+
const parent = dirname(canonical);
|
|
167
|
+
json(res, HTTP_STATUS.OK, {
|
|
168
|
+
path: canonical,
|
|
169
|
+
pathDisplay: abbreviateHomePath(canonical),
|
|
170
|
+
parent,
|
|
171
|
+
parentDisplay: abbreviateHomePath(parent),
|
|
172
|
+
truncated,
|
|
173
|
+
entries,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
async function handleContent(res, deps, pathRaw, params) {
|
|
177
|
+
const basePath = contentBasePath(pathRaw);
|
|
178
|
+
const sig = params.get("sig") ?? "";
|
|
179
|
+
const exp = params.get("exp") ?? "";
|
|
180
|
+
// content is whitelisted in auth-middleware.ts (media tags / downloads
|
|
181
|
+
// cannot send Authorization headers), so it must verify its own URL.
|
|
182
|
+
// Unlike the older attachment route, this new security-sensitive route
|
|
183
|
+
// fails closed when no signing secret is wired.
|
|
184
|
+
if (!deps.secret) {
|
|
185
|
+
json(res, HTTP_STATUS.SERVICE_UNAVAILABLE, {
|
|
186
|
+
error: "file_content_signing_unavailable",
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!sig || !exp || !verifyAttachmentSig(basePath, exp, sig, deps.secret)) {
|
|
191
|
+
res.writeHead(HTTP_STATUS.UNAUTHORIZED, {
|
|
192
|
+
"Content-Type": "application/json",
|
|
193
|
+
});
|
|
194
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const canonical = await resolvePath(pathRaw);
|
|
198
|
+
// `info` signs the realpath-canonical string. If that path now resolves to
|
|
199
|
+
// another target (for example it was replaced by a symlink), the old
|
|
200
|
+
// capability must not silently acquire authority over the new target.
|
|
201
|
+
if (canonical !== pathRaw) {
|
|
202
|
+
json(res, HTTP_STATUS.UNAUTHORIZED, { error: "Unauthorized" });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const handle = await openForStreaming(canonical);
|
|
206
|
+
try {
|
|
207
|
+
const stats = await handle.stat();
|
|
208
|
+
if (!stats.isFile()) {
|
|
209
|
+
throw new FilePathError(HTTP_STATUS.BAD_REQUEST, "Not a regular file");
|
|
210
|
+
}
|
|
211
|
+
const mime = await sniffMime(await readHandleHead(handle, stats.size, SNIFF_BYTES));
|
|
212
|
+
const previewLimit = previewLimitFor(mime);
|
|
213
|
+
const disposition = previewLimit !== null && stats.size <= previewLimit
|
|
214
|
+
? "inline"
|
|
215
|
+
: "attachment";
|
|
216
|
+
res.writeHead(HTTP_STATUS.OK, {
|
|
217
|
+
"Content-Type": mime,
|
|
218
|
+
"X-Content-Type-Options": "nosniff",
|
|
219
|
+
// Ordinary project files change in place; never let reopening show a
|
|
220
|
+
// cached pre-edit body under the same path-bound signed URL.
|
|
221
|
+
"Cache-Control": "no-store",
|
|
222
|
+
// Belt-and-braces: even if a mime mis-sniff ever lets a browser
|
|
223
|
+
// interpret this body as HTML, it can't load any subresource.
|
|
224
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
225
|
+
"Content-Disposition": buildContentDisposition(disposition, basename(canonical)),
|
|
226
|
+
});
|
|
227
|
+
await streamHandle(res, handle, stats.size);
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
await handle.close().catch(() => { });
|
|
231
|
+
}
|
|
232
|
+
}
|
package/lib/home-path.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, relative, sep } from "node:path";
|
|
3
|
+
const NATIVE_PATH = { isAbsolute, join, relative, sep };
|
|
4
|
+
function portableDisplayPath(input, path) {
|
|
5
|
+
return path.sep === "\\" ? input.replace(/\\/g, "/") : input;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Expand the current user's HOME shorthand with one authoritative grammar.
|
|
9
|
+
* Both `~/` and `~\` are accepted; named-user forms stay untouched.
|
|
10
|
+
*/
|
|
11
|
+
export function expandHomePath(input, home = homedir(), path = NATIVE_PATH) {
|
|
12
|
+
if (input === "~")
|
|
13
|
+
return home;
|
|
14
|
+
if (input.startsWith("~/") || input.startsWith("~\\")) {
|
|
15
|
+
const tail = input.slice(2).replace(/[\\/]/g, path.sep);
|
|
16
|
+
return path.join(home, tail);
|
|
17
|
+
}
|
|
18
|
+
return input;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Abbreviate HOME and normalize Windows display paths to portable `/`
|
|
22
|
+
* separators. Canonical filesystem paths remain native and are stored/signed
|
|
23
|
+
* separately; this function is exclusively for UI round-tripping.
|
|
24
|
+
*/
|
|
25
|
+
export function abbreviateHomePath(input, home = homedir(), path = NATIVE_PATH) {
|
|
26
|
+
const relativePath = path.relative(home, input);
|
|
27
|
+
if (relativePath === "")
|
|
28
|
+
return "~";
|
|
29
|
+
if (relativePath === ".." ||
|
|
30
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
31
|
+
path.isAbsolute(relativePath)) {
|
|
32
|
+
return portableDisplayPath(input, path);
|
|
33
|
+
}
|
|
34
|
+
return `~/${portableDisplayPath(relativePath, path)}`;
|
|
35
|
+
}
|
package/lib/http-status.js
CHANGED
package/lib/message-cleanup.js
CHANGED
|
@@ -6,13 +6,14 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
6
6
|
* Returns the number of rows removed. `now` is injectable for tests.
|
|
7
7
|
* ttlDays=0 means "keep forever" — returns 0 without touching the DB.
|
|
8
8
|
*/
|
|
9
|
-
export function sweepOnce(store, ttlDays, now = Date.now()) {
|
|
9
|
+
export function sweepOnce(store, ttlDays, now = Date.now(), onPendingCountChange) {
|
|
10
10
|
if (ttlDays <= 0)
|
|
11
11
|
return 0;
|
|
12
12
|
const threshold = now - ttlDays * DAY_MS;
|
|
13
13
|
const removed = store.deleteOlderThan(threshold);
|
|
14
14
|
if (removed > 0) {
|
|
15
15
|
mlog.info("ttl sweep", { removed, ttl_days: ttlDays });
|
|
16
|
+
onPendingCountChange?.(store.countUnprocessed());
|
|
16
17
|
}
|
|
17
18
|
return removed;
|
|
18
19
|
}
|
|
@@ -23,14 +24,14 @@ export function sweepOnce(store, ttlDays, now = Date.now()) {
|
|
|
23
24
|
*
|
|
24
25
|
* ttlDays=0 disables the scheduler entirely (handle.armed=false).
|
|
25
26
|
*/
|
|
26
|
-
export function startMessageCleanup(store, ttlDays) {
|
|
27
|
-
sweepOnce(store, ttlDays);
|
|
27
|
+
export function startMessageCleanup(store, ttlDays, onPendingCountChange) {
|
|
28
|
+
sweepOnce(store, ttlDays, Date.now(), onPendingCountChange);
|
|
28
29
|
if (ttlDays <= 0) {
|
|
29
30
|
return { armed: false, stop: () => { } };
|
|
30
31
|
}
|
|
31
32
|
const timer = setInterval(() => {
|
|
32
33
|
try {
|
|
33
|
-
sweepOnce(store, ttlDays);
|
|
34
|
+
sweepOnce(store, ttlDays, Date.now(), onPendingCountChange);
|
|
34
35
|
}
|
|
35
36
|
catch (err) {
|
|
36
37
|
mlog.error("ttl sweep failed", { error: err });
|