diff --git a/Makefile b/Makefile
index 1faecb84e..bd618699d 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ install:
-cp -n .env.dist .env
if [ ! -d "${KEYS_DIR}" ]; then mkdir -p ${KEYS_DIR}; openssl genrsa -out ${KEYS_DIR}/private.pem 2048; openssl rsa -in ${KEYS_DIR}/private.pem -outform PEM -pubout -out ${KEYS_DIR}/public.pem; fi
mkdir -p moderation
- touch moderation/sus.txt moderation/rules-users.md moderation/rules-terminology.md moderation/rules-sources.md moderation/timesheets.md moderation/expenses.md
+ touch moderation/sus.txt moderation/rules-users.md moderation/rules-terminology.md moderation/rules-sources.md moderation/timesheets.md moderation/expenses.md moderation/blog.md
pnpm install
pnpm run-file server/migrate.ts
diff --git a/components/AdminDashboardCard.vue b/components/AdminDashboardCard.vue
index 0eb46dad2..08a1c4ddb 100644
--- a/components/AdminDashboardCard.vue
+++ b/components/AdminDashboardCard.vue
@@ -1,3 +1,42 @@
+
+
@@ -8,10 +47,14 @@
{{ header }}
- -
+
-
{{ count }} {{ name || '' }}
@@ -24,43 +67,3 @@
-
-
diff --git a/components/BlogEntriesList.vue b/components/BlogEntriesList.vue
deleted file mode 100644
index b20a6dd20..000000000
--- a/components/BlogEntriesList.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/components/Stats.vue b/components/Stats.vue
index 4068b78f1..8629d196c 100644
--- a/components/Stats.vue
+++ b/components/Stats.vue
@@ -56,7 +56,7 @@ const formatDuration = (secondsCount: number): string => {
|
footer.stats.current
-
+
-
footer.stats.keys.cardsquotation.colon
diff --git a/components/blog/BlogEntriesList.vue b/components/blog/BlogEntriesList.vue
new file mode 100644
index 000000000..15ac3d2ad
--- /dev/null
+++ b/components/blog/BlogEntriesList.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
diff --git a/components/blog/BlogEntry.vue b/components/blog/BlogEntry.vue
new file mode 100644
index 000000000..29f1a2c56
--- /dev/null
+++ b/components/blog/BlogEntry.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/BlogReactionSection.vue b/components/blog/BlogReactionSection.vue
similarity index 100%
rename from components/BlogReactionSection.vue
rename to components/blog/BlogReactionSection.vue
diff --git a/pages/admin/blog/editor.vue b/pages/admin/blog/editor.vue
new file mode 100644
index 000000000..2b9619a8b
--- /dev/null
+++ b/pages/admin/blog/editor.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+ admin.header
+
+
+
+
+ Blog editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/admin/index.vue b/pages/admin/index.vue
index 98ca1fc8b..fcc0d8a50 100644
--- a/pages/admin/index.vue
+++ b/pages/admin/index.vue
@@ -1,3 +1,57 @@
+
+
@@ -9,10 +63,10 @@
admin.header
-
+
Stats calculated at:
-
+
{{ $datetime(stats.calculatedAt) }}
@@ -31,34 +85,34 @@
-
+
{{ name }}
(not published yet)
+ Editor
+
+
-
-
diff --git a/server/admin.ts b/server/admin.ts
new file mode 100644
index 000000000..a5191429d
--- /dev/null
+++ b/server/admin.ts
@@ -0,0 +1,51 @@
+import SQL from 'sql-template-strings';
+import { decodeTime } from 'ulid';
+
+import type { Config } from '~/locale/config.ts';
+import type { LocaleDescription } from '~/locale/locales.ts';
+import type { Database } from '~/server/db.ts';
+import type { LocaleStatsData, OverallStatsData } from '~/src/stats.ts';
+
+export type LocalDescriptionWithConfig = Omit & { config: Config };
+
+export interface StatRow {
+ id: string;
+ locale: string;
+ users: number;
+ data: string;
+}
+
+export interface Stats {
+ calculatedAt: number;
+ overall: { users: number } & OverallStatsData;
+ locales: Record;
+}
+
+export const fetchStats = async (db: Database): Promise => {
+ const maxId = (await db.get<{ maxId: StatRow['id'] | null }>('SELECT MAX(id) AS maxId FROM stats'))!.maxId;
+
+ if (maxId === null) {
+ return null;
+ }
+
+ let overall: ({ users: number } & OverallStatsData) | null = null;
+ const locales: Record = {};
+
+ for (const statsRow of await db.all>(SQL`SELECT locale, users, data FROM stats WHERE id = ${maxId}`)) {
+ const stats = {
+ users: statsRow.users,
+ ...JSON.parse(statsRow.data),
+ };
+ if (statsRow.locale === '_') {
+ overall = stats;
+ } else {
+ locales[statsRow.locale] = stats;
+ }
+ }
+
+ return {
+ calculatedAt: decodeTime(maxId) / 1000,
+ overall: overall!,
+ locales,
+ };
+};
diff --git a/server/api/admin/all-locales.get.ts b/server/api/admin/all-locales.get.ts
new file mode 100644
index 000000000..aa7ef543f
--- /dev/null
+++ b/server/api/admin/all-locales.get.ts
@@ -0,0 +1,24 @@
+import fs from 'fs';
+
+import Suml from 'suml';
+
+import type { Config } from '~/locale/config.ts';
+import buildLocaleList from '~/src/buildLocaleList.ts';
+
+export default defineEventHandler(async (event) => {
+ const { isGranted } = await useAuthentication(event);
+ if (!isGranted('panel')) {
+ throw createError({
+ status: 401,
+ statusMessage: 'Unauthorised',
+ });
+ }
+
+ return Object.fromEntries(Object.entries(buildLocaleList(global.config.locale, true))
+ .map(([locale, localeDescription]) => {
+ return [locale, {
+ ...localeDescription,
+ config: new Suml().parse(fs.readFileSync(`./locale/${locale}/config.suml`).toString()) as Config,
+ }];
+ }));
+});
diff --git a/server/api/admin/moderation.get.ts b/server/api/admin/moderation.get.ts
new file mode 100644
index 000000000..ca61564c5
--- /dev/null
+++ b/server/api/admin/moderation.get.ts
@@ -0,0 +1,31 @@
+import fs from 'node:fs/promises';
+
+import markdownit from 'markdown-it';
+
+import { rootDir } from '~/server/paths.ts';
+
+const md = markdownit({ html: true });
+
+export default defineEventHandler(async (event) => {
+ const { isGranted } = await useAuthentication(event);
+ if (!isGranted('panel')) {
+ throw createError({
+ status: 401,
+ statusMessage: 'Unauthorised',
+ });
+ }
+
+ const dir = `${rootDir}/moderation`;
+
+ return {
+ susRegexes: (await fs.readFile(`${dir}/sus.txt`, 'utf-8'))
+ .split('\n')
+ .filter((x) => !!x && !x.startsWith('#')),
+ rulesUsers: md.render(await fs.readFile(`${dir}/rules-users.md`, 'utf-8')),
+ rulesTerminology: md.render(await fs.readFile(`${dir}/rules-terminology.md`, 'utf-8')),
+ rulesSources: md.render(await fs.readFile(`${dir}/rules-sources.md`, 'utf-8')),
+ timesheets: md.render(await fs.readFile(`${dir}/timesheets.md`, 'utf-8')),
+ expenses: md.render(await fs.readFile(`${dir}/expenses.md`, 'utf-8')),
+ blog: md.render(await fs.readFile(`${dir}/blog.md`, 'utf-8')),
+ };
+});
diff --git a/server/api/admin/stats-public.get.ts b/server/api/admin/stats-public.get.ts
new file mode 100644
index 000000000..718a3a58e
--- /dev/null
+++ b/server/api/admin/stats-public.get.ts
@@ -0,0 +1,67 @@
+import { fetchStats } from '~/server/admin.ts';
+
+interface PublicStats {
+ calculatedAt: number;
+ overall: PublicLocaleStats;
+ current: Partial;
+}
+
+interface PublicLocaleStats {
+ users: number;
+ cards: number;
+ pageViews: number;
+ visitors: number;
+ online: number;
+ visitDuration?: number;
+ uptime?: number;
+ responseTime?: number;
+}
+
+export default defineEventHandler(async () => {
+ const db = useDatabase();
+ const statsAll = await fetchStats(db);
+ if (statsAll === null) {
+ throw createError({
+ status: 404,
+ statusMessage: 'Not Found',
+ });
+ }
+
+ const stats: PublicStats = {
+ calculatedAt: statsAll.calculatedAt,
+ overall: {
+ users: statsAll.overall.users,
+ cards: 0,
+ pageViews: statsAll.overall.plausible?.pageviews || 0,
+ visitors: statsAll.overall.plausible?.visitors || 0,
+ online: statsAll.overall.plausible?.realTimeVisitors || 0,
+ },
+ current: {},
+ };
+
+ for (const [locale, localeStats] of Object.entries(statsAll.locales)) {
+ stats.overall.cards += localeStats.users;
+ if (locale === global.config.locale) {
+ stats.current = {
+ cards: localeStats.users,
+ };
+ }
+ if (localeStats.plausible) {
+ stats.overall.pageViews += localeStats.plausible.pageviews;
+ stats.overall.visitors += localeStats.plausible.visitors;
+ stats.overall.online += localeStats.plausible.realTimeVisitors;
+ if (locale === global.config.locale) {
+ stats.current.pageViews = localeStats.plausible.pageviews;
+ stats.current.visitors = localeStats.plausible.visitors;
+ stats.current.online = localeStats.plausible.realTimeVisitors;
+ stats.current.visitDuration = localeStats.plausible.visit_duration;
+ }
+ }
+ if (localeStats.heartbeat && locale === global.config.locale) {
+ stats.current.uptime = localeStats.heartbeat.uptime;
+ stats.current.responseTime = localeStats.heartbeat.avgResponseTime;
+ }
+ }
+
+ return stats;
+});
diff --git a/server/api/admin/stats.get.ts b/server/api/admin/stats.get.ts
new file mode 100644
index 000000000..a498812d1
--- /dev/null
+++ b/server/api/admin/stats.get.ts
@@ -0,0 +1,28 @@
+import { fetchStats } from '~/server/admin.ts';
+
+export default defineEventHandler(async (event) => {
+ const { isGranted } = await useAuthentication(event);
+ if (!isGranted('panel')) {
+ throw createError({
+ status: 401,
+ statusMessage: 'Unauthorised',
+ });
+ }
+
+ const db = useDatabase();
+ const stats = await fetchStats(db);
+ if (stats === null) {
+ throw createError({
+ status: 404,
+ statusMessage: 'Not Found',
+ });
+ }
+
+ for (const locale of Object.keys(stats.locales)) {
+ if (!isGranted('panel', locale)) {
+ delete stats.locales[locale];
+ }
+ }
+
+ return stats;
+});
diff --git a/server/blog.ts b/server/blog.ts
index 42c2e7cb0..d2e49c237 100644
--- a/server/blog.ts
+++ b/server/blog.ts
@@ -3,68 +3,10 @@ import fs from 'node:fs/promises';
import { defineCachedFunction } from 'nitropack/runtime';
import SQL from 'sql-template-strings';
-import type { Config } from '~/locale/config.ts';
import type { Database } from '~/server/db.ts';
import type { UserRow } from '~/server/express/user.ts';
import { rootDir } from '~/server/paths.ts';
-
-export interface PostMetadata {
- title: string;
- date: string;
- authors: string[];
- hero: { src: string; alt: string; class: string } | undefined;
-}
-
-export interface Post extends PostMetadata {
- slug: string;
-}
-
-export const extractMetadata = (config: Config, content: string): PostMetadata | undefined => {
- const lines = content.split('\n').filter((l) => !!l);
-
- const title = lines[0].match(/^# (.*)$/)?.[1];
-
- const secondLineMatch = lines[1].match(/^(\d\d\d\d-\d\d-\d\d) \| ([^|]*).*<\/small>$/);
- const date = secondLineMatch?.[1];
- const authors = secondLineMatch?.[2].split(',').map((a) => {
- a = a.trim();
- const teamName = config.contact.team?.route;
- if (teamName && a.startsWith(teamName)) {
- return teamName;
- }
- const m = a.match(/^\[([^\]]+)]/);
- if (m) {
- return m[1];
- }
- return a;
- }) ?? [];
-
- let hero = undefined;
- const classHeroImages = lines
- .map((x) => x.match(/
!!x);
- if (classHeroImages.length) {
- hero = {
- src: classHeroImages[0][1],
- alt: classHeroImages[0][3],
- class: classHeroImages[0][2].replace('d-none', ''),
- };
- } else {
- const images = lines.map((x) => x.match(/^!\[([^\]]*)]\(([^)]+)\)$/)).filter((x) => !!x);
- if (images.length) {
- hero = {
- src: images[0][2],
- alt: images[0][1],
- class: '',
- };
- }
- }
-
- if (title === undefined || date === undefined) {
- return undefined;
- }
- return { title, date, authors, hero };
-};
+import { extractMetadata, type Post } from '~/src/blog/metadata.ts';
export const getPosts = defineCachedFunction(async (): Promise => {
const dir = `${rootDir}/data/blog`;
diff --git a/server/express/admin.ts b/server/express/admin.ts
index c67a4bf40..9ba0752b4 100644
--- a/server/express/admin.ts
+++ b/server/express/admin.ts
@@ -1,28 +1,21 @@
-import fs from 'fs';
-
import { Router } from 'express';
-import type { Request } from 'express';
-import markdownit from 'markdown-it';
import SQL from 'sql-template-strings';
-import Suml from 'suml';
import { encodeTime, decodeTime, ulid } from 'ulid';
-import type { Config } from '../../locale/config.ts';
import allLocales from '../../locale/locales.ts';
-import type { LocaleDescription } from '../../locale/locales.ts';
-import buildLocaleList from '../../src/buildLocaleList.ts';
import { buildDict, now, shuffle, handleErrorAsync, filterObjectKeys } from '../../src/helpers.ts';
import { auditLog, fetchAuditLog } from '../audit.ts';
import avatar from '../avatar.ts';
import { archiveBan, liftBan } from '../ban.ts';
import type { Database } from '../db.ts';
import mailer from '../mailer.ts';
-import { rootDir } from '../paths.ts';
import { profilesSnapshot } from './profile.ts';
import { loadCurrentUser } from './user.ts';
import type { UserRow } from './user.ts';
+import type { StatRow } from '~/server/admin.ts';
+
interface BanProposalRow {
id: string;
userId: string;
@@ -31,13 +24,6 @@ interface BanProposalRow {
bannedReason: string;
}
-interface StatRow {
- id: string;
- locale: string;
- users: number;
- data: string;
-}
-
interface UserMessageRow {
id: string;
userId: string;
@@ -171,112 +157,6 @@ router.get('/admin/users', handleErrorAsync(async (req, res) => {
});
}));
-const fetchStats = async (req: Request): Promise => {
- const maxId = (await req.db.get<{ maxId: StatRow['id'] | null }>('SELECT MAX(id) AS maxId FROM stats'))!.maxId;
-
- if (maxId == null) {
- return {
- _: {},
- };
- }
-
- const stats: any = {
- calculatedAt: decodeTime(maxId) / 1000,
- };
-
- for (const statsRow of await req.db.all>(SQL`SELECT locale, users, data FROM stats WHERE id = ${maxId}`)) {
- stats[statsRow.locale] = {
- users: statsRow.users,
- ...JSON.parse(statsRow.data),
- };
- }
-
- return stats;
-};
-
-router.get('/admin/stats', handleErrorAsync(async (req, res) => {
- if (!req.isGranted('panel')) {
- return res.status(401).json({ error: 'Unauthorised' });
- }
-
- const stats = await fetchStats(req);
-
- for (const locale of Object.keys(stats)) {
- if (locale === '_' || locale === 'calculatedAt') {
- continue;
- }
-
- if (!req.isGranted('panel', locale)) {
- delete stats[locale];
- }
- }
-
- return res.json(stats);
-}));
-
-interface Stats {
- calculatedAt: number;
- overall: LocaleStats;
- current: Partial;
-}
-
-interface LocaleStats {
- users: number;
- cards: number;
- pageViews?: number;
- visitors?: number;
- online?: number;
- visitDuration?: number;
- uptime?: number;
- responseTime?: number;
-}
-
-router.get('/admin/stats-public', handleErrorAsync(async (req, res) => {
- const statsAll = await fetchStats(req);
-
- const stats: Stats = {
- calculatedAt: statsAll.calculatedAt,
- overall: {
- users: statsAll._.users,
- cards: 0,
- pageViews: statsAll._.plausible?.pageviews || 0,
- visitors: statsAll._.plausible?.visitors || 0,
- online: statsAll._.plausible?.realTimeVisitors || 0,
- },
- current: {},
- };
-
- for (const [locale, localeStats] of Object.entries(statsAll) as any) {
- if (locale === '_' || locale === 'calculatedAt') {
- continue;
- }
-
- stats.overall.cards += localeStats.users;
- if (locale === global.config.locale) {
- stats.current = {
- cards: localeStats.users,
- };
- }
- if (localeStats.plausible) {
- stats.overall.pageViews += localeStats.plausible.pageviews;
- stats.overall.visitors += localeStats.plausible.visitors;
- stats.overall.online += localeStats.plausible.realTimeVisitors;
- if (locale === global.config.locale) {
- stats.current.pageViews = localeStats.plausible.pageviews;
- stats.current.visitors = localeStats.plausible.visitors;
- stats.current.online = localeStats.plausible.realTimeVisitors;
- stats.current.visitDuration = localeStats.plausible.visit_duration;
- }
- }
- if (localeStats.heartbeat && locale === global.config.locale) {
- stats.current.uptime = localeStats.heartbeat.uptime;
- stats.current.responseTime = localeStats.heartbeat.avgResponseTime;
- }
- }
-
- return res.json(stats);
-}));
-
router.get('/admin/stats/users-chart/:locale', handleErrorAsync(async (req, res) => {
if (!req.isGranted('users') && !req.isGranted('community')) {
return res.status(401).json({ error: 'Unauthorised' });
@@ -307,24 +187,6 @@ router.get('/admin/stats/users-chart/:locale', handleErrorAsync(async (req, res)
return res.json(incrementsChart);
}));
-type LocalDescriptionWithConfig = LocaleDescription & { config?: Config };
-
-router.get('/admin/all-locales', handleErrorAsync(async (req, res) => {
- if (!req.isGranted('panel')) {
- return res.status(401).json({ error: 'Unauthorised' });
- }
-
- const locales: Record = buildLocaleList(global.config.locale, true);
- for (const locale in locales) {
- if (!locales.hasOwnProperty(locale)) {
- continue;
- }
- locales[locale].config = new Suml().parse(fs.readFileSync(`./locale/${locale}/config.suml`).toString()) as Config;
- }
-
- return res.json(locales);
-}));
-
const normalise = (s: string): string => s.trim().toLowerCase();
const fetchUserByUsername = async (db: Database, username: string) => {
@@ -629,27 +491,6 @@ router.post('/admin/overwrite-sensitive/:username', handleErrorAsync(async (req,
return res.json(req.body.sensitive);
}));
-const md = markdownit({ html: true });
-
-router.get('/admin/moderation', handleErrorAsync(async (req, res) => {
- if (!req.isGranted('panel')) {
- return res.status(401).json({ error: 'Unauthorised' });
- }
-
- const dir = `${rootDir}/moderation`;
-
- return res.json({
- susRegexes: fs.readFileSync(`${dir}/sus.txt`).toString('utf-8')
- .split('\n')
- .filter((x) => !!x && !x.startsWith('#')),
- rulesUsers: md.render(fs.readFileSync(`${dir}/rules-users.md`).toString('utf-8')),
- rulesTerminology: md.render(fs.readFileSync(`${dir}/rules-terminology.md`).toString('utf-8')),
- rulesSources: md.render(fs.readFileSync(`${dir}/rules-sources.md`).toString('utf-8')),
- timesheets: md.render(fs.readFileSync(`${dir}/timesheets.md`).toString('utf-8')),
- expenses: md.render(fs.readFileSync(`${dir}/expenses.md`).toString('utf-8')),
- });
-}));
-
router.post('/admin/set-notification-frequency', handleErrorAsync(async (req, res) => {
if (!req.isGranted()) {
return res.status(401).json({ error: 'Unauthorised' });
diff --git a/server/index.ts b/server/index.ts
index 54e8c0fee..172bd9319 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -12,7 +12,6 @@ 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 { isGrantedForUser } from '../src/helpers.ts';
import type { User } from '../src/user.ts';
import './globals.ts';
@@ -119,9 +118,7 @@ router.use(async function (req, res, next) {
const authentication = await useAuthentication(getH3Event(req));
req.rawUser = authentication.rawUser;
req.user = authentication.user;
- req.isGranted = (area: string = '', locale = global.config.locale): boolean => {
- return !!req.user && isGrantedForUser(req.user, locale, area);
- };
+ req.isGranted = authentication.isGranted;
req.locales = buildLocaleList(global.config.locale, global.config.locale === '_');
req.db = new LazyDatabase();
req.isUserAllowedToPost = async (): Promise => {
diff --git a/server/utils/useAuthentication.ts b/server/utils/useAuthentication.ts
index 32460782b..58c119e5a 100644
--- a/server/utils/useAuthentication.ts
+++ b/server/utils/useAuthentication.ts
@@ -1,6 +1,7 @@
import type { H3Event } from 'h3';
import jwt from '~/server/jwt.ts';
+import { isGrantedForUser } from '~/src/helpers.ts';
import type { User } from '~/src/user.ts';
export default async (event: H3Event) => {
@@ -17,5 +18,8 @@ export default async (event: H3Event) => {
}
const user = rawUser?.authenticated ? rawUser : null;
- return { rawUser, user };
+ const isGranted = (area: string = '', locale = global.config.locale): boolean => {
+ return !!user && isGrantedForUser(user, locale, area);
+ };
+ return { rawUser, user, isGranted };
};
diff --git a/src/blog/metadata.ts b/src/blog/metadata.ts
new file mode 100644
index 000000000..25d27be66
--- /dev/null
+++ b/src/blog/metadata.ts
@@ -0,0 +1,59 @@
+import type { Config } from '~/locale/config.ts';
+
+export interface PostMetadata {
+ title: string;
+ date: string;
+ authors: string[];
+ hero: { src: string; alt: string; class: string } | undefined;
+}
+
+export interface Post extends PostMetadata {
+ slug: string;
+}
+
+export const extractMetadata = (config: Config, content: string): PostMetadata | undefined => {
+ const lines = content.split('\n').filter((l) => !!l);
+
+ const title = lines[0]?.match(/^# (.*)$/)?.[1];
+
+ const secondLineMatch = lines[1]?.match(/^(\d\d\d\d-\d\d-\d\d) \| ([^|]*).*<\/small>$/);
+ const date = secondLineMatch?.[1];
+ const authors = secondLineMatch?.[2].split(',').map((a) => {
+ a = a.trim();
+ const teamName = config.contact.team?.route;
+ if (teamName && a.startsWith(teamName)) {
+ return teamName;
+ }
+ const m = a.match(/^\[([^\]]+)]/);
+ if (m) {
+ return m[1];
+ }
+ return a;
+ }) ?? [];
+
+ let hero = undefined;
+ const classHeroImages = lines
+ .map((x) => x.match(/
!!x);
+ if (classHeroImages.length) {
+ hero = {
+ src: classHeroImages[0][1],
+ alt: classHeroImages[0][3],
+ class: classHeroImages[0][2].replace('d-none', ''),
+ };
+ } else {
+ const images = lines.map((x) => x.match(/^!\[([^\]]*)]\(([^)]+)\)$/)).filter((x) => !!x);
+ if (images.length) {
+ hero = {
+ src: images[0][2],
+ alt: images[0][1],
+ class: '',
+ };
+ }
+ }
+
+ if (title === undefined || date === undefined) {
+ return undefined;
+ }
+ return { title, date, authors, hero };
+};
diff --git a/src/stats.ts b/src/stats.ts
index 03bebf15b..a503ce2c8 100644
--- a/src/stats.ts
+++ b/src/stats.ts
@@ -109,13 +109,13 @@ const deduplicateAdminMail = (projectDir: string, type: string, seconds: number)
return true;
};
-interface Stats {
+export interface LocaleStats {
locale: string;
users: number;
data: OverallStatsData | LocaleStatsData;
}
-interface OverallStatsData {
+export interface OverallStatsData {
admins: number;
userReports: number;
bansPending: number;
@@ -125,7 +125,7 @@ interface OverallStatsData {
linksQueue: number;
}
-interface LocaleStatsData {
+export interface LocaleStatsData {
nouns: { approved: number; awaiting: number };
inclusive: { approved: number; awaiting: number };
terms: { approved: number; awaiting: number };
@@ -140,7 +140,7 @@ export const calculateStats = async (
db: Database,
allLocales: Record,
projectDir: string,
-): Promise => {
+): Promise => {
const id = ulid();
const heartbeat = await checkHeartbeat();
diff --git a/test/locales/blog.test.ts b/test/locales/blog.test.ts
index 8af9f5e6a..da3c264b2 100644
--- a/test/locales/blog.test.ts
+++ b/test/locales/blog.test.ts
@@ -13,7 +13,7 @@ import { loadSumlFromBase } from '../../server/loader.ts';
import parseMarkdown from '../../src/parseMarkdown.ts';
import { Translator } from '../../src/translator.ts';
-import { extractMetadata } from '~/server/blog.ts';
+import { extractMetadata } from '~/src/blog/metadata.ts';
const validator = new HtmlValidate({
extends: [