Added entity reporting.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -225,6 +225,7 @@ async function banEditor(revision) {
|
||||
await post('/bans', {
|
||||
userId: revision.user.id,
|
||||
banIp: true,
|
||||
scope: 'revisions',
|
||||
});
|
||||
|
||||
await reviewRevision(revision, false);
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user