[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>
</a>
<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>
<div class="form-group">
<p class="my-1"><label><strong><T>ban.terms</T><T>quotation.colon</T></strong></label></p>
@ -69,24 +105,30 @@
reported: false,
showBanForm: !!this.user.bannedReason,
isBanned: !!this.user.bannedReason,
saving: false,
forbidden,
abuseReports: [],
banProposals: [],
}
},
async mounted() {
if (!this.$isGranted('users')) { return; }
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: {
async ban() {
await this.$confirm(this.$t('ban.confirm', {username: this.user.username}), 'danger');
this.saving = true;
try {
await this.$post(`/admin/ban/${encodeURIComponent(this.user.username)}`, {
await this.$post(`/admin/propose-ban/${encodeURIComponent(this.user.username)}`, {
reason: this.user.bannedReason,
terms: this.user.bannedTerms,
});
@ -95,6 +137,16 @@
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() {
if (!this.reportComment) { return; }
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>

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>
</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')">
<h3>
<Icon v="siren-on"/>
@ -306,6 +341,7 @@ import {deepSet, head} from "../src/helpers";
missingTranslations: translator.listMissingTranslations(),
abuseReports: undefined,
translationProposals: undefined,
banProposals: undefined,
}
},
async asyncData({ app, store }) {
@ -326,6 +362,10 @@ import {deepSet, head} from "../src/helpers";
this.$axios.$get(`/translations/proposals`)
.then(r => this.translationProposals = r)
.catch();
this.$axios.$get(`/admin/ban-proposals`)
.then(r => this.banProposals = r)
.catch();
},
methods: {
async impersonate(email) {

View File

@ -12,7 +12,7 @@ import buildLocaleList from "../../src/buildLocaleList";
import {archiveBan, liftBan} from "../ban";
import marked from 'marked';
import {loadCurrentUser} from "./user";
import {encodeTime} from "ulid";
import {encodeTime, ulid} from "ulid";
const router = Router();
@ -194,12 +194,54 @@ router.get('/admin/stats-public', handleErrorAsync(async (req, res) => {
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')) {
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) {
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) {
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`
UPDATE users
SET bannedReason = ${req.body.reason},
bannedTerms = ${req.body.terms.join(',')},
SET bannedReason = ${proposal.bannedReason},
bannedTerms = ${proposal.bannedTerms},
bannedBy = ${req.user.id},
banSnapshot = ${await profilesSnapshot(req.db, normalise(req.params.username))}
WHERE id = ${user.id}
`);
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 {
if (!req.isGranted('*')) {
return res.status(401).json({error: 'Unauthorised'});
}
await req.db.get(SQL`
UPDATE users
SET bannedReason = null
SET bannedReason = null,
bannedBy = ${req.user.id}
WHERE id = ${user.id}
`);
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) => {
if (await lookupBanArchive(db, type, payload)) {
throw 'banned';
if (!user && await lookupBanArchive(db, type, payload)) {
throw new Error('banned');
}
const id = ulid();
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' })
}
if (await lookupBanArchive(req.db, 'email', payload)) {
throw 'banned';
if (!user && await lookupBanArchive(req.db, 'email', payload)) {
return res.status(401).json({error: 'Unauthorised'});
}
let codeKey;