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