alotta good shit
This commit is contained in:
+145
-22
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user