Files: sturdier Seafile pass-through (timeouts, retries, stop on disconnect), in-place sign-in, saved folder lists, upload name clashes, undo and recycle bin, library search, zip downloads, share link options, text viewer and editor
build-container / build (push) Successful in 20m19s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 09:16:40 -04:00
1 parent 004209ed4f
commit cedd6845ce
11 files changed
+399 -64

No files matched your search

+45 -27
View File
@@ -9,6 +9,7 @@ const { Readable } = require('stream');
const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const SEAFILE_TIMEOUT = Math.max(1000, parseInt(process.env.SEAFILE_TIMEOUT_MS, 10) || 30000); // (the tests shorten this)
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
@@ -46,43 +47,60 @@ const TYPES = {
'.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
};
// Seafile calls get 30 s to start answering, file transfers (seafhttp) 2 minutes; a failed read is retried twice; and when the browser goes away
// (tab closed, download cancelled) the upstream request is stopped instead of being left running.
async function proxySeafile(req, res) {
const tu = new URL(SEAFILE_URL + req.url.slice('/sf'.length));
const inline = tu.searchParams.has('hubinline'); // preview: show in the page instead of forcing a download
tu.searchParams.delete('hubinline');
const target = tu.toString();
const headers = {};
for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range']) {
for (const h of ['authorization', 'content-type', 'content-length', 'accept', 'range', 'if-range']) {
if (req.headers[h]) headers[h] = req.headers[h];
}
const hasBody = !['GET', 'HEAD'].includes(req.method);
try {
const upstream = await fetch(target, {
method: req.method, headers, redirect: 'manual',
body: hasBody ? Readable.toWeb(req) : undefined,
duplex: hasBody ? 'half' : undefined,
});
const out = {};
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges']) {
if (inline && h === 'content-disposition') continue;
if (h === 'content-length' && upstream.headers.get('content-encoding')) continue; // decompressed by fetch, so the length changed
const v = upstream.headers.get(h);
if (v) out[h] = v;
const hasBody = !['GET', 'HEAD'].includes(req.method), canRetry = !hasBody;
const wait = /^\/seafhttp\//.test(tu.pathname) ? 120000 : SEAFILE_TIMEOUT;
let gone = false, ac = null; res.on('close', () => { if (!res.writableFinished) { gone = true; if (ac) ac.abort(); } });
let upstream, lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
if (gone) return;
ac = new AbortController(); let timedOut = false;
const timer = setTimeout(() => { timedOut = true; ac.abort(); }, wait);
try {
upstream = await fetch(target, { method: req.method, headers, redirect: 'manual', signal: ac.signal,
body: hasBody ? Readable.toWeb(req) : undefined, duplex: hasBody ? 'half' : undefined });
clearTimeout(timer);
if (canRetry && [502, 503, 504].includes(upstream.status) && attempt < 2) { try { await upstream.body.cancel(); } catch {} await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); continue; }
break;
} catch (e) {
clearTimeout(timer); upstream = null; lastErr = timedOut ? Object.assign(new Error('Seafile took too long to answer'), { timeout: true }) : e;
if (gone) return;
if (canRetry && attempt < 2) { await new Promise(r => setTimeout(r, 300 * (attempt + 1) ** 2)); continue; }
break;
}
// Rewrite redirects so they stay on the proxy and on the Seafile host.
const loc = upstream.headers.get('location');
if (loc) {
const u = new URL(loc, SEAFILE_URL);
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
}
res.writeHead(upstream.status, out);
if (upstream.body) Readable.fromWeb(upstream.body).pipe(res); else res.end();
} catch (e) {
res.writeHead(502, { 'content-type': 'application/json' });
const why = e.cause ? (e.cause.code || e.cause.message) : '';
console.error('Seafile proxy:', target.split('?')[0], e.message, why);
res.end(JSON.stringify({ error: 'Seafile unreachable: ' + e.message + (why ? ' (' + why + ')' : '') }));
}
if (!upstream) {
const e = lastErr, why = e && e.cause ? (e.cause.code || e.cause.message) : '';
console.error('Seafile proxy:', target.split('?')[0], e && e.message, why);
if (res.headersSent) return res.destroy();
res.writeHead(e && e.timeout ? 504 : 502, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: e && e.timeout ? 'Seafile took too long to answer. Try again in a moment.' : 'Seafile unreachable: ' + (e && e.message) + (why ? ' (' + why + ')' : '') }));
}
const out = {};
for (const h of ['content-type', 'content-length', 'content-disposition', 'content-range', 'accept-ranges', 'etag', 'last-modified', 'retry-after']) {
if (inline && h === 'content-disposition') continue;
if (h === 'content-length' && upstream.headers.get('content-encoding')) continue; // decompressed by fetch, so the length changed
const v = upstream.headers.get(h);
if (v) out[h] = v;
}
// Rewrite redirects so they stay on the proxy and on the Seafile host.
const loc = upstream.headers.get('location');
if (loc) {
const u = new URL(loc, SEAFILE_URL);
if (u.origin === new URL(SEAFILE_URL).origin) out.location = '/sf' + u.pathname + u.search;
}
res.writeHead(upstream.status, out);
if (upstream.body) Readable.fromWeb(upstream.body).on('error', () => res.destroy()).pipe(res); else res.end();
}
function serveStatic(req, res) {