(refactor) migrate /api/nouns/** from express to h3

This commit is contained in:
Valentyne Stigloher 2025-03-20 11:59:55 +01:00
parent cfa9aca170
commit bdec6fa2b8
17 changed files with 394 additions and 335 deletions

View File

@ -5,21 +5,8 @@ import useConfig from '~/composables/useConfig.ts';
import useDialogue from '~/composables/useDialogue.ts';
import { Source } from '~/src/classes.ts';
interface FormData {
pronouns: string[];
type: string;
author: string;
title: string;
extra: string;
year: number | null;
fragments: string[];
comment: string | null;
images: string[];
link: string | null;
spoiler: boolean;
key: string | null;
base: string | null;
}
type FormData = Pick<Source, 'pronouns' | 'type' | 'author' | 'title' | 'extra' | 'year' | 'fragments' | 'comment'
| 'images' | 'link' | 'spoiler' | 'key'> & { base: string | null };
const config = useConfig();

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import type NounsSubmitForm from '~/components/nouns/NounsSubmitForm.vue';
import { Noun } from '~/src/classes.ts';
import type { NounRaw, Filter } from '~/src/classes.ts';
import type { Filter } from '~/src/classes.ts';
import { buildDict } from '~/src/helpers.ts';
import { availableGenders } from '~/src/nouns.ts';
@ -23,7 +23,7 @@ watch(filter, () => {
const form = useTemplateRef<InstanceType<typeof NounsSubmitForm>>('form');
const nounsAsyncData = useAsyncData(async () => {
const nounsRaw = await $fetch<NounRaw[]>('/api/nouns');
const nounsRaw = await $fetch('/api/nouns');
return buildDict(function* () {
const sorted = nounsRaw.sort((a, b) => {

View File

@ -0,0 +1,101 @@
import { createCanvas, loadImage, registerFont } from 'canvas';
import SQL from 'sql-template-strings';
import { getLocale, loadConfig, loadTranslator } from '~/server/data.ts';
import { registerLocaleFont } from '~/server/localeFont.ts';
import type { NounRow } from '~/server/nouns.ts';
import { availableGenders, iconUnicodesByGender, longIdentifierByGender } from '~/src/nouns.ts';
export default defineEventHandler(async (event) => {
const locale = getLocale(event);
const [config, translator] = await Promise.all([loadConfig(locale), loadTranslator(locale)]);
const { isGranted } = await useAuthentication(event);
if (!getRouterParam(event, 'id')?.endsWith('.png')) {
throw createError({
status: 404,
statusMessage: 'Not Found',
});
}
const id = getRouterParam(event, 'id')!.replace(/\.png$/, '');
const db = useDatabase();
const noun = await db.get<NounRow>(SQL`
SELECT * FROM nouns
WHERE locale = ${locale}
AND id = ${id}
AND approved >= ${isGranted('nouns') ? 0 : 1}
AND deleted = 0
`);
if (!noun) {
throw createError({
status: 404,
statusMessage: 'Not Found',
});
}
const genders = availableGenders(config);
let maxItems = 0;
genders.forEach((form) => {
let items = 0;
for (const key of ['', 'Pl'] as const) {
items += noun[`${form}${key}`].split('|').filter((x) => x.length).length;
}
if (items > maxItems) {
maxItems = items;
}
});
const padding = 48;
const width = genders.length * 400;
const height = padding * 2.5 + (maxItems + 1) * 48 + padding;
const mime = 'image/png';
const fontName = registerLocaleFont(config, 'fontHeadings', ['regular', 'bold']);
registerFont('node_modules/@fortawesome/fontawesome-pro/webfonts/fa-light-300.ttf', { family: 'FontAwesome', weight: 'regular' });
const canvas = createCanvas(width, height);
const context = canvas.getContext('2d');
const bg = await loadImage('public/bg.png');
context.drawImage(bg, 0, 0, width, height);
context.font = `bold 64pt '${fontName}'`;
genders.forEach((gender, column) => {
context.font = '24pt FontAwesome';
context.fillText(iconUnicodesByGender[gender], column * (width - 2 * padding) / genders.length + padding, padding * 1.5);
context.font = `bold 24pt '${fontName}'`;
const header = translator.translate(`nouns.${longIdentifierByGender[gender]}`);
context.fillText(header, column * (width - 2 * padding) / genders.length + padding + 36, padding * 1.5);
});
context.font = `24pt '${fontName}'`;
genders.forEach((form, column) => {
let i = 0;
for (const [key, symbol] of [['', '⋅'], ['Pl', '⁖']] as const) {
noun[`${form}${key}`].split('|').filter((x) => x.length)
.forEach((part) => {
context.fillText(`${symbol} ${part}`, column * (width - 2 * padding) / genders.length + padding, padding * 2.5 + i * 48);
i++;
});
}
});
context.fillStyle = '#C71585';
context.font = '16pt FontAwesome';
context.fillText('\uf02c', padding, height - padding + 12);
context.font = `16pt '${fontName}'`;
context.fillText(
`${translator.translate('domain')}/${config.nouns.routeMain || config.nouns.route}`,
padding + 36,
height - padding + 10,
);
setResponseHeader(event, 'content-type', mime);
return canvas.toBuffer(mime);
});

View File

@ -0,0 +1,19 @@
import { auditLog } from '~/server/audit.ts';
import { getLocale } from '~/server/data.ts';
import { approveNounEntry } from '~/server/nouns.ts';
export default defineEventHandler(async (event) => {
const { user, isGranted } = await useAuthentication(event);
if (!isGranted('nouns')) {
throw createError({
status: 401,
statusMessage: 'Unauthorised',
});
}
const id = getRouterParam(event, 'id')!;
const db = useDatabase();
await approveNounEntry(db, id, getLocale(event));
await auditLog({ user }, 'nouns/approved', { id });
});

View File

@ -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('nouns')) {
throw createError({
status: 401,
statusMessage: 'Unauthorised',
});
}
const id = getRouterParam(event, 'id');
const db = useDatabase();
await db.get(SQL`
UPDATE nouns
SET approved = 0
WHERE id = ${id}
`);
await invalidateCacheKind('nouns', getLocale(event));
await auditLog({ user }, 'nouns/hidden', { id });
});

View File

@ -0,0 +1,8 @@
import { getLocale } from '~/server/data.ts';
import { getNounEntries } from '~/server/nouns.ts';
export default defineEventHandler(async (event) => {
const { isGranted } = await useAuthentication(event);
const db = useDatabase();
return await getNounEntries(db, isGranted, getLocale(event));
});

View File

@ -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('nouns')) {
throw createError({
status: 401,
statusMessage: 'Unauthorised',
});
}
const id = getRouterParam(event, 'id');
const db = useDatabase();
await db.get(SQL`
UPDATE nouns
SET deleted=1
WHERE id = ${id}
`);
await invalidateCacheKind('nouns', getLocale(event));
await auditLog({ user }, 'nouns/removed', { id });
});

View File

@ -0,0 +1,22 @@
import SQL from 'sql-template-strings';
import { getLocale } from '~/server/data.ts';
import { addVersions } from '~/server/nouns.ts';
export default defineEventHandler(async (event) => {
const { isGranted } = await useAuthentication(event);
const locale = getLocale(event);
const term = `%${getRouterParam(event, 'term')}%`;
const db = useDatabase();
return await addVersions(db, isGranted, locale, await db.all(SQL`
SELECT n.*, u.username AS author FROM nouns n
LEFT JOIN users u ON n.author_id = u.id
WHERE n.locale = ${locale}
AND n.approved >= ${isGranted('nouns') ? 0 : 1}
AND n.deleted = 0
AND (n.masc like ${term} OR n.fem like ${term} OR n.neutr like ${term} OR n.nb like ${term} OR n.mascPl like ${term} OR n.femPl like ${term} OR n.neutrPl like ${term} OR n.nbPl like ${term})
ORDER BY n.approved, n.masc
`));
});

View File

@ -0,0 +1,43 @@
import SQL from 'sql-template-strings';
import { ulid } from 'ulid';
import { auditLog } from '~/server/audit.ts';
import { getLocale } from '~/server/data.ts';
import { approveNounEntry } from '~/server/nouns.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 nouns (id, masc, fem, neutr, nb, mascPl, femPl, neutrPl, nbPl, categories, sources, approved, base_id, locale, author_id)
VALUES (
${id},
${body.masc.join('|')}, ${body.fem.join('|')}, ${body.neutr.join('|')}, ${body.nb.join('|')},
${body.mascPl.join('|')}, ${body.femPl.join('|')}, ${body.neutrPl.join('|')}, ${body.nbPl.join('|')},
${body.categories.join('|')},
${body.sources ? body.sources.join(',') : null},
0, ${body.base}, ${locale}, ${user.id}
)
`);
await auditLog({ user }, 'nouns/submitted', body);
if (isGranted('nouns')) {
await approveNounEntry(db, id, locale);
await auditLog({ user }, 'nouns/approved', { id });
}
setResponseStatus(event, 201, 'Created');
});

View File

@ -11,9 +11,9 @@ import localeDescriptions from '~/locale/locales.ts';
import { getPosts } from '~/server/blog.ts';
import { getLocale, loadCalendar, loadConfig, loadPronounLibrary, loadTranslator } from '~/server/data.ts';
import { getInclusiveEntries } from '~/server/express/inclusive.ts';
import { getNounEntries } from '~/server/express/nouns.ts';
import { getSourcesEntries } from '~/server/express/sources.ts';
import { getTermsEntries } from '~/server/express/terms.ts';
import { getNounEntries } from '~/server/nouns.ts';
import { rootDir } from '~/server/paths.ts';
import { shortForVariant } from '~/src/buildPronoun.ts';
import { Day } from '~/src/calendar/helpers.ts';

View File

@ -1,296 +0,0 @@
import { createCanvas, loadImage, registerFont } from 'canvas';
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 { clearKey, handleErrorAsync } from '../../src/helpers.ts';
import type { User } from '../../src/user.ts';
import { auditLog } from '../audit.ts';
import type { Database } from '../db.ts';
import { registerLocaleFont } from '../localeFont.ts';
import type { SourceRow } from './sources.ts';
import type { UserRow } from './user.ts';
import { getLocale, loadConfig, loadTranslator } from '~/server/data.ts';
import { availableGenders, iconUnicodesByGender, longIdentifierByGender } from '~/src/nouns.ts';
interface NounRow {
id: string;
masc: string;
fem: string;
neutr: string;
nb: string;
mascPl: string;
femPl: string;
neutrPl: string;
nbPl: string;
approved: number;
base_id: string | null;
locale: string;
author_id: string | null;
deleted: number;
sources: string | null;
categories: string | null;
}
type NounRowWithAuthor = NounRow & { author: User['username'] };
const approve = async (db: Database, id: string, locale: string) => {
const { base_id } = (await db.get<Pick<NounRow, 'base_id'>>(SQL`SELECT base_id FROM nouns WHERE id=${id}`))!;
if (base_id) {
await db.get(SQL`
UPDATE nouns
SET deleted=1
WHERE id = ${base_id}
`);
}
await db.get(SQL`
UPDATE nouns
SET approved = 1, base_id = NULL
WHERE id = ${id}
`);
await invalidateCacheKind('nouns', locale);
};
const addVersions = async (db: Database, isGranted: Request['isGranted'], locale: string, nouns: NounRowWithAuthor[]) => {
const keys = new Set();
nouns.filter((s) => !!s && s.sources)
.forEach((s) => s.sources!.split(',').forEach((k) => keys.add(`'${clearKey(k.split('#')[0])}'`)));
const sources = await db.all<SourceRow & Pick<UserRow, 'username'>>(SQL`
SELECT s.*, u.username AS submitter FROM sources s
LEFT JOIN users u ON s.submitter_id = u.id
WHERE s.locale == ${locale}
AND s.deleted = 0
AND s.approved >= ${isGranted('sources') ? 0 : 1}
AND s.key IN (`.append([...keys].join(',')).append(SQL`)
`));
const sourcesMap: Record<string, SourceRow> = {};
sources.forEach((s) => sourcesMap[s.key!] = s);
return nouns.map((n) => ({
...n,
sourcesData: (n.sources ? n.sources.split(',') : []).map((s) => selectFragment(sourcesMap, s)),
}));
};
const selectFragment = (sourcesMap: Record<string, SourceRow>, keyAndFragment: string) => {
const [key, fragment] = keyAndFragment.split('#');
if (sourcesMap[key] === undefined) {
return undefined;
}
if (fragment === undefined) {
return sourcesMap[key];
}
const source = { ...sourcesMap[key] };
const fragments = source.fragments
? source.fragments.replace(/\\@/g, '###').split('@')
.map((x) => x.replace(/###/g, '@'))
: [];
source.fragments = fragments[parseInt(fragment) - 1];
return source;
};
const router = Router();
export const getNounEntries = defineCachedFunction(async (db: Database, isGranted: Request['isGranted'], locale: string) => {
return await addVersions(db, isGranted, locale, await db.all<NounRowWithAuthor>(SQL`
SELECT n.*, u.username AS author FROM nouns n
LEFT JOIN users u ON n.author_id = u.id
WHERE n.locale = ${locale}
AND n.deleted = 0
AND n.approved >= ${isGranted('nouns') ? 0 : 1}
ORDER BY n.approved, n.masc
`));
}, {
name: 'nouns',
getKey: (db, isGranted, locale) => locale,
shouldBypassCache: (db, isGranted) => isGranted('nouns'),
maxAge: 24 * 60 * 60,
});
router.get('/nouns', handleErrorAsync(async (req, res) => {
const locale = getLocale(getH3Event(req));
return res.json(await getNounEntries(req.db, req.isGranted, locale));
}));
router.get('/nouns/search/:term', handleErrorAsync(async (req, res) => {
const locale = getLocale(getH3Event(req));
const term = `%${req.params.term}%`;
return res.json(await addVersions(req.db, req.isGranted, locale, await req.db.all(SQL`
SELECT n.*, u.username AS author FROM nouns n
LEFT JOIN users u ON n.author_id = u.id
WHERE n.locale = ${locale}
AND n.approved >= ${req.isGranted('nouns') ? 0 : 1}
AND n.deleted = 0
AND (n.masc like ${term} OR n.fem like ${term} OR n.neutr like ${term} OR n.nb like ${term} OR n.mascPl like ${term} OR n.femPl like ${term} OR n.neutrPl like ${term} OR n.nbPl like ${term})
ORDER BY n.approved, n.masc
`)));
}));
router.post('/nouns/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 nouns (id, masc, fem, neutr, nb, mascPl, femPl, neutrPl, nbPl, categories, sources, approved, base_id, locale, author_id)
VALUES (
${id},
${req.body.masc.join('|')}, ${req.body.fem.join('|')}, ${req.body.neutr.join('|')}, ${req.body.nb.join('|')},
${req.body.mascPl.join('|')}, ${req.body.femPl.join('|')}, ${req.body.neutrPl.join('|')}, ${req.body.nbPl.join('|')},
${req.body.categories.join('|')},
${req.body.sources ? req.body.sources.join(',') : null},
0, ${req.body.base}, ${locale}, ${req.user ? req.user.id : null}
)
`);
await auditLog(req, 'nouns/submitted', { ...req.body });
if (req.isGranted('nouns')) {
await approve(req.db, id, locale);
await auditLog(req, 'nouns/approved', { id });
}
return res.json('ok');
}));
router.post('/nouns/hide/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('nouns')) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`
UPDATE nouns
SET approved = 0
WHERE id = ${req.params.id}
`);
await invalidateCacheKind('nouns', getLocale(getH3Event(req)));
await auditLog(req, 'nouns/hidden', { id: req.params.id });
return res.json('ok');
}));
router.post('/nouns/approve/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('nouns')) {
return res.status(401).json({ error: 'Unauthorised' });
}
await approve(req.db, req.params.id, getLocale(getH3Event(req)));
await auditLog(req, 'nouns/approved', { id: req.params.id });
return res.json('ok');
}));
router.post('/nouns/remove/:id', handleErrorAsync(async (req, res) => {
if (!req.isGranted('nouns')) {
return res.status(401).json({ error: 'Unauthorised' });
}
await req.db.get(SQL`
UPDATE nouns
SET deleted=1
WHERE id = ${req.params.id}
`);
await invalidateCacheKind('nouns', getLocale(getH3Event(req)));
await auditLog(req, 'nouns/removed', { id: req.params.id });
return res.json('ok');
}));
router.get('/nouns/:id.png', async (req, res) => {
const locale = getLocale(getH3Event(req));
const [config, translator] = await Promise.all([loadConfig(locale), loadTranslator(locale)]);
const noun = await req.db.get<NounRow>(SQL`
SELECT * FROM nouns
WHERE locale = ${locale}
AND id = ${req.params.id}
AND approved >= ${req.isGranted('nouns') ? 0 : 1}
AND deleted = 0
`);
if (!noun) {
return res.status(404).json({ error: 'Not found' });
}
const genders = availableGenders(config);
let maxItems = 0;
genders.forEach((form) => {
let items = 0;
for (const key of ['', 'Pl'] as const) {
items += noun[`${form}${key}`].split('|').filter((x) => x.length).length;
}
if (items > maxItems) {
maxItems = items;
}
});
const padding = 48;
const width = genders.length * 400;
const height = padding * 2.5 + (maxItems + 1) * 48 + padding;
const mime = 'image/png';
const fontName = registerLocaleFont(config, 'fontHeadings', ['regular', 'bold']);
registerFont('node_modules/@fortawesome/fontawesome-pro/webfonts/fa-light-300.ttf', { family: 'FontAwesome', weight: 'regular' });
const canvas = createCanvas(width, height);
const context = canvas.getContext('2d');
const bg = await loadImage('public/bg.png');
context.drawImage(bg, 0, 0, width, height);
context.font = `bold 64pt '${fontName}'`;
genders.forEach((gender, column) => {
context.font = '24pt FontAwesome';
context.fillText(iconUnicodesByGender[gender], column * (width - 2 * padding) / genders.length + padding, padding * 1.5);
context.font = `bold 24pt '${fontName}'`;
const header = translator.translate(`nouns.${longIdentifierByGender[gender]}`);
context.fillText(header, column * (width - 2 * padding) / genders.length + padding + 36, padding * 1.5);
});
context.font = `24pt '${fontName}'`;
genders.forEach((form, column) => {
let i = 0;
for (const [key, symbol] of [['', '⋅'], ['Pl', '⁖']] as const) {
noun[`${form}${key}`].split('|').filter((x) => x.length)
.forEach((part) => {
context.fillText(`${symbol} ${part}`, column * (width - 2 * padding) / genders.length + padding, padding * 2.5 + i * 48);
i++;
});
}
});
context.fillStyle = '#C71585';
context.font = '16pt FontAwesome';
context.fillText('\uf02c', padding, height - padding + 12);
context.font = `16pt '${fontName}'`;
context.fillText(
`${translator.translate('domain')}/${config.nouns.routeMain || config.nouns.route}`,
padding + 36,
height - padding + 10,
);
return res.set('content-type', mime).send(canvas.toBuffer(mime));
});
export default router;

View File

@ -9,12 +9,13 @@ import { auditLog } from '../audit.ts';
import type { Database } from '../db.ts';
import { getLocale } from '~/server/data.ts';
import type { SourceType } from '~/src/classes.ts';
export interface SourceRow {
id: string;
locale: string;
pronouns: string;
type: string;
type: SourceType;
author: string | null;
title: string;
extra: string | null;
@ -23,12 +24,12 @@ export interface SourceRow {
comment: string | null;
link: string | null;
submitter_id: string | null;
approved: number | null;
approved: boolean;
deleted: number | null;
base_id: string | null;
key: string | null;
images: string | null;
spoiler: string;
spoiler: boolean;
}
const approve = async (db: Database, id: string, locale: string) => {

View File

@ -6,12 +6,10 @@ import session from 'express-session';
import grant from 'grant';
import { useBase } from 'h3';
import { defineExpressHandler, getH3Event } from 'h3-express';
import SQL from 'sql-template-strings';
import buildLocaleList from '../src/buildLocaleList.ts';
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
import formatError from '../src/error.ts';
import type { User } from '../src/user.ts';
import './dotenv.ts';
@ -26,7 +24,6 @@ import imagesRoute from './express/images.ts';
import inclusiveRoute from './express/inclusive.ts';
import mfaRoute from './express/mfa.ts';
import namesRoute from './express/names.ts';
import nounsRoute from './express/nouns.ts';
import profileRoute from './express/profile.ts';
import pronounceRoute from './express/pronounce.ts';
import pronounsRoute from './express/pronouns.ts';
@ -40,6 +37,7 @@ 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 {
@ -139,13 +137,7 @@ router.use(async function (req, res, next) {
req.isGranted = authentication.isGranted;
req.locales = buildLocaleList(locale, locale === '_');
req.db = new LazyDatabase();
req.isUserAllowedToPost = async (): Promise<boolean> => {
if (!req.user) {
return false;
}
const user = await req.db.get(SQL`SELECT bannedReason FROM users WHERE id = ${req.user.id}`) as Pick<User, 'bannedReason'>;
return user && !user.bannedReason;
};
req.isUserAllowedToPost = (): Promise<boolean> => isAllowedToPost(req.db, req.user);
res.on('finish', async () => {
await req.db.close();
await closeAuditLogConnection();
@ -171,7 +163,6 @@ router.use(adminRoute);
router.use(mfaRoute);
router.use(pronounsRoute);
router.use(sourcesRoute);
router.use(nounsRoute);
router.use(inclusiveRoute);
router.use(termsRoute);
router.use(pronounceRoute);

117
server/nouns.ts Normal file
View File

@ -0,0 +1,117 @@
import SQL from 'sql-template-strings';
import type { Database } from '~/server/db.ts';
import type { SourceRow } from '~/server/express/sources.ts';
import type { UserRow } from '~/server/express/user.ts';
import type { IsGrantedFn } from '~/server/utils/useAuthentication.ts';
import { clearKey } from '~/src/helpers.ts';
import type { User } from '~/src/user.ts';
export interface NounRow {
id: string;
masc: string;
fem: string;
neutr: string;
nb: string;
mascPl: string;
femPl: string;
neutrPl: string;
nbPl: string;
approved: boolean;
base_id: string | null;
locale: string;
author_id: string | null;
deleted: boolean;
sources: string | null;
categories: string | null;
}
type NounRowWithAuthor = NounRow & { author: User['username'] };
export const addVersions = async (
db: Database,
isGranted: IsGrantedFn,
locale: string,
nouns: NounRowWithAuthor[],
) => {
const keys = new Set();
nouns.filter((s) => !!s && s.sources)
.forEach((s) => s.sources!.split(',').forEach((k) => keys.add(`'${clearKey(k.split('#')[0])}'`)));
const sources = await db.all<SourceRow & Pick<UserRow, 'username'>>(SQL`
SELECT s.*, u.username AS submitter FROM sources s
LEFT JOIN users u ON s.submitter_id = u.id
WHERE s.locale == ${locale}
AND s.deleted = 0
AND s.approved >= ${isGranted('sources') ? 0 : 1}
AND s.key IN (`.append([...keys].join(',')).append(SQL`)
`));
const sourcesMap: Record<string, SourceRow> = {};
sources.forEach((s) => sourcesMap[s.key!] = s);
return nouns.map((n) => ({
...n,
sourcesData: (n.sources ? n.sources.split(',') : [])
.map((s) => selectFragment(sourcesMap, s))
.filter((source) => source !== undefined),
}));
};
const selectFragment = (sourcesMap: Record<string, SourceRow>, keyAndFragment: string) => {
const [key, fragment] = keyAndFragment.split('#');
if (sourcesMap[key] === undefined) {
return undefined;
}
if (fragment === undefined) {
return sourcesMap[key];
}
const source = { ...sourcesMap[key] };
const fragments = source.fragments
? source.fragments.replace(/\\@/g, '###').split('@')
.map((x) => x.replace(/###/g, '@'))
: [];
source.fragments = fragments[parseInt(fragment) - 1];
return source;
};
export const getNounEntries = defineCachedFunction(async (
db: Database,
isGranted: IsGrantedFn,
locale: string,
) => {
return await addVersions(db, isGranted, locale, await db.all<NounRowWithAuthor>(SQL`
SELECT n.*, u.username AS author FROM nouns n
LEFT JOIN users u ON n.author_id = u.id
WHERE n.locale = ${locale}
AND n.deleted = 0
AND n.approved >= ${isGranted('nouns') ? 0 : 1}
ORDER BY n.approved, n.masc
`));
}, {
name: 'nouns',
getKey: (db, isGranted, locale) => locale,
shouldBypassCache: (db, isGranted) => isGranted('nouns'),
maxAge: 24 * 60 * 60,
});
export const approveNounEntry = async (db: Database, id: string, locale: string) => {
const { base_id } = (await db.get<Pick<NounRow, 'base_id'>>(SQL`SELECT base_id FROM nouns WHERE id=${id}`))!;
if (base_id) {
await db.get(SQL`
UPDATE nouns
SET deleted=1
WHERE id = ${base_id}
`);
}
await db.get(SQL`
UPDATE nouns
SET approved = 1, base_id = NULL
WHERE id = ${id}
`);
await invalidateCacheKind('nouns', locale);
};

12
server/user.ts Normal file
View File

@ -0,0 +1,12 @@
import SQL from 'sql-template-strings';
import type { Database } from '~/server/db.ts';
import type { User } from '~/src/user.ts';
export const isAllowedToPost = async (db: Database, user: User | null): Promise<boolean> => {
if (!user) {
return false;
}
const dbUser = await db.get<Pick<User, 'bannedReason'>>(SQL`SELECT bannedReason FROM users WHERE id = ${user.id}`);
return !!dbUser && !dbUser.bannedReason;
};

View File

@ -5,6 +5,8 @@ import jwt from '~/server/jwt.ts';
import { isGrantedForUser } from '~/src/helpers.ts';
import type { User } from '~/src/user.ts';
export type IsGrantedFn = (area?: string, locale?: string) => boolean;
export default async (event: H3Event) => {
let rawUser = undefined;

View File

@ -184,16 +184,16 @@ export interface Entry {
matches(filter: Filter): boolean;
}
type SourceType = '' | 'Book' | 'Article' | 'Movie' | 'Series' | 'Song' | 'Poetry' | 'Comics' | 'Game' | 'Other';
export type SourceType = '' | 'Book' | 'Article' | 'Movie' | 'Series' | 'Song' | 'Poetry' | 'Comics' | 'Game' | 'Other';
export interface SourceRaw {
id: string;
pronouns: string;
type: SourceType;
author: string;
author: string | null;
title: string;
extra: string;
year: number;
extra: string | null;
year: number | null;
fragments?: string;
comment?: string | null;
link?: string | null;
@ -211,10 +211,10 @@ export class Source implements Entry {
id: string;
pronouns: string[];
type: SourceType;
author: string;
author: string | null;
title: string;
extra: string;
year: number;
extra: string | null;
year: number | null;
fragments: string[];
comment: string | null;
link: string | null;