Added entity reporting.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user