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
+201
View File
@@ -0,0 +1,201 @@
import cfg from "./inc/config.mjs";
import path from "path";
import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
import audit from "./inc/audit.mjs";
import { getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
const execFile = promisify(_execFile);
const sendJson = (res, data, code = 200) => {
const body = JSON.stringify(data);
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
/** Shared admin session + CSRF lookup */
async function getAdminSession(req, res) {
if (!req.cookies || !req.cookies.session) {
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
return null;
}
const user = await db`
SELECT "user".id, "user".login, "user".user, "user".admin,
"user_sessions".id AS sess_id, "user_sessions".csrf_token
FROM "user_sessions"
LEFT JOIN "user" ON "user".id = "user_sessions".user_id
WHERE "user_sessions".session = ${lib.sha256(req.cookies.session)}
LIMIT 1
`;
if (user.length === 0 || !user[0].admin) {
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
return null;
}
const session = user[0];
// CSRF validation via header
if (session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== session.csrf_token) {
console.warn(`[CSRF] Blocked brand image request for user ${session.user}. Invalid token.`);
sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
return null;
}
}
return session;
}
/** Delete the physical file for a stored brand image URL (if any) */
async function deleteOldBrandFile() {
const current = getBrandImageUrl();
if (!current) return;
// Strip query string to get the bare filename
const urlPath = current.split('?')[0];
// Only delete files that live under our navbar img dir
if (!urlPath.startsWith('/s/img/navbar/brand.')) return;
const filename = path.basename(urlPath);
const filePath = path.join(cfg.paths.s, 'img', 'navbar', filename);
await fs.unlink(filePath).catch(() => {});
}
/** Persist brand image URL to site_settings DB and in-memory setting */
async function persistBrandImage(url) {
setBrandImageUrl(url);
await db`
INSERT INTO site_settings (key, value)
VALUES ('brand_image_url', ${url})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
}
// ── Upload ─────────────────────────────────────────────────────────────────
export const handleBrandImageUpload = async (req, res) => {
console.log('[BRAND UPLOAD] Started');
const session = await getAdminSession(req, res);
if (!session) return;
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid content type — multipart boundary missing' }, 400);
}
const body = await collectBody(req, 5 * 1024 * 1024); // 5 MB hard cap
const parts = parseMultipart(body, boundaryMatch[1]);
const file = parts.file;
if (!file || !file.data || file.data.length === 0) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// 2 MB soft cap
const maxSize = 2 * 1024 * 1024;
if (file.data.length > maxSize) {
return sendJson(res, {
success: false,
msg: `File too large. Maximum is 2 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB`
}, 400);
}
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/svg+xml'];
const mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
if (!allowedMimes.includes(mime)) {
return sendJson(res, {
success: false,
msg: `Invalid file type. Allowed: gif, jpg, png, webp, svg. Got: ${mime}`
}, 400);
}
const imgDir = path.join(cfg.paths.s, 'img', 'navbar');
await fs.mkdir(imgDir, { recursive: true });
await fs.mkdir(cfg.paths.tmp, { recursive: true });
const isSvg = mime === 'image/svg+xml';
const ts = Date.now();
// Timestamp baked into the filename — every upload is a unique file
const outFilename = isSvg ? `brand.${ts}.svg` : `brand.${ts}.webp`;
const tmpPath = path.join(cfg.paths.tmp, `brand_tmp_${ts}`);
const finalPath = path.join(imgDir, outFilename);
await fs.writeFile(tmpPath, file.data);
if (!isSvg) {
// Verify actual MIME with file(1)
try {
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
const safeActual = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (!safeActual.includes(actualMime.trim())) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime.trim()}` }, 400);
}
} catch (_) {
// file(1) not available — skip magic check
}
// Convert to WebP via ImageMagick
try {
await execFile('magick', [tmpPath, '-coalesce', '-quality', '85', finalPath]);
} catch (err) {
console.error('[BRAND UPLOAD] ImageMagick error:', err);
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'Failed to process image (ImageMagick required)' }, 500);
}
} else {
// SVG: copy as-is
await fs.copyFile(tmpPath, finalPath);
}
await fs.unlink(tmpPath).catch(() => {});
// Delete the previous brand file from disk
await deleteOldBrandFile();
const publicUrl = `/s/img/navbar/${outFilename}`;
await persistBrandImage(publicUrl);
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: publicUrl })})`.catch(() => {});
await audit.log(session.id, 'update_brand_image', 'system', 0, { url: publicUrl });
console.log('[BRAND UPLOAD] Done:', publicUrl);
return sendJson(res, { success: true, url: publicUrl, msg: 'Brand image updated' });
} catch (err) {
if (err.code === 'BODY_TOO_LARGE') {
return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413);
}
console.error('[BRAND UPLOAD ERROR]', err);
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
}
};
// ── Delete ─────────────────────────────────────────────────────────────────
export const handleBrandImageDelete = async (req, res) => {
console.log('[BRAND DELETE] Started');
const session = await getAdminSession(req, res);
if (!session) return;
try {
// Delete the physical file before clearing the setting
await deleteOldBrandFile();
await persistBrandImage('');
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: null })})`.catch(() => {});
await audit.log(session.id, 'delete_brand_image', 'system', 0, {});
console.log('[BRAND DELETE] Done');
return sendJson(res, { success: true, msg: 'Brand image removed' });
} catch (err) {
console.error('[BRAND DELETE ERROR]', err);
return sendJson(res, { success: false, msg: 'Delete failed: ' + err.message }, 500);
}
};
+30 -108
View File
@@ -3,89 +3,6 @@ import db from './sql.mjs';
import lib from './lib.mjs';
import cfg from './config.mjs';
const SPKI_ED25519_HEADER = Buffer.from('302a300506032b6570032100', 'hex');
/**
* Parse an OpenSSH formatted Ed25519 public key.
* Format: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... [comment]"
* @param {string} sshKey
* @returns {{ keyObject: crypto.KeyObject, rawPub: Buffer, wirePub: Buffer, fingerprint: string, shortFingerprint: string }}
*/
export function parseOpenSshPubkey(sshKey) {
if (!sshKey || typeof sshKey !== 'string') {
throw new Error('Missing or invalid SSH public key');
}
const parts = sshKey.trim().split(/\s+/);
if (parts.length < 2 || parts[0] !== 'ssh-ed25519') {
throw new Error('Only ssh-ed25519 keys are supported');
}
const wirePub = Buffer.from(parts[1], 'base64');
if (wirePub.length < 19) {
throw new Error('Invalid OpenSSH public key wire payload');
}
const typeLen = wirePub.readUInt32BE(0);
if (typeLen !== 11) {
throw new Error('Invalid key type length in OpenSSH wire format');
}
const type = wirePub.subarray(4, 4 + typeLen).toString('utf8');
if (type !== 'ssh-ed25519') {
throw new Error(`Expected ssh-ed25519, got ${type}`);
}
const keyLenOffset = 4 + typeLen;
const keyLen = wirePub.readUInt32BE(keyLenOffset);
if (keyLen !== 32) {
throw new Error(`Invalid Ed25519 key length: expected 32, got ${keyLen}`);
}
const rawPub = wirePub.subarray(keyLenOffset + 4, keyLenOffset + 4 + keyLen);
if (rawPub.length !== 32) {
throw new Error('Malformed Ed25519 raw public key');
}
// Construct standard SPKI DER for crypto.createPublicKey
const der = Buffer.concat([SPKI_ED25519_HEADER, rawPub]);
const keyObject = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
// Standard OpenSSH SHA256 fingerprint: SHA256:<base64-without-padding>
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(wirePub).digest('base64').replace(/=+$/, '');
const shortFingerprint = fingerprint.slice(7, 15);
return { keyObject, rawPub, wirePub, fingerprint, shortFingerprint };
}
/**
* Verify an Ed25519 signature against an OpenSSH public key.
* @param {string} sshPubkey
* @param {string|Buffer} message
* @param {string} signature (hex or base64)
* @returns {boolean}
*/
export function verifySignature(sshPubkey, message, signature) {
try {
const { keyObject } = parseOpenSshPubkey(sshPubkey);
const msgBuf = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8');
let sigBuf;
if (typeof signature === 'string') {
const isHex = /^[0-9a-fA-F]{128}$/.test(signature);
sigBuf = isHex ? Buffer.from(signature, 'hex') : Buffer.from(signature, 'base64');
} else if (Buffer.isBuffer(signature)) {
sigBuf = signature;
} else {
return false;
}
return crypto.verify(null, msgBuf, keyObject, sigBuf);
} catch (err) {
return false;
}
}
import security from './security.mjs';
import { getHashUserIps } from './settings.mjs';
@@ -127,35 +44,40 @@ export async function logAnonActivity(req, { action, targetId = null, details =
}
/**
* Find or create a shadow user in the database for an anonymous SSH identity.
* @param {string} pubkey
* @param {string} fingerprint
* Find or create a shadow user in the database for a passkey-authenticated anonymous identity.
*
* @param {string} credentialId - base64url WebAuthn credential ID
* @param {object} [req]
* @param {string} [hwFingerprint]
* @returns {Promise<{ userId: number, isNew: boolean }>}
* @returns {Promise<{ userId: number, isNew: boolean, fingerprint: string }>}
*/
export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFingerprint = null) {
const normPubkey = pubkey.trim();
export async function getOrCreateAnonUserByCredential(credentialId, req = null, hwFingerprint = null) {
const auditIp = req ? resolveAuditIP(req) : null;
// Derive a stable "fingerprint" from the credential ID (for ban checks / display)
const fpBytes = crypto.createHash('sha256').update(Buffer.from(credentialId)).digest();
const fingerprint = 'SHA256:' + fpBytes.toString('base64').replace(/=+$/, '');
// Check if we already have a row for this credential
const existing = await db`
SELECT user_id FROM anon_identities
WHERE pubkey = ${normPubkey}
SELECT user_id FROM anon_identities
WHERE credential_id = ${credentialId}
LIMIT 1
`;
if (existing.length > 0) {
await db`
UPDATE anon_identities
UPDATE anon_identities
SET last_seen = NOW()
${auditIp ? db`, last_ip = ${auditIp}` : db``}
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE pubkey = ${normPubkey}
`;
return { userId: existing[0].user_id, isNew: false };
WHERE credential_id = ${credentialId}
`.catch(() => {});
return { userId: existing[0].user_id, isNew: false, fingerprint };
}
// Generate unique shadow username
const shortHash = crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 8);
// Generate unique shadow username based on fingerprint short hash
const shortHash = fpBytes.toString('hex').slice(0, 8);
let baseLogin = `anon_${shortHash}`;
let finalLogin = baseLogin;
let counter = 1;
@@ -180,28 +102,28 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin
`;
await db`
INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
ON CONFLICT (pubkey) DO UPDATE
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
ON CONFLICT (credential_id) DO UPDATE
SET last_seen = NOW()
${auditIp ? db`, last_ip = ${auditIp}` : db``}
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
`;
return { userId, isNew: true };
return { userId, isNew: true, fingerprint };
}
/**
* Create a valid session in user_sessions for this anonymous user.
* @param {number} userId
* @param {object} req
* Create a valid session in user_sessions for an anonymous user.
* @param {number} userId
* @param {object} req
* @param {string} [hwFingerprint]
* @returns {Promise<{ session: string, csrf_token: string }>}
*/
export async function createAnonSession(userId, req, hwFingerprint = null) {
const auditIp = resolveAuditIP(req);
// Update anon_identities last_ip, created_ip, and hw_fingerprint
// Update anon_identities last_ip and hw_fingerprint
await db`
UPDATE anon_identities
SET last_ip = ${auditIp},
@@ -210,13 +132,13 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
WHERE user_id = ${userId}
`.catch(() => {});
// 1. If req.session is already active for this exact userId, reuse it!
// If req.session is already active for this exact userId, reuse it
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
await logAnonActivity(req, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: req.session.csrf_token };
}
// 2. If client has a session cookie that maps to this userId in DB, reuse it!
// If client has a session cookie that maps to this userId in DB, reuse it
if (req?.cookies?.session) {
const existingHash = lib.sha256(req.cookies.session);
const existing = await db`
@@ -245,7 +167,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
browser: ua,
created_at: stamp,
last_used: stamp,
last_action: '/anon/session',
last_action: '/anon/passkey/auth',
kmsi: 1,
ip: ip
};
+1 -18
View File
@@ -505,24 +505,7 @@ export default new class {
};
async loggedin(req, res, next) {
if (!req.session) {
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 && Math.abs(Date.now() - sshTimestamp) <= 300000 && getEnableAnonymousAccess()) {
try {
const { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } = await import('./anon_auth.mjs');
const message = `anon-auth:${sshTimestamp}:${sshPubkey}`;
if (verifySignature(sshPubkey, message, sshSig)) {
const parsed = parseOpenSshPubkey(sshPubkey);
const { userId } = await getOrCreateAnonUser(sshPubkey, parsed.fingerprint);
req.session = { id: userId, user: 'anonymous', display_name: 'Anonymous', is_anon: true, fingerprint: parsed.fingerprint };
}
} catch (e) {
console.warn('[LIB_LOGGEDIN] Anon header auth failed:', e);
}
}
}
// SSH header auth removed (hard cut) — anonymous users must use passkey sessions
if (!req.session) {
return res.reply({
code: 401,
+13 -2
View File
@@ -829,7 +829,9 @@ const f0cklib = {
const rows = (await db`
select
items.id,
items.title,
items.slug,
items.stamp,
items.visibility,
items.mime,
items.dest,
@@ -842,9 +844,10 @@ const f0cklib = {
items.album_count,
${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``}
${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`}
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag,
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' when min(ta.tag_id) = ${cfg.nsfl_tag_id || 3} then 'NSFL' else null end) as tag,
min(ta.tag_id) as tag_id,
max(uo.display_name) as display_name,
max(uo.username_color) as username_color,
${cfg.websrv.enable_dynamic_thumbs ? db`
(
(SELECT count(*) FROM favorites WHERE item_id = items.id) +
@@ -866,6 +869,9 @@ const f0cklib = {
row.xd_tier = meta.tier;
row.xd_label = meta.label;
row.is_audio = !!(row.mime && row.mime.startsWith('audio/'));
const tId = row.tag_id != null ? Number(row.tag_id) : null;
row.rating_class = tId === 1 ? 'sfw' : (tId === 2 ? 'nsfw' : (tId === nsflId ? 'nsfl' : 'untagged'));
row.rating_label = tId === 1 ? 'SFW' : (tId === 2 ? 'NSFW' : (tId === nsflId ? 'NSFL' : '?'));
}
if (rows.some(r => r.is_album)) {
@@ -2385,7 +2391,11 @@ const f0cklib = {
}
const items = rows.map(r => r.slug || String(r.id));
return { items, total: items.length, sampled: items.length >= 10000 };
const idMap = {};
for (const r of rows) {
if (r.slug) idMap[r.slug] = r.id;
}
return { items, id_map: idMap, total: items.length, sampled: items.length >= 10000 };
},
getComments: async (itemId, sort = 'new', process = true) => {
const numericId = await resolveNumericItemId(itemId);
@@ -2556,6 +2566,7 @@ const f0cklib = {
}
},
getSubscriptionStatus: async (userId, itemId) => {
if (cfg.enable_comments === false) return false;
const numericId = await resolveNumericItemId(itemId);
if (!userId || !numericId) return false;
const tStart = Date.now();
+2 -1
View File
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs, getBrandImageUrl } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -299,6 +299,7 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
shitpost_mode: getShitpostMode(),
enable_cleanup_config: cfg.websrv.enable_cleanup !== false,
current_brand_image: getBrandImageUrl(),
tmp: null
}, req)
});
+47 -1
View File
@@ -241,6 +241,21 @@ export default (router, tpl) => {
} catch (_) {}
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemNumericId = data.item?.id ? String(data.item.id) : itemid;
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
const itemUser = isAnon
? 'anonymous'
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
const itemUsername = isAnon
? 'anonymous'
: (data.item?.username || 'anonymous');
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -248,8 +263,16 @@ export default (router, tpl) => {
pagination: paginationHtml,
title: data.title,
id: itemid,
numeric_id: itemNumericId,
slug: data.item?.slug || null,
page: itemPage,
thumb: itemThumb,
mode: itemMode,
tag_id: itemTagId,
mime: itemMime,
user: itemUser,
username: itemUsername,
dest: itemDest,
is_random: true
})
});
@@ -489,6 +512,21 @@ export default (router, tpl) => {
} catch (_) {}
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemNumericId = data.item?.id ? String(data.item.id) : (/^\d+$/.test(itemid) ? itemid : null);
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
const itemUser = isAnon
? 'anonymous'
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
const itemUsername = isAnon
? 'anonymous'
: (data.item?.username || 'anonymous');
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -496,8 +534,16 @@ export default (router, tpl) => {
pagination: paginationHtml,
title: data.title,
id: itemid,
numeric_id: itemNumericId,
slug: data.item?.slug || null,
page: itemPage
page: itemPage,
thumb: itemThumb,
mode: itemMode,
tag_id: itemTagId,
mime: itemMime,
user: itemUser,
username: itemUsername,
dest: itemDest
})
});
});
+369 -155
View File
@@ -1,17 +1,26 @@
import crypto from 'node:crypto';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs';
import {
getOrCreateAnonUserByCredential,
createAnonSession,
resolveAuditIP
} from '../../anon_auth.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs';
export default router => {
router.group(/^\/api\/v2\/anon/, group => {
/**
* POST /api/v2/anon/session
* Authenticate via OpenSSH Ed25519 signature and establish an anonymous session.
*/
// ─── Helpers ─────────────────────────────────────────────────────────────
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
if (!sourceReason) return prefix;
let clean = sourceReason;
@@ -30,123 +39,60 @@ export default router => {
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
};
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({
success: false,
banned: true,
msg: 'YOU ARE BANNED!',
reason: ipBan.reason || 'IP address is banned',
expires: ipBan.expires ? new Date(ipBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
const body = req.post || req.body || {};
const pubkey = (body.pubkey || '').trim();
const timestamp = parseInt(body.timestamp, 10);
const signature = (body.signature || '').trim();
if (!pubkey || !timestamp || !signature) {
return res.json({ success: false, msg: 'Missing pubkey, timestamp, or signature' }, 400);
}
// Freshness check (5-minute window for clock skew)
const now = Date.now();
if (Math.abs(now - timestamp) > 300000) {
return res.json({ success: false, msg: 'Timestamp expired or out of bounds' }, 401);
}
const message = `anon-auth:${timestamp}:${pubkey}`;
const isValid = verifySignature(pubkey, message, signature);
if (!isValid) {
return res.json({ success: false, msg: 'Invalid Ed25519 signature' }, 401);
}
const parsed = parseOpenSshPubkey(pubkey);
const hwFingerprint = (body.hw_fingerprint || '').trim() || null;
// Check tombstone token sent from client (fingerprint or hardware ID)
if (body.tombstone && body.tombstone.banned) {
const tombstoneFp = body.tombstone.fingerprint;
const tombstoneHw = body.tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint || tombstoneHw,
msg: 'YOU ARE BANNED!',
reason: activeTombstoneBan.reason || 'Device is banned',
expires: activeTombstoneBan.expires ? new Date(activeTombstoneBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
const checkAndCascadeBans = async (res, fingerprint, hwFingerprint, credentialId, tombstone) => {
// Tombstone cascade
if (tombstone && tombstone.banned) {
const tombstoneFp = tombstone.fingerprint;
const tombstoneHw = tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
return activeTombstoneBan;
}
}
// Check hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: hwBan.reason || 'Hardware ID is banned',
expires: hwBan.expires ? new Date(hwBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
// Hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
return hwBan;
}
}
// Check fingerprint ban
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
// Fingerprint ban
if (fingerprint) {
const fpBan = await security.isFingerprintBanned(fingerprint);
if (fpBan) {
if (hwFingerprint) {
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
if (!alreadyHwBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
fingerprint,
hwFingerprint,
bannedBy: fpBan.banned_by,
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
@@ -156,69 +102,332 @@ export default router => {
});
}
}
setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires);
return fpBan;
}
}
return null;
};
// ─── Deprecated SSH endpoint — hard cut ───────────────────────────────────
group.post(/\/session$/, async (req, res) => {
return res.json({
success: false,
msg: 'SSH-key anonymous authentication has been replaced by passkeys. Please refresh the page.'
}, 410);
});
// ─── Passkey Registration ─────────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/register/begin
* Returns WebAuthn registration options (challenge + rp + user config).
* The client does NOT need to be logged in.
*/
group.post(/\/passkey\/register\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-register' });
// Generate a temporary opaque user handle (32 random bytes, base64url)
// This will be replaced by the real user_id after finish, but WebAuthn requires
// a user.id at registration time. We store it in the challenge.
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
// Store the user handle in the challenge so finish can retrieve it
// (The challenge entry is keyed by challenge string)
// We re-issue the challenge with the user handle attached
const challengeWithHandle = generateChallenge({ type: 'anon-register', userHandle });
// Temporary display name for the registration prompt
const tmpName = `anon_new@${cfg.main?.url?.domain || 'f0ck.dev'}`;
const options = buildRegistrationOptions({
challenge: challengeWithHandle,
userId: userHandle,
userName: tmpName,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] register/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/register/finish
* Verify attestation, create shadow user + credential, establish session.
*/
group.post(/\/passkey\/register\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-register') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Verify the attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
// Get fingerprint before ban checks (derived from credentialId)
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
// Ban checks
const ban = await checkAndCascadeBans(res, fingerprint, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: fpBan.reason || 'Key fingerprint is banned',
expires: fpBan.expires ? new Date(fpBan.expires).toLocaleString() : 'Permanent',
success: false, banned: true,
fingerprint, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint, req, hwFingerprint);
// Get or create shadow user
const { userId, isNew, fingerprint: fp } = await getOrCreateAnonUserByCredential(
credentialId, req, hwFingerprint || null
);
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
userId,
fingerprint: parsed.fingerprint,
hwFingerprint,
reason: u.ban_reason || 'Banned',
expires: u.ban_expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: u.ban_reason || 'Banned',
expires: u.ban_expires ? new Date(u.ban_expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint);
// Store / update passkey credential in passkey_credentials
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW()
`;
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
is_new: isNew,
user_id: userId,
fingerprint: parsed.fingerprint,
short_fingerprint: parsed.shortFingerprint,
hw_fingerprint: hwFingerprint,
csrf_token: csrf_token
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Session establishment error:', err);
console.error('[ANON_PASSKEY] register/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Passkey Authentication ───────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/auth/begin
* Returns authentication options. allowCredentials is empty (discoverable credential flow).
*/
group.post(/\/passkey\/auth\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-auth' });
const options = buildAuthenticationOptions({
challenge,
allowCredentials: [], // discoverable — let the browser/Bitwarden pick
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] auth/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/auth/finish
* Verify assertion, establish anonymous session.
*/
group.post(/\/passkey\/auth\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-auth') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up stored credential
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count, ai.fingerprint
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = ${credentialId}
WHERE pc.credential_id = ${credentialId}
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'Passkey not registered. Please register first.' }, 401);
}
const { user_id: userId, public_key_spki: spki, sign_count: storedSignCount, fingerprint } = credRows[0];
// Verify the assertion
let authResult;
try {
authResult = await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki,
storedSignCount,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[ANON_PASSKEY] Auth verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Derive fingerprint if not stored yet (legacy or first-time)
const fpForBan = fingerprint || (() => {
return 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
})();
// Ban checks
const ban = await checkAndCascadeBans(res, fpForBan, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false, banned: true,
fingerprint: fpForBan, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
// Update sign count and last_used
await db`
UPDATE passkey_credentials
SET sign_count = ${authResult.newSignCount}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Update anon_identities (hw_fingerprint, last_seen)
await db`
UPDATE anon_identities
SET last_seen = NOW()
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE user_id = ${userId} AND credential_id = ${credentialId}
`.catch(() => {});
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
user_id: userId,
fingerprint: fpForBan,
short_fingerprint: fpForBan.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_PASSKEY] auth/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Identity ─────────────────────────────────────────────────────────────
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
@@ -234,9 +443,11 @@ export default router => {
}
const rows = await db`
SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen
FROM anon_identities
WHERE user_id = ${req.session.id}
SELECT ai.credential_id, ai.fingerprint, ai.hw_fingerprint, ai.created_at, ai.last_seen,
pc.name AS passkey_name, pc.aaguid
FROM anon_identities ai
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
WHERE ai.user_id = ${req.session.id}
LIMIT 1
`;
@@ -247,9 +458,10 @@ export default router => {
is_anon: true,
user_id: req.session.id,
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
short_fingerprint: fp ? fp.slice(7, 15) : null,
hw_fingerprint: rows[0].hw_fingerprint,
pubkey: rows[0].pubkey,
credential_id: rows[0].credential_id,
passkey_name: rows[0].passkey_name,
csrf_token: req.session.csrf_token
});
}
@@ -262,11 +474,13 @@ export default router => {
csrf_token: req.session.csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Identity lookup error:', err);
console.error('[ANON_PASSKEY] Identity lookup error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────
/**
* POST /api/v2/anon/logout
* Clear anonymous session cookie and remove active session from database.
@@ -282,7 +496,7 @@ export default router => {
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
return res.json({ success: true });
} catch (err) {
console.error('[ANON_AUTH] Logout error:', err);
console.error('[ANON_PASSKEY] Logout error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
+142 -1
View File
@@ -9,7 +9,7 @@ import path from "path";
import f0cklib from '../../routeinc/f0cklib.mjs';
import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { purgeExpiredUploads, safeDeleteMediaFile } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs';
import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs';
import { logAnonActivity } from '../../anon_auth.mjs';
@@ -1714,6 +1714,147 @@ export default router => {
});
});
group.post(/\/admin\/delete-album-item$/, lib.loggedin, async (req, res) => {
const postid = +(req.post?.postid ?? req.body?.postid);
if (!postid || postid <= 0) {
return res.json({ success: false, msg: 'Invalid postid' }, 400);
}
const items = await db`
SELECT id, dest, mime, username, is_album, album_count, is_deleted, active
FROM items
WHERE id = ${postid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!items.length) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const item = items[0];
if (!item.is_album) {
return res.json({ success: false, msg: 'Item is not an album' }, 400);
}
const isMod = req.session.admin || req.session.is_moderator;
const isOwner = req.session.user && item.username && req.session.user.toLowerCase() === item.username.toLowerCase();
if (!isMod && !isOwner) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
const subId = req.post?.sub_id ?? req.post?.subf0ck_id ?? req.body?.sub_id;
const subSlug = req.post?.sub_slug ?? req.body?.sub_slug;
const subIndex = req.post?.order_index ?? req.body?.order_index;
const allSubs = await db`
SELECT id, item_id, dest, mime, size, checksum, order_index, slug
FROM album_items
WHERE item_id = ${postid}
ORDER BY order_index ASC
`;
if (!allSubs.length) {
return res.json({ success: false, msg: 'No album items found' }, 404);
}
let targetSub = null;
if (subId !== undefined && subId !== null && subId !== '') {
targetSub = allSubs.find(s => s.id === +subId || s.slug === String(subId));
}
if (!targetSub && subSlug) {
targetSub = allSubs.find(s => s.slug === String(subSlug));
}
if (!targetSub && subIndex !== undefined && subIndex !== null && subIndex !== '') {
targetSub = allSubs.find(s => s.order_index === +subIndex);
}
if (!targetSub) {
return res.json({ success: false, msg: 'Album sub-item not found' }, 404);
}
const reason = req.post?.reason || req.body?.reason || 'No reason provided';
// If this is the last remaining item in the album, delete the whole post
if (allSubs.length <= 1) {
await safeDeleteMediaFile(item.dest, postid);
const thumbName = `${postid}.webp`;
await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
await fs.unlink(path.join(cfg.paths.t, `${postid}_blur.webp`)).catch(() => {});
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, thumbName)).catch(() => {});
}
await db`DELETE FROM album_items_tags_assign WHERE album_item_id = ${targetSub.id}`.catch(() => {});
await db`DELETE FROM album_items WHERE id = ${targetSub.id}`.catch(() => {});
await db`UPDATE items SET active = false, is_deleted = true WHERE id = ${postid}`;
await audit.log(req.session.id, 'delete_item', 'item', postid, { filename: item.dest, reason });
db.notify('delete_item', JSON.stringify({ id: postid })).catch(() => {});
return res.json({ success: true, post_deleted: true, msg: 'Last album item removed, post deleted' });
}
// Multi-item album: safely remove this sub-item media
if (targetSub.dest && !targetSub.dest.startsWith('yt:')) {
await safeDeleteMediaFile(targetSub.dest, postid);
const subBase = targetSub.dest.replace(/\.[^.]+$/, '');
await fs.unlink(path.join(cfg.paths.t, `${subBase}.webp`)).catch(() => {});
if (targetSub.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${subBase}.webp`)).catch(() => {});
}
} else if (targetSub.dest?.startsWith('yt:')) {
await fs.unlink(path.join(cfg.paths.t, `${targetSub.id}.webp`)).catch(() => {});
}
await db`DELETE FROM album_items_tags_assign WHERE album_item_id = ${targetSub.id}`.catch(() => {});
await db`DELETE FROM album_items WHERE id = ${targetSub.id}`;
const remainingSubs = allSubs.filter(s => s.id !== targetSub.id);
const isCoverDeleted = targetSub.order_index === 0;
// Re-index remaining album items so order_index is contiguous
await db`
UPDATE album_items
SET order_index = order_index - 1
WHERE item_id = ${postid} AND order_index > ${targetSub.order_index}
`;
if (isCoverDeleted) {
const newCover = remainingSubs[0];
await db`
UPDATE items
SET dest = ${newCover.dest},
mime = ${newCover.mime},
size = ${newCover.size || 0},
checksum = ${newCover.checksum || ''},
album_count = ${remainingSubs.length}
WHERE id = ${postid}
`;
try {
await queue.genThumbnail(newCover.dest, newCover.mime, postid, '');
await queue.genBlurredThumbnail(postid);
} catch (thumbErr) {
console.error('[DELETE-ALBUM-ITEM] Failed to regen thumbnail for promoted cover:', thumbErr);
}
} else {
await db`
UPDATE items
SET album_count = ${remainingSubs.length}
WHERE id = ${postid}
`;
}
await audit.log(req.session.id, 'delete_album_item', 'album_item', targetSub.id, {
postid,
filename: targetSub.dest,
order_index: targetSub.order_index,
reason
});
return res.json({
success: true,
post_deleted: false,
deleted_sub_id: targetSub.id,
deleted_order_index: targetSub.order_index,
remaining_count: remainingSubs.length,
promoted_cover: isCoverDeleted
});
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('favorite')) {
return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403);
+274
View File
@@ -5,6 +5,12 @@ import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { canAnonDo, isAnonSession } from '../../settings.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, getRpIdFromHost
} from '../../webauthn.mjs';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -1194,6 +1200,274 @@ export default router => {
}
});
// ─── Passkey Management (registered users) ──────────────────────────────
/**
* GET /api/v2/settings/passkeys
* List the current user's registered passkeys.
*/
group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => {
try {
const rows = await db`
SELECT id, credential_id, name, aaguid, created_at, last_used
FROM passkey_credentials
WHERE user_id = ${req.session.id}
ORDER BY created_at DESC
`;
return res.json({ success: true, passkeys: rows });
} catch (err) {
console.error('[PASSKEYS] List error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/begin
* Generate WebAuthn registration options for a logged-in user.
*/
group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => {
try {
// Get existing credentials to exclude (prevent re-registering same authenticator)
const existing = await db`
SELECT credential_id FROM passkey_credentials
WHERE user_id = ${req.session.id}
`;
const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' }));
const challenge = generateChallenge({ type: 'user-register', userId: req.session.id });
// User handle: SHA256 of user_id encoded as base64url (stable, opaque)
const userHandle = base64url(
crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16)
);
const body = req.post || req.body || {};
const passkeyName = (body.name || '').trim().slice(0, 64) || null;
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: req.session.login || req.session.user,
displayName: req.session.display_name || req.session.user,
excludeCredentials,
rpId: getRpIdFromHost(req.headers.host)
});
// Stash the intended passkey name in challenge metadata
if (passkeyName) {
// Re-consume and re-store with name (challenges are stored by their value)
// Simpler: store name in a temporary Map keyed by challenge
options._passkeyName = passkeyName;
}
return res.json({ success: true, options, passkey_name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/finish
* Verify attestation and store new passkey for the logged-in user.
*/
group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, name } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-register' || challengeMeta.userId !== req.session.id) {
return res.json({ success: false, msg: 'Challenge mismatch' }, 400);
}
// Verify attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[PASSKEYS] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey';
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName}
`;
return res.json({ success: true, credential_id: credentialId, name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/delete
* Remove a passkey credential owned by the current user.
* Uses POST + JSON body to avoid URL-encoding issues with credential IDs.
*/
group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const credentialId = body.credential_id;
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* DELETE /api/v2/settings/passkeys/:id
* Legacy path — kept for compatibility.
*/
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
try {
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/begin
* Start a passkey login challenge for a registered user (no session required).
*/
group.post(/\/passkeys\/login\/begin$/, async (req, res) => {
try {
const challenge = generateChallenge({ type: 'user-login' });
const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) });
return res.json({ success: true, options });
} catch (err) {
console.error('[PASSKEYS] login/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/finish
* Verify assertion and create a full registered-user session.
*/
group.post(/\/passkeys\/login\/finish$/, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-login') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up credential — must belong to a registered (non-anon) user
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
u.login, u.user, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
FROM passkey_credentials pc
JOIN "user" u ON u.id = pc.user_id
WHERE pc.credential_id = ${credentialId}
AND u.login IS NOT NULL
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401);
}
const row = credRows[0];
if (row.banned) {
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
}
// Verify the assertion
try {
await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki: row.public_key_spki,
storedSignCount: row.sign_count,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[PASSKEYS] login/finish verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Update sign count
await db`
UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Create a full user session (same as normal login)
const stamp = Math.floor(Date.now() / 1000);
const ip = (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.socket?.remoteAddress || '').split(',')[0].trim();
const sessionToken = crypto.randomBytes(32).toString('hex');
const csrfToken = crypto.randomBytes(32).toString('hex');
const sessRecord = {
user_id: row.user_id,
session: lib.sha256(sessionToken),
csrf_token: csrfToken,
browser: req.headers['user-agent'] || '',
created_at: stamp,
last_used: stamp,
last_action: '/passkey-login',
kmsi: 1,
ip
};
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`;
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
} catch (err) {
console.error('[PASSKEYS] login/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
return group;
});
+10 -6
View File
@@ -481,9 +481,11 @@ export default router => {
}
// Auto-subscribe uploader
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
}
// Download YouTube thumbnail as our thumbnail
try {
@@ -820,9 +822,11 @@ export default router => {
addPrivateItem(itemid, filename, session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
}
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
+3 -22
View File
@@ -7,13 +7,12 @@ import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { getEnableAnonymousAccess, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
// Get comments for an item
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
const itemId = req.params.itemid;
@@ -399,28 +398,10 @@ export default (router, tpl) => {
// Post a comment
router.post('/api/comments', async (req, res) => {
if (!req.session) {
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)) {
try {
const parsed = parseOpenSshPubkey(sshPubkey);
const { userId } = await getOrCreateAnonUser(sshPubkey, parsed.fingerprint);
req.session = { id: userId, user: 'anonymous', display_name: 'Anonymous', is_anon: true, fingerprint: parsed.fingerprint };
} catch (e) {
console.error('[ANON_COMMENTS] Auth header error:', e);
}
}
}
}
}
// Anonymous users must have an active passkey session — no SSH header fallback (hard cut)
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
if (isAnonSession(req.session) && !canAnonDo('comment')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) });
}
+15 -9
View File
@@ -382,9 +382,11 @@ export default (router) => {
if (repost) {
await fs.unlink(finalTmp).catch(() => {});
// Auto-subscribe user to the existing item they attempted to rehost
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
}
return res.reply({
code: 200,
@@ -402,9 +404,11 @@ export default (router) => {
if (phashMatch) {
await fs.unlink(finalTmp).catch(() => {});
// Auto-subscribe user to the existing item they attempted to rehost (visual match)
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
}
return res.reply({
code: 200,
@@ -444,9 +448,11 @@ export default (router) => {
`;
// Automatically subscribe user to the new item
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
}
// Process thumbnail
try {
+25 -2
View File
@@ -580,9 +580,32 @@ export default (router, tpl) => {
it.is_onara_active = false;
}
}
// If not in first page of feed, unshift it so thumbnail is present in background
// If not in first page of feed, add it so thumbnail is present in background
if (!foundInGrid && data.item) {
gridData.items.unshift({ ...data.item, is_onara_active: true });
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const tagId = data.item.tag_id || (data.item.is_nsfl ? nsflId : (data.item.is_nsfw ? 2 : (data.item.is_sfw ? 1 : null)));
const thumbUrl = data.item.thumb || data.item.thumbnail || (data.item.id ? `/t/${data.item.id}.webp` : null);
const cleanDest = data.item.dest ? String(data.item.dest).replace(/^\/b\//, '') : '';
const gridItem = {
...data.item,
id: data.item.id,
slug: data.item.slug,
tag_id: tagId,
thumb: thumbUrl,
dest: cleanDest,
display_name: data.item.author_display_name || data.item.display_name || data.item.username,
username: data.item.username,
mime: data.item.matching_sub_mime || data.item.mime,
thumb_size: data.item.thumb_size || 1,
is_onara_active: true
};
if (isRandom && gridData.items.length > 0) {
const insertIdx = Math.floor(Math.random() * (gridData.items.length + 1));
gridData.items.splice(insertIdx, 0, gridItem);
} else {
gridData.items.unshift(gridItem);
}
}
}
+18 -2
View File
@@ -422,16 +422,28 @@ export default (router, tpl) => {
const page = +(req.url.qs?.page || 1);
const limit = 50;
const offset = (page - 1) * limit;
const filterAction = req.url.qs?.action?.trim() || '';
const filterUser = req.url.qs?.user?.trim() || '';
const logs = await db`
SELECT al.*, u.user as username
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
WHERE true
${filterAction ? db`AND al.action = ${filterAction}` : db``}
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
ORDER BY al.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const totalResult = await db`SELECT count(*) as c FROM audit_log`;
const totalResult = await db`
SELECT count(*) as c
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
WHERE true
${filterAction ? db`AND al.action = ${filterAction}` : db``}
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
`;
const total = totalResult[0].c;
const pages = Math.ceil(total / limit);
@@ -493,7 +505,9 @@ export default (router, tpl) => {
logs: processed,
page,
pages,
hasMore: page < pages
hasMore: page < pages,
filterAction,
filterUser
});
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -506,6 +520,8 @@ export default (router, tpl) => {
logs: processedLogs,
page,
pages,
filterAction,
filterUser,
tmp: null
}, req)
});
+16 -2
View File
@@ -318,6 +318,20 @@ db.listen('motd', (payload) => {
}
}).catch(err => console.error('DB Listen MOTD error:', err));
// Global listener for brand image updates
db.listen('brand_image', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting brand_image update to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'brand_image', data: { url: data.url || null } });
}
} catch (e) {
console.error('Brand image broadcast error:', e);
}
}).catch(err => console.error('DB Listen brand_image error:', err));
// Global listener for new items (live grid updates)
db.listen('new_item', (payload) => {
try {
@@ -864,7 +878,7 @@ export default (router, tpl) => {
// Notification History Page
router.get('/notifications', async (req, res) => {
if (!req.session) return res.redirect('/login');
const tab = req.url.qs?.tab || 'user';
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs?.tab || 'user');
const data = await getNotificationHistory(req.session.id, 1, 50, tab);
data.session = req.session;
data.hidePagination = true;
@@ -885,7 +899,7 @@ export default (router, tpl) => {
success: false
}, 401);
const page = parseInt(req.url.qs.page) || 1;
const tab = req.url.qs.tab || null;
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs.tab || null);
const data = await getNotificationHistory(req.session.id, page, 50, tab);
const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req);
+19 -4
View File
@@ -10,9 +10,21 @@ export default (router, tpl) => {
router.post(/^\/api\/v2\/report\/?$/, async (req, res) => {
try {
const { item_id, comment_id, reported_user_id, reason } = req.post;
// The framework's readBody uses Object.fromEntries which loses duplicate keys
// and decodeURIComponent on an array joins it as comma-separated string.
// So categories[]= ends up as a single comma-joined string — split it back.
const VALID_CATEGORIES = ['wrong_rating', 'spam', 'duplicate', 'copyright', 'illegal', 'other'];
let rawCats = req.post['categories[]'] || req.post['categories'] || '';
let categories = [];
if (Array.isArray(rawCats)) {
categories = rawCats;
} else if (typeof rawCats === 'string' && rawCats.length > 0) {
categories = rawCats.split(',').map(s => s.trim());
}
categories = categories.filter(c => VALID_CATEGORIES.includes(c));
if (!reason || reason.trim().length === 0) {
return res.json({ success: false, msg: "Reason is required." }, 400);
if ((!reason || reason.trim().length === 0) && categories.length === 0) {
return res.json({ success: false, msg: "Please select at least one reason or provide a description." }, 400);
}
// At least one target must be specified
@@ -50,14 +62,15 @@ export default (router, tpl) => {
}
const reportRes = await db`
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason)
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories)
VALUES (
${req.session ? req.session.id : null},
${ip},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
${reason.trim()}
${reason ? reason.trim() : ''},
${db.array(categories)}
)
RETURNING id
`;
@@ -108,6 +121,7 @@ export default (router, tpl) => {
COALESCE(tgt_u.user, tgt_auth.user, comm_auth.user) AS reported_user_name,
COALESCE(NULLIF(r.user_id, 0), tgt_auth.id, comm_auth.id) AS reported_user_id,
COALESCE(tgt_u.admin, tgt_auth.admin, comm_auth.admin) AS reported_user_is_admin,
resolver.user AS resolver_name,
i.dest AS item_dest,
tgt_auth.id AS item_user_id,
tgt_auth.user AS item_user_name,
@@ -123,6 +137,7 @@ export default (router, tpl) => {
FROM reports r
LEFT JOIN "user" rep ON r.reporter_id = rep.id
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
LEFT JOIN "user" resolver ON r.resolved_by = resolver.id
LEFT JOIN items i ON r.item_id = i.id
LEFT JOIN "user" tgt_auth ON i.username = tgt_auth.user
LEFT JOIN comments c ON r.comment_id = c.id
+9
View File
@@ -246,3 +246,12 @@ export const setNsfpIds = (ids) => {
cfg.nsfp = [...nsfp_ids];
};
// Brand image URL — stored in site_settings DB, not config.json
// Falls back to cfg.websrv.custom_brand_image (array or string) on first boot
let brand_image_url = (() => {
const raw = cfg.websrv?.custom_brand_image;
return Array.isArray(raw) ? (raw[0] || '') : (raw || '');
})();
export const getBrandImageUrl = () => brand_image_url;
export const setBrandImageUrl = (val) => { brand_image_url = val || ''; };
+2 -2
View File
@@ -760,7 +760,7 @@ export default async bot => {
// Auto-subscribe uploader
try {
if (websiteUser?.id) {
if (cfg.enable_comments !== false && websiteUser?.id) {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${websiteUser.id}, ${itemid})
@@ -867,7 +867,7 @@ export default async bot => {
// Auto-subscribe uploader
try {
if (websiteUser?.id) {
if (cfg.enable_comments !== false && websiteUser?.id) {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${websiteUser.id}, ${itemid})
+474
View File
@@ -0,0 +1,474 @@
/**
* webauthn.mjs — Pure Node.js WebAuthn / Passkey helpers (ES-256 / P-256 only)
*
* No external dependencies — uses Node.js built-in `crypto` (WebCrypto).
*
* Supported algorithm: ES-256 (COSE alg -7, P-256).
* Supported attestation formats: "none" (passkey managers always send "none").
*
* Challenge store: in-memory Map with TTL. Challenges are single-use.
*/
import crypto from 'node:crypto';
import cfg from './config.mjs';
// ---------------------------------------------------------------------------
// Challenge store (in-memory, single-use, 5-minute TTL)
// ---------------------------------------------------------------------------
const challengeStore = new Map();
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [k, v] of challengeStore) {
if (now > v.expiresAt) challengeStore.delete(k);
}
}, 60_000);
/**
* Generate a random challenge and store it with optional metadata.
* @param {object} [meta] - e.g. { userId, type }
* @returns {string} base64url-encoded challenge
*/
export function generateChallenge(meta = {}) {
const buf = crypto.getRandomValues(new Uint8Array(32));
const challenge = base64url(buf);
challengeStore.set(challenge, { ...meta, expiresAt: Date.now() + CHALLENGE_TTL_MS });
return challenge;
}
/**
* Consume (use-once) a challenge. Returns stored metadata or throws.
* @param {string} challenge base64url
* @returns {object} stored metadata
*/
export function consumeChallenge(challenge) {
const entry = challengeStore.get(challenge);
if (!entry) throw new Error('Challenge not found or expired');
if (Date.now() > entry.expiresAt) {
challengeStore.delete(challenge);
throw new Error('Challenge expired');
}
challengeStore.delete(challenge);
return entry;
}
// ---------------------------------------------------------------------------
// Relying Party config
// ---------------------------------------------------------------------------
function getRpId(override) {
// Explicit override wins (e.g. derived from request Host header for localhost dev)
if (override) return override;
return cfg.websrv?.passkey_rp_id || cfg.main?.url?.domain || 'localhost';
}
/**
* Derive an rpId from a Host header value.
* e.g. "localhost:1337" → "localhost", "f0ck.dev" → "f0ck.dev"
* Use this in route handlers and pass the result to build-x and verify-x functions.
*/
export function getRpIdFromHost(hostHeader) {
if (!hostHeader) return null;
return hostHeader.split(':')[0] || null;
}
function getExpectedOrigins(rpId) {
const origins = [`https://${rpId}`];
// Also allow http for localhost dev
if (rpId === 'localhost' || rpId.startsWith('127.') || rpId === '[::1]') {
origins.push(`http://${rpId}`);
// Also allow http://localhost:PORT
if (cfg.websrv?.port) origins.push(`http://${rpId}:${cfg.websrv.port}`);
}
// Allow any extra origins from config
if (Array.isArray(cfg.websrv?.passkey_extra_origins)) {
origins.push(...cfg.websrv.passkey_extra_origins);
}
return origins;
}
// ---------------------------------------------------------------------------
// Base64url helpers
// ---------------------------------------------------------------------------
export function base64url(buf) {
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
return b.toString('base64url');
}
export function fromBase64url(str) {
return Buffer.from(str, 'base64url');
}
function fromBase64(str) {
return Buffer.from(str, 'base64');
}
function toBase64(buf) {
return Buffer.from(buf).toString('base64');
}
// ---------------------------------------------------------------------------
// CBOR minimal decoder (only what we need for authenticatorData / COSE key)
// ---------------------------------------------------------------------------
/**
* Minimal CBOR decoder supporting:
* - unsigned int (major 0)
* - negative int (major 1)
* - byte string (major 2)
* - text string (major 3)
* - array (major 4)
* - map (major 5)
* - simple/float (major 7) — ignored/skipped
*/
function cborDecode(buf, offset = 0) {
const [value, newOffset] = _cborDecodeItem(buf, offset);
return value;
}
function _cborDecodeItem(buf, offset) {
const byte = buf[offset++];
const major = (byte >> 5) & 0x7;
const info = byte & 0x1f;
let additionalInt;
if (info < 24) {
additionalInt = info;
} else if (info === 24) {
additionalInt = buf[offset++];
} else if (info === 25) {
additionalInt = (buf[offset] << 8) | buf[offset + 1];
offset += 2;
} else if (info === 26) {
additionalInt = (buf[offset] << 24 | buf[offset+1] << 16 | buf[offset+2] << 8 | buf[offset+3]) >>> 0;
offset += 4;
} else if (info === 27) {
// 64-bit uint — read as BigInt then convert (sign_count fits in 53-bit JS int normally)
const hi = (buf[offset] << 24 | buf[offset+1] << 16 | buf[offset+2] << 8 | buf[offset+3]) >>> 0;
const lo = (buf[offset+4] << 24 | buf[offset+5] << 16 | buf[offset+6] << 8 | buf[offset+7]) >>> 0;
additionalInt = hi * 0x100000000 + lo;
offset += 8;
} else {
throw new Error(`CBOR: unsupported additional info ${info}`);
}
switch (major) {
case 0: return [additionalInt, offset];
case 1: return [-(additionalInt + 1), offset];
case 2: {
const slice = buf.slice(offset, offset + additionalInt);
return [slice, offset + additionalInt];
}
case 3: {
const str = buf.slice(offset, offset + additionalInt).toString('utf8');
return [str, offset + additionalInt];
}
case 4: {
const arr = [];
for (let i = 0; i < additionalInt; i++) {
let item;
[item, offset] = _cborDecodeItem(buf, offset);
arr.push(item);
}
return [arr, offset];
}
case 5: {
const map = {};
for (let i = 0; i < additionalInt; i++) {
let key, val;
[key, offset] = _cborDecodeItem(buf, offset);
[val, offset] = _cborDecodeItem(buf, offset);
map[key] = val;
}
return [map, offset];
}
case 7: {
// simple/float — skip
if (info < 20) return [undefined, offset];
if (info === 20) return [false, offset];
if (info === 21) return [true, offset];
if (info === 22) return [null, offset];
if (info === 25) return [undefined, offset + 2]; // float16, skip
if (info === 26) return [undefined, offset + 4]; // float32, skip
if (info === 27) return [undefined, offset + 8]; // float64, skip
return [undefined, offset];
}
default:
throw new Error(`CBOR: unsupported major type ${major}`);
}
}
// ---------------------------------------------------------------------------
// Parse authenticatorData (binary, defined in WebAuthn spec)
// ---------------------------------------------------------------------------
function parseAuthenticatorData(buf) {
// 32 bytes rpIdHash + 1 byte flags + 4 bytes signCount + optional attested cred data
if (buf.length < 37) throw new Error('authenticatorData too short');
const rpIdHash = buf.slice(0, 32);
const flags = buf[32];
const signCount = buf.readUInt32BE(33);
const UP = (flags & 0x01) !== 0; // user presence
const UV = (flags & 0x04) !== 0; // user verification
const AT = (flags & 0x40) !== 0; // attested credential data included
const ED = (flags & 0x80) !== 0; // extension data included
let credentialData = null;
let offset = 37;
if (AT) {
const aaguid = buf.slice(offset, offset + 16);
offset += 16;
const credIdLen = buf.readUInt16BE(offset);
offset += 2;
const credentialId = buf.slice(offset, offset + credIdLen);
offset += credIdLen;
// Remaining bytes (up to ED extension) are COSE public key
const coseKeyBuf = ED
? buf.slice(offset) // we'll just parse until COSE map ends
: buf.slice(offset);
const coseKey = cborDecode(coseKeyBuf);
credentialData = { aaguid, credentialId, coseKey };
}
return { rpIdHash, flags: { UP, UV, AT, ED }, signCount, credentialData };
}
// ---------------------------------------------------------------------------
// Convert COSE ES-256 public key map to raw P-256 point (uncompressed)
// ---------------------------------------------------------------------------
function coseToSpki(coseKey) {
// COSE map keys: 1=kty, 3=alg, -1=crv, -2=x, -3=y
const kty = coseKey[1];
const alg = coseKey[3];
if (kty !== 2) throw new Error(`COSE: expected kty=2 (EC2), got ${kty}`);
if (alg !== -7) throw new Error(`COSE: expected alg=-7 (ES-256), got ${alg}`);
const x = Buffer.from(coseKey[-2]); // 32 bytes
const y = Buffer.from(coseKey[-3]); // 32 bytes
if (x.length !== 32 || y.length !== 32) throw new Error('COSE: invalid P-256 point length');
// Build DER SPKI for P-256:
// SEQUENCE {
// SEQUENCE { OID 1.2.840.10045.2.1 (ecPublicKey), OID 1.2.840.10045.3.1.7 (P-256) }
// BIT STRING { 0x04 || x || y }
// }
const oid = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
const point = Buffer.concat([Buffer.from([0x04]), x, y]);
const spki = Buffer.concat([oid, point]);
return spki;
}
// ---------------------------------------------------------------------------
// Registration verification
// ---------------------------------------------------------------------------
/**
* Verify a WebAuthn registration (attestation) response.
*
* @param {object} params
* @param {string} params.challenge - base64url challenge that was sent to client
* @param {string} params.clientDataJSON - base64url from CredentialCreationResponse
* @param {string} params.attestationObject - base64url from CredentialCreationResponse
* @param {string} params.credentialId - base64url credential ID from response
*
* @returns {{ credentialId: string, spki: string, signCount: number, aaguid: string }}
* credentialId: base64url, spki: base64-encoded DER, signCount, aaguid: hex
*/
export async function verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: rpIdOverride }) {
// 1. Decode and verify clientDataJSON
const clientData = JSON.parse(fromBase64url(clientDataJSON).toString('utf8'));
if (clientData.type !== 'webauthn.create') {
throw new Error('clientData.type must be webauthn.create');
}
if (clientData.challenge !== challenge) {
throw new Error('Challenge mismatch');
}
const rpId = getRpId(rpIdOverride);
const expectedOrigins = getExpectedOrigins(rpId);
if (!expectedOrigins.includes(clientData.origin)) {
throw new Error(`Unexpected origin: ${clientData.origin}. Expected one of: ${expectedOrigins.join(', ')}`);
}
// 2. Parse attestationObject (CBOR)
const attObjBuf = fromBase64url(attestationObject);
const attObj = cborDecode(attObjBuf);
const fmt = attObj['fmt'];
const authDataBuf = Buffer.from(attObj['authData']);
// Only accept "none" attestation (all passkey managers use this)
if (fmt !== 'none' && fmt !== 'packed' && fmt !== 'fido-u2f') {
// We parse but don't verify attestation statements for non-none formats;
// passkeys always use "none" so this is fine in practice.
}
// 3. Parse authenticatorData
const authData = parseAuthenticatorData(authDataBuf);
// 4. Verify rpIdHash
const expectedRpIdHash = crypto.createHash('sha256').update(rpId).digest();
if (!authData.rpIdHash.equals(expectedRpIdHash)) {
throw new Error('rpIdHash mismatch');
}
// 5. Check user presence flag (UP must be set for passkeys)
if (!authData.flags.UP) {
throw new Error('User presence flag not set');
}
// 6. Extract credential data
if (!authData.credentialData) {
throw new Error('No attested credential data in authenticatorData');
}
const { credentialId: credIdBuf, coseKey, aaguid } = authData.credentialData;
// Verify credentialId matches what the browser sent
const credIdFromAuthData = base64url(credIdBuf);
if (credIdFromAuthData !== credentialId) {
throw new Error('credentialId mismatch between attestation and response');
}
// 7. Convert COSE key to SPKI DER
const spkiBuf = coseToSpki(coseKey);
const spki = toBase64(spkiBuf);
const aaguidHex = Buffer.from(aaguid).toString('hex');
return {
credentialId,
spki,
signCount: authData.signCount,
aaguid: aaguidHex
};
}
// ---------------------------------------------------------------------------
// Authentication verification
// ---------------------------------------------------------------------------
/**
* Verify a WebAuthn authentication (assertion) response.
*
* @param {object} params
* @param {string} params.challenge - base64url challenge that was sent to client
* @param {string} params.clientDataJSON - base64url from CredentialAssertionResponse
* @param {string} params.authenticatorData - base64url from CredentialAssertionResponse
* @param {string} params.signature - base64url from CredentialAssertionResponse
* @param {string} params.spki - base64 DER SPKI stored at registration
* @param {number} params.storedSignCount - sign_count stored in DB
*
* @returns {{ newSignCount: number }} updated sign count
*/
export async function verifyAuthentication({ challenge, clientDataJSON, authenticatorData, signature, spki, storedSignCount, rpId: rpIdOverride }) {
// 1. Decode and verify clientDataJSON
const clientData = JSON.parse(fromBase64url(clientDataJSON).toString('utf8'));
if (clientData.type !== 'webauthn.get') {
throw new Error('clientData.type must be webauthn.get');
}
if (clientData.challenge !== challenge) {
throw new Error('Challenge mismatch');
}
const rpId = getRpId(rpIdOverride);
const expectedOrigins = getExpectedOrigins(rpId);
if (!expectedOrigins.includes(clientData.origin)) {
throw new Error(`Unexpected origin: ${clientData.origin}`);
}
// 2. Parse authenticatorData
const authDataBuf = fromBase64url(authenticatorData);
const authData = parseAuthenticatorData(authDataBuf);
// 3. Verify rpIdHash
const expectedRpIdHash = crypto.createHash('sha256').update(rpId).digest();
if (!authData.rpIdHash.equals(expectedRpIdHash)) {
throw new Error('rpIdHash mismatch');
}
// 4. Check user presence flag
if (!authData.flags.UP) {
throw new Error('User presence flag not set');
}
// 5. Verify signature
// sig = ECDSA-SHA256(authData || SHA256(clientDataJSON))
const clientDataHash = crypto.createHash('sha256').update(fromBase64url(clientDataJSON)).digest();
const verifyData = Buffer.concat([authDataBuf, clientDataHash]);
const sigBuf = fromBase64url(signature);
const spkiBuf = fromBase64(spki);
const keyObject = crypto.createPublicKey({ key: spkiBuf, format: 'der', type: 'spki' });
// Node.js crypto.verify: algorithm must be a string ('SHA256'), not a WebCrypto object.
// For ECDSA the digest algorithm is specified here; the curve is derived from the key.
const valid = crypto.verify('SHA256', verifyData, keyObject, sigBuf);
if (!valid) {
throw new Error('Signature verification failed');
}
// 6. Check sign counter (0 means authenticator doesn't support it — skip check)
const newSignCount = authData.signCount;
if (storedSignCount > 0 && newSignCount !== 0 && newSignCount <= storedSignCount) {
throw new Error(`Sign count replay attack detected: stored=${storedSignCount} got=${newSignCount}`);
}
return { newSignCount };
}
/**
* Build registration options to send to the client.
* @param {object} params
* @param {string} params.challenge - base64url challenge
* @param {string} params.userId - base64url user handle (should be opaque, e.g. SHA256 of user.id)
* @param {string} params.userName - e.g. "anon_abc123"
* @param {string} params.displayName - e.g. "Anonymous"
* @param {Array} params.excludeCredentials - list of {id, type} to exclude (prevent re-registration)
* @returns {object} PublicKeyCredentialCreationOptions-compatible JSON
*/
export function buildRegistrationOptions({ challenge, userId, userName, displayName, excludeCredentials = [], rpId: rpIdOverride }) {
const rpId = getRpId(rpIdOverride);
return {
rp: {
name: cfg.main?.sitename || 'f0ck.dev',
id: rpId
},
user: {
id: userId,
name: userName,
displayName: displayName || userName
},
challenge,
pubKeyCredParams: [
{ type: 'public-key', alg: -7 } // ES-256
],
timeout: 60000,
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
},
attestation: 'none'
};
}
/**
* Build authentication options to send to the client.
* @param {object} params
* @param {string} params.challenge - base64url challenge
* @param {Array} params.allowCredentials - list of {id, type} (empty = discoverable / any)
* @returns {object} PublicKeyCredentialRequestOptions-compatible JSON
*/
export function buildAuthenticationOptions({ challenge, allowCredentials = [], rpId: rpIdOverride }) {
const rpId = getRpId(rpIdOverride);
return {
challenge,
rpId,
allowCredentials,
userVerification: 'preferred',
timeout: 60000
};
}
+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) {
+15 -11
View File
@@ -422,9 +422,11 @@ export const handleUpload = async (req, res, self) => {
addPrivateItem(itemid, filename, req.session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
}
try {
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval);
@@ -1100,14 +1102,16 @@ export const handleUpload = async (req, res, self) => {
}
// Automatically subscribe uploader to comment thread
try {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${req.session.id}, ${itemid})
ON CONFLICT DO NOTHING
`;
} catch (err) {
console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err);
if (cfg.enable_comments !== false) {
try {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${req.session.id}, ${itemid})
ON CONFLICT DO NOTHING
`;
} catch (err) {
console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err);
}
}
// Thumbnail & Coverart