mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +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:
@@ -351,7 +351,6 @@
|
||||
'Remote Access': '远程访问',
|
||||
'Cloudflare Tunnel': 'Cloudflare 隧道',
|
||||
'Tunnel URL': '隧道地址',
|
||||
'Upload URL': '上传地址',
|
||||
Updates: '更新',
|
||||
'Current Version': '当前版本',
|
||||
'Check for Updates': '检查更新',
|
||||
|
||||
@@ -2933,10 +2933,6 @@
|
||||
<button class="btn-icon-sm" id="tunnelQrBtn" onclick="app.showTunnelQR()" title="Show QR code">⊞</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -1708,17 +1708,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
_updateTunnelUrlRow(rowId, displayId, url, suffix = '') {
|
||||
const row = document.getElementById(rowId);
|
||||
const display = document.getElementById(displayId);
|
||||
_updateTunnelUrlDisplay(url) {
|
||||
const row = document.getElementById('tunnelUrlRow');
|
||||
const display = document.getElementById('tunnelUrlDisplay');
|
||||
if (!row || !display) return;
|
||||
if (url) {
|
||||
const fullUrl = url + suffix;
|
||||
row.style.display = '';
|
||||
display.textContent = fullUrl;
|
||||
display.textContent = url;
|
||||
display.onclick = () => {
|
||||
navigator.clipboard.writeText(fullUrl).then(() => {
|
||||
this.showToast(`${suffix ? 'Upload' : 'Tunnel'} URL copied`, 'success');
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.showToast('Tunnel URL copied', 'success');
|
||||
});
|
||||
};
|
||||
} else {
|
||||
@@ -1728,11 +1727,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
_updateTunnelUrlDisplay(url) {
|
||||
this._updateTunnelUrlRow('tunnelUrlRow', 'tunnelUrlDisplay', url);
|
||||
this._updateTunnelUrlRow('tunnelUploadUrlRow', 'tunnelUploadUrlDisplay', url, '/upload.html');
|
||||
},
|
||||
|
||||
showTunnelQR() {
|
||||
// Close existing popup if open
|
||||
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 ==========
|
||||
// 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) => {
|
||||
warnScreenshotsDeprecated();
|
||||
const contentType = req.headers['content-type'] ?? '';
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data');
|
||||
@@ -1383,6 +1395,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/screenshots', async () => {
|
||||
warnScreenshotsDeprecated();
|
||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||
return { files: [] };
|
||||
}
|
||||
@@ -1396,6 +1409,7 @@ export function registerSystemRoutes(
|
||||
});
|
||||
|
||||
app.get('/api/screenshots/:name', async (req, reply) => {
|
||||
warnScreenshotsDeprecated();
|
||||
const { name } = req.params as { name: string };
|
||||
// Prevent path traversal
|
||||
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.
|
||||
// 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.
|
||||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||||
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
|
||||
// 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.
|
||||
setHeaders: (reply, path) => {
|
||||
setHeaders: (reply) => {
|
||||
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
||||
// 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()
|
||||
@@ -941,12 +943,7 @@ export class WebServer extends EventEmitter {
|
||||
// no-store` its route asks for — a service worker that can never update.
|
||||
// So: a route that already decided keeps its answer.
|
||||
if (reply.getHeader('Cache-Control') !== undefined) return;
|
||||
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
||||
if (path.includes('.html')) {
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
} else {
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user