#219 reporting profiles

This commit is contained in:
Avris 2021-07-24 19:18:39 +02:00
parent 617cf70641
commit c45a8c0bf6
23 changed files with 265 additions and 66 deletions

View File

@ -83,7 +83,7 @@
</template>
<ul v-if="profiles !== undefined" class="list-group">
<li v-for="(options, locale) in locales" :key="locale" :class="['list-group-item', locale === config.locale ? 'profile-current' : '']">
<ProfileOverview :profile="profiles[locale]" :locale="locale" @update="setProfiles"/>
<ProfileOverview :username="username" :profile="profiles[locale]" :locale="locale" @update="setProfiles"/>
</li>
</ul>
</Loading>

View File

@ -1,15 +1,35 @@
<template>
<client-only v-if="config.profile.editorEnabled">
<div v-if="config.profile.editorEnabled">
<section v-if="$user()">
<a v-if="!showReportForm" href="#" @click.prevent="showReportForm = true" class="small">
<Icon v="spider"/>
<T>report.action</T>
</a>
<div v-else-if="!reported">
<textarea v-model="reportComment" class="form-control" rows="3" :placeholder="$t('report.comment')" :disabled="saving" required></textarea>
<button class="btn btn-danger d-block w-100 mt-2" :disabled="saving || !reportComment" @click="report">
<Icon v="spider"/>
<T>report.action</T>
</button>
</div>
<div v-else class="alert alert-success">
<T>report.sent</T>
</div>
</section>
<section v-if="$isGranted('users')">
<div class="alert alert-warning">
<a v-if="!showBanForm" href="#" @click.prevent="showBanForm = true" class="small">
<Icon v="ban"/>
<T>ban.action</T>
</a>
<div v-else>
<textarea v-model="user.bannedReason" class="form-control" rows="3" :placeholder="$t('ban.reason') + ' ' + $t('ban.visible')" :disabled="saving"></textarea>
<button class="btn btn-danger d-block w-100 mt-2" :disabled="saving" @click="ban">
<Icon v="ban"/>
{{$t('ban.action')}}
<T>ban.action</T>
</button>
</div>
</section>
</client-only>
</div>
</template>
<script>
@ -22,6 +42,12 @@
},
data() {
return {
showReportForm: false,
reportComment: '',
reported: false,
showBanForm: !!this.user.bannedReason,
saving: false,
}
},
@ -37,7 +63,20 @@
} finally {
this.saving = false;
}
}
},
async report() {
if (!this.reportComment) { return; }
await this.$confirm(this.$t('report.confirm', {username: this.user.username}), 'danger');
this.saving = true;
try {
await this.$post(`/profile/report/${encodeURIComponent(this.user.username)}`, {
comment: this.reportComment,
});
this.reported = true;
} finally {
this.saving = false;
}
},
},
}
</script>

View File

@ -23,7 +23,7 @@
</div>
</section>
<Table :data="visibleNouns()" columns="3" :marked="(el) => !el.approved" fixed ref="dictionarytable">
<Table :data="visibleNouns()" :columns="3" :marked="(el) => !el.approved" fixed ref="dictionarytable">
<template v-slot:header>
<th class="text-nowrap">
<Icon v="mars"/>

View File

@ -31,7 +31,7 @@
</div>
</section>
<Table :data="visibleEntries()" columns="3" :marked="(el) => !el.approved" fixed ref="dictionarytable">
<Table :data="visibleEntries()" :columns="3" :marked="(el) => !el.approved" fixed ref="dictionarytable">
<template v-slot:header>
<th class="text-nowrap">
<Icon v="comment-times"/>

View File

