#303 easier moderation
This commit is contained in:
parent
138fd57fd5
commit
fcc86d8a52
|
@ -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
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
@ -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');
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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')});
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
|
@ -10,7 +10,9 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Profile :user="user" :profile="profile" :terms="terms"/>
|
<MarkSus>
|
||||||
|
<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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 [
|
||||||
|
|
Reference in New Issue