Added entity reporting.

This commit is contained in:
2026-11-13 00:23:13 +01:00
parent 6143c41668
commit 1d219850d7
18 changed files with 840 additions and 5 deletions
+7
View File
@@ -25,6 +25,7 @@ import redis from './redis.js';
import { curateRevision } from './revisions.js';
import { curateStash } from './stashes.js';
import { syncActors, syncManticoreScenes, syncScenes, syncStashes } from './sync.js';
import { verifyNotBanned } from './users.js';
const logger = initLogger();
const mj = new MerkleJson();
@@ -1489,6 +1490,12 @@ export async function createActorRevision(actorId, {
apply,
...options
}, reqUser) {
if (!reqUser) {
throw new HttpError('Must be authenticated to create actor revision', 401);
}
await verifyNotBanned(reqUser, 'revisions', 'You are not allowed to submit revisions');
const [
[actor],
openRevisions,
+30
View File
@@ -0,0 +1,30 @@
import { canReviewReports, countUnreadReports } from './reports.js';
// items awaiting attention in the admin panel, shown as counters on the admin menu items
// add an entry to surface something else, e.g. unreviewed revisions
const indicators = [
{
key: 'reports',
label: 'unread reports',
isVisible: canReviewReports,
count: countUnreadReports,
},
];
export function canAccessAdmin(reqUser) {
return reqUser?.role === 'admin' || reqUser?.role === 'editor';
}
export async function fetchAdminIndicators(reqUser) {
if (!canAccessAdmin(reqUser)) {
return null;
}
const visibleIndicators = indicators.filter((indicator) => indicator.isVisible(reqUser));
const counts = await Promise.all(visibleIndicators.map((indicator) => indicator.count(reqUser)));
return Object.fromEntries(visibleIndicators.map((indicator, index) => [indicator.key, {
count: counts[index],
label: indicator.label,
}]));
}
+166
View File
@@ -0,0 +1,166 @@
import reportReasons from '../utils/report-reasons.js';
import { HttpError } from './errors.js';
import { knexOwner as knex } from './knex.js';
import initLogger from './logger.js';
import { verifyNotBanned } from './users.js';
const logger = initLogger();
const maxCommentLength = 2000;
// roles that can review reports in the admin panel
export function canReviewReports(reqUser) {
return !!reqUser && reqUser.role !== 'user';
}
function curateReport(report) {
return {
id: report.id,
reasons: report.reasons?.split(',').filter(Boolean) || [],
comment: report.comment,
isRead: report.is_read,
isCompleted: report.is_completed,
createdAt: report.created_at,
entity: report.entity_id && {
id: report.entity_id,
name: report.entity_name,
slug: report.entity_slug,
type: report.entity_type,
},
user: report.user_id && {
id: report.user_id,
username: report.username,
},
};
}
export async function createReport(entityId, { reasons, comment }, reqUser) {
if (!reqUser) {
throw new HttpError('You must be logged in to submit a report', 401);
}
const curatedReasons = Array.from(new Set(Array.isArray(reasons) ? reasons : []));
const invalidReasons = curatedReasons.filter((reason) => !Object.hasOwn(reportReasons, reason));
const curatedComment = typeof comment === 'string' ? comment.trim() : '';
if (invalidReasons.length > 0) {
throw new HttpError(`Invalid report reasons: ${invalidReasons.join(', ')}`, 400);
}
if (curatedReasons.length === 0 && !curatedComment) {
throw new HttpError('Please select a reason or describe the problem', 400);
}
if (curatedComment.length > maxCommentLength) {
throw new HttpError(`Comments can't be longer than ${maxCommentLength} characters`, 400);
}
await verifyNotBanned(reqUser, 'reports', 'You are not allowed to submit reports');
const entity = await knex('entities').select('id', 'name').where('id', entityId).first();
if (!entity) {
throw new HttpError(`Entity ${entityId} does not exist`, 404);
}
// the partial unique index on open reports makes this race-proof, the error is just friendlier
const [report] = await knex('reports')
.insert({
entity_id: entity.id,
user_id: reqUser.id,
reasons: curatedReasons.join(',') || null,
comment: curatedComment || null,
})
.onConflict(knex.raw('(entity_id, user_id) WHERE is_completed = false'))
.ignore()
.returning('id');
if (!report) {
throw new HttpError(`You already reported ${entity.name}, your report is awaiting review`, 409);
}
logger.info(`User ${reqUser.username} (${reqUser.id}) reported ${entity.name} (${entity.id}): ${curatedReasons.join(', ') || 'no reasons'}`);
return report.id;
}
export async function hasOpenReport(entityId, reqUser) {
if (!reqUser) {
return false;
}
const report = await knex('reports')
.where('entity_id', entityId)
.where('user_id', reqUser.id)
.where('is_completed', false)
.first();
return !!report;
}
export async function fetchReports({ completed = false } = {}, reqUser) {
if (!canReviewReports(reqUser)) {
throw new HttpError('You are not allowed to view reports', 404);
}
const reports = await knex('reports')
.select(
'reports.*',
'entities.name as entity_name',
'entities.slug as entity_slug',
'entities.type as entity_type',
'users.username',
)
.leftJoin('entities', 'entities.id', 'reports.entity_id')
.leftJoin('users', 'users.id', 'reports.user_id')
.where('reports.is_completed', completed)
.orderBy('reports.created_at', 'desc')
.limit(500);
return reports.map((report) => curateReport(report));
}
export async function updateReport(reportId, { isRead, isCompleted }, reqUser) {
if (!canReviewReports(reqUser)) {
throw new HttpError('You are not allowed to update reports', 404);
}
const update = {
...(typeof isRead === 'boolean' && { is_read: isRead }),
...(typeof isCompleted === 'boolean' && { is_completed: isCompleted }),
...(isCompleted === true && { is_read: true }), // a completed report has necessarily been read
};
if (Object.keys(update).length === 0) {
throw new HttpError('Nothing to update', 400);
}
const [updatedReport] = await knex('reports')
.where('id', reportId)
.update(update)
.returning('*')
.catch((error) => {
// reopening a report conflicts with a newer open report by the same user for the same entity
if (error.code === '23505') {
throw new HttpError('The reporter has since submitted a new report for this entity, which is still open', 409);
}
throw error;
});
if (!updatedReport) {
throw new HttpError(`Report ${reportId} does not exist`, 404);
}
return curateReport(updatedReport);
}
export async function countUnreadReports() {
const { count } = await knex('reports')
.where('is_read', false)
.where('is_completed', false)
.count()
.first();
return Number(count);
}
+3
View File
@@ -19,6 +19,7 @@ import { curateRevision } from './revisions.js';
import { curateStash } from './stashes.js';
import { syncScenes } from './sync.js';
import { fetchTagsById } from './tags.js';
import { verifyNotBanned } from './users.js';
const logger = initLogger();
const mj = new MerkleJson();
@@ -1130,6 +1131,8 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
throw new HttpError('Must be authenticated to create scene revision', 401);
}
await verifyNotBanned(reqUser, 'revisions', 'You are not allowed to submit revisions');
const [
[scene],
openRevisions,
+36 -2
View File
@@ -126,19 +126,53 @@ export async function removeTemplate(templateId, reqUser) {
.delete();
}
// a ban without a scope applies to everything that checks for bans
export const banScopes = ['revisions', 'reports'];
export async function createBan(ban, reqUser) {
if (reqUser.role !== 'admin') {
if (reqUser?.role !== 'admin') {
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
}
if (ban.scope && !banScopes.includes(ban.scope)) {
throw new HttpError(`Invalid ban scope '${ban.scope}', must be one of: ${banScopes.join(', ')}`, 400);
}
const targetUser = ban.userId && await knex('users').where('id', ban.userId).first();
const curatedBan = {
user_id: ban.userId,
username: ban.username,
ip: ban.banIp && targetUser.last_ip,
ip: ban.banIp && targetUser?.last_ip,
scope: ban.scope || null,
banned_by: reqUser.id,
expires_at: knex.raw('now() + make_interval(mins => :minutes)', { minutes: config.bans.defaultExpiry }),
};
await knex('bans').insert(curatedBan);
}
// throws if an active ban for this scope (or an unscoped ban) matches the user's ID, username or IP
export async function verifyNotBanned(reqUser, scope, message = 'You are not allowed to do this') {
const ban = await knex('bans')
.where('expires_at', '>', knex.fn.now())
.where((builder) => {
builder
.whereNull('scope')
.orWhere('scope', scope);
})
.where((builder) => {
builder
.where('user_id', reqUser.id)
.orWhere('username', reqUser.username);
if (reqUser.ip) {
builder.orWhereRaw('ip >>= ?::inet', [reqUser.ip]); // ip is a cidr, so a range can be banned
}
})
.first();
if (ban) {
throw new HttpError(message, 403);
}
}
+4 -1
View File
@@ -3,16 +3,18 @@ import { renderPage } from 'vike/server';
import { socials } from '../../common/actors.mjs';
import pkg from '../../package.json' with { type: 'json' };
import { fetchAdminIndicators } from '../admin-indicators.js';
import { fetchUnseenNotificationsCount } from '../alerts.js';
import { fetchUserStashes } from '../stashes.js';
import { fetchUserTemplates } from '../users.js';
export default async function mainHandler(req, res, next) {
const [stashes, templates, unseenNotifications] = req.user
const [stashes, templates, unseenNotifications, adminIndicators] = req.user
? await Promise.all([
fetchUserStashes(req.user.id, req.user),
fetchUserTemplates(req.user),
fetchUnseenNotificationsCount(req.user),
fetchAdminIndicators(req.user),
])
: [];
@@ -38,6 +40,7 @@ export default async function mainHandler(req, res, next) {
primaryStash: stashes.find((stash) => stash.isPrimary),
templates,
unseenNotifications,
adminIndicators,
}
: null,
env: {
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import { createReport, updateReport } from '../reports.js';
async function createReportApi(req, res) {
const reportId = await createReport(Number(req.params.entityId), req.body, req.user);
res.send({ id: reportId });
}
async function updateReportApi(req, res) {
const report = await updateReport(Number(req.params.reportId), req.body, req.user);
res.send(report);
}
export const router = Router();
router.post('/api/entities/:entityId/reports', createReportApi);
router.patch('/api/reports/:reportId', updateReportApi);
+4 -2
View File
@@ -27,11 +27,12 @@ import { graphqlApi } from './graphql.js';
import mainHandler from './main.js';
import { fetchMoviesApi } from './movies.js';
import { router as reportsRouter } from './reports.js';
import initRestrictionHandler from './restrictions.js';
import { scenesRouter } from './scenes.js';
import serializeApiResponses from './serialize.js';
import serializeApiResponses from './serialize.js';
import { router as stashesRouter } from './stashes.js';
import { syncRouter } from './sync.js';
import { initCachesApi } from './system.js';
@@ -128,6 +129,7 @@ export default async function initServer() {
router.use(userRouter);
router.use(stashesRouter);
router.use(reportsRouter);
router.use(alertsRouter);
router.use(authRouter);
router.use(scenesRouter);