diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 1c2167f..673f468 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,6 +12,7 @@ import Feedback from './components/Feedback.svelte'; import PublicList from './components/PublicList.svelte'; import Admin from './components/Admin.svelte'; + import ModerateTags from './components/ModerateTags.svelte'; import ActivityPermalink from './components/ActivityPermalink.svelte'; import Personvern from './components/Personvern.svelte'; import TagPage from './components/TagPage.svelte'; @@ -34,7 +35,7 @@ | 'login' | 'signup' | 'recovery' | 'public-home' // "/" — public/semi only, anyone | 'home' // "/home" — full authenticated dashboard - | 'profile' | 'feedback' | 'admin' + | 'profile' | 'feedback' | 'admin' | 'moderate-tags' | 'public-list' // "//list" | 'permalink' // "/a/:id" | 'personvern' // "/personvern" @@ -210,6 +211,12 @@ {#if session.user.is_admin} {/if} + {#if session.user.is_moderator || session.user.is_admin} + + {/if} + + +

+ Sletting fjerner etiketten fra alle offentlige, anonyme og vennlige + aktiviteter den finnes på. Private etiketter er kryptert og ikke + synlige her. +

+ + + + + {#if error}{/if} + + {#if loading} +

Laster …

+ {:else if tags.length === 0} +

+ {query ? `Ingen etiketter matcher «${query}».` : 'Ingen etiketter ennå.'} +

+ {:else} +
+ + + + + + + + + + {#each tags as t (t.name)} + + + + + + {/each} + +
EtikettBruk
+ {t.name} + + {t.usage_count} + + +
+
+ {/if} + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2d3aef8..c5fde72 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -91,6 +91,10 @@ export const api = { // --- tags ----------------------------------------------------------------- tagSuggestions: (q: string, limit = 20) => http(`/tags?q=${encodeURIComponent(q)}&limit=${limit}`), + // Moderator/admin: delete a public tag entirely (also detaches it from all + // activities). Forbidden for regular users. + deleteTag: (name: string) => + http(`/tags/${encodeURIComponent(name)}`, { method: 'DELETE' }), // --- users (opt-in public list) ------------------------------------------- publicList: (username: string) => diff --git a/server/tags.ts b/server/tags.ts index 14212df..f08b7f6 100644 --- a/server/tags.ts +++ b/server/tags.ts @@ -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 { } // --- 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); +}); diff --git a/tests/admin.test.ts b/tests/admin.test.ts index 2722ccd..6aae510 100644 --- a/tests/admin.test.ts +++ b/tests/admin.test.ts @@ -152,6 +152,66 @@ describe('admin user-list shape', () => { }); }); +describe('tag deletion (moderator + admin)', () => { + ttest('moderator can delete a public tag and it detaches from activities', async () => { + const admin = await signupAndGetCookie(ctx, 'tag-admin@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + const mod = await signupAndGetCookie(ctx, 'tag-mod@test.invalid'); + getDb().prepare('UPDATE users SET is_moderator = 1 WHERE id = ?').run(mod.me.id); + + // Two activities sharing a tag. + await createActivity(ctx, admin.cookie, { + visibility: 'public', title: 'with-tag-1', tags: ['Skismøring'], + }); + await createActivity(ctx, admin.cookie, { + visibility: 'semi', title: 'with-tag-2', tags: ['skismøring'], + }); + + // Tag was normalised to lowercase. + const before = await reqJson<{ name: string; usage_count: number }[]>( + ctx, 'GET', '/api/tags?q=ski', + ); + expect(before.some((t) => t.name === 'skismøring')).toBe(true); + + const res = await req(ctx, 'DELETE', '/api/tags/skism%C3%B8ring', { cookie: mod.cookie }); + expect(res.status).toBe(204); + + // Tag is gone from the suggestion list. + const after = await reqJson<{ name: string }[]>(ctx, 'GET', '/api/tags?q=ski'); + expect(after.some((t) => t.name === 'skismøring')).toBe(false); + + // activity_tags rows for the tag are also gone — verify the join surface. + const db = getDb(); + const tagRow = db.prepare("SELECT id FROM tags WHERE name = 'skismøring'").get(); + expect(tagRow).toBeNull(); + }); + + ttest('plain user cannot delete a tag', async () => { + const admin = await signupAndGetCookie(ctx, 'tag-admin2@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + const plain = await signupAndGetCookie(ctx, 'tag-plain@test.invalid'); + + await createActivity(ctx, admin.cookie, { + visibility: 'public', title: 'plain-cannot', tags: ['victim-tag'], + }); + + const res = await req(ctx, 'DELETE', '/api/tags/victim-tag', { cookie: plain.cookie }); + expect(res.status).toBe(403); + + // Anonymous: 401 (requireAuth fires before the role check). + const anon = await req(ctx, 'DELETE', '/api/tags/victim-tag'); + expect(anon.status).toBe(401); + }); + + ttest('deleting an unknown tag returns 404', async () => { + const admin = await signupAndGetCookie(ctx, 'tag-admin3@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + + const res = await req(ctx, 'DELETE', '/api/tags/does-not-exist', { cookie: admin.cookie }); + expect(res.status).toBe(404); + }); +}); + describe('role activity-delete crossover (admin/moderator can delete semi)', () => { ttest('admin can delete another user semi activity', async () => { const admin = await signupAndGetCookie(ctx, 'cross-admin@test.invalid');