@ -2,7 +2,7 @@
<div class="d-flex justify-content-between align-items-center">
{{ locales[locale].name }}
<span v-if="profile">
<LocaleLink :locale="locale" :link="`/@${profile.username}`" class="badge bg-primary text-white text-white">
<LocaleLink :locale="locale" :link="`/@${username}`" class="badge bg-primary text-white text-white">
<Icon v="id-card"/>
<T>profile.show</T>
</LocaleLink>
@ -27,6 +27,7 @@
<script>
export default {
props: {
username: { required: true },
profile: { required: true },
locale: { required: true },
},

View File

@ -3,7 +3,7 @@
<table :class="['table table-striped table-hover', fixed ? 'table-fixed-' + columns : '']">
<thead ref="thead">
<tr>
<td :colspan="columns + 1">
<td :colspan="columns">
<nav v-if="pages > 1">
<ul class="pagination pagination-sm justify-content-center mb-0">
<li v-for="p in pagesRange" :class="['page-item', p.page === page ? 'active' : '', p.enabled ? '' : 'disabled']">

View File

@ -23,7 +23,7 @@
</div>
</section>
<Table :data="visibleEntries()" columns="1" fixed :marked="(el) => !el.approved" ref="dictionarytable">
<Table :data="visibleEntries()" :columns="1" fixed :marked="(el) => !el.approved" ref="dictionarytable">
<template v-slot:header>
<th class="cell-wide"></th>
<th></th>

View File

@ -511,6 +511,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/bedingungen=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abrosexual: 'Abrosexuell'
Achillean: 'Achillean'

View File

@ -608,3 +608,9 @@ ban:
header: 'You''re banned. Your profile will not be shown to anyone.'
banned: 'Banned'
termsIntro: 'According to our {/terms=Terms of Service}:'
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'

View File

@ -523,6 +523,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/terinos=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abroromantic: 'Abrorrománti{inflection_c}'
Abrosexual: 'Abrosexual'

View File

@ -511,6 +511,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/terms=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abroromantic: 'Abroromantyczn{adjective_n}'
Abrosexual: 'Abroseksualn{adjective_n}'

View File

@ -518,6 +518,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/voorwaarden=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abroromantic: 'Abroromantisch'
Abrosexual: 'Abroseksueel'

View File

@ -1123,6 +1123,12 @@ ban:
banned: 'Zbanowanx'
termsIntro: 'Zgodnie z naszym {/regulamin=Regulaminem}:'
report:
action: 'Zgłoś profil'
comment: 'Opisz krótko, w czym problem'
confirm: 'Czy na pewno chcesz zgłosić profil @%username%?'
sent: 'Twoje zgłoszenie zostało wysłane. Dziękujemy za pomoc!'
flags:
Abroromantic: 'Abroromantyczn{adjective_n}'
Abrosexual: 'Abroseksualn{adjective_n}'

View File

@ -521,6 +521,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/termos=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abroromantic: 'Abrorromânti{inflection_c}'
Abrosexual: 'Abrossexual'

View File

@ -1094,6 +1094,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/terinos=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abroromantic: 'Abroromantyczn{adjective_n}'
Abrosexual: 'Abroseksualn{adjective_n}'

View File

@ -535,6 +535,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/terinos=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abroromantic: 'אַבראָראָמאַנטיש'
Abrosexual: 'אַבראָסעקסועל'

View File

@ -503,6 +503,13 @@ ban:
banned: 'Banned'
termsIntro: 'According to our {/terinos=Terms of Service}:'
# TODO
report:
action: 'Report abuse'
comment: 'Please explain briefly what''s wrong with this profile'
confirm: 'Are you sure you want to report @%username%?'
sent: 'Your report has been sent. Thanks for your help!'
flags:
Abrosexual: '嫩性戀'
Abroromantic: '嫩浪漫傾向'

View File

@ -0,0 +1,20 @@
-- Up
CREATE TABLE reports (
id TEXT NOT NULL PRIMARY KEY,
userId TEXT NOT NULL,
reporterId TEXT NULL,
comment TEXT NOT NULL,
isAutomatic INTEGER,
isHandled INTEGER,
FOREIGN KEY(userId) REFERENCES users(id),
FOREIGN KEY(reporterId) REFERENCES users(id)
);
CREATE INDEX "reports_isAutomatic" ON "reports" ("isAutomatic");
CREATE INDEX "reports_isHandled" ON "reports" ("isHandled");
-- Down
DROP TABLE reports;

View File

@ -3,6 +3,7 @@ import t from '../src/translator';
import config from '../data/config.suml';
import {buildDict} from "../src/helpers";
import {DateTime} from "luxon";
import {decodeTime} from 'ulid';
export default ({ app, store }) => {
Vue.prototype.$eventHub = new Vue();
@ -47,4 +48,8 @@ export default ({ app, store }) => {
const dt = DateTime.fromSeconds(timestamp);
return dt.toFormat('y-MM-dd HH:mm')
}
Vue.prototype.$ulidTime = (ulid) => {
return decodeTime(ulid) / 1000;
}
}

View File

@ -90,33 +90,53 @@
{{ stats.cards * 100 }}%
</section>
<section v-if="$isGranted('users') && suspiciousUsers.length > 0">
<section v-if="$isGranted('users')">
<h3>
<Icon v="siren-on"/>
Suspicious accounts
Abuse reports
</h3>
<Table :data="suspiciousUsers" columns="2">
<Table :data="abuseReports" :columns="4">
<template v-slot:header>
<th class="text-nowrap">
Suspicious account
</th>
<th class="text-nowrap">
Reported by
</th>
<th class="text-nowrap">
Comment
</th>
<th class="text-nowrap">
Action
</th>
</template>
<template v-slot:row="s"><template v-if="s">
<td>
<LocaleLink :link="`/@${s.el.username}`" :locale="s.el.locale">
{{s.el.username}}
<span class="badge bg-light text-dark">{{s.el.locale}}</span>
</LocaleLink>
<a :href="`https://pronouns.page/${s.el.susUsername}`" target="_blank" rel="noopener">@{{s.el.susUsername}}</a>
</td>
<td>
<a href="#" class="badge bg-light text-success border border-success float-end"
@click.prevent="checkedSuspicious(s.el.id)"
<span v-if="s.el.isAutomatic" class="badge bg-info">
Keyword found
</span>
<a v-else :href="`https://pronouns.page/${s.el.reporterUsername}`" target="_blank" rel="noopener">@{{s.el.reporterUsername}}</a>
<small>({{$datetime($ulidTime(s.el.id))}})</small>
</td>
<td class="small">
{{ s.el.comment }}
</td>
<td>
<span v-if="s.el.isHandled" class="badge bg-success">
Case closed
</span>
<a v-else href="#" class="badge bg-light text-success border border-success"
@click.prevent="handleReport(s.el.id)"
>
<Icon v="thumbs-up"/>
I checked the profile, it's OK.
</a>
</td>
</template></template>
<template v-slot:empty>
<Icon v="search"/>
<T>nouns.empty</T>
</template>
</Table>
</section>
@ -190,14 +210,14 @@
stats = await app.$axios.$get(`/admin/stats`);
} catch {}
let suspiciousUsers = [];
let abuseReports = [];
try {
suspiciousUsers = await app.$axios.$get(`/admin/suspicious`);
abuseReports = await app.$axios.$get(`/admin/reports`);
} catch {}
return {
stats,
suspiciousUsers,
abuseReports,
};
},
methods: {
@ -206,10 +226,13 @@
this.users = await this.$axios.$get(`/admin/users`);
}
},
async checkedSuspicious(id) {
await this.$confirm('Are you sure you want to mark this profile as not suspicious?', 'success');
await this.$post(`/admin/suspicious/checked/${id}`);
this.suspiciousUsers = this.suspiciousUsers.filter(u => u.id !== id);
async handleReport(id) {
await this.$confirm('Are you sure you want to mark this report as handled?', 'success');
await this.$post(`/admin/reports/handle/${id}`);
this.abuseReports = this.abuseReports.map(r => {
if (r.id === id) { r.isHandled = true; }
return r;
});
},
},
computed: {

View File

@ -18,16 +18,16 @@
{{options.name}}
</LocaleLink>
</div>
<div v-if="$user() && $user().username === profile.username">
<div v-if="$user() && $user().username === user.username">
<nuxt-link to="/editor" class="btn btn-primary btn-sm mb-2 mx-1">
<Icon v="edit"/>
<T>profile.edit</T>
</nuxt-link>
<a :href="`https://pronouns.page/@${profile.username}`" v-if="Object.keys(user.profiles).length > 1"
<a :href="`https://pronouns.page/@${user.username}`" v-if="Object.keys(user.profiles).length > 1"
class="btn btn-outline-secondary btn-sm mb-2 mx-1"
>
<Icon v="external-link"/>
pronouns.page/@{{profile.username}}
pronouns.page/@{{user.username}}
</a>
</div>
<div v-if="($user() && $user().username === profile.username) || $isGranted('users')">

View File

@ -139,50 +139,28 @@ router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => {
return res.json(true);
}));
router.get('/admin/suspicious', handleErrorAsync(async (req, res) => {
router.get('/admin/reports', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
return res.json(await req.db.all(SQL`
SELECT users.id, users.username, profiles.locale FROM profiles
LEFT JOIN users ON profiles.userId = users.id
WHERE users.suspiciousChecked != 1
AND users.bannedReason IS NULL
AND (
lower(customFlags) LIKE '%superstr%'
OR lower(description) LIKE '%superstr%'
OR lower(customFlags) LIKE '%superhet%'
OR lower(description) LIKE '%superhet%'
OR lower(customFlags) LIKE '%super-%'
OR lower(description) LIKE '%super-%'
OR lower(customFlags) LIKE '%phobe%'
OR lower(description) LIKE '%phobe%'
OR lower(customFlags) LIKE '%phobic%'
OR lower(description) LIKE '%phobic%'
OR lower(customFlags) LIKE '%terf%'
OR lower(description) LIKE '%terf%'
OR lower(customFlags) LIKE '%radfem%'
OR lower(description) LIKE '%radfem%'
OR lower(customFlags) LIKE '%gender critical%'
OR lower(description) LIKE '%gender critical%'
OR lower(customFlags) LIKE '%helicopter%'
OR lower(description) LIKE '%helicopter%'
OR lower(pronouns) LIKE '%helicopter%'
OR lower(pronouns) LIKE '%nor/mal%'
)
ORDER BY users.id DESC
SELECT reports.id, sus.username AS susUsername, reporter.username AS reporterUsername, reports.comment, reports.isAutomatic, reports.isHandled
FROM reports
LEFT JOIN users sus ON reports.userId = sus.id
LEFT JOIN users reporter ON reports.reporterId = reporter.id
ORDER BY reports.isHandled ASC, reports.id ASC
`));
}));
router.post('/admin/suspicious/checked/:id', handleErrorAsync(async (req, res) => {
router.post('/admin/reports/handle/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL`
UPDATE users
SET suspiciousChecked = 1
UPDATE reports
SET isHandled = 1
WHERE id=${req.params.id}
`);

View File

@ -53,6 +53,46 @@ const fetchProfiles = async (db, username, self, isAdmin) => {
return p;
};
function* isSuspicious(profile) {
const description = profile.description.toLowerCase();
const flags = JSON.stringify(profile.customFlags).toLowerCase();
const pronouns = JSON.stringify(profile.pronouns).toLowerCase();
if (description.includes('superstr') || description.includes('superhet') || description.includes('super-') ||
flags.includes('superstr') || flags.includes('superhet') || flags.includes('super-')
) {
yield 'Superstraight';
}
if (description.includes('phobe') || description.includes('phobic') ||
flags.includes('phobe') || flags.includes('phobic')
) {
yield '-phobic';
}
if (description.includes('terf') || description.includes('radfem') || description.includes('gender critical') ||
flags.includes('terf') || flags.includes('radfem') || flags.includes('gender critical')
) {
yield 'TERF';
}
if (description.includes('helicopter') ||
flags.includes('helicopter') ||
pronouns.includes('helicopter')
) {
yield 'Helicopter';
}
if (pronouns.includes('nor/mal')
) {
yield 'nor/mal';
}
}
const hasAutomatedHandledReports = async (db, id) => {
return (await db.get(SQL`SELECT COUNT(*) AS c FROM reports WHERE userId = ${id} AND isAutomatic = 1 AND isHandled = 1`)).c > 0;
}
const router = Router();
router.get('/profile/get/:username', handleErrorAsync(async (req, res) => {
@ -121,6 +161,14 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => {
)`);
}
const sus = [...isSuspicious(req.body)];
if (sus.length && !await hasAutomatedHandledReports(req.db, req.user.id)) {
await req.db.get(SQL`
INSERT INTO reports (id, userId, reporterId, isAutomatic, comment, isHandled)
VALUES (${ulid()}, ${req.user.id}, null, 1, ${sus.join(', ')}, 0);
`);
}
if (req.body.teamName) {
await caches.admins.invalidate();
await caches.adminsFooter.invalidate();
@ -135,4 +183,21 @@ router.post('/profile/delete/:locale', handleErrorAsync(async (req, res) => {
return res.json(await fetchProfiles(req.db, req.user.username, true));
}));
router.post('/profile/report/:username', handleErrorAsync(async (req, res) => {
const user = await req.db.get(SQL`SELECT id FROM users WHERE usernameNorm = ${normalise(req.params.username)}`);
if (!user) {
return res.status(400).json({error: 'Missing user'});
}
if (!req.body.comment) {
return res.status(400).json({error: 'Missing comment'});
}
await req.db.get(SQL`
INSERT INTO reports (id, userId, reporterId, isAutomatic, comment, isHandled)
VALUES (${ulid()}, ${user.id}, ${req.user.id}, 0, ${req.body.comment}, 0);
`);
return res.json('OK');
}));
export default router;