Merge branch 'blocking' into 'main'

(user) allow blocking accounts

See merge request PronounsPage/PronounsPage!604
This commit is contained in:
Andrea Vos 2025-04-13 11:42:35 +00:00
commit d2a166da81
10 changed files with 197 additions and 8 deletions

View File

@ -283,7 +283,7 @@ const addBrackets = (str: string): string => {
</div>
<TabsNav
:tabs="['general', 'cards', 'socials', 'circles', 'backup']"
:tabs="['general', 'cards', 'socials', 'circles', 'blocks', 'backup']"
pills
showheaders
navclass="mb-3 border-bottom-0"
@ -562,6 +562,14 @@ const addBrackets = (str: string): string => {
</nuxt-link>
</template>
<template #blocks-header>
<Icon v="ban" />
<T>profile.blocks.header</T>
</template>
<template #blocks>
<BlocksList />
</template>
<template #backup-header>
<Icon v="copy" />
<T>profile.backup.headerShort</T>

View File

@ -1,6 +1,6 @@
<template>
<div v-if="$user() && $user().username !== user.username">
<section v-if="$user()">
<section>
<a v-if="!showReportForm" href="#" class="small" @click.prevent="showReportForm = true">
<Icon v="spider" />
<T>report.action</T>
@ -37,6 +37,12 @@
<T>report.sent</T>
</div>
</section>
<section>
<a href="#" class="small" @click.prevent="block">
<Icon v="ban" />
<T>profile.blocks.action</T>
</a>
</section>
<section v-if="$isGranted('users') || $isGranted('community')">
<div v-if="banSnapshot" class="my-3">
<a
@ -380,6 +386,11 @@ Unfortunately, I need to remove your account.
this.saving = false;
}
},
async block() {
await this.dialogue.confirm(this.$t('profile.blocks.confirm', { username: this.user.username }), 'danger');
await this.dialogue.postWithAlertOnError(`/api/user/block/${this.user.id}`);
window.location.reload();
},
copyProposal(proposal) {
this.user.bannedReason = proposal.bannedReason;
this.user.bannedTerms = proposal.bannedTerms.split(',');

42
components/BlocksList.vue Normal file
View File

@ -0,0 +1,42 @@
<script lang="ts" setup>
import { useFetch } from 'nuxt/app';
import useDialogue from '~/composables/useDialogue.ts';
const { $translator: translator } = useNuxtApp();
const dialogue = useDialogue();
type Block = {
id: string;
to_userId: string;
to_username: string;
};
const blocks = useFetch<Block[]>(`/api/user/blocks`, { lazy: true });
const unblock = async (block: Block) => {
await dialogue.confirm(translator.translate('profile.blocks.unblock.confirm', { username: block.to_username }), 'danger');
await dialogue.postWithAlertOnError(`/api/user/unblock/${block.to_userId}`);
await blocks.refresh();
};
</script>
<template>
<Loading :value="blocks.data.value">
<ul v-if="blocks.data.value">
<li v-if="blocks.data.value.length === 0">
<T>profile.blocks.empty</T>
</li>
<li v-for="block in blocks.data.value" :key="block.id">
@{{ block.to_username }}
<small class="text-muted">({{ $datetime($ulidTime(block.id)) }})</small>
<a href="#" :aria-label="$t('profile.blocks.unblock.action')" class="btn btn-link btn-sm" @click.prevent="unblock(block)">
<Icon v="trash" />
</a>
</li>
</ul>
<p class="small mb-0">
<Icon v="info-circle" /> <T>profile.blocks.instruction</T>
</p>
</Loading>
</template>

View File

@ -850,6 +850,20 @@ profile:
action: 'Remove yourself'
confirm: 'Are you sure you want to remove your profile from @%username%''s circle?'
add: 'Add people to your circle'
blocks:
header: 'Blocked accounts'
action: 'Block this person'
confirm: >
Are you sure you want to block @%username%?
You will not be able to see each other's cards or add each other to your circles.
This can be reversed in your Account page.
unblock:
action: 'Unblock this person'
confirm: >
Are you sure you want to unblock @%username%?
You will be able to see each other's cards or add each other to your circles.
empty: 'You currently do not have any accounts blocked.'
instruction: 'You can block a person by visiting their profile.'
sensitive:
header: 'Content warning'
info: >

View File

@ -1067,6 +1067,20 @@ profile:
action: 'Remove yourself'
confirm: 'Are you sure you want to remove your profile from @%username%''s circle?'
add: 'Add people to your circle'
blocks:
header: 'Blocked accounts'
action: 'Block this person'
confirm: >
Are you sure you want to block @%username%?
You will not be able to see each other's cards or add each other to your circles.
This can be reversed in your Account page.
unblock:
action: 'Unblock this person'
confirm: >
Are you sure you want to unblock @%username%?
You will be able to see each other's cards or add each other to your circles.
empty: 'You currently do not have any accounts blocked.'
instruction: 'You can block a person by visiting their profile.'
sensitive:
header: 'Content warning'
info: >

View File

@ -1690,6 +1690,20 @@ profile:
action: 'Usuń się'
confirm: 'Czy na pewno chcesz usunąć link do swojej wizytówki z kręgów osoby @%username%?'
add: 'Dodaj osoby do swojego kręgu'
blocks:
header: 'Zablokowane konta'
action: 'Zablokuj tę osobę'
confirm: >
Czy na pewno chcesz zablokować @%username%?
Nie będziecie mogły widzieć swoich wizytówek ani dodawać się nawzajem do kręgów.
Możesz cofnąć blokadę na stronie „Twoje konto”.
unblock:
action: 'Unblock this person'
confirm: >
Czy na pewno chcesz odblokować @%username%?
Będziecie mogły znowu widzieć swoje wizytówki oraz dodawać się nawzajem do kręgów.
empty: 'You currently do not have any accounts blocked.'
instruction: 'You can block a person by visiting their profile.'
sensitive:
header: 'Ostrzeżenie o zawartości'
info: >
@ -2119,7 +2133,7 @@ ban:
reason: 'Powód blokady'
visible: '(to będzie widoczne dla osoby zbanowanej)'
terms: 'Złamane punkty regulaminu (wymagane)'
action: 'Zablokuj tę osobę'
action: 'Zbanuj tę osobę'
confirm: 'Czy na pewno chcesz zbanować @%username%?'
header: 'Twoje konto jest zablokowane. Twoje profile nie są widoczne publicznie.'
banned: 'Konto nieaktywne'

View File

@ -0,0 +1,12 @@
-- Up
CREATE TABLE block_connections (
id TEXT NOT NULL PRIMARY KEY,
from_userId TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
to_userId TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX "block_connections_from_userId" ON "block_connections" ("from_userId");
CREATE INDEX "block_connections_to_userId" ON "block_connections" ("to_userId");
-- Down

View File

@ -60,7 +60,7 @@ const username = computed(() => {
return usernameFromRoute;
}
if (userAsyncData.data.value.username !== usernameFromRoute && import.meta.client) {
if (userAsyncData.data.value.username && userAsyncData.data.value.username !== usernameFromRoute && import.meta.client) {
history.pushState(
'',
document.title,
@ -74,9 +74,9 @@ const username = computed(() => {
const { mainPronoun } = useMainPronoun(pronounLibrary, profile, translator);
const flags = buildFlags(config.locale);
useSimpleHead({
title: `@${username.value}`,
title: username.value ? `@${username.value}` : undefined,
description: computed(() => profile.value ? profile.value.description ?? null : null),
banner: `api/banner/@${username.value}.png`,
banner: username.value ? `api/banner/@${username.value}.png` : undefined,
noindex: true,
keywords: computed(() => profile.value?.flags?.map((flagName) => flags[flagName])
.filter((flag) => flag !== undefined)

View File

@ -495,14 +495,30 @@ const fetchCircles = async (
}));
};
const isUserBlocked = async (db: Database, user: any, otherUser: any): Promise<boolean> => {
if (!user || !otherUser || user.id === otherUser.id) {
return false;
}
const row = await db.get<{ c: number }>(SQL`
SELECT count(*) as c
FROM block_connections
WHERE (from_userId = ${user.id} AND to_userId = ${otherUser.id})
OR (from_userId = ${otherUser.id} AND to_userId = ${user.id})
`);
return row!.c > 0;
};
const router = Router();
const fetchProfilesRoute = async (req: Request, res: Response, locale: string, user: any): Promise<Response> => {
const isSelf = !!req.user && req.user.username === req.params.username;
const isAdmin = req.isGranted('users') || req.isGranted('community');
const opts = new ProfileOptions(req.query, req.locales);
const isBlocked = await isUserBlocked(req.db, req.user, user);
if (!user || user.bannedReason !== null && !isAdmin && !isSelf) {
if (!user || isBlocked || user.bannedReason !== null && !isAdmin && !isSelf) {
return res.json({
profiles: {},
});
@ -573,6 +589,11 @@ router.get('/profile/get-id/:id', handleErrorAsync(async (req, res) => {
}));
router.get('/profile/versions/:username', handleErrorAsync(async (req, res) => {
const user = await req.db.get<Pick<UserRow, 'id'>>(SQL`SELECT * FROM users WHERE usernameNorm = ${normalise(req.params.username)}`);
if (await isUserBlocked(req.db, req.user, user)) {
return res.json([]);
}
return res.json((await req.db.all<Pick<ProfileRow, 'locale'>>(SQL`
SELECT
profiles.locale
@ -832,7 +853,7 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => {
for (const connection of profile.circle) {
const toUserId = usernameIdMap[normaliseWithLink(connection.username)];
const relationship = connection.relationship.substring(0, 64).trim();
if (toUserId === undefined || !relationship) {
if (toUserId === undefined || !relationship || await isUserBlocked(req.db, req.user, { id: toUserId })) {
continue;
}
await req.db.get(SQL`INSERT INTO user_connections (id, from_profileId, to_userId, relationship) VALUES (

View File

@ -1006,4 +1006,57 @@ router.get('/user/social-lookup/:provider/:identifier', handleErrorAsync(async (
return res.json(row ? row.username : null);
}));
router.get('/user/blocks', handleErrorAsync(async (req, res) => {
if (!req.user) {
return res.status(401).json({ error: 'Unauthorised' });
}
const rows = await req.db.all<{ id: string; to_userId: string; to_username: string }[]>(SQL`
SELECT b.id, b.to_userId, u.username AS to_username
FROM block_connections b
LEFT JOIN users u ON b.to_userId = u.id
WHERE from_userId = ${req.user.id}
ORDER BY b.id DESC
`);
return res.json(rows);
}));
router.post('/user/block/:id', handleErrorAsync(async (req, res) => {
const id = req.params.id;
const blockedUser = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE id = ${id}`);
if (!req.user || !blockedUser || req.user.id === blockedUser.id) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`
INSERT INTO block_connections (id, from_userId, to_userId)
VALUES (${ulid()}, ${req.user.id}, ${blockedUser.id})
`);
await req.db.get(SQL`
DELETE FROM user_connections
WHERE (from_profileId IN (SELECT id FROM profiles WHERE userId = ${req.user.id}) AND to_userId = ${blockedUser.id})
OR (from_profileId IN (SELECT id FROM profiles WHERE userId = ${blockedUser.id}) AND to_userId = ${req.user.id})
`);
return res.json(null);
}));
router.post('/user/unblock/:id', handleErrorAsync(async (req, res) => {
const id = req.params.id;
const blockedUser = await req.db.get<UserRow>(SQL`SELECT * FROM users WHERE id = ${id}`);
if (!req.user || !blockedUser || req.user.id === blockedUser.id) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`
DELETE FROM block_connections
WHERE from_userId = ${req.user.id}
AND to_userId = ${blockedUser.id}
`);
return res.json(null);
}));
export default router;