#303 easier moderation

This commit is contained in:
Andrea 2022-04-06 13:52:14 +02:00
parent 138fd57fd5
commit fcc86d8a52
14 changed files with 222 additions and 59 deletions

2
.gitignore vendored
View File

@ -20,7 +20,7 @@
/static/img-local /static/img-local
/static/docs-local /static/docs-local
sus.txt moderation/*
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)
### Node template ### Node template

View File

@ -6,7 +6,7 @@ KEYS_DIR=./keys
install: install:
-cp -n .env.dist .env -cp -n .env.dist .env
if [ ! -d "${KEYS_DIR}" ]; then mkdir -p ${KEYS_DIR}; openssl genrsa -out ${KEYS_DIR}/private.pem 2048; openssl rsa -in ${KEYS_DIR}/private.pem -outform PEM -pubout -out ${KEYS_DIR}/public.pem; fi if [ ! -d "${KEYS_DIR}" ]; then mkdir -p ${KEYS_DIR}; openssl genrsa -out ${KEYS_DIR}/private.pem 2048; openssl rsa -in ${KEYS_DIR}/private.pem -outform PEM -pubout -out ${KEYS_DIR}/public.pem; fi
touch sus.txt touch moderation/sus.txt moderation/rules-users.md moderation/rules-terminology.md moderation/rules-sources.md
yarn yarn
node server/migrate.js node server/migrate.js

View File

@ -0,0 +1,68 @@
<template>
<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 && s.el.susUsername">
<td>
<a :href="`https://pronouns.page/@${s.el.susUsername}`" target="_blank" rel="noopener">@{{s.el.susUsername}}</a>
</td>
<td>
<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" v-html="s.el.isAutomatic ? formatComment(s.el.comment) : s.el.comment"></td>
<td>
<span v-if="s.el.isHandled" class="badge bg-success">
Case closed
</span>
<a v-else-if="allowResolving" 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>
</Table>
</template>
<script>
export default {
props: {
abuseReports: {required: true},
allowResolving: {type: Boolean},
},
methods: {
formatComment(comment) {
return comment
.split(', ')
.map(x => x.replace(/^(.*) \((.*)\)$/, '<dfn title="$2">$1</dfn>'))
.join(', ');
},
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;
});
},
}
};
</script>

View File

@ -47,6 +47,8 @@
<T>ban.action</T> <T>ban.action</T>
</button> </button>
</div> </div>
<ModerationRules type="rulesUsers" emphasise class="mt-4"/>
<AbuseReports v-if="abuseReports.length" :abuseReports="abuseReports" allowResolving/>
</section> </section>
</div> </div>
</template> </template>
@ -71,8 +73,14 @@
saving: false, saving: false,
forbidden, forbidden,
abuseReports: [],
} }
}, },
async mounted() {
if (!this.$isGranted('users')) { return; }
this.abuseReports = await this.$axios.$get(`/admin/reports/${this.user.id}`);
},
methods: { methods: {
async ban() { async ban() {
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger'); await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');

45
components/MarkSus.vue Normal file
View File

@ -0,0 +1,45 @@
<template>
<span>
<span ref="original" v-show="!$isGranted('users')">
<slot ref="original"></slot>
</span>
<span ref="marked" v-show="$isGranted('users')"></span>
</span>
</template>
<script>
export default {
data() {
return {
moderation: null,
}
},
async mounted() {
if (!this.$isGranted('users')) { return; }
this.moderation = await this.$axios.$get(`/admin/moderation`);
this.update();
const observer = new MutationObserver(this.update);
observer.observe(this.$refs.original, {
childList: true,
subtree: true
});
this.observer = observer;
},
beforeUnmount() {
this.observer.disconnect();
},
methods: {
update() {
if (!this.moderation) { return; }
let html = this.$refs.original.innerHTML;
for (let sus of this.moderation.susRegexes) {
html = html.replace(new RegExp(sus, 'gi'), m => `<mark>${m}</mark>`);
}
this.$refs.marked.innerHTML = html;
},
},
};
</script>

View File

@ -0,0 +1,32 @@
<template>
<details v-if="moderation" class="border mb-3">
<summary :class="[emphasise ? 'bg-warning' : 'bg-light', 'p-3']">
<h4 class="h5 d-inline">{{label}}</h4>
</summary>
<div class="border-top p-3">
<div v-if="typeof(moderation[type]) === 'string'" v-html="moderation[type]"/>
<ul v-else>
<li v-for="k in moderation[type]"><code>{{k}}</code></li>
</ul>
</div>
</details>
</template>
<script>
export default {
props: {
type: {required: true},
label: {'default': 'Moderation rules'},
emphasise: {type: Boolean},
},
data() {
return {
moderation: null,
}
},
async mounted() {
if (!this.$isGranted('users')) { return; }
this.moderation = await this.$axios.$get(`/admin/moderation`);
},
}
</script>

View File

@ -274,6 +274,7 @@ export default {
} }
routes.push({ path: '/license', component: resolve(__dirname, 'routes/license.vue') }); routes.push({ path: '/license', component: resolve(__dirname, 'routes/license.vue') });
routes.push({ path: '/admin', component: resolve(__dirname, 'routes/admin.vue') }); routes.push({ path: '/admin', component: resolve(__dirname, 'routes/admin.vue') });
routes.push({ path: '/admin/moderation', component: resolve(__dirname, 'routes/adminModeration.vue') });
if (config.profile.enabled) { if (config.profile.enabled) {
routes.push({path: '/u/*', component: resolve(__dirname, 'routes/profile.vue')}); routes.push({path: '/u/*', component: resolve(__dirname, 'routes/profile.vue')});

View File

@ -8,6 +8,8 @@
<p>Stats counted: {{$datetime(stats.calculatedAt)}}</p> <p>Stats counted: {{$datetime(stats.calculatedAt)}}</p>
<p><nuxt-link to="/admin/moderation" class="btn btn-outline-primary">Moderation rules</nuxt-link></p>
<section v-if="$isGranted('users')"> <section v-if="$isGranted('users')">
<details class="border mb-3" @click="usersShown = true"> <details class="border mb-3" @click="usersShown = true">
<summary class="bg-light p-3"> <summary class="bg-light p-3">
@ -119,48 +121,11 @@
<h3> <h3>
<Icon v="siren-on"/> <Icon v="siren-on"/>
Abuse reports Abuse reports
({{abuseReportsActiveCount}})
</h3> </h3>
<Table :data="abuseReports" :columns="4"> <ModerationRules type="rulesUsers" emphasise/>
<template v-slot:header> <ModerationRules type="susRegexes" label="Keywords for automated triggers"/>
<th class="text-nowrap"> <AbuseReports :abuseReports="abuseReports"/>
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 && s.el.susUsername">
<td>
<a :href="`https://pronouns.page/@${s.el.susUsername}`" target="_blank" rel="noopener">@{{s.el.susUsername}}</a>
</td>
<td>
<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" v-html="s.el.isAutomatic ? formatComment(s.el.comment) : 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>
</Table>
</section> </section>
<section v-for="(locale, k) in stats.locales" :key="k"> <section v-for="(locale, k) in stats.locales" :key="k">
@ -246,14 +211,6 @@
}; };
}, },
methods: { methods: {
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;
});
},
async impersonate(email) { async impersonate(email) {
const { token } = await this.$axios.$get(`/admin/impersonate/${encodeURIComponent(email)}`); const { token } = await this.$axios.$get(`/admin/impersonate/${encodeURIComponent(email)}`);
this.$cookies.set('impersonator', this.$cookies.get('token')); this.$cookies.set('impersonator', this.$cookies.get('token'));
@ -261,12 +218,6 @@
await this.$router.push('/' + this.config.user.route); await this.$router.push('/' + this.config.user.route);
setTimeout(() => window.location.reload(), 500); setTimeout(() => window.location.reload(), 500);
}, },
formatComment(comment) {
return comment
.split(', ')
.map(x => x.replace(/^(.*) \((.*)\)$/, '<dfn title="$2">$1</dfn>'))
.join(', ');
},
}, },
computed: { computed: {
profilesByLocale() { profilesByLocale() {
@ -276,6 +227,9 @@
} }
return r; return r;
}, },
abuseReportsActiveCount() {
return this.abuseReports.filter(r => !r.isHandled).length;
},
}, },
watch: { watch: {
userFilter() { userFilter() {

View File

@ -0,0 +1,14 @@
<template>
<NotFound v-if="!$isGranted('users')"/>
<div v-else>
<h2>
<Icon v="user-cog"/>
Moderation rules
</h2>
<ModerationRules type="rulesUsers" label="Banning accounts" open/>
<ModerationRules type="rulesTerminology" label="Terminology" open/>
<ModerationRules type="rulesSources" label="Sources" open/>
<ModerationRules type="susRegexes" label="Keywords for automated triggers"/>
</div>
</template>

View File

@ -10,7 +10,9 @@
</div> </div>
</section> </section>
<MarkSus>
<Profile :user="user" :profile="profile" :terms="terms"/> <Profile :user="user" :profile="profile" :terms="terms"/>
</MarkSus>
<aside class="row"> <aside class="row">
<div v-if="$user() && $user().username === user.username" class="list-group list-group-flare my-2 col-12 col-lg-4 col-xxl-12"> <div v-if="$user() && $user().username === user.username" class="list-group list-group-flare my-2 col-12 col-lg-4 col-xxl-12">

View File

@ -79,6 +79,8 @@
</div> </div>
</section> </section>
<ModerationRules type="rulesSources" emphasise/>
<section class="sticky-top bg-white"> <section class="sticky-top bg-white">
<div class="input-group mb-1 bg-white"> <div class="input-group mb-1 bg-white">
<span class="input-group-text"> <span class="input-group-text">

View File

@ -12,6 +12,8 @@
<Share :title="$t('terminology.headerLong')"/> <Share :title="$t('terminology.headerLong')"/>
</section> </section>
<ModerationRules type="rulesTerminology" emphasise/>
<TermsDictionary load ref="termsdictionary"/> <TermsDictionary load ref="termsdictionary"/>
</div> </div>
</template> </template>

View File

@ -10,6 +10,7 @@ import mailer from "../../src/mailer";
import {profilesSnapshot} from "./profile"; import {profilesSnapshot} from "./profile";
import buildLocaleList from "../../src/buildLocaleList"; import buildLocaleList from "../../src/buildLocaleList";
import {archiveBan, liftBan} from "../ban"; import {archiveBan, liftBan} from "../ban";
import marked from 'marked';
const router = Router(); const router = Router();
@ -196,6 +197,21 @@ router.get('/admin/reports', handleErrorAsync(async (req, res) => {
`)); `));
})); }));
router.get('/admin/reports/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
return res.json(await req.db.all(SQL`
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
WHERE reports.userId = ${req.params.id}
ORDER BY reports.isHandled ASC, reports.id DESC
`));
}));
router.post('/admin/reports/handle/:id', handleErrorAsync(async (req, res) => { router.post('/admin/reports/handle/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) { if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'}); return res.status(401).json({error: 'Unauthorised'});
@ -210,4 +226,23 @@ router.post('/admin/reports/handle/:id', handleErrorAsync(async (req, res) => {
return res.json(true); return res.json(true);
})); }));
router.get('/admin/moderation', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
const dir = __dirname + '/../../moderation';
const susRegexes = fs.readFileSync(dir + '/sus.txt').toString('utf-8').split('\n').filter(x => !!x);
const rulesUsers = marked(fs.readFileSync(dir + '/rules-users.md').toString('utf-8'));
const rulesTerminology = marked(fs.readFileSync(dir + '/rules-terminology.md').toString('utf-8'));
const rulesSources = marked(fs.readFileSync(dir + '/rules-sources.md').toString('utf-8'));
return res.json({
susRegexes,
rulesUsers,
rulesTerminology,
rulesSources,
})
}));
export default router; export default router;

View File

@ -92,7 +92,7 @@ export const profilesSnapshot = async (db, username) => {
return JSON.stringify(await fetchProfiles(db, username, true), null, 4); return JSON.stringify(await fetchProfiles(db, username, true), null, 4);
} }
const susRegexes = fs.readFileSync(__dirname + '/../../sus.txt').toString('utf-8').split('\n').filter(x => !!x); const susRegexes = fs.readFileSync(__dirname + '/../../moderation/sus.txt').toString('utf-8').split('\n').filter(x => !!x);
function* isSuspicious(profile) { function* isSuspicious(profile) {
for (let s of [ for (let s of [