mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-09 15:37:18 -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 useDialogue from '~/composables/useDialogue.ts';
|
||||||
import { Source } from '~/src/classes.ts';
|
import { Source } from '~/src/classes.ts';
|
||||||
|
|
||||||
interface FormData {
|
type FormData = Pick<Source, 'pronouns' | 'type' | 'author' | 'title' | 'extra' | 'year' | 'fragments' | 'comment'
|
||||||
pronouns: string[];
|
| 'images' | 'link' | 'spoiler' | 'key'> & { base: string | null };
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type NounsSubmitForm from '~/components/nouns/NounsSubmitForm.vue';
|
import type NounsSubmitForm from '~/components/nouns/NounsSubmitForm.vue';
|
||||||
import { Noun } from '~/src/classes.ts';
|
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 { buildDict } from '~/src/helpers.ts';
|
||||||
import { availableGenders } from '~/src/nouns.ts';
|
import { availableGenders } from '~/src/nouns.ts';
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ watch(filter, () => {
|
|||||||
const form = useTemplateRef<InstanceType<typeof NounsSubmitForm>>('form');
|
const form = useTemplateRef<InstanceType<typeof NounsSubmitForm>>('form');
|
||||||
|
|
||||||
const nounsAsyncData = useAsyncData(async () => {
|
const nounsAsyncData = useAsyncData(async () => {
|
||||||
const nounsRaw = await $fetch<NounRaw[]>('/api/nouns');
|
const nounsRaw = await $fetch('/api/nouns');
|
||||||
|
|
||||||
return buildDict(function* () {
|
return buildDict(function* () {
|
||||||
const sorted = nounsRaw.sort((a, b) => {
|
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 { getPosts } from '~/server/blog.ts';
|
||||||
import { getLocale, loadCalendar, loadConfig, loadPronounLibrary, loadTranslator } from '~/server/data.ts';
|
import { getLocale, loadCalendar, loadConfig, loadPronounLibrary, loadTranslator } from '~/server/data.ts';
|
||||||
import { getInclusiveEntries } from '~/server/express/inclusive.ts';
|
import { getInclusiveEntries } from '~/server/express/inclusive.ts';
|
||||||
import { getNounEntries } from '~/server/express/nouns.ts';
|
|
||||||
import { getSourcesEntries } from '~/server/express/sources.ts';
|
import { getSourcesEntries } from '~/server/express/sources.ts';
|
||||||
import { getTermsEntries } from '~/server/express/terms.ts';
|
import { getTermsEntries } from '~/server/express/terms.ts';
|
||||||
|
import { getNounEntries } from '~/server/nouns.ts';
|
||||||
import { rootDir } from '~/server/paths.ts';
|
import { rootDir } from '~/server/paths.ts';
|
||||||
import { shortForVariant } from '~/src/buildPronoun.ts';
|
import { shortForVariant } from '~/src/buildPronoun.ts';
|
||||||
import { Day } from '~/src/calendar/helpers.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 type { Database } from '../db.ts';
|
||||||
|
|
||||||
import { getLocale } from '~/server/data.ts';
|
import { getLocale } from '~/server/data.ts';
|
||||||
|
import type { SourceType } from '~/src/classes.ts';
|
||||||
|
|
||||||
export interface SourceRow {
|
export interface SourceRow {
|
||||||
id: string;
|
id: string;
|
||||||
locale: string;
|
locale: string;
|
||||||
pronouns: string;
|
pronouns: string;
|
||||||
type: string;
|
type: SourceType;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
extra: string | null;
|
extra: string | null;
|
||||||
@ -23,12 +24,12 @@ export interface SourceRow {
|
|||||||
comment: string | null;
|
comment: string | null;
|
||||||
link: string | null;
|
link: string | null;
|
||||||
submitter_id: string | null;
|
submitter_id: string | null;
|
||||||
approved: number | null;
|
approved: boolean;
|
||||||
deleted: number | null;
|
deleted: number | null;
|
||||||
base_id: string | null;
|
base_id: string | null;
|
||||||
key: string | null;
|
key: string | null;
|
||||||
images: string | null;
|
images: string | null;
|
||||||
spoiler: string;
|
spoiler: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const approve = async (db: Database, id: string, locale: string) => {
|
const approve = async (db: Database, id: string, locale: string) => {
|
||||||
|
@ -6,12 +6,10 @@ import session from 'express-session';
|
|||||||
import grant from 'grant';
|
import grant from 'grant';
|
||||||
import { useBase } from 'h3';
|
import { useBase } from 'h3';
|
||||||
import { defineExpressHandler, getH3Event } from 'h3-express';
|
import { defineExpressHandler, getH3Event } from 'h3-express';
|
||||||
import SQL from 'sql-template-strings';
|
|
||||||
|
|
||||||
import buildLocaleList from '../src/buildLocaleList.ts';
|
import buildLocaleList from '../src/buildLocaleList.ts';
|
||||||
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
|
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
|
||||||
import formatError from '../src/error.ts';
|
import formatError from '../src/error.ts';
|
||||||
import type { User } from '../src/user.ts';
|
|
||||||
|
|
||||||
import './dotenv.ts';
|
import './dotenv.ts';
|
||||||
|
|
||||||
@ -26,7 +24,6 @@ import imagesRoute from './express/images.ts';
|
|||||||
import inclusiveRoute from './express/inclusive.ts';
|
import inclusiveRoute from './express/inclusive.ts';
|
||||||
import mfaRoute from './express/mfa.ts';
|
import mfaRoute from './express/mfa.ts';
|
||||||
import namesRoute from './express/names.ts';
|
import namesRoute from './express/names.ts';
|
||||||
import nounsRoute from './express/nouns.ts';
|
|
||||||
import profileRoute from './express/profile.ts';
|
import profileRoute from './express/profile.ts';
|
||||||
import pronounceRoute from './express/pronounce.ts';
|
import pronounceRoute from './express/pronounce.ts';
|
||||||
import pronounsRoute from './express/pronouns.ts';
|
import pronounsRoute from './express/pronouns.ts';
|
||||||
@ -40,6 +37,7 @@ import { config } from './social.ts';
|
|||||||
|
|
||||||
import { closeAuditLogConnection } from '~/server/audit.ts';
|
import { closeAuditLogConnection } from '~/server/audit.ts';
|
||||||
import { getLocale } from '~/server/data.ts';
|
import { getLocale } from '~/server/data.ts';
|
||||||
|
import { isAllowedToPost } from '~/server/user.ts';
|
||||||
|
|
||||||
class StorageStore extends session.Store {
|
class StorageStore extends session.Store {
|
||||||
get(sid: string, callback: (err: unknown, session?: (session.SessionData | null)) => void): void {
|
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.isGranted = authentication.isGranted;
|
||||||
req.locales = buildLocaleList(locale, locale === '_');
|
req.locales = buildLocaleList(locale, locale === '_');
|
||||||
req.db = new LazyDatabase();
|
req.db = new LazyDatabase();
|
||||||
req.isUserAllowedToPost = async (): Promise<boolean> => {
|
req.isUserAllowedToPost = (): Promise<boolean> => isAllowedToPost(req.db, req.user);
|
||||||
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;
|
|
||||||
};
|
|
||||||
res.on('finish', async () => {
|
res.on('finish', async () => {
|
||||||
await req.db.close();
|
await req.db.close();
|
||||||
await closeAuditLogConnection();
|
await closeAuditLogConnection();
|
||||||
@ -171,7 +163,6 @@ router.use(adminRoute);
|
|||||||
router.use(mfaRoute);
|
router.use(mfaRoute);
|
||||||
router.use(pronounsRoute);
|
router.use(pronounsRoute);
|
||||||
router.use(sourcesRoute);
|
router.use(sourcesRoute);
|
||||||
router.use(nounsRoute);
|
|
||||||
router.use(inclusiveRoute);
|
router.use(inclusiveRoute);
|
||||||
router.use(termsRoute);
|
router.use(termsRoute);
|
||||||
router.use(pronounceRoute);
|
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 { isGrantedForUser } from '~/src/helpers.ts';
|
||||||
import type { User } from '~/src/user.ts';
|
import type { User } from '~/src/user.ts';
|
||||||
|
|
||||||
|
export type IsGrantedFn = (area?: string, locale?: string) => boolean;
|
||||||
|
|
||||||
export default async (event: H3Event) => {
|
export default async (event: H3Event) => {
|
||||||
let rawUser = undefined;
|
let rawUser = undefined;
|
||||||
|
|
||||||
|
@ -184,16 +184,16 @@ export interface Entry {
|
|||||||
matches(filter: Filter): boolean;
|
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 {
|
export interface SourceRaw {
|
||||||
id: string;
|
id: string;
|
||||||
pronouns: string;
|
pronouns: string;
|
||||||
type: SourceType;
|
type: SourceType;
|
||||||
author: string;
|
author: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
extra: string;
|
extra: string | null;
|
||||||
year: number;
|
year: number | null;
|
||||||
fragments?: string;
|
fragments?: string;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
link?: string | null;
|
link?: string | null;
|
||||||
@ -211,10 +211,10 @@ export class Source implements Entry {
|
|||||||
id: string;
|
id: string;
|
||||||
pronouns: string[];
|
pronouns: string[];
|
||||||
type: SourceType;
|
type: SourceType;
|
||||||
author: string;
|
author: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
extra: string;
|
extra: string | null;
|
||||||
year: number;
|
year: number | null;
|
||||||
fragments: string[];
|
fragments: string[];
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
link: string | null;
|
link: string | null;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user