#303 easier moderation
This commit is contained in:
parent
138fd57fd5
commit
fcc86d8a52
|
@ -20,7 +20,7 @@
|
|||
/static/img-local
|
||||
/static/docs-local
|
||||
|
||||
sus.txt
|
||||
moderation/*
|
||||
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Node template
|
||||
|
|
2
Makefile
2
Makefile
|
@ -6,7 +6,7 @@ KEYS_DIR=./keys
|
|||
install:
|
||||
-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
|
||||
touch sus.txt
|
||||
touch moderation/sus.txt moderation/rules-users.md moderation/rules-terminology.md moderation/rules-sources.md
|
||||
yarn
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
<ModerationRules type="rulesUsers" emphasise class="mt-4"/>
|
||||
<AbuseReports v-if="abuseReports.length" :abuseReports="abuseReports" allowResolving/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -71,8 +73,14 @@
|
|||
saving: false,
|
||||
|
||||
forbidden,
|
||||
|
||||
abuseReports: [],
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!this.$isGranted('users')) { return; }
|
||||
this.abuseReports = await this.$axios.$get(`/admin/reports/${this.user.id}`);
|
||||
},
|
||||
methods: {
|
||||
async ban() {
|
||||
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: '/admin', component: resolve(__dirname, 'routes/admin.vue') });
|
||||
routes.push({ path: '/admin/moderation', component: resolve(__dirname, 'routes/adminModeration.vue') });
|
||||
|
||||
if (config.profile.enabled) {
|
||||
routes.push({path: '/u/*', component: resolve(__dirname, 'routes/profile.vue')});
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
<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')">
|
||||
<details class="border mb-3" @click="usersShown = true">
|
||||
<summary class="bg-light p-3">
|
||||
|
@ -119,48 +121,11 @@
|
|||
<h3>
|
||||
<Icon v="siren-on"/>
|
||||
Abuse reports
|
||||
({{abuseReportsActiveCount}})
|
||||
</h3>
|
||||
<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 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>
|
||||
<ModerationRules type="rulesUsers" emphasise/>
|
||||
<ModerationRules type="susRegexes" label="Keywords for automated triggers"/>
|
||||
<AbuseReports :abuseReports="abuseReports"/>
|
||||
</section>
|
||||
|
||||
<section v-for="(locale, k) in stats.locales" :key="k">
|
||||
|
@ -246,14 +211,6 @@
|
|||
};
|
||||
},
|
||||
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) {
|
||||
const { token } = await this.$axios.$get(`/admin/impersonate/${encodeURIComponent(email)}`);
|
||||
this.$cookies.set('impersonator', this.$cookies.get('token'));
|
||||
|
@ -261,12 +218,6 @@
|
|||
await this.$router.push('/' + this.config.user.route);
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
},
|
||||
formatComment(comment) {
|
||||
return comment
|
||||
.split(', ')
|
||||
.map(x => x.replace(/^(.*) \((.*)\)$/, '<dfn title="$2">$1</dfn>'))
|
||||
.join(', ');
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
profilesByLocale() {
|
||||
|
@ -276,6 +227,9 @@
|
|||
}
|
||||
return r;
|
||||
},
|
||||
abuseReportsActiveCount() {
|
||||
return this.abuseReports.filter(r => !r.isHandled).length;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
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>
|
||||
</section>
|
||||
|
||||
<MarkSus>
|
||||
<Profile :user="user" :profile="profile" :terms="terms"/>
|
||||
</MarkSus>
|
||||
|
||||
<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">
|
||||
|
|
|
@ -79,6 +79,8 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<ModerationRules type="rulesSources" emphasise/>
|
||||
|
||||
<section class="sticky-top bg-white">
|
||||
<div class="input-group mb-1 bg-white">
|
||||
<span class="input-group-text">
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
<Share :title="$t('terminology.headerLong')"/>
|
||||
</section>
|
||||
|
||||
<ModerationRules type="rulesTerminology" emphasise/>
|
||||
|
||||
<TermsDictionary load ref="termsdictionary"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -10,6 +10,7 @@ import mailer from "../../src/mailer";
|
|||
import {profilesSnapshot} from "./profile";
|
||||
import buildLocaleList from "../../src/buildLocaleList";
|
||||
import {archiveBan, liftBan} from "../ban";
|
||||
import marked from 'marked';
|
||||
|
||||
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) => {
|
||||
if (!req.isGranted('users')) {
|
||||
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);
|
||||
}));
|
||||
|
||||
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;
|
||||
|
|
|
@ -92,7 +92,7 @@ export const profilesSnapshot = async (db, username) => {
|
|||
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) {
|
||||
for (let s of [
|
||||
|
|
Reference in New Issue