[user] require multiple mods for a ban

This commit is contained in:
Andrea Vos 2022-09-22 22:01:37 +02:00
parent 023da6e0d5
commit 9e2d8263df
5 changed files with 209 additions and 12 deletions

View File

@ -30,6 +30,42 @@
<T>ban.action</T> <T>ban.action</T>
</a> </a>
<div v-else> <div v-else>
<div class="alert alert-warning">
<h5>Ban proposals</h5>
<p>
After at least 3 moderators had proposed bans,
you'll be able to pick one of the proposals in order to actually issue the ban.
If the proposed reasons/term points significantly differ
or if you think the person shouldn't be banned despite another moderator thinking otherwise,
please start a thread on Teams to discuss it.
</p>
<table class="table table-striped">
<thead>
<tr>
<th>Proposed at</th>
<th>Proposed by</th>
<th>Reason</th>
<th>Terms</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="proposal in banProposals">
<td>{{$datetime($ulidTime(proposal.id))}}</td>
<td class="small">
(TODO)<br/>
<span style="font-size: 0.5em">{{proposal.bannedBy}}</span>
</td>
<td>{{proposal.bannedReason}}</td>
<td><ul><li v-for="term in proposal.bannedTerms.split(',')">{{term}}</li></ul></td>
<td>
<button v-if="canApplyBan" class="btn btn-outline-danger btn-sm" @click="applyBan(proposal.id)">Apply ban</button>
</td>
</tr>
</tbody>
</table>
<button v-if="isBanned && $isGranted('*')" class="btn btn-success btn-sm" @click="applyBan(0)">Unban</button>
</div>
<textarea v-model="user.bannedReason" class="form-control" rows="3" :placeholder="$t('ban.reason') + ' ' + $t('ban.visible')" :disabled="saving"></textarea> <textarea v-model="user.bannedReason" class="form-control" rows="3" :placeholder="$t('ban.reason') + ' ' + $t('ban.visible')" :disabled="saving"></textarea>
<div class="form-group"> <div class="form-group">
<p class="my-1"><label><strong><T>ban.terms</T><T>quotation.colon</T></strong></label></p> <p class="my-1"><label><strong><T>ban.terms</T><T>quotation.colon</T></strong></label></p>
@ -69,24 +105,30 @@
reported: false, reported: false,
showBanForm: !!this.user.bannedReason, showBanForm: !!this.user.bannedReason,
isBanned: !!this.user.bannedReason,
saving: false, saving: false,
forbidden, forbidden,
abuseReports: [], abuseReports: [],
banProposals: [],
} }
}, },
async mounted() { async mounted() {
if (!this.$isGranted('users')) { return; } if (!this.$isGranted('users')) { return; }
this.abuseReports = await this.$axios.$get(`/admin/reports/${this.user.id}`); this.abuseReports = await this.$axios.$get(`/admin/reports/${this.user.id}`);
this.banProposals = await this.$axios.$get(`/admin/ban-proposals/${encodeURIComponent(this.user.username)}`);
if (this.banProposals.length > 0) {
this.showBanForm = true;
}
}, },
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');
this.saving = true; this.saving = true;
try { try {
await this.$post(`/admin/ban/${encodeURIComponent(this.user.username)}`, { await this.$post(`/admin/propose-ban/${encodeURIComponent(this.user.username)}`, {
reason: this.user.bannedReason, reason: this.user.bannedReason,
terms: this.user.bannedTerms, terms: this.user.bannedTerms,
}); });
@ -95,6 +137,16 @@
this.saving = false; this.saving = false;
} }
}, },
async applyBan(proposalId) {
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');
this.saving = true;
try {
await this.$post(`/admin/apply-ban/${encodeURIComponent(this.user.username)}/${proposalId}`);
window.location.reload();
} finally {
this.saving = false;
}
},
async report() { async report() {
if (!this.reportComment) { return; } if (!this.reportComment) { return; }
await this.$confirm(this.$t('report.confirm', {username: this.user.username}), 'danger'); await this.$confirm(this.$t('report.confirm', {username: this.user.username}), 'danger');
@ -109,5 +161,10 @@
} }
}, },
}, },
computed: {
canApplyBan() {
return !this.isBanned || this.$isGranted('users') && (this.banProposals.length >= 3 || this.$isGranted('*'));
},
}
} }
</script> </script>

