This commit is contained in:
2026-09-12 10:10:34 +02:00
parent 217f1be72d
commit 53055ea5c6
35 changed files with 2261 additions and 138 deletions
+192
View File
@@ -0,0 +1,192 @@
import crypto from 'node:crypto';
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;
}
}
/**
* Find or create a shadow user in the database for an anonymous SSH identity.
* @param {string} pubkey
* @param {string} fingerprint
* @returns {Promise<{ userId: number, isNew: boolean }>}
*/
export async function getOrCreateAnonUser(pubkey, fingerprint) {
const normPubkey = pubkey.trim();
const existing = await db`
SELECT user_id FROM anon_identities
WHERE pubkey = ${normPubkey}
LIMIT 1
`;
if (existing.length > 0) {
await db`UPDATE anon_identities SET last_seen = NOW() WHERE pubkey = ${normPubkey}`;
return { userId: existing[0].user_id, isNew: false };
}
// Generate unique shadow username
const shortHash = crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 8);
let baseLogin = `anon_${shortHash}`;
let finalLogin = baseLogin;
let counter = 1;
while (true) {
const check = await db`SELECT id FROM "user" WHERE login = ${finalLogin} LIMIT 1`;
if (check.length === 0) break;
finalLogin = `${baseLogin}_${counter++}`;
}
const userRows = await db`
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated)
VALUES (${finalLogin}, ${finalLogin}, '!', false, false, true)
RETURNING id
`;
const userId = userRows[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name)
VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous')
ON CONFLICT (user_id) DO NOTHING
`;
await db`
INSERT INTO anon_identities (user_id, pubkey, fingerprint)
VALUES (${userId}, ${normPubkey}, ${fingerprint})
ON CONFLICT (pubkey) DO NOTHING
`;
return { userId, isNew: true };
}
/**
* Create a valid session in user_sessions for this anonymous user.
* @param {number} userId
* @param {object} req
* @returns {Promise<{ session: string, csrf_token: string }>}
*/
export async function createAnonSession(userId, req) {
// 1. 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) {
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 (req?.cookies?.session) {
const existingHash = lib.sha256(req.cookies.session);
const existing = await db`
SELECT session, csrf_token FROM user_sessions
WHERE user_id = ${userId} AND session = ${existingHash}
LIMIT 1
`;
if (existing.length > 0) {
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
}
}
const session = crypto.randomBytes(32).toString('hex');
const sessionHash = lib.sha256(session);
const csrfToken = crypto.randomBytes(24).toString('hex');
const stamp = ~~(Date.now() / 1e3);
const ip = req?.ip || req?.socket?.remoteAddress || '127.0.0.1';
const ua = req?.headers ? (req.headers['user-agent'] || '') : '';
const sessRecord = {
user_id: userId,
session: sessionHash,
csrf_token: csrfToken,
browser: ua,
created_at: stamp,
last_used: stamp,
last_action: '/anon/session',
kmsi: 1,
ip: ip
};
await db`
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
`;
return { session, csrf_token: csrfToken };
}
+52
View File
@@ -4,6 +4,7 @@ import db from "./sql.mjs";
import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess } from "./settings.mjs";
@@ -158,6 +159,7 @@ export default new class {
if (env.tag) link.push("tag", encodeURIComponent(env.tag));
if (env.hall) link.push("h", encodeURIComponent(env.hall));
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
else if (env.type === 'favs') link.push("favs");
let tmp = link.length === 0 ? '/' : link.join('/');
if (!tmp.endsWith('/'))
@@ -375,13 +377,63 @@ export default new class {
body: "401 - Unauthorized"
});
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
if (pathname.startsWith('/api/')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Registered account required" }), type: 'application/json' });
}
return res.redirect('/login');
}
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout' && req.url.pathname !== '/settings') {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
}
return next();
};
// Require a real registered user account (explicitly denies anonymous SSH identities)
async registeredUser(req, res, next) {
if (!req.session) {
return res.reply({
code: 401,
body: "401 - Unauthorized"
});
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
if (pathname.startsWith('/api/')) {
return res.reply({
code: 403,
body: JSON.stringify({ success: false, msg: "Action requires a registered account" }),
type: 'application/json'
});
}
return res.redirect('/login');
}
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
}
return next();
};
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);
}
}
}
if (!req.session) {
return res.reply({
code: 401,
+3
View File
@@ -548,6 +548,9 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten",
"guest_favs_saved": "Du hast {count} Gast-Favoriten auf diesem Gerät gespeichert.",
"sync_guest_favs": "In Account importieren",
"guest_favs_imported": "Favoriten erfolgreich in deinen Account importiert!",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
+3
View File
@@ -553,6 +553,9 @@
"subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found",
"no_favs": "no favorites",
"guest_favs_saved": "You have {count} guest favorites saved on this device.",
"sync_guest_favs": "Import to Account",
"guest_favs_imported": "Imported favorites to your account!",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User",
+3
View File
@@ -546,6 +546,9 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten",
"guest_favs_saved": "Je hebt {count} gastfavorieten opgeslagen op dit apparaat.",
"sync_guest_favs": "Importeren naar account",
"guest_favs_imported": "Favorieten succesvol geïmporteerd naar je account!",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen",
+3
View File
@@ -547,6 +547,9 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten",
"guest_favs_saved": "Du hast {count} Kaltgast-Favs auf diesem Gerät rumgammeln.",
"sync_guest_favs": "In Account ballern",
"guest_favs_imported": "Favs erfolgreich ins Konto geballert!",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
+55 -16
View File
@@ -487,7 +487,7 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -513,7 +513,7 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${fav ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -531,7 +531,7 @@ const f0cklib = {
const totalBefore = Number(countRows[0]?.total_before || 0);
return Math.floor(totalBefore / eps) + 1;
},
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger, ids, total: explicitTotal } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
@@ -543,6 +543,20 @@ const f0cklib = {
}
}
const cleanIds = Array.isArray(ids)
? ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
if (cleanIds !== null && cleanIds.length === 0) {
return {
success: false,
message: "404 - no uploads found",
items: [],
total: 0
};
}
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const filters = await buildFeedFilters({
rawUser,
rawTag,
@@ -593,20 +607,21 @@ const f0cklib = {
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = getCachedCount(cacheKey);
let total = (explicitTotal !== undefined && explicitTotal !== null) ? Number(explicitTotal) : getCachedCount(cacheKey);
if (total === null) {
const totalRows = await db`
select count(distinct items.id) as total
from items
${fav ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
${fav && user ? db`inner join favorites on favorites.item_id = items.id inner join "user" fav_u on fav_u.id = favorites.user_id` : db``}
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -617,7 +632,7 @@ const f0cklib = {
${xdFilter}
`;
total = Number(totalRows[0].total);
if (total > 0) setCachedCount(cacheKey, total);
if (total > 0 && !cleanIds) setCachedCount(cacheKey, total);
}
if (!total || total === 0) {
@@ -639,7 +654,7 @@ const f0cklib = {
const pageIdRows = await db`
select items.id, items.is_pinned
from items
${fav ? db`
${fav && user ? db`
inner join favorites on favorites.item_id = items.id
inner join "user" fav_u on fav_u.id = favorites.user_id
` : db``}
@@ -649,7 +664,8 @@ const f0cklib = {
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
${idsFilter}
${fav && user ? db`and (fav_u.user ilike ${user} or fav_u.login ilike ${user})` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${hallFilter}
@@ -658,8 +674,14 @@ const f0cklib = {
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
${fav ? db`group by items.id, items.is_pinned` : db``}
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
${fav && user ? db`group by items.id, items.is_pinned` : db``}
order by ${
random ? db`random()` : (
(fav && !user && cleanIds && cleanIds.length > 0)
? db`array_position(${cleanIds}::int[], items.id)`
: db`items.is_pinned desc, items.id desc`
)
}
offset ${newerThan ? 0 : offset}
limit ${eps}
`;
@@ -774,7 +796,7 @@ const f0cklib = {
view_mode: fav ? 'favs' : 'uploads'
};
},
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang, ids } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
@@ -786,6 +808,11 @@ const f0cklib = {
}
}
const cleanIds = Array.isArray(ids)
? ids.map(Number).filter(n => Number.isInteger(n) && n > 0)
: (typeof ids === 'string' ? ids.split(',').map(Number).filter(n => Number.isInteger(n) && n > 0) : null);
const idsFilter = (cleanIds && cleanIds.length > 0) ? db`and items.id = ANY(${cleanIds}::int[])` : db``;
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -899,8 +926,9 @@ const f0cklib = {
${titleFilter}
${hallFilter}
${userHallFilter}
${fav ? db`and "user"."user" ilike ${user}` : db``}
${fav && user ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``}
${idsFilter}
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -1030,7 +1058,7 @@ const f0cklib = {
// Determine the effective mode for optimization check (similar to Random)
const nsfl_id = cfg.nsfl_tag_id || 3;
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall && (!cleanIds || cleanIds.length === 0);
const baseQuery = (whereClause, orderBy, limit = 1) => {
return db`
@@ -1038,7 +1066,7 @@ const f0cklib = {
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
${fav
${fav && user
? db`inner join favorites on favorites.item_id = items.id inner join "user" on "user".id = favorites.user_id`
: db`left join favorites on favorites.item_id = items.id left join "user" on "user".id = favorites.user_id`
}
@@ -1569,16 +1597,27 @@ const f0cklib = {
COALESCE(c.is_pinned, false) as is_pinned,
c.video_time,
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count,
ai.fingerprint as anon_fingerprint
FROM comments c
JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id
LEFT JOIN anon_identities ai ON ai.user_id = u.id
WHERE c.item_id = ${numericId} AND c.is_deleted = false
ORDER BY COALESCE(c.is_pinned, false) DESC,
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
`;
for (const c of comments) {
if (c.anon_fingerprint) {
c.is_anon = true;
c.username = 'anonymous';
c.display_name = 'Anonymous';
c.anon_short_fingerprint = c.anon_fingerprint.slice(7, 15);
}
}
// Fetch comment file attachments
if (comments.length > 0) {
const commentIds = comments.map(c => c.id);
+7 -1
View File
@@ -24,6 +24,8 @@ export default (router, tpl) => {
contextUrl = query.fav === 'true'
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
} else if (query.fav === 'true') {
contextUrl = `/favs/${req.params.itemid}`;
}
if (query.mime) {
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
@@ -50,6 +52,7 @@ export default (router, tpl) => {
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
fav: query.fav === 'true',
ids: query.ids || null,
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
@@ -156,8 +159,9 @@ export default (router, tpl) => {
data.uploader.color = null;
}
}
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
@@ -244,6 +248,8 @@ export default (router, tpl) => {
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
fav: query.fav === 'true',
ids: query.ids || null,
total: query.total ? parseInt(query.total, 10) : undefined,
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
+129
View File
@@ -0,0 +1,129 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.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.
*/
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 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 { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint);
const { session, csrf_token } = await createAnonSession(userId, req);
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,
csrf_token: csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Session establishment error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
*/
group.get(/\/identity$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ logged_in: false, is_anon: false, disabled: true });
}
if (!req.session) {
return res.json({ logged_in: false, is_anon: false });
}
const rows = await db`
SELECT pubkey, fingerprint, created_at, last_seen
FROM anon_identities
WHERE user_id = ${req.session.id}
LIMIT 1
`;
if (rows.length > 0) {
const fp = rows[0].fingerprint;
return res.json({
logged_in: true,
is_anon: true,
user_id: req.session.id,
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
pubkey: rows[0].pubkey,
csrf_token: req.session.csrf_token
});
}
return res.json({
logged_in: true,
is_anon: false,
user: req.session.user,
user_id: req.session.id,
csrf_token: req.session.csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Identity lookup error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/anon/logout
* Clear anonymous session cookie and remove active session from database.
*/
group.post(/\/logout$/, async (req, res) => {
try {
if (req.session && req.session.sess_id) {
await db`
DELETE FROM user_sessions
WHERE id = ${+req.session.sess_id}
`;
}
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);
return res.json({ success: false, msg: err.message }, 500);
}
});
});
};
+52 -6
View File
@@ -762,6 +762,7 @@ export default router => {
xd_score: item.xd_score,
xd_tier: item.xd_tier,
personalized: !!item.personalized,
tags: item.tags || [],
score: typeof item.score === 'number' ? item.score : (typeof item.rank_score === 'number' ? item.rank_score : (item.xd_score || 0)),
rank_score: item.rank_score ?? item.score ?? 0
}))
@@ -1231,7 +1232,7 @@ export default router => {
// PATCH /api/v2/items/:id/title — set or clear the title for an item
// Allowed by: item owner, moderators, admins
group.patch(/\/items\/(?<id>\d+)\/title$/, lib.loggedin, async (req, res) => {
group.patch(/\/items\/(?<id>\d+)\/title$/, lib.registeredUser, async (req, res) => {
const id = +req.params.id;
if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400);
@@ -1412,8 +1413,53 @@ export default router => {
favs
});
});
group.post(/\/favorites\/import$/, lib.loggedin, async (req, res) => {
try {
const rawIds = req.post?.ids ?? req.body?.ids;
let ids = [];
if (Array.isArray(rawIds)) {
ids = rawIds.map(Number);
} else if (typeof rawIds === 'string') {
ids = rawIds.split(',').map(Number);
}
ids = ids.filter(n => Number.isInteger(n) && n > 0);
if (ids.length === 0) {
return res.json({ success: true, imported: 0 });
}
// Limit import batch to 500 items max for safety
const sliceIds = ids.slice(0, 500);
// Fetch valid existing items
const validItems = await db`
SELECT id FROM items WHERE id = ANY(${sliceIds}::int[]) AND active = true AND is_deleted = false
`;
const validIds = validItems.map(i => i.id);
let count = 0;
for (const itemId of validIds) {
const inserted = await db`
INSERT INTO favorites (user_id, item_id)
VALUES (${req.session.id}, ${itemId})
ON CONFLICT DO NOTHING
RETURNING item_id
`;
if (inserted.length > 0) {
count++;
f0cklib.updateUserAffinity({ user_id: req.session.id, item_id: itemId, scoreDelta: 5.0 }).catch(() => {});
}
}
return res.json({ success: true, imported: count });
} catch (err) {
console.error('[FAVORITES_IMPORT_ERROR]', err);
return res.status(500).json({ success: false, message: err.message });
}
});
group.post(/\/toggle-oc$/, lib.loggedin, async (req, res) => {
group.post(/\/toggle-oc$/, lib.registeredUser, async (req, res) => {
const postid = +req.post.postid;
if (!postid) return res.json({ success: false, msg: 'No postid provided' }, 400);
@@ -1495,7 +1541,7 @@ export default router => {
});
});
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
group.post(/\/item\/visibility$/, lib.registeredUser, async (req, res) => {
if (cfg.enable_private_uploads === false && !req.session?.admin) {
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
}
@@ -1551,7 +1597,7 @@ export default router => {
});
});
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.registeredUser, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
@@ -1593,7 +1639,7 @@ export default router => {
}
});
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.registeredUser, async (req, res) => {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
}
@@ -1638,7 +1684,7 @@ export default router => {
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.registeredUser, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
+22 -22
View File
@@ -10,7 +10,7 @@ import crypto from 'crypto';
export default router => {
router.group(/^\/api\/v2\/settings/, group => {
group.put(/\/setAvatar/, lib.loggedin, async (req, res) => {
group.put(/\/setAvatar/, lib.registeredUser, async (req, res) => {
if (!req.post.avatar) {
return res.json({
msg: 'no avatar provided',
@@ -46,7 +46,7 @@ export default router => {
});
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
group.put(/\/useCustomAvatar/, lib.loggedin, async (req, res) => {
group.put(/\/useCustomAvatar/, lib.registeredUser, async (req, res) => {
// Check if user has a custom avatar file
const userOpts = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
@@ -139,7 +139,7 @@ export default router => {
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
group.post(/\/link\/token/, lib.loggedin, async (req, res) => {
group.post(/\/link\/token/, lib.registeredUser, async (req, res) => {
// 6-char alphanumeric code
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
@@ -179,7 +179,7 @@ export default router => {
});
// Get linked accounts (Discord & Matrix)
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
group.get(/\/link\/accounts/, lib.registeredUser, async (req, res) => {
try {
const aliases = await db`
SELECT alias, type FROM user_alias
@@ -199,7 +199,7 @@ export default router => {
});
// Unlink account
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
try {
const alias = decodeURIComponent(req.params.alias);
const type = req.params.type;
@@ -225,7 +225,7 @@ export default router => {
// Backward compatibility routes for Discord (Deprecated)
// Discord Token Generation (Redirect to generic)
group.post(/\/discord\/token/, lib.loggedin, async (req, res) => {
group.post(/\/discord\/token/, lib.registeredUser, async (req, res) => {
// Just call the logic inline
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
try {
@@ -238,13 +238,13 @@ export default router => {
});
// Get linked Discord accounts (Legacy)
group.get(/\/discord\/linked/, lib.loggedin, async (req, res) => {
group.get(/\/discord\/linked/, lib.registeredUser, async (req, res) => {
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
});
// Unlink Discord account (Legacy)
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
const alias = decodeURIComponent(req.params.alias);
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, msg: 'Account unlinked' }, 200);
@@ -365,7 +365,7 @@ export default router => {
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
group.put(/\/default_upload_visibility/, lib.registeredUser, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
@@ -389,7 +389,7 @@ export default router => {
});
// Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
group.put(/\/username_color/, lib.registeredUser, async (req, res) => {
const { color } = req.post;
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
@@ -420,7 +420,7 @@ export default router => {
});
// Update password
group.put(/\/password/, lib.loggedin, async (req, res) => {
group.put(/\/password/, lib.registeredUser, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post;
if (!new_password || !new_password_confirm) {
@@ -461,7 +461,7 @@ export default router => {
});
// Update email
group.put(/\/email/, lib.loggedin, async (req, res) => {
group.put(/\/email/, lib.registeredUser, async (req, res) => {
const { email } = req.post;
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
const cleanEmail = email.trim();
@@ -484,7 +484,7 @@ export default router => {
});
// Update Display Name
group.put(/\/display_name/, lib.loggedin, async (req, res) => {
group.put(/\/display_name/, lib.registeredUser, async (req, res) => {
const { display_name } = req.post;
if (display_name !== undefined && typeof display_name !== 'string') {
@@ -517,7 +517,7 @@ export default router => {
});
// Update Description
group.put(/\/description/, lib.loggedin, async (req, res) => {
group.put(/\/description/, lib.registeredUser, async (req, res) => {
if (!cfg.websrv.enable_profile_description) {
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
}
@@ -553,7 +553,7 @@ export default router => {
});
// Update Font preference
group.put(/\/font/, lib.loggedin, async (req, res) => {
group.put(/\/font/, lib.registeredUser, async (req, res) => {
const { font } = req.post;
// F-023 Security: Validate font against actual files on disk
@@ -846,7 +846,7 @@ export default router => {
// GET /api/v2/settings/api-key
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
group.get(/\/api-key$/, lib.loggedin, async (req, res) => {
group.get(/\/api-key$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -876,7 +876,7 @@ export default router => {
// POST /api/v2/settings/api-key/regenerate
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => {
group.post(/\/api-key\/regenerate$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -904,7 +904,7 @@ export default router => {
// DELETE /api/v2/settings/api-key
// Revokes (deletes) the user's API key.
group.delete(/\/api-key$/, lib.loggedin, async (req, res) => {
group.delete(/\/api-key$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -928,7 +928,7 @@ export default router => {
// GET /api/v2/settings/api-key/sharex-config
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
group.get(/\/api-key\/sharex-config$/, lib.loggedin, async (req, res) => {
group.get(/\/api-key\/sharex-config$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.status(403).reply({ body: 'API keys are disabled' });
}
@@ -1005,7 +1005,7 @@ export default router => {
// GET /api/v2/settings/invites
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
group.get(/\/invites$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1085,7 +1085,7 @@ export default router => {
// POST /api/v2/settings/invites/create
// Generates a new invite token if eligible and slots remain.
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
group.post(/\/invites\/create$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1151,7 +1151,7 @@ export default router => {
// POST /api/v2/settings/invites/delete
// Deletes an unused invite token owned by the calling user.
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
group.post(/\/invites\/delete$/, lib.registeredUser, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
+3 -3
View File
@@ -234,7 +234,7 @@ export default router => {
router.group(/^\/api\/v2/, group => {
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => {
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.registeredUser, (req, res) => {
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
const state = progressMap.get(jobId);
res.setHeader?.('Cache-Control', 'no-store');
@@ -307,7 +307,7 @@ export default router => {
return [...new Set(tags)];
};
group.get(/\/meta\/extract-url$/, lib.loggedin, async (req, res) => {
group.get(/\/meta\/extract-url$/, lib.registeredUser, async (req, res) => {
const url = req.url.qs?.url;
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
@@ -358,7 +358,7 @@ export default router => {
}
});
group.post(/\/upload-url$/, lib.loggedin, async (req, res) => {
group.post(/\/upload-url$/, lib.registeredUser, async (req, res) => {
try {
if (!cfg.websrv.web_url_upload) {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
+33 -5
View File
@@ -7,6 +7,8 @@ import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } from "../anon_auth.mjs";
import { getEnableAnonymousAccess } from "../settings.mjs";
export default (router, tpl) => {
@@ -389,6 +391,26 @@ 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);
}
}
}
}
}
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
// Rate limit regular users (admins and mods are exempt)
@@ -627,10 +649,13 @@ export default (router, tpl) => {
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
created_at: new Date().toISOString(),
username_color: req.session.username_color,
display_name: req.session.display_name || null,
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
xd_score: xdRow?.xd_score ?? null,
video_time: newComment[0]?.video_time ?? null,
files: activityFiles
files: activityFiles,
is_anon: !!req.session.is_anon,
anon_fingerprint: req.session.fingerprint || null,
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
};
// 1. Thread live update
@@ -656,11 +681,14 @@ export default (router, tpl) => {
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
username: req.session.user,
username: req.session.is_anon ? 'anonymous' : req.session.user,
username_color: req.session.username_color,
display_name: req.session.display_name || null,
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
files: activityFiles,
is_long: activityIsLong
is_long: activityIsLong,
is_anon: !!req.session.is_anon,
anon_fingerprint: req.session.fingerprint || null,
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
}));
// Automatically subscribe user to the thread
+24 -1
View File
@@ -233,6 +233,12 @@ export default (router, tpl) => {
return res.redirect('/login');
}
// Redirect anonymous users requesting /user/anonymous/favs to their personal shadow username favs
if (req.params.mode === 'favs' && req.params.user?.toLowerCase() === 'anonymous' && req.session?.is_anon && req.session?.login) {
res.writeHead(302, { Location: `/user/${encodeURIComponent(req.session.login.toLowerCase())}/favs` });
return res.end();
}
// Auto-persist strict mode from URL to session if it's there
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
@@ -389,7 +395,8 @@ export default (router, tpl) => {
// Is the current user a moderator/admin?
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
// Can the current user manage this item (owner, admin, or mod)?
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Is the item's MIME type suitable for metadata extraction?
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
@@ -550,6 +557,22 @@ export default (router, tpl) => {
return res.reply({ body });
};
// Favorites route: redirect logged in users (or anon users) to /user/:user/favs, redirect clean guests to /login
router.get(/^\/favs(?:\/p\/(?<page>\d+))?\/?(?:\?.*)?$/, async (req, res) => {
if (req.session && req.session.user) {
const targetUser = (req.session.is_anon && req.session.login) ? req.session.login : req.session.user;
res.writeHead(302, { Location: `/user/${encodeURIComponent(targetUser.toLowerCase())}/favs` });
return res.end();
}
res.writeHead(302, { Location: `/login` });
return res.end();
});
router.get(/^\/favs\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)$/, (req, res) => {
req.params.mode = 'favs';
return handleGenericRoute(req, res);
});
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute);
+7 -3
View File
@@ -60,9 +60,9 @@ export default (router, tpl) => {
joined: user?.created_at || null,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
enable_swf: cfg.enable_swf,
enable_data_export: cfg.websrv.enable_data_export,
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: cfg.websrv.enable_user_invites !== false,
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
enable_user_api_keys: !req.session?.is_anon && cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: !req.session?.is_anon && cfg.websrv.enable_user_invites !== false,
site_domain: cfg.main.url.domain,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
@@ -74,6 +74,10 @@ export default (router, tpl) => {
});
});
group.get('/export-data', auth, async (req, res) => {
if (req.session?.is_anon) {
res.status(403).reply({ body: 'Export requires a registered account' });
return;
}
if (!cfg.websrv.enable_data_export) {
res.status(403).reply({ body: 'Export disabled' });
return;
+2 -2
View File
@@ -169,9 +169,9 @@ export default (router, tpl) => {
// Precompute boolean helpers for template @if() — must match index.mjs pattern
if (data.item) {
const session = data.session;
const item = data.item;
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
+5
View File
@@ -30,6 +30,11 @@ export const getEnableItemSlugs = () => {
return true;
};
export const getEnableAnonymousAccess = () => {
if (cfg.enable_anonymous_access === false || cfg.anonymous_access === false || cfg.websrv?.enable_anonymous_access === false || cfg.websrv?.anonymous_access === false) return false;
return true;
};
export const ensureAllItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;