gsdf
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(',') : '';
|
||||
|
||||
@@ -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 = ''`;
|
||||
|
||||
Reference in New Issue
Block a user