feat(tags): moderators and admins can delete public tags
Add DELETE /api/tags/:name (gated by isModerator(), which also passes for admins per the admin-implies-moderator invariant in roles.ts). The endpoint normalises the name the same way creation does so the URL casing doesn't matter, then deletes the tag and detaches it from every activity_tags row in one transaction. UI: new "Etiketter" nav entry visible to moderators + admins, opens a ModerateTags.svelte view with search-as-you-type (reusing the /api/tags suggestion endpoint) and a Slett button per row. Private tags are unaffected — they're encrypted in the activity payload and never reach the server tag table. Tests: 3 new cases on top of the admin suite — moderator can delete, plain user gets 403 (anonymous gets 401), unknown tag gets 404.
This commit is contained in:
parent
dc349a9373
commit
54d8ed22f4
5 changed files with 231 additions and 2 deletions
|
|
@ -1,5 +1,7 @@
|
|||
import { Hono } from 'hono';
|
||||
import { getDb } from './db';
|
||||
import { requireAuth, type AppVariables } from './session';
|
||||
import { isModerator } from './roles';
|
||||
import type { TagSuggestion } from '../shared/types';
|
||||
|
||||
/**
|
||||
|
|
@ -116,7 +118,7 @@ export function bulkTagsFor(activityIds: string[]): Map<string, string[]> {
|
|||
}
|
||||
|
||||
// --- Routes ------------------------------------------------------------------
|
||||
export const tagsRoutes = new Hono();
|
||||
export const tagsRoutes = new Hono<{ Variables: AppVariables }>();
|
||||
|
||||
// GET /api/tags?q=foo&limit=20
|
||||
tagsRoutes.get('/', (c) => {
|
||||
|
|
@ -143,3 +145,33 @@ tagsRoutes.get('/', (c) => {
|
|||
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// --- DELETE /api/tags/:name -------------------------------------------------
|
||||
// Moderation: remove a public tag entirely. Detaches it from every
|
||||
// activity_tags row, then drops the tag itself. Private tags never reach the
|
||||
// server (they live encrypted in the activity payload), so this only touches
|
||||
// public/semi/friends data.
|
||||
//
|
||||
// Gated by isModerator() — admins also pass because admin implies moderator
|
||||
// (see server/roles.ts).
|
||||
tagsRoutes.delete('/:name', requireAuth, (c) => {
|
||||
const userId = c.get('userId');
|
||||
if (!isModerator(userId)) return c.json({ error: 'forbidden' }, 403);
|
||||
|
||||
const raw = c.req.param('name');
|
||||
const name = normaliseTag(decodeURIComponent(raw));
|
||||
if (!name) return c.json({ error: 'invalid_name' }, 400);
|
||||
|
||||
const db = getDb();
|
||||
const txn = db.transaction(() => {
|
||||
const tag = db.prepare('SELECT id FROM tags WHERE name = ?').get(name) as { id: string } | null;
|
||||
if (!tag) return false;
|
||||
db.prepare('DELETE FROM activity_tags WHERE tag_id = ?').run(tag.id);
|
||||
db.prepare('DELETE FROM tags WHERE id = ?').run(tag.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const removed = txn();
|
||||
if (!removed) return c.json({ error: 'not_found' }, 404);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue