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
+32
View File
@@ -4,6 +4,7 @@ import { inject } from 'vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
const indicators = pageContext.assets?.adminIndicators || {};
async function initCaches() {
await post('/caches', null, {
@@ -50,6 +51,22 @@ async function initCaches() {
>Entity Health</a>
</li>
<li class="nav-item">
<a
href="/admin/reports"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'reports' }"
>
Reports
<span
v-if="indicators.reports?.count > 0"
:title="`${indicators.reports.count} ${indicators.reports.label}`"
class="nav-attention"
>{{ indicators.reports.count }}</span>
</a>
</li>
<li
v-if="pageContext.user.role === 'admin'"
class="nav-item"
@@ -120,6 +137,21 @@ async function initCaches() {
}
}
.nav-attention {
min-width: 1.1rem;
height: 1.1rem;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0 .25rem;
margin-left: .25rem;
border-radius: .55rem;
background: var(--primary);
color: var(--text-light);
font-size: .7rem;
}
.content {
display: flex;
flex-direction: column;
+1
View File
@@ -225,6 +225,7 @@ async function banEditor(revision) {
await post('/bans', {
userId: revision.user.id,
banIp: true,
scope: 'revisions',
});
await reviewRevision(revision, false);
+144
View File
@@ -0,0 +1,144 @@
<script setup>
import { computed, ref } from 'vue';
import Dialog from '#/components/dialog/dialog.vue';
import Checkbox from '#/components/form/checkbox.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { post } from '#/src/api.js';
import reportReasons from '#/utils/report-reasons.js';
const props = defineProps({
entity: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close', 'reported']);
const reasons = ref(new Set());
const comment = ref('');
const submitted = ref(false);
const canSubmit = computed(() => reasons.value.size > 0 || comment.value.trim().length > 0);
const hasChanges = computed(() => reasons.value.size > 0 || comment.value.length > 0);
function toggleReason(reason, checked) {
const updated = new Set(reasons.value);
if (checked) {
updated.add(reason);
}
else {
updated.delete(reason);
}
reasons.value = updated;
}
async function submitReport() {
submitted.value = true;
try {
await post(`/entities/${props.entity.id}/reports`, {
reasons: Array.from(reasons.value),
comment: comment.value,
}, {
successFeedback: `Reported ${props.entity.name}, thank you!`,
errorFeedback: `Failed to report ${props.entity.name}`,
appendErrorMessage: true,
});
emit('reported');
}
catch (_error) {
submitted.value = false; // feedback is shown by the api module
}
}
</script>
<template>
<Dialog
:title="`Report '${entity.name}'`"
:confirm-close="hasChanges && !submitted"
@close="emit('close')"
>
<form
class="dialog-body"
@submit.prevent="submitReport"
>
<p class="intro">What's wrong with this {{ entity.type || 'site' }} index?</p>
<div class="reasons">
<Checkbox
v-for="(label, reason) in reportReasons"
:key="`reason-${reason}`"
:checked="reasons.has(reason)"
:label="label"
class="reason"
@change="(checked) => toggleReason(reason, checked)"
/>
</div>
<textarea
v-model="comment"
placeholder="Optional details that may help verify or address the problem"
rows="3"
maxlength="2000"
class="input comment"
/>
<div class="dialog-actions">
<Ellipsis v-if="submitted" />
<button
v-else
:disabled="!canSubmit"
class="button button-primary"
>Submit report</button>
</div>
</form>
</Dialog>
</template>
<style scoped>
.dialog-body {
width: 30rem;
max-width: 100%;
box-sizing: border-box;
gap: .75rem;
padding: 1rem;
}
.intro {
margin: 0;
color: var(--glass-strong-10);
}
.reasons {
display: flex;
flex-direction: column;
gap: .5rem;
}
.reason {
padding: .15rem 0;
justify-content: space-between;
}
.comment {
width: 100%;
resize: vertical;
flex-basis: auto;
}
.dialog-actions {
display: flex;
justify-content: center;
.button {
width: 100%;
}
}
</style>
+25
View File
@@ -25,6 +25,9 @@ const theme = ref(pageContext.env.theme);
const user = pageContext.user;
const primaryStash = pageContext.assets?.primaryStash;
const unseen = ref(pageContext.assets?.unseenNotifications);
const adminIndicators = Object.values(pageContext.assets?.adminIndicators || {}).filter((indicator) => indicator.count > 0);
const adminAttention = adminIndicators.reduce((total, indicator) => total + indicator.count, 0);
const adminAttentionLabel = adminIndicators.map((indicator) => `${indicator.count} ${indicator.label}`).join(', ');
const query = ref(pageContext.urlParsed.search.q || '');
const allowLogin = pageContext.env.allowLogin;
const searchFocused = ref(false);
@@ -316,6 +319,12 @@ function blurSearch(event) {
>
<Icon icon="wrench" />
Admin
<span
v-if="adminAttention > 0"
:title="adminAttentionLabel"
class="admin-attention"
>{{ adminAttention }}</span>
</a>
</li>
@@ -523,6 +532,22 @@ function blurSearch(event) {
margin-bottom: .1rem;
}
.admin-attention {
min-width: 1.25rem;
height: 1.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0 .25rem;
margin-left: auto;
border-radius: .625rem;
background: var(--primary);
color: var(--text-light);
font-size: .75rem;
font-weight: bold;
}
.notifs-unseen {
bottom: 0;
font-size: .65rem;
+7
View File
@@ -34,6 +34,13 @@ import Admin from '#/components/admin/admin.vue';
class="link"
>Entity Health</a>
</li>
<li>
<a
href="/admin/reports"
class="link"
>Reports</a>
</li>
</ul>
</Admin>
</template>
+276
View File
@@ -0,0 +1,276 @@
<script setup>
import { format } from 'date-fns';
import { inject, ref } from 'vue';
import Admin from '#/components/admin/admin.vue';
import Checkbox from '#/components/form/checkbox.vue';
import { patch, post } from '#/src/api.js';
import reportReasons from '#/utils/report-reasons.js';
const { pageProps, user } = inject('pageContext');
const { completed } = pageProps;
const reports = ref(pageProps.reports);
async function updateReport(report, update) {
const updatedReport = await patch(`/reports/${report.id}`, update, {
errorFeedback: 'Failed to update report',
appendErrorMessage: true,
}).catch(() => null); // feedback is shown by the api module
if (updatedReport) {
reports.value = reports.value.map((listedReport) => (listedReport.id === report.id
? {
...listedReport,
isRead: updatedReport.isRead,
isCompleted: updatedReport.isCompleted,
}
: listedReport));
}
}
async function banReporter(report) {
if (!confirm(`Ban ${report.user.username} from submitting reports?`)) { // eslint-disable-line no-alert
return;
}
await post('/bans', {
userId: report.user.id,
banIp: true,
scope: 'reports',
}, {
successFeedback: `Banned ${report.user.username} from submitting reports`,
errorFeedback: `Failed to ban ${report.user.username}`,
appendErrorMessage: true,
}).catch(() => {}); // feedback is shown by the api module
}
</script>
<template>
<Admin class="page">
<div class="header">
<h2 class="heading">{{ completed ? 'Completed reports' : 'Open reports' }}</h2>
<a
:href="completed ? '/admin/reports' : '/admin/reports?completed=true'"
class="link toggle-completed"
>{{ completed ? 'Show open reports' : 'Show completed reports' }}</a>
</div>
<p
v-if="reports.length === 0"
class="empty"
>No {{ completed ? 'completed' : 'open' }} reports.</p>
<table
v-else
class="table"
>
<thead class="table-header">
<tr>
<th class="table-heading">Site</th>
<th class="table-heading">Reasons</th>
<th class="table-heading">Comment</th>
<th class="table-heading">Reporter</th>
<th class="table-heading">Date</th>
<th class="table-heading table-check">Read</th>
<th class="table-heading table-check">Done</th>
<th
v-if="user.role === 'admin'"
class="table-heading"
>Ban</th>
</tr>
</thead>
<tbody>
<tr
v-for="report in reports"
:key="`report-${report.id}`"
class="report"
:class="{ unread: !report.isRead, completed: report.isCompleted !== completed }"
>
<td
:title="report.entity?.id"
class="table-cell table-name ellipsis"
>
<a
v-if="report.entity"
:href="`/${report.entity.type}/${report.entity.slug}`"
target="_blank"
class="link"
>{{ report.entity.name }}</a>
</td>
<td class="table-cell table-reasons">
<span
v-for="reason in report.reasons"
:key="`report-${report.id}-${reason}`"
class="reason"
:class="{ urgent: reason === 'inappropriate' }"
>{{ reportReasons[reason] || reason }}</span>
</td>
<td class="table-cell table-comment">{{ report.comment }}</td>
<td class="table-cell table-username ellipsis">
<a
v-if="report.user"
:href="`/user/${report.user.username}`"
target="_blank"
class="link"
>{{ report.user.username }}</a>
<span
v-else
class="deleted"
>deleted user</span>
</td>
<td class="table-cell table-date">{{ format(report.createdAt, 'yyyy-MM-dd HH:mm') }}</td>
<td class="table-cell table-check">
<Checkbox
:checked="report.isRead"
@change="(checked) => updateReport(report, { isRead: checked })"
/>
</td>
<td class="table-cell table-check">
<Checkbox
:checked="report.isCompleted"
@change="(checked) => updateReport(report, { isCompleted: checked })"
/>
</td>
<td
v-if="user.role === 'admin'"
class="table-cell table-actions"
>
<Icon
v-if="report.user"
v-tooltip="`Ban ${report.user.username} from submitting reports`"
icon="blocked"
class="ban"
@click="banReporter(report)"
/>
</td>
</tr>
</tbody>
</table>
</Admin>
</template>
<style scoped>
.page {
flex-grow: 1;
}
.header {
display: flex;
align-items: baseline;
gap: 1rem;
margin-bottom: 1rem;
}
.heading {
margin: 0;
}
.empty {
color: var(--glass);
font-style: italic;
}
.table {
border-collapse: collapse;
border: 0;
}
.table-heading {
text-align: left;
padding: 0 .5rem .25rem .5rem;
}
.table-cell {
padding: .25rem .5rem .25rem .5rem;
border-collapse: collapse;
border: 0;
}
.table-name {
max-width: 12rem;
}
.table-comment {
min-width: 15rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.table-date {
white-space: nowrap;
}
.table-check {
width: 3rem;
text-align: center;
:deep(.check-container) {
justify-content: center;
}
}
.report {
&:nth-child(2) {
background: var(--glass-weak-50);
}
&.unread .table-name .link {
color: var(--primary);
}
/* marked completed (or reopened) on this page, it moves to the other list on reload */
&.completed {
opacity: .5;
}
}
.reason {
display: inline-block;
padding: .25rem .5rem;
margin: 0 .25rem .25rem 0;
border-radius: .25rem;
background: var(--background-dark-20);
font-size: .9rem;
white-space: nowrap;
&.urgent {
background: var(--warn);
color: var(--text-light);
font-weight: bold;
}
}
.link {
color: var(--text);
}
.toggle-completed {
color: var(--primary);
}
.deleted {
color: var(--glass);
font-style: italic;
}
.ban {
fill: var(--glass);
&:hover {
fill: var(--warn);
cursor: pointer;
}
}
</style>
+25
View File
@@ -0,0 +1,25 @@
import { render } from 'vike/abort';
import { canReviewReports, fetchReports } from '#/src/reports.js';
export async function onBeforeRender(pageContext) {
if (!canReviewReports(pageContext.user)) {
throw render(404);
}
const completed = pageContext.urlParsed.search.completed === 'true';
const reports = await fetchReports({ completed }, pageContext.user);
return {
pageContext: {
title: 'Reports',
pageProps: {
reports,
completed,
},
routeParams: {
section: 'reports',
},
},
};
}
+46
View File
@@ -2,6 +2,7 @@
import { computed, inject, ref } from 'vue';
import Domains from '#/components/domains/domains.vue';
import ReportDialog from '#/components/entities/report.vue';
import EntityTile from '#/components/entities/tile.vue';
import Movies from '#/components/movies/movies.vue';
import Scenes from '#/components/scenes/scenes.vue';
@@ -10,6 +11,9 @@ import Heart from '#/components/stashes/heart.vue';
const { pageProps, routeParams, user } = inject('pageContext');
const { entity } = pageProps;
const isReported = ref(pageProps.isReported);
const showReportDialog = ref(false);
const children = ref(null);
const expanded = ref(false);
@@ -77,8 +81,32 @@ const entityUrl = entity.affiliateUrl || entity.url || null;
:item="entity"
class="light"
/>
<template v-if="user">
<Icon
v-if="isReported"
v-tooltip="`Your report for this ${entity.type} is awaiting review`"
icon="warning2"
class="report reported"
/>
<Icon
v-else
v-tooltip="`Report a problem with '${entity.name}'`"
icon="warning2"
class="report"
@click="showReportDialog = true"
/>
</template>
</div>
<ReportDialog
v-if="showReportDialog"
:entity="entity"
@reported="showReportDialog = false; isReported = true;"
@close="showReportDialog = false"
/>
<Domains
:domain="domain"
:path="`/${entity.type}/${entity.slug}`"
@@ -319,6 +347,24 @@ const entityUrl = entity.affiliateUrl || entity.url || null;
display: none;
}
.report {
width: 1.1rem;
height: 100%;
padding: 0 1rem;
margin: -0.25rem 0 0 .75rem;
fill: var(--highlight-weak-10);
&:hover {
cursor: pointer;
fill: var(--warn);
}
&.reported {
fill: var(--primary);
cursor: default;
}
}
.plainurl {
height: 100%;
display: flex;
@@ -5,6 +5,7 @@ import { getCampaignIndex, getRandomCampaigns } from '#/src/campaigns.js';
import entityPrefixes from '#/src/entities-prefixes.js';
import { fetchEntitiesById } from '#/src/entities.js';
import { fetchMovies } from '#/src/movies.js';
import { hasOpenReport } from '#/src/reports.js';
import { fetchScenes } from '#/src/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
@@ -50,11 +51,13 @@ export async function onBeforeRender(pageContext) {
const [
[entity],
entityReleases,
isReported,
] = await Promise.all([
fetchEntitiesById([Number(entityId)], { includeChildren: true }, pageContext.user, {
restriction: pageContext.geo.restriction,
}),
fetchReleases(pageContext, entityId),
hasOpenReport(Number(entityId), pageContext.user),
]);
const entityIds = entity.isIndependent || !entity.parent
@@ -92,6 +95,7 @@ export async function onBeforeRender(pageContext) {
title: entity.name,
pageProps: {
entity,
isReported,
...entityReleases,
},
campaigns: {
+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);
+10
View File
@@ -0,0 +1,10 @@
// standard reasons for reporting an entity, shared by the report dialog and the server-side validation
const reportReasons = {
broken: 'It\'s not tracking new releases',
incomplete: 'It\'s missing older releases',
misplaced: 'It\'s tracking the wrong releases',
channel: 'It\'s missing a channel',
illegal: 'It contains illegal or deeply concerning content',
};
export default reportReasons;