Spaces:
Runtime error
Runtime error
incognitolm commited on
Commit ·
92f389e
1
Parent(s): 1a7c116
Update index.js
Browse files- server/index.js +108 -15
server/index.js
CHANGED
|
@@ -32,6 +32,7 @@ const CDN_BASE = `https://cdn.jsdelivr.net/gh/${GITHUB_REPO}`;
|
|
| 32 |
let latestSHA = null;
|
| 33 |
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
|
| 34 |
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9';
|
|
|
|
| 35 |
const LOCAL_UI_DIR = [
|
| 36 |
process.env.UI_LOCAL_PATH,
|
| 37 |
path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
|
|
@@ -45,7 +46,16 @@ const LOCAL_UI_DIR = [
|
|
| 45 |
}) || null;
|
| 46 |
|
| 47 |
// Rate limiter for admin endpoints (5 attempts per IP per minute)
|
| 48 |
-
const verifyLimiter = rateLimit({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
const DATA_DIR = "/data";
|
| 51 |
const VERSION_FILE = path.join(DATA_DIR, 'version.json');
|
|
@@ -58,6 +68,27 @@ function getRequestIp(req) {
|
|
| 58 |
|| 'unknown';
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
function getCookieMap(req) {
|
| 62 |
const cookies = (req.headers.cookie || '')
|
| 63 |
.split(';')
|
|
@@ -78,12 +109,36 @@ function hasAdminTurnstile(req) {
|
|
| 78 |
|
| 79 |
function requireAdminTurnstile(req, res, next) {
|
| 80 |
if (hasAdminTurnstile(req)) return next();
|
|
|
|
| 81 |
return res.status(403).json({ error: 'turnstile:required' });
|
| 82 |
}
|
| 83 |
|
| 84 |
-
function logAdminEvent(req, action, detail =
|
| 85 |
-
const
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
async function verifyTurnstileToken(token, remoteIp) {
|
|
@@ -236,10 +291,14 @@ app.post('/api/media/upload', express.raw({ type: () => true, limit: '100mb' }),
|
|
| 236 |
const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null;
|
| 237 |
const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null;
|
| 238 |
const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
const item = await mediaStore.storeBuffer(resolved.owner, {
|
| 240 |
name,
|
| 241 |
mimeType,
|
| 242 |
-
buffer
|
| 243 |
parentId,
|
| 244 |
sessionId,
|
| 245 |
source: 'user_upload',
|
|
@@ -277,11 +336,16 @@ app.post('/api/media/documents', async (req, res) => {
|
|
| 277 |
const resolved = await requireRequestOwner(req, res);
|
| 278 |
if (!resolved) return;
|
| 279 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
const item = await mediaStore.createDocument(resolved.owner, {
|
| 281 |
name: req.body?.name,
|
| 282 |
parentId: req.body?.parentId || null,
|
| 283 |
-
richText
|
| 284 |
-
content
|
| 285 |
source: 'user_upload',
|
| 286 |
sessionId: req.body?.sessionId || null,
|
| 287 |
});
|
|
@@ -366,8 +430,12 @@ app.put('/api/media/:id/text', async (req, res) => {
|
|
| 366 |
const resolved = await requireRequestOwner(req, res);
|
| 367 |
if (!resolved) return;
|
| 368 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
|
| 370 |
-
buffer
|
| 371 |
mimeType: req.body?.mimeType || null,
|
| 372 |
kind: req.body?.richText ? 'rich_text' : null,
|
| 373 |
name: req.body?.name || null,
|
|
@@ -438,6 +506,7 @@ if (!latestSHA) {
|
|
| 438 |
|
| 439 |
// --- Admin endpoints ---
|
| 440 |
app.get('/admin.html', async (req, res) => {
|
|
|
|
| 441 |
if (serveLocalUiFile('/admin.html', res)) return;
|
| 442 |
if (!latestSHA) return res.status(500).send('Server not ready');
|
| 443 |
|
|
@@ -457,6 +526,7 @@ app.get('/admin.html', async (req, res) => {
|
|
| 457 |
});
|
| 458 |
|
| 459 |
app.get('/admin/config', (req, res) => {
|
|
|
|
| 460 |
res.json({
|
| 461 |
siteKey: TURNSTILE_SITE_KEY || null,
|
| 462 |
verified: hasAdminTurnstile(req),
|
|
@@ -467,11 +537,18 @@ app.post('/admin/turnstile', async (req, res) => {
|
|
| 467 |
try {
|
| 468 |
const token = req.body?.token;
|
| 469 |
const result = await verifyTurnstileToken(token, getRequestIp(req));
|
| 470 |
-
if (!result?.success)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
res.cookie('admin_turnstile', '1', { maxAge: 2 * 3600 * 1000, path: '/', sameSite: 'lax' });
|
|
|
|
| 472 |
return res.json({ success: true });
|
| 473 |
} catch (err) {
|
| 474 |
console.error('admin turnstile verify', err);
|
|
|
|
| 475 |
return res.status(500).json({ error: err.message || 'Server error' });
|
| 476 |
}
|
| 477 |
});
|
|
@@ -479,32 +556,48 @@ app.post('/admin/turnstile', async (req, res) => {
|
|
| 479 |
app.get('/admin/verify', requireAdminTurnstile, verifyLimiter, (req,res)=>{
|
| 480 |
const token = req.query.token;
|
| 481 |
const success = token===ADMIN_TOKEN;
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
| 483 |
res.json({success});
|
| 484 |
});
|
| 485 |
|
| 486 |
app.get('/admin/refresh', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 487 |
const token = req.query.token;
|
| 488 |
-
if (token !== ADMIN_TOKEN)
|
|
|
|
|
|
|
|
|
|
| 489 |
|
| 490 |
const sha = req.query.sha?.trim();
|
| 491 |
if (sha) {
|
| 492 |
-
if (!/^[0-9a-f]{7,40}$/.test(sha))
|
|
|
|
|
|
|
|
|
|
| 493 |
latestSHA = sha;
|
| 494 |
saveStoredSHA(latestSHA);
|
| 495 |
-
logAdminEvent(req, 'set_sha',
|
| 496 |
console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`);
|
| 497 |
return res.send(`Version set to commit ${latestSHA}`);
|
| 498 |
}
|
| 499 |
|
| 500 |
await fetchLatestSHA();
|
| 501 |
-
logAdminEvent(req, 'refresh_latest',
|
| 502 |
res.send(`Latest version refreshed: ${latestSHA}`);
|
| 503 |
});
|
| 504 |
|
| 505 |
app.get('/admin/status', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 506 |
const token = req.query.token;
|
| 507 |
-
if (token !== ADMIN_TOKEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
res.json({
|
| 509 |
publicUrl: PUBLIC_URL,
|
| 510 |
currentSha: latestSHA,
|
|
|
|
| 32 |
let latestSHA = null;
|
| 33 |
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'supersecret';
|
| 34 |
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '0x4AAAAAAC1ZXKIhZ9Kdz8j9';
|
| 35 |
+
const MAX_TEXT_UPLOAD_BYTES = 100 * 1024;
|
| 36 |
const LOCAL_UI_DIR = [
|
| 37 |
process.env.UI_LOCAL_PATH,
|
| 38 |
path.resolve(__dirname, '..', '..', 'InferencePort-Pages'),
|
|
|
|
| 46 |
}) || null;
|
| 47 |
|
| 48 |
// Rate limiter for admin endpoints (5 attempts per IP per minute)
|
| 49 |
+
const verifyLimiter = rateLimit({
|
| 50 |
+
windowMs: 60 * 1000,
|
| 51 |
+
max: 5,
|
| 52 |
+
standardHeaders: true,
|
| 53 |
+
legacyHeaders: false,
|
| 54 |
+
handler: (req, res) => {
|
| 55 |
+
logAdminEvent(req, 'rate_limited');
|
| 56 |
+
res.status(429).json({ error: 'rate_limited' });
|
| 57 |
+
},
|
| 58 |
+
});
|
| 59 |
|
| 60 |
const DATA_DIR = "/data";
|
| 61 |
const VERSION_FILE = path.join(DATA_DIR, 'version.json');
|
|
|
|
| 68 |
|| 'unknown';
|
| 69 |
}
|
| 70 |
|
| 71 |
+
function truncateForLog(value, max = 180) {
|
| 72 |
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
| 73 |
+
if (!text) return '';
|
| 74 |
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function extensionFromName(name = '') {
|
| 78 |
+
const ext = path.extname(String(name || '')).toLowerCase();
|
| 79 |
+
return ext.startsWith('.') ? ext.slice(1) : ext;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function isTextLikeUpload(name = '', mimeType = '', kind = '') {
|
| 83 |
+
const normalizedKind = String(kind || '').toLowerCase();
|
| 84 |
+
const mime = String(mimeType || '').toLowerCase();
|
| 85 |
+
const ext = extensionFromName(name);
|
| 86 |
+
if (normalizedKind === 'text' || normalizedKind === 'rich_text') return true;
|
| 87 |
+
if (mime.startsWith('text/')) return true;
|
| 88 |
+
if (['application/json', 'application/javascript', 'application/xml'].includes(mime)) return true;
|
| 89 |
+
return ['txt', 'md', 'json', 'js', 'ts', 'css', 'py', 'html', 'htm', 'xml', 'csv', 'rtf'].includes(ext);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
function getCookieMap(req) {
|
| 93 |
const cookies = (req.headers.cookie || '')
|
| 94 |
.split(';')
|
|
|
|
| 109 |
|
| 110 |
function requireAdminTurnstile(req, res, next) {
|
| 111 |
if (hasAdminTurnstile(req)) return next();
|
| 112 |
+
logAdminEvent(req, 'blocked_missing_turnstile');
|
| 113 |
return res.status(403).json({ error: 'turnstile:required' });
|
| 114 |
}
|
| 115 |
|
| 116 |
+
function logAdminEvent(req, action, detail = null) {
|
| 117 |
+
const parts = [
|
| 118 |
+
`[ADMIN ${new Date().toISOString()}]`,
|
| 119 |
+
`action=${action}`,
|
| 120 |
+
`ip=${getRequestIp(req)}`,
|
| 121 |
+
`method=${req.method}`,
|
| 122 |
+
`path=${truncateForLog(req.originalUrl || req.url, 220)}`,
|
| 123 |
+
];
|
| 124 |
+
const userAgent = truncateForLog(req.headers['user-agent'] || 'unknown', 140);
|
| 125 |
+
if (userAgent) parts.push(`ua="${userAgent}"`);
|
| 126 |
+
if (typeof detail === 'string' && detail.trim()) {
|
| 127 |
+
parts.push(detail.trim());
|
| 128 |
+
} else if (detail && typeof detail === 'object') {
|
| 129 |
+
Object.entries(detail).forEach(([key, value]) => {
|
| 130 |
+
if (value === undefined || value === null || value === '') return;
|
| 131 |
+
parts.push(`${key}=${JSON.stringify(String(value))}`);
|
| 132 |
+
});
|
| 133 |
+
}
|
| 134 |
+
console.log(parts.join(' | '));
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function respondTextUploadTooLarge(res) {
|
| 138 |
+
return res.status(413).json({
|
| 139 |
+
error: 'media:text_too_large',
|
| 140 |
+
message: 'Text files must be 100 KB or smaller.',
|
| 141 |
+
});
|
| 142 |
}
|
| 143 |
|
| 144 |
async function verifyTurnstileToken(token, remoteIp) {
|
|
|
|
| 291 |
const parentId = req.headers['x-parent-id'] ? String(req.headers['x-parent-id']) : null;
|
| 292 |
const sessionId = req.headers['x-session-id'] ? String(req.headers['x-session-id']) : null;
|
| 293 |
const kindHeader = req.headers['x-file-kind'] ? String(req.headers['x-file-kind']) : null;
|
| 294 |
+
const buffer = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || []);
|
| 295 |
+
if (isTextLikeUpload(name, mimeType, kindHeader) && buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
|
| 296 |
+
return respondTextUploadTooLarge(res);
|
| 297 |
+
}
|
| 298 |
const item = await mediaStore.storeBuffer(resolved.owner, {
|
| 299 |
name,
|
| 300 |
mimeType,
|
| 301 |
+
buffer,
|
| 302 |
parentId,
|
| 303 |
sessionId,
|
| 304 |
source: 'user_upload',
|
|
|
|
| 336 |
const resolved = await requireRequestOwner(req, res);
|
| 337 |
if (!resolved) return;
|
| 338 |
try {
|
| 339 |
+
const richText = !!req.body?.richText;
|
| 340 |
+
const content = String(req.body?.content || '');
|
| 341 |
+
if (Buffer.byteLength(content, 'utf8') > MAX_TEXT_UPLOAD_BYTES) {
|
| 342 |
+
return respondTextUploadTooLarge(res);
|
| 343 |
+
}
|
| 344 |
const item = await mediaStore.createDocument(resolved.owner, {
|
| 345 |
name: req.body?.name,
|
| 346 |
parentId: req.body?.parentId || null,
|
| 347 |
+
richText,
|
| 348 |
+
content,
|
| 349 |
source: 'user_upload',
|
| 350 |
sessionId: req.body?.sessionId || null,
|
| 351 |
});
|
|
|
|
| 430 |
const resolved = await requireRequestOwner(req, res);
|
| 431 |
if (!resolved) return;
|
| 432 |
try {
|
| 433 |
+
const buffer = Buffer.from(String(req.body?.content || ''), 'utf8');
|
| 434 |
+
if (buffer.byteLength > MAX_TEXT_UPLOAD_BYTES) {
|
| 435 |
+
return respondTextUploadTooLarge(res);
|
| 436 |
+
}
|
| 437 |
const item = await mediaStore.updateContent(resolved.owner, req.params.id, {
|
| 438 |
+
buffer,
|
| 439 |
mimeType: req.body?.mimeType || null,
|
| 440 |
kind: req.body?.richText ? 'rich_text' : null,
|
| 441 |
name: req.body?.name || null,
|
|
|
|
| 506 |
|
| 507 |
// --- Admin endpoints ---
|
| 508 |
app.get('/admin.html', async (req, res) => {
|
| 509 |
+
logAdminEvent(req, 'page_view');
|
| 510 |
if (serveLocalUiFile('/admin.html', res)) return;
|
| 511 |
if (!latestSHA) return res.status(500).send('Server not ready');
|
| 512 |
|
|
|
|
| 526 |
});
|
| 527 |
|
| 528 |
app.get('/admin/config', (req, res) => {
|
| 529 |
+
logAdminEvent(req, 'config_view', { verified: hasAdminTurnstile(req) });
|
| 530 |
res.json({
|
| 531 |
siteKey: TURNSTILE_SITE_KEY || null,
|
| 532 |
verified: hasAdminTurnstile(req),
|
|
|
|
| 537 |
try {
|
| 538 |
const token = req.body?.token;
|
| 539 |
const result = await verifyTurnstileToken(token, getRequestIp(req));
|
| 540 |
+
if (!result?.success) {
|
| 541 |
+
logAdminEvent(req, 'turnstile_failed', {
|
| 542 |
+
errorCodes: Array.isArray(result?.['error-codes']) ? result['error-codes'].join(',') : '',
|
| 543 |
+
});
|
| 544 |
+
return res.status(403).json({ error: 'Verification failed' });
|
| 545 |
+
}
|
| 546 |
res.cookie('admin_turnstile', '1', { maxAge: 2 * 3600 * 1000, path: '/', sameSite: 'lax' });
|
| 547 |
+
logAdminEvent(req, 'turnstile_verified');
|
| 548 |
return res.json({ success: true });
|
| 549 |
} catch (err) {
|
| 550 |
console.error('admin turnstile verify', err);
|
| 551 |
+
logAdminEvent(req, 'turnstile_error', { message: err.message || 'unknown' });
|
| 552 |
return res.status(500).json({ error: err.message || 'Server error' });
|
| 553 |
}
|
| 554 |
});
|
|
|
|
| 556 |
app.get('/admin/verify', requireAdminTurnstile, verifyLimiter, (req,res)=>{
|
| 557 |
const token = req.query.token;
|
| 558 |
const success = token===ADMIN_TOKEN;
|
| 559 |
+
logAdminEvent(req, success ? 'login_success' : 'login_failed', {
|
| 560 |
+
turnstile: hasAdminTurnstile(req),
|
| 561 |
+
tokenProvided: !!token,
|
| 562 |
+
});
|
| 563 |
res.json({success});
|
| 564 |
});
|
| 565 |
|
| 566 |
app.get('/admin/refresh', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 567 |
const token = req.query.token;
|
| 568 |
+
if (token !== ADMIN_TOKEN) {
|
| 569 |
+
logAdminEvent(req, 'refresh_denied', { reason: 'bad_token' });
|
| 570 |
+
return res.status(403).send('Forbidden');
|
| 571 |
+
}
|
| 572 |
|
| 573 |
const sha = req.query.sha?.trim();
|
| 574 |
if (sha) {
|
| 575 |
+
if (!/^[0-9a-f]{7,40}$/.test(sha)) {
|
| 576 |
+
logAdminEvent(req, 'set_sha_invalid', { requestedSha: sha });
|
| 577 |
+
return res.status(400).send('Invalid SHA');
|
| 578 |
+
}
|
| 579 |
latestSHA = sha;
|
| 580 |
saveStoredSHA(latestSHA);
|
| 581 |
+
logAdminEvent(req, 'set_sha', { requestedSha: latestSHA });
|
| 582 |
console.log(`[${PUBLIC_URL}] Manual SHA set by admin: ${latestSHA}`);
|
| 583 |
return res.send(`Version set to commit ${latestSHA}`);
|
| 584 |
}
|
| 585 |
|
| 586 |
await fetchLatestSHA();
|
| 587 |
+
logAdminEvent(req, 'refresh_latest', { resolvedSha: latestSHA });
|
| 588 |
res.send(`Latest version refreshed: ${latestSHA}`);
|
| 589 |
});
|
| 590 |
|
| 591 |
app.get('/admin/status', requireAdminTurnstile, verifyLimiter, async (req, res) => {
|
| 592 |
const token = req.query.token;
|
| 593 |
+
if (token !== ADMIN_TOKEN) {
|
| 594 |
+
logAdminEvent(req, 'status_denied', { reason: 'bad_token' });
|
| 595 |
+
return res.status(403).json({ error: 'Forbidden' });
|
| 596 |
+
}
|
| 597 |
+
logAdminEvent(req, 'status_view', {
|
| 598 |
+
currentSha: latestSHA,
|
| 599 |
+
servingMode: LOCAL_UI_DIR ? 'local-ui' : 'cdn',
|
| 600 |
+
});
|
| 601 |
res.json({
|
| 602 |
publicUrl: PUBLIC_URL,
|
| 603 |
currentSha: latestSHA,
|