diff --git a/pages/names.vue b/pages/names.vue index 42b029e07..fc8774109 100644 --- a/pages/names.vue +++ b/pages/names.vue @@ -6,7 +6,7 @@ import useConfig from '~/composables/useConfig.ts'; import useDialogue from '~/composables/useDialogue.ts'; import useHash from '~/composables/useHash.ts'; import useSimpleHead from '~/composables/useSimpleHead.ts'; -import { Name, type NameRaw } from '~/src/classes.ts'; +import { Name } from '~/src/classes.ts'; import { buildDict } from '~/src/helpers.ts'; definePageMeta({ @@ -21,7 +21,7 @@ useSimpleHead({ const { handleHash, setHash } = useHash(); const namesAsyncData = useAsyncData(async () => { - const namesRaw = await $fetch('/api/names'); + const namesRaw = await $fetch('/api/names'); return buildDict(function* () { const sorted = namesRaw.sort((a, b) => { diff --git a/server/api/names/approve/[id].post.ts b/server/api/names/approve/[id].post.ts new file mode 100644 index 000000000..8319d01b6 --- /dev/null +++ b/server/api/names/approve/[id].post.ts @@ -0,0 +1,19 @@ +import { auditLog } from '~/server/audit.ts'; +import { getLocale } from '~/server/data.ts'; +import { approveNameEntry } from '~/server/names.ts'; + +export default defineEventHandler(async (event) => { + const { user, isGranted } = await useAuthentication(event); + if (!isGranted('names')) { + throw createError({ + status: 401, + statusMessage: 'Unauthorised', + }); + } + + const id = getRouterParam(event, 'id')!; + const db = useDatabase(); + await approveNameEntry(db, id, getLocale(event)); + + await auditLog({ user }, 'names/approved', { id }); +}); diff --git a/server/api/names/hide/[id].post.ts b/server/api/names/hide/[id].post.ts new file mode 100644 index 000000000..e1c6659ec --- /dev/null +++ b/server/api/names/hide/[id].post.ts @@ -0,0 +1,26 @@ +import SQL from 'sql-template-strings'; + +import { auditLog } from '~/server/audit.ts'; +import { getLocale } from '~/server/data.ts'; + +export default defineEventHandler(async (event) => { + const { user, isGranted } = await useAuthentication(event); + if (!isGranted('names')) { + throw createError({ + status: 401, + statusMessage: 'Unauthorised', + }); + } + + const id = getRouterParam(event, 'id'); + const db = useDatabase(); + await db.get(SQL` + UPDATE names + SET approved = 0 + WHERE id = ${id} + `); + + await invalidateCache('names', getLocale(event)); + + await auditLog({ user }, 'names/hidden', { id }); +}); diff --git a/server/api/names/index.get.ts b/server/api/names/index.get.ts new file mode 100644 index 000000000..7e7b810f2 --- /dev/null +++ b/server/api/names/index.get.ts @@ -0,0 +1,8 @@ +import { getLocale } from '~/server/data.ts'; +import { getNameEntries } from '~/server/names.ts'; + +export default defineEventHandler(async (event) => { + const { isGranted } = await useAuthentication(event); + const db = useDatabase(); + return await getNameEntries(db, isGranted, getLocale(event)); +}); diff --git a/server/api/names/remove/[id].post.ts b/server/api/names/remove/[id].post.ts new file mode 100644 index 000000000..0c986ff58 --- /dev/null +++ b/server/api/names/remove/[id].post.ts @@ -0,0 +1,26 @@ +import SQL from 'sql-template-strings'; + +import { auditLog } from '~/server/audit.ts'; +import { getLocale } from '~/server/data.ts'; + +export default defineEventHandler(async (event) => { + const { user, isGranted } = await useAuthentication(event); + if (!isGranted('names')) { + throw createError({ + status: 401, + statusMessage: 'Unauthorised', + }); + } + + const id = getRouterParam(event, 'id'); + const db = useDatabase(); + await db.get(SQL` + UPDATE names + SET deleted=1 + WHERE id = ${id} + `); + + await invalidateCache('names', getLocale(event)); + + await auditLog({ user }, 'names/removed', { id }); +}); diff --git a/server/api/names/submit.post.ts b/server/api/names/submit.post.ts new file mode 100644 index 000000000..864e34c23 --- /dev/null +++ b/server/api/names/submit.post.ts @@ -0,0 +1,44 @@ +import SQL from 'sql-template-strings'; +import { ulid } from 'ulid'; + +import { auditLog } from '~/server/audit.ts'; +import { getLocale } from '~/server/data.ts'; +import { approveNameEntry } from '~/server/names.ts'; +import { isAllowedToPost } from '~/server/user.ts'; + +export default defineEventHandler(async (event) => { + const { user, isGranted } = await useAuthentication(event); + const db = useDatabase(); + + if (!user || !await isAllowedToPost(db, user)) { + throw createError({ + status: 401, + statusMessage: 'Unauthorised', + }); + } + + const locale = getLocale(event); + const body = await readBody(event); + + const id = ulid(); + await db.get(SQL` + INSERT INTO names (id, name, locale, origin, meaning, usage, legally, pros, cons, notablePeople, links, namedays, namedaysComment, deleted, approved, base_id, author_id) + VALUES ( + ${id}, + ${body.name}, ${locale}, + ${body.origin || null}, ${body.meaning || null}, ${body.usage || null}, ${body.legally || null}, + ${body.pros.length ? body.pros.join('|') : null}, ${body.cons.length ? body.cons.join('|') : null}, + ${body.notablePeople.length ? body.notablePeople.join('|') : null}, ${body.links.length ? body.links.join('|') : null}, + ${body.namedays.length ? body.namedays.join('|') : null}, ${body.namedaysComment || null}, + 0, 0, ${body.base}, ${user.id} + ) + `); + await auditLog({ user }, 'names/submitted', body); + + if (isGranted('names')) { + await approveNameEntry(db, id, locale); + await auditLog({ user }, 'names/approved', { id }); + } + + setResponseStatus(event, 201, 'Created'); +}); diff --git a/server/express/names.ts b/server/express/names.ts deleted file mode 100644 index 0a2c1c65b..000000000 --- a/server/express/names.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Router } from 'express'; -import type { Request } from 'express'; -import { getH3Event } from 'h3-express'; -import SQL from 'sql-template-strings'; -import { ulid } from 'ulid'; - -import { handleErrorAsync } from '../../src/helpers.ts'; -import { auditLog } from '../audit.ts'; -import type { Database } from '../db.ts'; - -import { getLocale } from '~/server/data.ts'; - -interface NameRow { - id: string; - name: string; - locale: string; - origin: string | null; - meaning: string | null; - usage: string | null; - legally: string | null; - pros: string | null; - cons: string | null; - notablePeople: string | null; - links: string | null; - namedays: string | null; - namedaysComment: string | null; - deleted: number; - approved: number; - base_id: string | null; - author_id: string | null; -} - -const approve = async (db: Database, id: string, locale: string) => { - const { base_id } = (await db.get>(SQL`SELECT base_id FROM names WHERE id=${id}`))!; - if (base_id) { - await db.get(SQL` - UPDATE names - SET deleted=1 - WHERE id = ${base_id} - `); - } - await db.get(SQL` - UPDATE names - SET approved = 1, base_id = NULL - WHERE id = ${id} - `); - await invalidateCache('names', locale); -}; - -const router = Router(); - -const getNames = defineCachedFunction(async (db: Database, isGranted: Request['isGranted'], locale: string) => { - return await db.all(SQL` - SELECT n.*, u.username AS author FROM names n - LEFT JOIN users u ON n.author_id = u.id - WHERE n.locale = ${locale} - AND n.deleted = 0 - AND n.approved >= ${isGranted('names') ? 0 : 1} - ORDER BY n.approved, n.name - `); -}, { - name: 'names', - getKey: (db, isGranted, locale) => locale, - shouldBypassCache: (db, isGranted) => isGranted('names'), - maxAge: 24 * 60 * 60, -}); - -router.get('/names', handleErrorAsync(async (req, res) => { - const locale = getLocale(getH3Event(req)); - return res.json(await getNames(req.db, req.isGranted, locale)); -})); - -router.post('/names/submit', handleErrorAsync(async (req, res) => { - if (!req.user || !await req.isUserAllowedToPost()) { - return res.status(401).json({ error: 'Unauthorised' }); - } - - const locale = getLocale(getH3Event(req)); - - const id = ulid(); - await req.db.get(SQL` - INSERT INTO names (id, name, locale, origin, meaning, usage, legally, pros, cons, notablePeople, links, namedays, namedaysComment, deleted, approved, base_id, author_id) - VALUES ( - ${id}, - ${req.body.name}, ${locale}, - ${req.body.origin || null}, ${req.body.meaning || null}, ${req.body.usage || null}, ${req.body.legally || null}, - ${req.body.pros.length ? req.body.pros.join('|') : null}, ${req.body.cons.length ? req.body.cons.join('|') : null}, - ${req.body.notablePeople.length ? req.body.notablePeople.join('|') : null}, ${req.body.links.length ? req.body.links.join('|') : null}, - ${req.body.namedays.length ? req.body.namedays.join('|') : null}, ${req.body.namedaysComment || null}, - 0, 0, ${req.body.base}, ${req.user ? req.user.id : null} - ) - `); - await auditLog(req, 'names/submitted', { ...req.body }); - - if (req.isGranted('names')) { - await approve(req.db, id, locale); - await auditLog(req, 'names/approved', { id }); - } - - return res.json('ok'); -})); - -router.post('/names/hide/:id', handleErrorAsync(async (req, res) => { - if (!req.isGranted('names')) { - return res.status(401).json({ error: 'Unauthorised' }); - } - - await req.db.get(SQL` - UPDATE names - SET approved = 0 - WHERE id = ${req.params.id} - `); - - await invalidateCache('names', getLocale(getH3Event(req))); - - await auditLog(req, 'names/hidden', { id: req.params.id }); - - return res.json('ok'); -})); - -router.post('/names/approve/:id', handleErrorAsync(async (req, res) => { - if (!req.isGranted('names')) { - return res.status(401).json({ error: 'Unauthorised' }); - } - - await approve(req.db, req.params.id, getLocale(getH3Event(req))); - - await auditLog(req, 'names/approved', { id: req.params.id }); - - return res.json('ok'); -})); - -router.post('/names/remove/:id', handleErrorAsync(async (req, res) => { - if (!req.isGranted('names')) { - return res.status(401).json({ error: 'Unauthorised' }); - } - - await req.db.get(SQL` - UPDATE names - SET deleted=1 - WHERE id = ${req.params.id} - `); - - await invalidateCache('names', getLocale(getH3Event(req))); - - await auditLog(req, 'names/removed', { id: req.params.id }); - - return res.json('ok'); -})); - -export default router; diff --git a/server/global.d.ts b/server/global.d.ts index c2fdfc54f..245949de9 100644 --- a/server/global.d.ts +++ b/server/global.d.ts @@ -13,7 +13,6 @@ declare global { isGranted: (area?: string, locale?: string) => boolean; locales: Record; db: Database; - isUserAllowedToPost: () => Promise; } export interface Response { diff --git a/server/index.ts b/server/index.ts index 295546a3f..fd39f2c1a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -22,7 +22,6 @@ import discord from './express/discord.ts'; import grantOverridesRoute from './express/grantOverrides.ts'; import imagesRoute from './express/images.ts'; import mfaRoute from './express/mfa.ts'; -import namesRoute from './express/names.ts'; import profileRoute from './express/profile.ts'; import pronounceRoute from './express/pronounce.ts'; import sentryRoute from './express/sentry.ts'; @@ -33,7 +32,6 @@ import { config } from './social.ts'; import { closeAuditLogConnection } from '~/server/audit.ts'; import { getLocale } from '~/server/data.ts'; -import { isAllowedToPost } from '~/server/user.ts'; class StorageStore extends session.Store { get(sid: string, callback: (err: unknown, session?: (session.SessionData | null)) => void): void { @@ -133,7 +131,6 @@ router.use(async function (req, res, next) { req.isGranted = authentication.isGranted; req.locales = buildLocaleList(locale, locale === '_'); req.db = new LazyDatabase(); - req.isUserAllowedToPost = (): Promise => isAllowedToPost(req.db, req.user); res.on('finish', async () => { await req.db.close(); await closeAuditLogConnection(); @@ -159,7 +156,6 @@ router.use(adminRoute); router.use(mfaRoute); router.use(pronounceRoute); router.use(censusRoute); -router.use(namesRoute); router.use(imagesRoute); router.use(calendarRoute); router.use(translationsRoute); diff --git a/server/names.ts b/server/names.ts new file mode 100644 index 000000000..de95b2a0b --- /dev/null +++ b/server/names.ts @@ -0,0 +1,63 @@ +import SQL from 'sql-template-strings'; + +import type { Database } from '~/server/db.ts'; +import type { User } from '~/src/user.ts'; + +interface NameRow { + id: string; + name: string; + locale: string; + origin: string | null; + meaning: string | null; + usage: string | null; + legally: string | null; + pros: string | null; + cons: string | null; + notablePeople: string | null; + links: string | null; + namedays: string | null; + namedaysComment: string | null; + deleted: boolean; + approved: boolean; + base_id: string | null; + author_id: string | null; +} + +type NameRowWithAuthor = NameRow & { author: User['username'] }; + +export const getNameEntries = defineCachedFunction(async ( + db: Database, + isGranted: IsGrantedFn, + locale: string, +) => { + return await db.all(SQL` + SELECT n.*, u.username AS author FROM names n + LEFT JOIN users u ON n.author_id = u.id + WHERE n.locale = ${locale} + AND n.deleted = 0 + AND n.approved >= ${isGranted('names') ? 0 : 1} + ORDER BY n.approved, n.name + `); +}, { + name: 'names', + getKey: (db, isGranted, locale) => locale, + shouldBypassCache: (db, isGranted) => isGranted('names'), + maxAge: 24 * 60 * 60, +}); + +export const approveNameEntry = async (db: Database, id: string, locale: string) => { + const { base_id } = (await db.get>(SQL`SELECT base_id FROM names WHERE id=${id}`))!; + if (base_id) { + await db.get(SQL` + UPDATE names + SET deleted=1 + WHERE id = ${base_id} + `); + } + await db.get(SQL` + UPDATE names + SET approved = 1, base_id = NULL + WHERE id = ${id} + `); + await invalidateCache('names', locale); +}; diff --git a/src/classes.ts b/src/classes.ts index f78156c45..1d1a7c968 100644 --- a/src/classes.ts +++ b/src/classes.ts @@ -1317,16 +1317,16 @@ export class TermsEntry implements Entry { export interface NameRaw { id: string; name: string; - origin: string; - meaning: string; - usage: string; - legally: string; - pros: string; - cons: string; - notablePeople: string; - links: string; - namedays: string; - namedaysComment: string; + origin: string | null; + meaning: string | null; + usage: string | null; + legally: string | null; + pros: string | null; + cons: string | null; + notablePeople: string | null; + links: string | null; + namedays: string | null; + namedaysComment: string | null; approved: boolean; base_id: string | null; author: string | null; @@ -1335,16 +1335,16 @@ export interface NameRaw { export class Name { id: string; name: string; - origin: string; - meaning: string; - usage: string; - legally: string; + origin: string | null; + meaning: string | null; + usage: string | null; + legally: string | null; pros: string[]; cons: string[]; notablePeople: string[]; links: string[]; namedays: string[]; - namedaysComment: string; + namedaysComment: string | null; approved: boolean; base: string | null; author: string | null;