mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-08 15:00:37 -04:00
(refactor) migrate /api/nouns/** from express to h3
This commit is contained in:
parent
cfa9aca170
commit
bdec6fa2b8
@ -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();
|
||||
|
||||
|
@ -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) => {
|
||||
|
101
server/api/nouns/[id].get.ts
Normal file
101
server/api/nouns/[id].get.ts
Normal 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);
|
||||
});
|
19
server/api/nouns/approve/[id].post.ts
Normal file
19
server/api/nouns/approve/[id].post.ts
Normal 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 });
|
||||
});
|
26
server/api/nouns/hide/[id].post.ts
Normal file
26
server/api/nouns/hide/[id].post.ts
Normal 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 });
|
||||
});
|
8
server/api/nouns/index.get.ts
Normal file
8
server/api/nouns/index.get.ts
Normal 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));
|
||||
});
|
26
server/api/nouns/remove/[id].post.ts
Normal file
26
server/api/nouns/remove/[id].post.ts
Normal 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 });
|
||||
});
|
22
server/api/nouns/search/[term].get.ts
Normal file
22
server/api/nouns/search/[term].get.ts
Normal 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
|
||||
`));
|
||||
});
|
43
server/api/nouns/submit.post.ts
Normal file
43
server/api/nouns/submit.post.ts
Normal 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');
|
||||
});
|
@ -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';
|
||||
|
@ -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;
|
@ -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) => {
|
||||
|
@ -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
117
server/nouns.ts
Normal 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
12
server/user.ts
Normal 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;
|
||||
};
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user