View File

@ -0,0 +1,14 @@
-- Up
CREATE TABLE ban_proposals (
id TEXT NOT NULL PRIMARY KEY,
userId TEXT NULL NOT NULL REFERENCES users ON DELETE CASCADE,
bannedBy TEXT NOT NULL REFERENCES users ON DELETE SET NULL,
bannedTerms TEXT NULL NOT NULL,
bannedReason TEXT NOT NULL
);
CREATE INDEX "ban_proposals_userId" ON "ban_proposals" ("userId");
-- Down

View File

@ -221,6 +221,41 @@
</Loading> </Loading>
</template> </template>
<section v-if="$isGranted('users')">
<h3>
<Icon v="ban"/>
Pending bans
({{banProposals ? banProposals.length : 0}})
</h3>
<Loading :value="banProposals">
<table class="table table-striped">
<thead>
<tr>
<th>User</th>
<th>Votes</th>
</tr>
</thead>
<tbody>
<tr v-for="proposal in banProposals">
<td>
<a :href="`https://pronouns.page/@${proposal.username}`" target="_blank" rel="noopener">@{{proposal.username}}</a>
<ul class="list-unstyled">
<li v-for="locale in proposal.profiles.split(',')" v-if="locales[locale]">
<LocaleLink :link="`/@${proposal.username}`" :locale="locale">
{{ locales[locale].name }}
</LocaleLink>
</li>
</ul>
</td>
<td>
{{proposal.votes}}
</td>
</tr>
</tbody>
</table>
</Loading>
</section>
<section v-if="$isGranted('users')"> <section v-if="$isGranted('users')">
<h3> <h3>
<Icon v="siren-on"/> <Icon v="siren-on"/>
@ -306,6 +341,7 @@ import {deepSet, head} from "../src/helpers";
missingTranslations: translator.listMissingTranslations(), missingTranslations: translator.listMissingTranslations(),
abuseReports: undefined, abuseReports: undefined,
translationProposals: undefined, translationProposals: undefined,
banProposals: undefined,
} }
}, },
async asyncData({ app, store }) { async asyncData({ app, store }) {
@ -326,6 +362,10 @@ import {deepSet, head} from "../src/helpers";
this.$axios.$get(`/translations/proposals`) this.$axios.$get(`/translations/proposals`)
.then(r => this.translationProposals = r) .then(r => this.translationProposals = r)
.catch(); .catch();
this.$axios.$get(`/admin/ban-proposals`)
.then(r => this.banProposals = r)
.catch();
}, },
methods: { methods: {
async impersonate(email) { async impersonate(email) {

View File

@ -12,7 +12,7 @@ import buildLocaleList from "../../src/buildLocaleList";
import {archiveBan, liftBan} from "../ban"; import {archiveBan, liftBan} from "../ban";
import marked from 'marked'; import marked from 'marked';
import {loadCurrentUser} from "./user"; import {loadCurrentUser} from "./user";
import {encodeTime} from "ulid"; import {encodeTime, ulid} from "ulid";
const router = Router(); const router = Router();
@ -194,12 +194,54 @@ router.get('/admin/stats-public', handleErrorAsync(async (req, res) => {
const normalise = s => s.trim().toLowerCase(); const normalise = s => s.trim().toLowerCase();
router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => { const fetchUserByUsername = async (db, username) => {
return await db.get(SQL`SELECT id, email FROM users WHERE usernameNorm = ${normalise(username)}`);
}
const fetchBanProposals = async (db, userId) => {
return await db.all(SQL`
SELECT * FROM ban_proposals WHERE userId = ${userId}
`);
}
router.get('/admin/ban-proposals', 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'});
} }
const user = await req.db.get(SQL`SELECT id, email FROM users WHERE usernameNorm = ${normalise(req.params.username)}`); const cutoff = encodeTime(Date.now() - 3*31*24*60*60*1000, 10) + '0'.repeat(16);
return res.json(await req.db.all(SQL`
SELECT u.username, group_concat(p.locale) as profiles, count(bp.id) / count(p.locale) as votes
FROM ban_proposals bp
LEFT JOIN users u ON bp.userId = u.id
LEFT JOIN profiles p on u.id = p.userId
WHERE bp.id > ${cutoff}
AND u.bannedBy IS NULL
GROUP BY u.username
`));
}));
router.get('/admin/ban-proposals/:username', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
const user = await fetchUserByUsername(req.db, req.params.username);
if (!user) {
return res.status(400).json({error: 'No such user'});
}
return res.json(await fetchBanProposals(req.db, user.id));
}));
router.post('/admin/propose-ban/:username', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
const user = await fetchUserByUsername(req.db, req.params.username);
if (!user) { if (!user) {
return res.status(400).json({error: 'No such user'}); return res.status(400).json({error: 'No such user'});
} }
@ -208,20 +250,64 @@ router.post('/admin/ban/:username', handleErrorAsync(async (req, res) => {
if (!req.body.terms.length) { if (!req.body.terms.length) {
return res.status(400).json({error: 'Terms are required'}); return res.status(400).json({error: 'Terms are required'});
} }
await req.db.get(SQL`
DELETE FROM ban_proposals
WHERE userId = ${user.id} AND bannedBy = ${req.user.id}
`);
await req.db.get(SQL`
INSERT INTO ban_proposals (id, userId, bannedBy, bannedTerms, bannedReason) VALUES (
${ulid()}, ${user.id},
${req.user.id}, ${req.body.terms.join(',')}, ${req.body.reason}
)`
);
} else {
await req.db.get(SQL`
DELETE FROM ban_proposals
WHERE userId = ${user.id} AND bannedBy = ${req.user.id}
`);
}
return res.json(true);
}));
router.post('/admin/apply-ban/:username/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users')) {
return res.status(401).json({error: 'Unauthorised'});
}
const user = await fetchUserByUsername(req.db, req.params.username);
if (!user) {
return res.status(400).json({error: 'No such user'});
}
const proposals = await fetchBanProposals(req.db, user.id);
if (req.params.id && req.params.id !== '0') {
if (!req.isGranted('*') && proposals.length < 3) {
return res.status(401).json({error: 'Unauthorised'});
}
const proposal = await req.db.get(SQL`SELECT * FROM ban_proposals WHERE id = ${req.params.id}`);
if (!proposal || proposal.userId !== user.id) {
return res.status(400).json({error: 'Invalid ban proposal id'});
}
await req.db.get(SQL` await req.db.get(SQL`
UPDATE users UPDATE users
SET bannedReason = ${req.body.reason}, SET bannedReason = ${proposal.bannedReason},
bannedTerms = ${req.body.terms.join(',')}, bannedTerms = ${proposal.bannedTerms},
bannedBy = ${req.user.id}, bannedBy = ${req.user.id},
banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))} banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))}
WHERE id = ${user.id} WHERE id = ${user.id}
`); `);
await archiveBan(req.db, user); await archiveBan(req.db, user);
mailer(user.email, 'ban', {reason: req.body.reason, username: normalise(req.params.username)}); mailer(user.email, 'ban', {reason: proposal.bannedReason, username: normalise(req.params.username)});
} else { } else {
if (!req.isGranted('*')) {
return res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL` await req.db.get(SQL`
UPDATE users UPDATE users
SET bannedReason = null SET bannedReason = null,
bannedBy = ${req.user.id}
WHERE id = ${user.id} WHERE id = ${user.id}
`); `);
await liftBan(req.db, user); await liftBan(req.db, user);

View File

@ -38,8 +38,8 @@ const replaceExtension = username => username
; ;
export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => { export const saveAuthenticator = async (db, type, user, payload, validForMinutes = null) => {
if (await lookupBanArchive(db, type, payload)) { if (!user && await lookupBanArchive(db, type, payload)) {
throw 'banned'; throw new Error('banned');
} }
const id = ulid(); const id = ulid();
await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES ( await db.get(SQL`INSERT INTO authenticators (id, userId, type, payload, validUntil) VALUES (
@ -316,8 +316,8 @@ router.post('/user/init', handleErrorAsync(async (req, res) => {
return res.json({ error: 'user.account.changeEmail.invalid' }) return res.json({ error: 'user.account.changeEmail.invalid' })
} }
if (await lookupBanArchive(req.db, 'email', payload)) { if (!user && await lookupBanArchive(req.db, 'email', payload)) {
throw 'banned'; return res.status(401).json({error: 'Unauthorised'});
} }
let codeKey; let codeKey;