mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +02:00
chore(uploads): remove the broken upload page and deprecate /api/screenshots
The tunnel Upload URL page (upload.html) has been broken since the response envelope landed in 458fb81c: it reads j.filename and j.files while the server answers { success, data: { filename } } and { success, data: { files } }, so every upload reported "Saved: undefined" and the recent list stayed empty. Nothing else reads ~/.codeman/screenshots/, and handing a file to an agent goes through POST /api/sessions/:id/paste-image into the session's own workspace, so the page, its Settings row, the suffix branch of the tunnel row helper (now folded into its one caller) and the Upload URL i18n key go.
The three /api/screenshots routes keep working unchanged and log one deprecation warning per process on first use, naming paste-image as the replacement. Per docs/versioning-policy.md they are removed in a later MAJOR, after at least one MINOR release that carries the warning; the docs, CLAUDE.md and the multi-user plan say so.
Static caching: with upload.html gone no HTML is served by @fastify/static any more (every page has its own no-cache route; on a built tree only the precompressed index.html.gz artifact is reachable, as application/gzip, and nothing requests it). The .html branch of setHeaders was therefore dead and goes with the test that fetched upload.html to reach it; a comment now says a new static HTML page needs its own route. The index.html no-cache assertion on the route stays.
This commit is contained in:
@@ -24,7 +24,6 @@ src/web/public/settings-ui.js
|
|||||||
src/web/public/sw.js
|
src/web/public/sw.js
|
||||||
src/web/public/terminal-ui.js
|
src/web/public/terminal-ui.js
|
||||||
src/web/public/voice-input.js
|
src/web/public/voice-input.js
|
||||||
src/web/public/upload.html
|
|
||||||
scripts/remotion/
|
scripts/remotion/
|
||||||
|
|
||||||
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
# Hand-maintained; Prettier escapes underscores in glob paths and corrupts paragraphs.
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||||
|
|
||||||
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, upload.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
**Prettier scope is deliberately narrow.** `npm run format` globs only `src/**/*.ts` and `src/web/public/**` (`lint` only `src/**/*.ts`), and `.prettierignore` then exempts most of `src/web/public/*.js` (app.js, styles.css, **mobile.css**, index.html, and 15 hand-formatted modules) plus `CLAUDE.md`. Those files are hand-formatted by design; `npm run check:public-assets` and `check:frontend-syntax` are what guard them (NUL bytes + JS syntax), not Prettier. Do not "fix" a file by adding it back to Prettier's scope.
|
||||||
|
|
||||||
## Common Gotchas
|
## Common Gotchas
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ curl -sk https://localhost:3000/api/subagents | jq # Background agents
|
|||||||
cat ~/.codeman/state.json | jq # Persisted state
|
cat ~/.codeman/state.json | jq # Persisted state
|
||||||
```
|
```
|
||||||
|
|
||||||
Mobile screenshots: `~/.codeman/screenshots/`, accessed via `GET/POST /api/screenshots`.
|
Legacy screenshots (deprecated): `GET/POST /api/screenshots` still read and write `~/.codeman/screenshots/`, but the upload page that fed them is gone, they log a one-time deprecation warning, and they are removed in a later MAJOR, after at least one MINOR release that carries the warning (`docs/versioning-policy.md`). To hand a file to an agent use `POST /api/sessions/:id/paste-image`.
|
||||||
|
|
||||||
## Performance & Limits
|
## Performance & Limits
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ payload return `{ "success": true, "data": {} }`.
|
|||||||
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
> `GET /api/sessions/:id/tail-file` (SSE), `GET /api/download`,
|
||||||
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
> `GET /api/screenshots/:name`, `GET /q/:code` (QR redirect), and the
|
||||||
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
> `GET /ws/sessions/:id/terminal` WebSocket upgrade.
|
||||||
|
>
|
||||||
|
> **Deprecated:** `POST /api/screenshots`, `GET /api/screenshots` and
|
||||||
|
> `GET /api/screenshots/:name` keep working but log a one-time warning on first
|
||||||
|
> use. They are removed in a later MAJOR, after at least one MINOR release that
|
||||||
|
> carries this warning (see `docs/versioning-policy.md`). To hand
|
||||||
|
> a file to an agent, use `POST /api/sessions/:id/paste-image`, which saves it into
|
||||||
|
> that session's workspace.
|
||||||
|
|
||||||
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
> The [agent wait endpoints](#long-polling-agent-wait) use the normal envelope but
|
||||||
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
> are the only JSON endpoints that deliberately **hold the connection open**, for up
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ Codeman now ships a global **Startup Mode** picker (App Settings, Claude CLI tab
|
|||||||
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
| `GET /api/away-digest` | Aggregate only owned sessions/events |
|
||||||
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
| `GET /api/subagents`, workflow runs | Filter by owning session (`claudeSessionId -> session -> owner`); agents not attributable to any session: admin-only |
|
||||||
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
| Push (`push-routes.ts`) | Subscription records currently carry NO identity (keyed by endpoint only): `subscribe` stamps `username`. All 8 `PUSH_EVENT_MAP` events are session-scoped, so routing = resolve owner from `data.sessionId`, deliver to that owner's (plus admins') subscriptions. Legacy identity-less subscriptions: admin-only delivery |
|
||||||
| Screenshots `/api/screenshots` | Per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
| Screenshots `/api/screenshots` (deprecated) | Deprecated: removed in a later MAJOR, so no per-user subdir is planned; the replacement `POST /api/sessions/:id/paste-image` is already session-scoped. Former plan: per-user subdir `~/.codeman/screenshots/<username>/` in multi-user mode. Note: `GET /:name` deliberately rejects `/` in names as traversal, so derive the subdir server-side from `req.authUser` and keep client-visible names flat |
|
||||||
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
| Attachments | Already session-scoped; inherits the session owner check. `attachmentConfineToWorkspace` is a global, default-OFF setting today: in multi-user mode it is FORCED ON for non-admins regardless of the setting (their attachments must resolve inside their own space); the setting keeps meaning what it means for admins |
|
||||||
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
| File routes (browse/preview) | Path allowlist adds: non-admin paths must resolve (realpath) inside their own space or their own sessions' workingDirs |
|
||||||
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
| Settings (`settings.json`) | Global, admin-only writes in multi-user mode; reads allowed (per-device display keys stay in localStorage as today). Per-user server settings: out of scope v1 |
|
||||||
@@ -228,7 +228,7 @@ These operate directly on `users.json` via `user-store.ts` (no server needed), h
|
|||||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
| Default (no flag) | No behavior change. No new file reads on the hot path. All new fields optional in state |
|
||||||
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
| State round-trip | `SessionState.owner`, `MuxSession.owner`, `CronJob.owner`, registry `owner` fields are optional; old state loads clean; new state loaded by an old build ignores unknown fields (existing tolerant parsing) |
|
||||||
| Instance isolation | `users.json`, audit log, screenshots subdirs all via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
| Instance isolation | `users.json` and the audit log via `dataPath()`; user spaces dir is shared across instances like `~/codeman-cases` is today (documented) |
|
||||||
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
| API versioning | HTTP API is internal per `docs/versioning-policy.md`; still, all changes are additive. Ship as a **minor** version |
|
||||||
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
| Hooks | Unchanged (instance-level hook secret; owner resolved from the session) |
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts
|
|||||||
### System
|
### System
|
||||||
|
|
||||||
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
`CLAUDE.md` template for new cases, default working directory, the image watcher, and
|
||||||
Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs
|
Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs
|
||||||
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office
|
||||||
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
tools with their versions and install hints (admin only in multi-user mode). In multi-user
|
||||||
mode, the **Users** administration entry is injected here.
|
mode, the **Users** administration entry is injected here.
|
||||||
|
|||||||
@@ -351,7 +351,6 @@
|
|||||||
'Remote Access': '远程访问',
|
'Remote Access': '远程访问',
|
||||||
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
||||||
'Tunnel URL': '隧道地址',
|
'Tunnel URL': '隧道地址',
|
||||||
'Upload URL': '上传地址',
|
|
||||||
Updates: '更新',
|
Updates: '更新',
|
||||||
'Current Version': '当前版本',
|
'Current Version': '当前版本',
|
||||||
'Check for Updates': '检查更新',
|
'Check for Updates': '检查更新',
|
||||||
|
|||||||
@@ -2933,10 +2933,6 @@
|
|||||||
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="tunnelUploadUrlRow" style="display:none">
|
|
||||||
<div class="set-row-text"><span class="set-row-label">Upload URL</span></div>
|
|
||||||
<span id="tunnelUploadUrlDisplay" class="set-copy" title="Click to copy"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1708,17 +1708,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
|
_updateTunnelUrlDisplay(url) {
|
||||||
const row = document.getElementById(rowId);
|
const row = document.getElementById('tunnelUrlRow');
|
||||||
const display = document.getElementById(displayId);
|
const display = document.getElementById('tunnelUrlDisplay');
|
||||||
if (!row || !display) return;
|
if (!row || !display) return;
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url + suffix;
|
|
||||||
row.style.display = '';
|
row.style.display = '';
|
||||||
display.textContent = fullUrl;
|
display.textContent = url;
|
||||||
display.onclick = () => {
|
display.onclick = () => {
|
||||||
navigator.clipboard.writeText(fullUrl).then(() => {
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
|
this.showToast('Tunnel URL copied', 'success');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -1728,11 +1727,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
_updateTunnelUrlDisplay(url) {
|
|
||||||
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
|
|
||||||
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
|
|
||||||
},
|
|
||||||
|
|
||||||
showTunnelQR() {
|
showTunnelQR() {
|
||||||
// Close existing popup if open
|
// Close existing popup if open
|
||||||
this.closeTunnelQR();
|
this.closeTunnelQR();
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en"><head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
||||||
<title>Upload Screenshot - Codeman</title>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
||||||
background: #0d1117; color: #c9d1d9;
|
|
||||||
min-height: 100vh; min-height: 100dvh;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
.container { max-width: 480px; width: 100%; padding: 24px; }
|
|
||||||
h1 { font-size: 1.4em; margin-bottom: 16px; color: #58a6ff; }
|
|
||||||
.drop-zone {
|
|
||||||
border: 2px dashed #30363d; border-radius: 12px;
|
|
||||||
padding: 48px 20px; text-align: center;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
transition: border-color 0.2s, background 0.2s;
|
|
||||||
}
|
|
||||||
.drop-zone.active { border-color: #58a6ff; background: #161b22; }
|
|
||||||
.drop-zone p { margin-bottom: 12px; font-size: 1.1em; }
|
|
||||||
.drop-zone small { color: #8b949e; }
|
|
||||||
input[type=file] { display: none; }
|
|
||||||
.preview { margin-top: 16px; text-align: center; display: none; }
|
|
||||||
.preview img { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #30363d; }
|
|
||||||
.preview .name { margin-top: 6px; font-size: 0.85em; color: #8b949e; word-break: break-all; }
|
|
||||||
button {
|
|
||||||
width: 100%; padding: 14px; margin-top: 16px;
|
|
||||||
background: #238636; color: #fff; border: none;
|
|
||||||
border-radius: 8px; font-size: 1.05em;
|
|
||||||
font-weight: 600; -webkit-appearance: none;
|
|
||||||
opacity: 0.4; pointer-events: none;
|
|
||||||
}
|
|
||||||
button.ready { opacity: 1; pointer-events: auto; }
|
|
||||||
button:active { background: #2ea043; }
|
|
||||||
.status { margin-top: 12px; padding: 10px; border-radius: 6px; text-align: center; display: none; }
|
|
||||||
.status.ok { display: block; background: #1a3a2a; color: #3fb950; }
|
|
||||||
.status.err { display: block; background: #3a1a1a; color: #f85149; }
|
|
||||||
.files { margin-top: 24px; }
|
|
||||||
.files h2 { font-size: 0.95em; color: #8b949e; margin-bottom: 8px; }
|
|
||||||
.files a { display: block; color: #58a6ff; text-decoration: none; padding: 6px 0; font-size: 0.9em; word-break: break-all; }
|
|
||||||
.files a:active { color: #79c0ff; }
|
|
||||||
.back { display: inline-block; margin-bottom: 12px; color: #8b949e; text-decoration: none; font-size: 0.9em; }
|
|
||||||
.back:active { color: #c9d1d9; }
|
|
||||||
</style>
|
|
||||||
</head><body>
|
|
||||||
<div class="container">
|
|
||||||
<a class="back" href="/">← Back to Codeman</a>
|
|
||||||
<h1>Upload Screenshot</h1>
|
|
||||||
<div class="drop-zone" id="drop">
|
|
||||||
<p>Tap to select image</p>
|
|
||||||
<small>PNG, JPG, WebP — up to 10 MB</small>
|
|
||||||
</div>
|
|
||||||
<input type="file" id="file" accept="image/*">
|
|
||||||
<div class="preview" id="preview">
|
|
||||||
<img id="previewImg" alt="Preview">
|
|
||||||
<div class="name" id="previewName"></div>
|
|
||||||
</div>
|
|
||||||
<button id="btn">Upload</button>
|
|
||||||
<div class="status" id="status"></div>
|
|
||||||
<div class="files" id="files"></div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function() {
|
|
||||||
var drop = document.getElementById('drop');
|
|
||||||
var fileInput = document.getElementById('file');
|
|
||||||
var preview = document.getElementById('preview');
|
|
||||||
var previewImg = document.getElementById('previewImg');
|
|
||||||
var previewName = document.getElementById('previewName');
|
|
||||||
var btn = document.getElementById('btn');
|
|
||||||
var status = document.getElementById('status');
|
|
||||||
var filesDiv = document.getElementById('files');
|
|
||||||
var selectedFile = null;
|
|
||||||
|
|
||||||
drop.addEventListener('click', function() { fileInput.click(); });
|
|
||||||
|
|
||||||
fileInput.addEventListener('change', function() {
|
|
||||||
if (fileInput.files && fileInput.files[0]) pick(fileInput.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drag-and-drop (desktop fallback)
|
|
||||||
drop.addEventListener('dragover', function(e) { e.preventDefault(); drop.classList.add('active'); });
|
|
||||||
drop.addEventListener('dragleave', function() { drop.classList.remove('active'); });
|
|
||||||
drop.addEventListener('drop', function(e) {
|
|
||||||
e.preventDefault(); drop.classList.remove('active');
|
|
||||||
if (e.dataTransfer && e.dataTransfer.files[0]) pick(e.dataTransfer.files[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
function pick(f) {
|
|
||||||
selectedFile = f;
|
|
||||||
previewImg.src = URL.createObjectURL(f);
|
|
||||||
previewName.textContent = f.name + ' (' + (f.size / 1024).toFixed(0) + ' KB)';
|
|
||||||
preview.style.display = 'block';
|
|
||||||
btn.classList.add('ready');
|
|
||||||
status.className = 'status';
|
|
||||||
status.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
if (!selectedFile) return;
|
|
||||||
btn.classList.remove('ready');
|
|
||||||
btn.textContent = 'Uploading\u2026';
|
|
||||||
var form = new FormData();
|
|
||||||
form.append('file', selectedFile);
|
|
||||||
fetch('/api/screenshots', { method: 'POST', body: form })
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(j) {
|
|
||||||
if (j.success) {
|
|
||||||
status.className = 'status ok';
|
|
||||||
status.textContent = 'Saved: ' + j.filename;
|
|
||||||
status.style.display = 'block';
|
|
||||||
selectedFile = null;
|
|
||||||
preview.style.display = 'none';
|
|
||||||
btn.textContent = 'Upload';
|
|
||||||
loadFiles();
|
|
||||||
} else {
|
|
||||||
status.className = 'status err';
|
|
||||||
status.textContent = j.error || 'Upload failed';
|
|
||||||
status.style.display = 'block';
|
|
||||||
btn.classList.add('ready');
|
|
||||||
btn.textContent = 'Upload';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function(e) {
|
|
||||||
status.className = 'status err';
|
|
||||||
status.textContent = e.message;
|
|
||||||
status.style.display = 'block';
|
|
||||||
btn.classList.add('ready');
|
|
||||||
btn.textContent = 'Upload';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadFiles() {
|
|
||||||
fetch('/api/screenshots')
|
|
||||||
.then(function(r) { return r.json(); })
|
|
||||||
.then(function(j) {
|
|
||||||
if (j.files && j.files.length) {
|
|
||||||
var html = '<h2>Recent uploads</h2>';
|
|
||||||
j.files.forEach(function(f) {
|
|
||||||
html += '<a href="/api/screenshots/' + encodeURIComponent(f.name) + '" target="_blank">' + f.name + '</a>';
|
|
||||||
});
|
|
||||||
filesDiv.innerHTML = html;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFiles();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body></html>
|
|
||||||
@@ -1295,8 +1295,20 @@ export function registerSystemRoutes(
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
// ========== Screenshots ==========
|
// ========== Screenshots ==========
|
||||||
|
// Deprecated (the upload page is gone): removed in a later MAJOR per
|
||||||
|
// docs/versioning-policy.md. Warns once per process on first use.
|
||||||
|
let screenshotsDeprecationWarned = false;
|
||||||
|
const warnScreenshotsDeprecated = (): void => {
|
||||||
|
if (screenshotsDeprecationWarned) return;
|
||||||
|
screenshotsDeprecationWarned = true;
|
||||||
|
console.warn(
|
||||||
|
'[deprecated] /api/screenshots is deprecated and will be removed in a future major release. ' +
|
||||||
|
'Use POST /api/sessions/:id/paste-image to hand a file to a session.'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
app.post('/api/screenshots', async (req, reply) => {
|
app.post('/api/screenshots', async (req, reply) => {
|
||||||
|
warnScreenshotsDeprecated();
|
||||||
const contentType = req.headers['content-type'] ?? '';
|
const contentType = req.headers['content-type'] ?? '';
|
||||||
if (!contentType.includes('multipart/form-data')) {
|
if (!contentType.includes('multipart/form-data')) {
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||||
@@ -1383,6 +1395,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/screenshots', async () => {
|
app.get('/api/screenshots', async () => {
|
||||||
|
warnScreenshotsDeprecated();
|
||||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||||
return { files: [] };
|
return { files: [] };
|
||||||
}
|
}
|
||||||
@@ -1396,6 +1409,7 @@ export function registerSystemRoutes(
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/screenshots/:name', async (req, reply) => {
|
app.get('/api/screenshots/:name', async (req, reply) => {
|
||||||
|
warnScreenshotsDeprecated();
|
||||||
const { name } = req.params as { name: string };
|
const { name } = req.params as { name: string };
|
||||||
// Prevent path traversal
|
// Prevent path traversal
|
||||||
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
||||||
|
|||||||
+5
-8
@@ -920,7 +920,9 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
||||||
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
|
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys, and every
|
||||||
|
// HTML page has its own route that says so (/, /index.html, /session/:id). ⚠️ A new static .html
|
||||||
|
// needs such a route too: this plugin would hand it a year of `immutable`.
|
||||||
// cacheControl disabled so setHeaders owns Cache-Control for plain static assets.
|
// cacheControl disabled so setHeaders owns Cache-Control for plain static assets.
|
||||||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||||||
await this.app.register(fastifyStatic, {
|
await this.app.register(fastifyStatic, {
|
||||||
@@ -932,7 +934,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
|
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
|
||||||
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
|
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
|
||||||
// request, which is every page load. See the v10.0.0 release notes.
|
// request, which is every page load. See the v10.0.0 release notes.
|
||||||
setHeaders: (reply, path) => {
|
setHeaders: (reply) => {
|
||||||
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
||||||
// callback wrote to the raw response and Fastify's staged reply headers then
|
// callback wrote to the raw response and Fastify's staged reply headers then
|
||||||
// overwrote it, so a route that set its own Cache-Control before .sendFile()
|
// overwrote it, so a route that set its own Cache-Control before .sendFile()
|
||||||
@@ -941,12 +943,7 @@ export class WebServer extends EventEmitter {
|
|||||||
// no-store` its route asks for — a service worker that can never update.
|
// no-store` its route asks for — a service worker that can never update.
|
||||||
// So: a route that already decided keeps its answer.
|
// So: a route that already decided keeps its answer.
|
||||||
if (reply.getHeader('Cache-Control') !== undefined) return;
|
if (reply.getHeader('Cache-Control') !== undefined) return;
|
||||||
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
if (path.includes('.html')) {
|
|
||||||
reply.header('Cache-Control', 'no-cache');
|
|
||||||
} else {
|
|
||||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -680,6 +680,24 @@ describe('system-routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/api/screenshots deprecation', () => {
|
||||||
|
it('warns once across requests and leaves the response unchanged', async () => {
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
try {
|
||||||
|
mockedExistsSync.mockReturnValue(false);
|
||||||
|
const first = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
||||||
|
const second = await harness.app.inject({ method: 'GET', url: '/api/screenshots' });
|
||||||
|
expect(JSON.parse(first.body)).toEqual({ files: [] });
|
||||||
|
expect(JSON.parse(second.body)).toEqual({ files: [] });
|
||||||
|
const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots'));
|
||||||
|
expect(deprecations).toHaveLength(1);
|
||||||
|
expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image');
|
||||||
|
} finally {
|
||||||
|
warn.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ========== GET /api/screenshots/:name ==========
|
// ========== GET /api/screenshots/:name ==========
|
||||||
|
|
||||||
describe('GET /api/screenshots/:name', () => {
|
describe('GET /api/screenshots/:name', () => {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
*
|
*
|
||||||
* That contract is load-bearing: assets are served `immutable` for a year, and
|
* That contract is load-bearing: assets are served `immutable` for a year, and
|
||||||
* `index.html` must revalidate every time or a deploy leaves browsers on stale
|
* `index.html` must revalidate every time or a deploy leaves browsers on stale
|
||||||
* markup (see `cacheBustAssets` in server.ts).
|
* markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static
|
||||||
|
* plugin (every page has its own route), so the HTML half is asserted on the route.
|
||||||
*
|
*
|
||||||
* These tests drive a REAL WebServer on purpose. Asserting against an inline
|
* These tests drive a REAL WebServer on purpose. Asserting against an inline
|
||||||
* re-registration of the plugin would keep passing after a revert in server.ts.
|
* re-registration of the plugin would keep passing after a revert in server.ts.
|
||||||
@@ -61,18 +62,6 @@ describe('static asset Cache-Control headers', () => {
|
|||||||
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('makes static HTML revalidate so deploys are picked up', async () => {
|
|
||||||
// ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that
|
|
||||||
// answers from renderIndexHtml() and never reaches @fastify/static, so asserting
|
|
||||||
// on it passes even with setHeaders fully broken (verified: reverting server.ts
|
|
||||||
// to the v9 form fails the two /app.js tests and leaves an index.html assertion
|
|
||||||
// green). upload.html has no route of its own, so it is the only HTML that
|
|
||||||
// actually exercises the `.html` branch of setHeaders.
|
|
||||||
const res = await get(`${baseUrl}/upload.html`);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.headers.get('cache-control')).toBe('no-cache');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
|
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
|
||||||
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
|
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
|
||||||
// sendFile() too, and v10 moved it from the raw response onto the reply, which
|
// sendFile() too, and v10 moved it from the raw response onto the reply, which
|
||||||
|
|||||||
Reference in New Issue
Block a user