alotta good shit

This commit is contained in:
2026-09-19 06:20:37 +02:00
parent 74f7884525
commit 1477d56658
45 changed files with 4363 additions and 2069 deletions
+145 -22
View File
@@ -11,6 +11,7 @@ import flummpress from "flummpress";
import { handleUpload } from "./upload_handler.mjs";
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
import { handleBannerUpload, handleBannerDelete } from "./banner_handler.mjs";
import { handleBrandImageUpload, handleBrandImageDelete } from "./brand_image_handler.mjs";
import { handleRethumbUpload } from "./rethumb_handler.mjs";
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
@@ -20,14 +21,13 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { verifySignature } from "./inc/anon_auth.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -120,6 +120,16 @@ function getGateLoginInjection(req) {
<label style="font-size:12px;color:#555;display:flex;align-items:center;gap:6px;"><input type="checkbox" name="kmsi" style="margin:0;"> Stay signed in</label>
<button type="submit" id="gate-login-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Sign in</button>
<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<hr style="flex:1;border:none;border-top:1px solid #ccc;">
<span style="font-size:11px;color:#999;">or</span>
<hr style="flex:1;border:none;border-top:1px solid #ccc;">
</div>
<button type="button" id="gate-passkey-btn" style="background:#f5f5f5;color:#333;border:1px solid #ccc;padding:9px;font-weight:600;font-size:13px;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;gap:8px;"
onmouseover="this.style.background='#eaeaea'" onmouseout="this.style.background='#f5f5f5'">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
Sign in with Passkey
</button>
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
No account? <a href="#" id="gate-to-register" style="color:#0051c3;text-decoration:underline;">Register</a>
</p>
@@ -273,6 +283,85 @@ function getGateLoginInjection(req) {
if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} }
});
};
// Passkey sign-in button (works for both registered users and anonymous passkey holders)
var passkeyBtn = document.getElementById('gate-passkey-btn');
if (passkeyBtn && window.PublicKeyCredential) {
passkeyBtn.addEventListener('click', async function() {
gateSetError('gate-login-error', '');
passkeyBtn.disabled = true;
passkeyBtn.style.opacity = '0.65';
try {
// 1. Get challenge from server
var beginRes = await fetch('/api/v2/anon/passkey/auth/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
var beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Failed to begin passkey authentication');
var rawChallenge = beginData.options.challenge; // save original base64url string for finish
var opts = beginData.options;
// 2. Decode base64url fields for WebAuthn API
function b64urlToArr(b64) {
var bin = atob(b64.replace(/-/g,'+').replace(/_/g,'/'));
var arr = new Uint8Array(bin.length);
for (var i=0; i<bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function arrToB64url(arr) {
var bin = '';
new Uint8Array(arr).forEach(function(b) { bin += String.fromCharCode(b); });
return btoa(bin).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
var pkOpts = {
challenge: b64urlToArr(rawChallenge),
rpId: opts.rpId,
userVerification: opts.userVerification || 'preferred',
timeout: opts.timeout || 60000,
allowCredentials: (opts.allowCredentials || []).map(function(c) {
return { type: c.type, id: b64urlToArr(c.id) };
})
};
// 3. Invoke browser passkey picker
var assertion = await navigator.credentials.get({ publicKey: pkOpts });
// 4. Send to server — use original base64url challenge string
var finishRes = await fetch('/api/v2/anon/passkey/auth/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: rawChallenge,
clientDataJSON: arrToB64url(assertion.response.clientDataJSON),
authenticatorData: arrToB64url(assertion.response.authenticatorData),
signature: arrToB64url(assertion.response.signature),
credentialId: arrToB64url(assertion.rawId)
})
});
var finishData = await finishRes.json();
if (finishData.banned) {
gateSetError('gate-login-error', 'You are banned: ' + (finishData.reason || ''));
return;
}
if (!finishData.success) throw new Error(finishData.msg || 'Passkey authentication failed');
window.location.reload();
} catch(err) {
if (err && err.name === 'NotAllowedError') {
gateSetError('gate-login-error', 'Passkey prompt was cancelled.');
} else {
gateSetError('gate-login-error', err.message || 'Passkey sign-in failed.');
}
} finally {
passkeyBtn.disabled = false;
passkeyBtn.style.opacity = '1';
}
});
} else if (passkeyBtn) {
passkeyBtn.style.display = 'none'; // hide if no WebAuthn support
}
});
var _sb = '';
@@ -610,6 +699,28 @@ process.on('uncaughtException', err => {
}
});
// Block all comment-related routes when enable_comments is disabled
app.use(async (req, res) => {
if (cfg.enable_comments !== false) return;
const p = req.url?.pathname || '';
const isCommentRoute =
/^\/api\/comments/.test(p) ||
/^\/api\/comment\//.test(p) ||
/^\/api\/subscribe\//.test(p) ||
/^\/api\/subscriptions/.test(p) ||
/^\/subscriptions(\/|$)/.test(p) ||
/^\/ajax\/subscriptions(\/|$)/.test(p) ||
/^\/api\/polls\//.test(p) ||
/^\/activity(\/|$)/.test(p) ||
/^\/user\/[^/]+\/comments/.test(p) ||
/^\/api\/v2\/comments/.test(p) ||
/^\/api\/v2\/user\/subscribe-all-uploads/.test(p);
if (isCommentRoute) {
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ success: false, message: "Comments are disabled" }));
req.url.pathname = '/comments_disabled_bypass';
}
});
// Global CORS & OPTIONS preflight handler for API routes (enables standalone config_editor.html)
app.use(async (req, res) => {
if (req.url?.pathname?.startsWith('/api/')) {
@@ -1302,20 +1413,6 @@ process.on('uncaughtException', err => {
// CSRF validation helper — used by route handlers and global middleware
const validateCsrf = async (req, res) => {
if (req.session && req.session.csrf_token) {
// Cryptographically proven requests signed by the client's private Ed25519 key are origin-bound and immune to CSRF
const sshPubkey = req.headers['x-ssh-pubkey'];
const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10);
const sshSig = req.headers['x-ssh-signature'];
if (sshPubkey && sshTimestamp && sshSig && getEnableAnonymousAccess()) {
const now = Date.now();
if (Math.abs(now - sshTimestamp) <= 300000) {
const message = `anon-auth:${sshTimestamp}:${sshPubkey}`;
if (verifySignature(sshPubkey, message, sshSig)) {
return true;
}
}
}
let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
// If header/query token is missing and body is not parsed yet on a non-GET method, parse it now
@@ -1340,7 +1437,7 @@ process.on('uncaughtException', err => {
// because the session middleware will have completed by the time router callbacks execute.
app.use(async (req, res) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/anon/passkey/register/begin', '/api/v2/anon/passkey/register/finish', '/api/v2/anon/passkey/auth/begin', '/api/v2/anon/passkey/auth/finish', '/api/v2/settings/passkeys/register/begin', '/api/v2/settings/passkeys/register/finish', '/api/v2/settings/passkeys/delete', '/api/v2/settings/passkeys/login/begin', '/api/v2/settings/passkeys/login/finish', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import', '/admin/brand_image/upload', '/admin/brand_image/delete'].includes(req.url.pathname)) return;
// DM attachment upload validates CSRF internally
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
// Hall manager routes are handled by bypass middleware with their own session auth
@@ -1385,6 +1482,18 @@ process.on('uncaughtException', err => {
}
});
// Bypass middleware for brand image upload/delete (multipart — needs raw body before router)
// CSRF is validated inside handleBrandImageUpload/handleBrandImageDelete after their own session lookups
app.use(async (req, res) => {
if (req.method === 'POST' && req.url.pathname === '/admin/brand_image/upload') {
await handleBrandImageUpload(req, res);
req.url.pathname = '/handled_brand_image_upload_bypass';
} else if (req.method === 'POST' && req.url.pathname === '/admin/brand_image/delete') {
await handleBrandImageDelete(req, res);
req.url.pathname = '/handled_brand_image_delete_bypass';
}
});
// Bypass middleware for banner upload (needs raw body before router consumes it)
// CSRF is validated inside handleBannerUpload/handleBannerDelete after their own session lookups
app.use(async (req, res) => {
@@ -1638,6 +1747,18 @@ process.on('uncaughtException', err => {
console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message);
}
// Fetch brand_image_url setting (DB overrides config.json — no writes to config.json at runtime)
try {
const biSetting = await db`SELECT value FROM site_settings WHERE key = 'brand_image_url' LIMIT 1`;
if (biSetting.length > 0) {
setBrandImageUrl(biSetting[0].value);
console.log(`[BOOT] Brand image URL loaded from DB: ${getBrandImageUrl()}`);
} else {
console.log(`[BOOT] No brand image URL in DB, using config default: ${getBrandImageUrl()}`);
}
} catch (e) {
console.warn(`[BOOT] Brand image URL fetch failed:`, e.message);
}
// Set enable_pdf from config (pure config setting)
setEnablePdf(!!cfg.enable_pdf);
@@ -1799,6 +1920,7 @@ process.on('uncaughtException', err => {
halls_enabled: cfg.websrv.halls_enabled !== false,
userhalls_enabled: cfg.websrv.userhalls_enabled !== false,
enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false,
enable_oc: cfg.websrv.enable_oc !== false,
abyss_enabled: cfg.websrv.abyss_enabled !== false,
smtp_enabled: !!(cfg.smtp && cfg.smtp.enabled && cfg.smtp.mail_reset_password),
recaptcha_enabled: !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key),
@@ -1822,6 +1944,7 @@ process.on('uncaughtException', err => {
default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl,
enable_comments: cfg.enable_comments !== false,
public_nsfw: !!cfg.websrv.public_nsfw,
public_untagged: !!cfg.websrv.public_untagged,
onara: !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara),
@@ -1876,7 +1999,7 @@ process.on('uncaughtException', err => {
return JSON.stringify(cfg.websrv.koepfe || []);
}
},
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
custom_brand_images_json: JSON.stringify(getBrandImageUrl() ? [getBrandImageUrl()] : []),
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
paths_images: cfg.websrv.paths?.images || '/b',
@@ -1997,10 +2120,10 @@ process.on('uncaughtException', err => {
globals.is_anonymized = isAnonymized;
globals.anon_anonymize = anonAnonymize;
// Random brand image per-render
const brand = cfg.websrv.custom_brand_image;
if (Array.isArray(brand) && brand.length > 0) {
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
// Brand image per-render — sourced from live in-memory setting (DB-backed, not config.json)
const brandUrl = getBrandImageUrl();
if (brandUrl) {
data.custom_brand_image = brandUrl;
}
if (activeReq) {