This commit is contained in:
2026-09-28 22:12:38 +02:00
parent b96f188a59
commit 2cc768f1fd
24 changed files with 962 additions and 78 deletions
+6 -8
View File
@@ -4,17 +4,15 @@ import lib from './lib.mjs';
import cfg from './config.mjs';
import security from './security.mjs';
import { getHashUserIps } from './settings.mjs';
/**
* Get IP for audit/logging, hashed if hash_user_ips is enabled in config.
* IP of the request in its storable form (see security.storableIP): null when IP logging is off.
* @param {object} req
* @returns {string}
* @returns {string|null}
*/
export function resolveAuditIP(req) {
if (!req) return 'unknown';
const rawIp = security.getRealIP(req);
return getHashUserIps() ? security.hashIP(rawIp) : rawIp;
if (!req) return null;
return security.storableIP(security.getRealIP(req));
}
/**
@@ -25,7 +23,7 @@ export function resolveAuditIP(req) {
export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) {
try {
const rawIp = security.getRealIP(req);
const ip = getHashUserIps() ? security.hashIP(rawIp) : rawIp;
const ip = security.storableIP(rawIp);
const userId = req?.session?.id || null;
if (!userId) return;
const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null;
@@ -157,7 +155,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
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 ip = auditIp;
const ua = req?.headers ? (req.headers['user-agent'] || '') : '';
const sessRecord = {
+30
View File
@@ -0,0 +1,30 @@
import db from "./sql.mjs";
/**
* IDs of items that are not live (pending approval, soft-deleted, purged).
* The public media routes (/t/, /ca/) consult this so a leftover file on disk can never
* expose a pending or removed item. Cached briefly because every thumbnail request hits it.
*/
const TTL_MS = 5000;
let cache = new Set();
let loadedAt = 0;
let inflight = null;
const refresh = () => {
if (!inflight) {
inflight = db`select id from items where active = false`
.then(rows => { cache = new Set(rows.map(r => r.id)); loadedAt = Date.now(); })
.catch(e => { console.warn('[HIDDEN ITEMS] refresh failed:', e.message); })
.finally(() => { inflight = null; });
}
return inflight;
};
export const isHiddenItem = async (id) => {
if (Date.now() - loadedAt > TTL_MS) await refresh();
return cache.has(+id);
};
// Call after an item changes state (approve / withdraw) so the next lookup reloads
export const invalidateHiddenItems = () => { loadedAt = 0; };
+97
View File
@@ -0,0 +1,97 @@
import db from "./sql.mjs";
import cfg from "./config.mjs";
/**
* retention.mjs — automatic deletion of personal data after a configurable period.
*
* All periods are in days, configured under websrv.* (0 = keep forever):
* retention_ip_days stored IPs: user_ips rows are deleted; IP columns on sessions, anon identities,
* uploads, comments, reports and ToS acceptances are set to NULL
* retention_activity_log_days anon_activity_log rows (action, IP, fingerprints) are deleted
* retention_login_attempts_days login_attempts rows (hashed IP + attempted username) are deleted
* retention_sessions_days sessions unused for this long are deleted (logs that device out)
* retention_fingerprint_days hardware fingerprint is cleared from anonymous identities inactive this long
*
* Active bans (banned_ips / banned_fingerprints / banned_hardware_fingerprints) are not touched: they are kept
* until they expire or are lifted.
*/
const DEFAULTS = {
ip: 30,
activity_log: 90,
login_attempts: 30,
sessions: 365,
fingerprint: 365
};
const days = (key) => {
const v = cfg.websrv?.[`retention_${key}_days`];
if (v === undefined || v === null || v === '') return DEFAULTS[key];
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
};
export const getRetention = () => ({
ip: days('ip'),
activity_log: days('activity_log'),
login_attempts: days('login_attempts'),
sessions: days('sessions'),
fingerprint: days('fingerprint')
});
const RUN_INTERVAL_MS = 60 * 60 * 1000; // hourly
export const runRetention = async () => {
const r = getRetention();
const nowSecs = ~~(Date.now() / 1e3);
const before = (d) => new Date(Date.now() - d * 86400e3);
const counts = {};
const step = async (name, fn) => {
try {
const res = await fn();
if (res?.count) counts[name] = res.count;
} catch (e) {
console.error(`[RETENTION] ${name} failed:`, e.message);
}
};
if (r.ip) {
const cutoff = before(r.ip);
const cutoffSecs = nowSecs - r.ip * 86400;
await step('user_ips', () => db`delete from user_ips where last_seen < ${cutoff}`);
await step('sessions.ip', () => db`update user_sessions set ip = null where ip is not null and last_used < ${cutoffSecs}`);
await step('anon.created_ip', () => db`update anon_identities set created_ip = null where created_ip is not null and created_at < ${cutoff}`);
await step('anon.last_ip', () => db`update anon_identities set last_ip = null where last_ip is not null and last_seen < ${cutoff}`);
await step('items.uploader_ip', () => db`update items set uploader_ip = null where uploader_ip is not null and stamp < ${cutoffSecs}`);
await step('comments.ip', () => db`update comments set ip = null where ip is not null and created_at < ${cutoff}`);
await step('reports.reporter_ip', () => db`update reports set reporter_ip = null where reporter_ip is not null and created_at < ${cutoff}`);
await step('tos.accepted_ip', () => db`update user_tos_acceptance set accepted_ip = null where accepted_ip is not null and accepted_at < ${cutoff}`);
}
if (r.activity_log) {
await step('anon_activity_log', () => db`delete from anon_activity_log where created_at < ${before(r.activity_log)}`);
}
if (r.login_attempts) {
await step('login_attempts', () => db`delete from login_attempts where attempted_at < ${before(r.login_attempts)}`);
}
if (r.sessions) {
await step('user_sessions', () => db`delete from user_sessions where last_used < ${nowSecs - r.sessions * 86400}`);
}
if (r.fingerprint) {
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null and last_seen < ${before(r.fingerprint)}`);
}
const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(', ');
if (summary) console.log(`[RETENTION] Cleaned: ${summary}`);
};
export const startRetention = () => {
const r = getRetention();
const fmt = (d) => d ? `${d}d` : 'forever';
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${fmt(r.fingerprint)}`);
setTimeout(runRetention, 30_000);
setInterval(runRetention, RUN_INTERVAL_MS);
};
+1 -1
View File
@@ -113,7 +113,7 @@ export default (router, tpl) => {
last_used: stamp,
last_action: "/login",
kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0,
ip: ip
ip: security.storableIP(ip)
};
await db`
+2 -1
View File
@@ -1,6 +1,7 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
@@ -1444,7 +1445,7 @@ export default router => {
// Create a full user session (same as normal login)
const stamp = Math.floor(Date.now() / 1000);
const ip = (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.socket?.remoteAddress || '').split(',')[0].trim();
const ip = security.storableIP(security.getRealIP(req));
const sessionToken = crypto.randomBytes(32).toString('hex');
const csrfToken = crypto.randomBytes(32).toString('hex');
const sessRecord = {
+44 -1
View File
@@ -4,7 +4,8 @@ import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs";
import { render502 } from "../private_items.mjs";
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor, getHashUserIps, getLogUserIps, getEnableAnonymousAccess } from "../settings.mjs";
import { getRetention } from "../retention.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -689,6 +690,48 @@ export default (router, tpl) => {
});
});
// Everything /privacy states is derived from the running config, so the page can't drift from reality
const privacyState = () => {
const r = getRetention();
const period = (n) => n ? `${n} day${n === 1 ? '' : 's'}` : 'indefinitely';
const logIps = getLogUserIps();
const hashIps = getHashUserIps();
return {
log_ips: logIps,
hash_ips: hashIps,
ip_mode: !logIps ? 'off' : (hashIps ? 'hashed' : 'raw'),
anon: getEnableAnonymousAccess(),
https: String(cfg.main?.url?.full || '').startsWith('https'),
domain: cfg.main?.url?.domain || '',
ret: {
ip: period(r.ip),
activity: period(r.activity_log),
login: period(r.login_attempts),
sessions: period(r.sessions),
fp: period(r.fingerprint)
},
ret_on: {
ip: !!r.ip, activity: !!r.activity_log, login: !!r.login_attempts, sessions: !!r.sessions, fp: !!r.fingerprint
}
};
};
router.get(/^\/privacy\/?$/, (req, res) => {
res.reply({
body: tpl.render('privacy', {
tmp: null,
mail: cfg.main.mail,
pv: privacyState(),
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'privacy',
description: 'Privacy: what is stored when you use the site',
url: `https://${cfg.main.url.domain}/privacy`
}
}, req)
});
});
router.get(/^\/(rules)$/, (req, res) => {
res.reply({
body: tpl.render('rules', {
+3
View File
@@ -1,6 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { invalidateHiddenItems } from "../hidden_items.mjs";
import { promises as fs } from "fs";
import cfg from "../config.mjs";
import fetch from "flumm-fetch";
@@ -317,6 +318,7 @@ export default (router, tpl) => {
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
// This prevents duplicate webhooks from double-clicks or race conditions.
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
invalidateHiddenItems();
if (result.count === 1) {
// Mark pending upload notifications as read for staff
@@ -886,6 +888,7 @@ export default (router, tpl) => {
const item = rows[0];
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`;
invalidateHiddenItems();
if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
await moveItemFilesToPublic(item, id);
+192
View File
@@ -0,0 +1,192 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import path from "path";
import { promises as fs, createReadStream } from "fs";
import audit from "../audit.mjs";
import { getManualApproval, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
import { invalidateHiddenItems } from "../hidden_items.mjs";
/**
* pending.mjs — upload status for the uploader
* GET /pending own recent uploads with status (pending / live / denied / removed)
* GET /pending/t/:id.webp thumbnail of an own pending upload (pending files are not public)
* POST /pending/delete withdraw an own upload that is still pending (files + row are removed)
* GET /api/v2/uploads/:id/status JSON status for API clients (session or X-Api-Key)
*/
const RECENT_LIMIT = 50;
const statusOf = (row) => {
if (row.is_purged) return 'removed';
if (row.is_deleted) return 'denied';
if (row.active) return 'live';
return 'pending';
};
const itemPath = (row) => (getEnableItemSlugs() && row.slug) ? row.slug : row.id;
// Latest moderator reason for denied/removed items (deny, delete or purge)
const reasonsFor = async (ids) => {
if (!ids.length) return new Map();
const rows = await db`
select distinct on (target_id) target_id, details->>'reason' as reason
from audit_log
where target_id = any(${ids.map(String)}::text[])
and action in ('deny_item', 'delete_item', 'purge_item')
order by target_id, created_at desc
`.catch(() => []);
return new Map(rows.map(r => [Number(r.target_id), r.reason]));
};
// Session user, or the account behind an X-Api-Key header (for API clients)
const resolveUser = async (req) => {
if (req.session?.id) return req.session;
const key = req.headers['x-api-key'];
if (!key || cfg.websrv.enable_user_api_keys === false) return null;
const rows = await db`
select u.id, u.user, u.login, u.admin, u.is_moderator, u.banned
from user_api_keys k join "user" u on u.id = k.user_id
where k.api_key = ${key} limit 1
`.catch(() => []);
if (!rows.length || rows[0].banned) return null;
return rows[0];
};
const isOwnerOf = (row, session) => {
const owner = getSessionOwnerName(session);
return !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase());
};
// Every file a pending upload keeps in the pending folder (main file, thumbs, cover art, album sub-items)
const pendingFilesOf = async (row) => {
const p = (...parts) => path.join(cfg.paths.pending, ...parts);
const files = [p('t', `${row.id}.webp`), p('t', `${row.id}_blur.webp`), p('ca', `${row.id}.webp`)];
if (row.dest && row.mime !== 'video/youtube') files.push(p('b', row.dest));
if (row.is_album) {
const subs = await db`select dest from album_items where item_id = ${row.id}`.catch(() => []);
for (const sub of subs) {
files.push(p('b', sub.dest), p('t', `${sub.dest.replace(/\.[^.]+$/, '')}.webp`));
}
}
return files;
};
const json = (res, code, obj) => {
const body = JSON.stringify(obj);
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
export default (router, tpl) => {
router.get(/^\/pending\/?$/, async (req, res) => {
if (!req.session) return res.redirect('/login');
const owner = getSessionOwnerName(req.session);
const rows = owner ? await db`
select id, slug, mime, title, original_filename, stamp, size, active, is_deleted, is_purged, is_album, album_count, visibility
from items
where lower(username) = ${owner.toLowerCase()}
order by id desc
limit ${RECENT_LIMIT}
` : [];
const reasons = await reasonsFor(rows.filter(r => r.is_deleted).map(r => r.id));
const items = rows.map(r => {
const status = statusOf(r);
return {
id: r.id,
path: itemPath(r),
status,
title: r.title || r.original_filename || '',
mime: r.mime,
is_album: !!r.is_album,
album_count: r.album_count || 0,
size_fmt: r.size ? lib.formatSize(r.size) : '',
time_ago: r.stamp ? lib.timeAgo(new Date(r.stamp * 1000), req.lang) : '',
time_full: r.stamp ? new Date(r.stamp * 1000).toISOString() : '',
thumb: status === 'live' ? `/t/${r.id}.webp` : (status === 'pending' ? `/pending/t/${r.id}.webp` : ''),
reason: status === 'denied' || status === 'removed' ? (reasons.get(r.id) || '') : ''
};
});
const counts = { pending: 0, live: 0, denied: 0, removed: 0 };
items.forEach(i => { counts[i.status]++; });
res.reply({
body: tpl.render('pending', {
items,
counts,
manual_approval_on: getManualApproval(),
session: req.session,
tmp: null
}, req)
});
});
// Thumbnail of an own pending upload (moderators may view any)
router.get(/^\/pending\/t\/(?<id>\d+)\.webp$/, async (req, res) => {
if (!req.session) return res.writeHead(401).end();
const id = +req.params.id;
const [row] = await db`select username, active, is_deleted from items where id = ${id} limit 1`;
const isStaff = !!(req.session.admin || req.session.is_moderator);
const isOwner = isOwnerOf(row, req.session);
if (!row || row.active || row.is_deleted || !(isOwner || isStaff)) return res.writeHead(404).end();
const file = path.join(cfg.paths.pending, 't', `${id}.webp`);
try {
const stat = await fs.stat(file);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': stat.size, 'Cache-Control': 'private, max-age=60' });
createReadStream(file).pipe(res);
} catch {
res.writeHead(404).end();
}
});
// Withdraw an own upload before a moderator has looked at it. Only the uploader, only while pending.
router.post(/^\/pending\/delete\/?$/, async (req, res) => {
if (!req.session) return json(res, 401, { success: false, msg: 'Login required' });
const id = +(req.post?.id || req.body?.id || 0);
if (!id) return json(res, 400, { success: false, msg: 'No ID provided' });
const [row] = await db`select id, username, dest, mime, is_album, active, is_deleted, is_purged from items where id = ${id} limit 1`;
if (!row || !isOwnerOf(row, req.session)) return json(res, 404, { success: false, msg: 'Upload not found' });
if (row.active || row.is_deleted || row.is_purged) return json(res, 409, { success: false, msg: 'Only pending uploads can be deleted' });
// Collect files before the row goes (album_items cascade). The active = false guard lets a concurrent
// approval win; files are only touched once the row is gone. Cascades clear tags, notifications, reports.
const files = await pendingFilesOf(row);
const deleted = await db`delete from items where id = ${id} and active = false and is_deleted = false returning id`;
if (!deleted.length) return json(res, 409, { success: false, msg: 'Upload was already reviewed' });
invalidateHiddenItems();
await Promise.all(files.map(f => fs.unlink(f).catch(() => {})));
await audit.log(req.session.id, 'withdraw_item', 'item', id, { filename: row.dest, uploader_name: row.username });
return json(res, 200, { success: true, id });
});
// JSON status for API clients (e.g. f0ckm-uploader polling after an upload went to manual approval)
router.get(/^\/api\/v2\/uploads\/(?<id>\d+)\/status\/?$/, async (req, res) => {
const user = await resolveUser(req);
if (!user) return json(res, 401, { success: false, msg: 'Login or X-Api-Key required' });
const id = +req.params.id;
const [row] = await db`select id, slug, username, active, is_deleted, is_purged from items where id = ${id} limit 1`;
const owner = getSessionOwnerName(user.is_anon !== undefined ? user : { ...user, is_anon: false });
const isStaff = !!(user.admin || user.is_moderator);
const isOwner = !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase());
if (!row || !(isOwner || isStaff)) return json(res, 404, { success: false, msg: 'Upload not found' });
const status = statusOf(row);
const reason = (status === 'denied' || status === 'removed') ? ((await reasonsFor([row.id])).get(row.id) || null) : null;
return json(res, 200, {
success: true,
itemid: row.id,
slug: row.slug || null,
status,
reason,
url: status === 'live' ? `${cfg.main.url.full}/${itemPath(row)}` : `${cfg.main.url.full}/pending#i${row.id}`,
status_url: `${cfg.main.url.full}/pending#i${row.id}`
});
});
};
+1 -1
View File
@@ -65,7 +65,7 @@ export default (router, tpl) => {
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories)
VALUES (
${req.session ? req.session.id : null},
${ip},
${security.storableIP(ip)},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
+15
View File
@@ -3,6 +3,7 @@ import fs from "fs/promises";
import path from "path";
import db from "../sql.mjs";
import queue from "../queue.mjs";
import { isHiddenItem } from "../hidden_items.mjs";
export default (router, tpl) => {
router.static({
@@ -48,8 +49,21 @@ export default (router, tpl) => {
return 'application/octet-stream';
};
// <id>.webp / <id>_blur.webp belonging to an item that isn't live (pending, deleted) is never public,
// even if a stale file with that name exists. Uploaders use /pending/t/, staff /mod/pending/t/.
const isHiddenMedia = async (file) => {
const m = /^(\d+)(?:_blur)?\.webp$/.exec(file);
return !!m && await isHiddenItem(m[1]);
};
const notFound = (res) => {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
};
router.get(/^\/t\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file;
if (await isHiddenMedia(file)) return notFound(res);
const filePath = path.join(cfg.paths.t, file);
try {
const stat = await fs.stat(filePath);
@@ -115,6 +129,7 @@ export default (router, tpl) => {
router.get(/^\/ca\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file;
if (await isHiddenMedia(file)) return notFound(res);
const filePath = path.join(cfg.paths.ca, file);
try {
const stat = await fs.stat(filePath);
+12 -4
View File
@@ -178,11 +178,19 @@ export default new class {
* @param {number} userId
* @param {string} ip
*/
/**
* The form in which an IP may be written to the database, following the config:
* null when websrv.log_user_ips is off, HMAC-SHA256 when websrv.hash_user_ips is on, raw otherwise.
* Every stored IP (sessions, activity, uploads, comments, reports) goes through here so /privacy stays true.
*/
storableIP(ip) {
if (!cfg.websrv.log_user_ips || !ip || ip === 'unknown') return null;
return cfg.websrv.hash_user_ips ? this.hashIP(ip) : ip;
}
async logUserIP(userId, ip) {
if (!cfg.websrv.log_user_ips || !userId || !ip) return;
const { getHashUserIps } = await import("./settings.mjs");
const finalIp = getHashUserIps() ? this.hashIP(ip) : ip;
const finalIp = this.storableIP(ip);
if (!userId || !finalIp) return;
await db`
insert into user_ips (user_id, ip)
+8
View File
@@ -28,6 +28,7 @@ import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { startRetention } from "./inc/retention.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -615,6 +616,8 @@ process.on('uncaughtException', err => {
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip character varying(128) DEFAULT NULL`);
// IPs are only stored when websrv.log_user_ips is on (security.storableIP), so activity rows may have none
await runMigration(db`ALTER TABLE anon_activity_log ALTER COLUMN ip DROP NOT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS is_album boolean DEFAULT false`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0`);
await runMigration(db`
@@ -1930,6 +1933,8 @@ process.on('uncaughtException', err => {
get manual_approval() { return getManualApproval(); },
get min_tags() { return getMinTags(); },
get registration_open() { return getRegistrationOpen(); },
// 'off' | 'hashed' | 'raw' — how IPs are persisted (security.storableIP); used for privacy disclosures
get privacy_ip_mode() { return !cfg.websrv.log_user_ips ? 'off' : (cfg.websrv.hash_user_ips ? 'hashed' : 'raw'); },
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
registration_require_mail_andor_token: !!cfg.websrv.open_registration_require_mail_andor_token,
get trusted_uploads() { return getTrustedUploads(); },
@@ -2307,4 +2312,7 @@ process.on('uncaughtException', err => {
setInterval(banInactiveUsers, INACTIVITY_BAN_INTERVAL_MS);
}
// ── Data retention — delete / scrub personal data after websrv.retention_*_days (shown on /privacy)
startRetention();
})();
+13 -4
View File
@@ -284,11 +284,13 @@ export const handleUpload = async (req, res, self) => {
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null;
if (!is_shitpost && !effectiveRating) {
// Admins uploading via API key (ShareX, f0ckm-uploader, …) may skip the rating in every mode; the post is then untagged
const isAdminApiUpload = !!(req.session.api_key_auth && req.session.admin);
if (!is_shitpost && !isAdminApiUpload && !effectiveRating) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
}
if (is_shitpost && cfg.websrv.shitpost_require_rating === true && !effectiveRating) {
if (is_shitpost && !isAdminApiUpload && cfg.websrv.shitpost_require_rating === true && !effectiveRating) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required for each item' }, 400);
}
@@ -556,7 +558,10 @@ export const handleUpload = async (req, res, self) => {
visibility: targetVisibility,
manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/pending#i${itemid}`,
status: manualApproval ? 'pending' : 'live',
status_url: `${cfg.main.url.full}/pending#i${itemid}`,
status_api: `${cfg.main.url.full}/api/v2/uploads/${itemid}/status`,
file_url: null,
dest: filename,
mime: 'video/youtube',
@@ -1422,7 +1427,11 @@ export const handleUpload = async (req, res, self) => {
visibility: targetVisibility,
manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
// Pending uploads aren't public yet: point clients at the uploader's status page instead of the homepage
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/pending#i${itemid}`,
status: manualApproval ? 'pending' : 'live',
status_url: `${cfg.main.url.full}/pending#i${itemid}`,
status_api: `${cfg.main.url.full}/api/v2/uploads/${itemid}/status`,
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
// Fields for immediate client-side grid injection (avoids SSE race condition)
dest: filename,