Merge branch 'admin-blog-preview' into 'main'

admin blog preview

See merge request PronounsPage/PronounsPage!548
This commit is contained in:
Valentyne Stigloher 2025-01-02 13:27:45 +00:00
commit 9335a1bd48
21 changed files with 588 additions and 479 deletions

View File

@ -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

View File

@ -1,3 +1,42 @@
<script setup lang="ts">
interface Count {
name?: string;
count: number;
warning?: number;
danger?: number;
link?: string;
enabled?: boolean;
}
const props = withDefaults(
defineProps<{
baseUrl?: string;
icon: string;
header: string;
link?: string;
counts?: Count[];
}>(),
{
baseUrl: '',
counts: () => [],
},
);
const visibleCounts = computed((): Count[] => {
return props.counts.filter(({ enabled }) => enabled !== false);
});
const counterClass = (count: number, warning: number | undefined, danger: number | undefined): string => {
if (danger && count >= danger) {
return 'text-bg-danger';
}
if (warning && count >= warning) {
return 'text-bg-warning';
}
return 'bg-light text-dark border';
};
</script>
<template>
<div class="col-12 col-lg-3">
<component :is="link ? 'a' : 'div'" :href="baseUrl + link" class="card mb-3" style="min-height: 128px;">
@ -8,10 +47,14 @@
{{ header }}
</h4>
<ul class="list-inline h5">
<li v-for="{ name, count, warning, danger, link } in visibleCounts" class="list-inline-item">
<li
v-for="{ name, count, warning, danger, link: sublink } in visibleCounts"
:key="name"
class="list-inline-item"
>
<component
:is="link ? 'a' : 'span'"
:href="baseUrl + link"
:is="sublink ? 'a' : 'span'"
:href="baseUrl + sublink"
:class="['badge', counterClass(count, warning, danger)]"
>
{{ count }} {{ name || '' }}
@ -24,43 +67,3 @@
</component>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import type { PropType } from 'vue';
interface Count {
name?: string;
count: number;
warning?: number;
danger?: number;
link?: string;
enabled?: boolean;
}
export default defineComponent({
props: {
baseUrl: { default: '', type: String },
icon: { required: true, type: String },
header: { required: true, type: String },
link: { required: false, type: String },
counts: { default: () => [], type: Array as PropType<Count[]> },
},
computed: {
visibleCounts(): Count[] {
return this.counts.filter(({ enabled }) => enabled !== false);
},
},
methods: {
counterClass(count: number, warning: number | undefined, danger: number | undefined): string {
if (danger && count >= danger) {
return 'text-bg-danger';
}
if (warning && count >= warning) {
return 'text-bg-warning';
}
return 'bg-light text-dark border';
},
},
});
</script>

View File

@ -1,95 +0,0 @@
<script setup lang="ts">
import Columnist from 'avris-columnist';
import type { RouteLocationRaw } from 'vue-router';
import useConfig from '../composables/useConfig.ts';
import type { Post } from '~/server/blog.ts';
const props = defineProps<{
posts: string[] | Post[];
details?: boolean;
}>();
const { data: postsFull } = useAsyncData(`posts-${JSON.stringify(props.posts)}`, async () => {
if (!props.posts.length) {
return [];
}
if (typeof props.posts[0] === 'object') {
return props.posts as Post[];
}
return await $fetch('/api/blog', {
params: {
slugs: props.posts,
},
});
});
const config = useConfig();
const shortcuts: Record<string, string | undefined> = {};
if (config.blog && config.blog.shortcuts) {
for (const shortcut in config.blog.shortcuts) {
if (!config.blog.shortcuts.hasOwnProperty(shortcut)) {
continue;
}
shortcuts[config.blog.shortcuts[shortcut]] = shortcut;
}
}
const generateLink = (slug: string): RouteLocationRaw => {
const keepFullPath = config.blog?.keepFullPath || [];
return shortcuts[slug] !== undefined && !keepFullPath.includes(slug)
? `/${shortcuts[slug]}`
: { name: 'blogEntry', params: { slug } };
};
const entries = useTemplateRef<HTMLDivElement>('entries');
onMounted(async () => {
if (entries.value) {
const columnist = new Columnist(entries.value);
columnist.start();
}
});
</script>
<template>
<div ref="entries" class="columnist-wall row">
<div v-for="post in postsFull" class="columnist-column col-12 col-sm-6 col-md-4 mb-3">
<div class="card shadow">
<nuxt-link v-if="post.hero" :to="generateLink(post.slug)">
<img :src="post.hero.src" :class="['w-100', post.hero.class]" :alt="post.hero.alt" loading="lazy">
</nuxt-link>
<nuxt-link :to="generateLink(post.slug)" class="card-body text-center h4 p-3 mb-0 post-title">
<Spelling :text="post.title" />
</nuxt-link>
<div v-if="details" class="card-footer small">
<ul class="list-inline mb-0">
<li class="list-inline-item small">
<Icon v="calendar" />
{{ post.date }}
</li>
<li v-for="author in post.authors" class="list-inline-item">
<nuxt-link v-if="author.startsWith('@')" :to="`/${author}`" class="badge bg-light text-dark border">
<Icon v="collective-logo.svg" class="invertible" />
{{ author }}
</nuxt-link>
<span v-else class="badge bg-light text-dark border">
{{ author }}
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.columnist-wall > .columnist-column {
transition: margin-top .2s ease-in-out;
}
.post-title {
text-wrap: balance;
}
</style>

View File

@ -56,7 +56,7 @@ const formatDuration = (secondsCount: number): string => {
|
<strong><T>footer.stats.current</T></strong>
</p>
<ul :key="`${overall}`" class="list-unstyled">
<ul v-if="activeStats" :key="`${overall}`" class="list-unstyled">
<li v-if="activeStats.cards" class="mb-2">
<Icon v="id-card" />
<T>footer.stats.keys.cards</T><T>quotation.colon</T>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import Columnist from 'avris-columnist';
import type { Post } from '~/src/blog/metadata.ts';
const props = defineProps<{
posts: string[] | Post[];
details?: boolean;
}>();
const { data: postsFull } = useAsyncData(`posts-${JSON.stringify(props.posts)}`, async () => {
if (!props.posts.length) {
return [];
}
if (typeof props.posts[0] === 'object') {
return props.posts as Post[];
}
return await $fetch('/api/blog', {
params: {
slugs: props.posts,
},
});
});
const entries = useTemplateRef<HTMLDivElement>('entries');
onMounted(async () => {
if (entries.value) {
const columnist = new Columnist(entries.value);
columnist.start();
}
});
</script>
<template>
<div ref="entries" class="columnist-wall row">
<div v-for="post in postsFull" :key="post.slug" class="columnist-column col-12 col-sm-6 col-md-4 mb-3">
<BlogEntry :post :details />
</div>
</div>
</template>
<style lang="scss" scoped>
.columnist-wall > .columnist-column {
transition: margin-top .2s ease-in-out;
}
.post-title {
text-wrap: balance;
}
</style>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router';
import useConfig from '~/composables/useConfig.ts';
import type { Post } from '~/src/blog/metadata.ts';
const props = defineProps<{
post: Post;
details?: boolean;
}>();
const config = useConfig();
const link = computed((): RouteLocationRaw => {
const shortcuts = config.blog?.shortcuts ?? {};
const keepFullPath = config.blog?.keepFullPath ?? [];
const shortcut = Object.entries(shortcuts)
.find(([_, slug]) => slug === props.post.slug);
return shortcut !== undefined && !keepFullPath.includes(props.post.slug)
? `/${shortcut[0]}`
: { name: 'blogEntry', params: { slug: props.post.slug } };
});
</script>
<template>
<div class="card shadow">
<nuxt-link v-if="post.hero" :to="link">
<img :src="post.hero.src" :class="['w-100', post.hero.class]" :alt="post.hero.alt" loading="lazy">
</nuxt-link>
<nuxt-link :to="link" class="card-body text-center h4 p-3 mb-0 post-title">
<Spelling :text="post.title" />
</nuxt-link>
<div v-if="details" class="card-footer small">
<ul class="list-inline mb-0">
<li class="list-inline-item small">
<Icon v="calendar" />
{{ post.date }}
</li>
<li v-for="author in post.authors" class="list-inline-item">
<nuxt-link v-if="author.startsWith('@')" :to="`/${author}`" class="badge bg-light text-dark border">
<Icon v="collective-logo.svg" class="invertible" />
{{ author }}
</nuxt-link>
<span v-else class="badge bg-light text-dark border">
{{ author }}
</span>
</li>
</ul>
</div>
</div>
</template>

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { useDebounce, useLocalStorage } from '@vueuse/core';
import marked from 'marked';
import { extractMetadata, type Post } from '~/src/blog/metadata.ts';
import parseMarkdown, { type MarkdownInfo } from '~/src/parseMarkdown.ts';
const content = useLocalStorage(
'admin/blog/editor',
'# title\n<small>YYYY-MM-DD | author</small>\n\n![alt text for image](/img-local/blog/slug.png)\n\ncontent',
{ initOnMounted: true },
);
const debouncedContent = useDebounce(content, 500, { maxWait: 5_000 });
const post = ref<Post | undefined>();
const parsed = ref<MarkdownInfo | undefined>();
const { $translator: translator } = useNuxtApp();
const config = useConfig();
watch(debouncedContent, async () => {
const metadata = extractMetadata(config, debouncedContent.value);
post.value = metadata ? { slug: 'slug', ...metadata } : undefined;
const markdown = marked(debouncedContent.value);
parsed.value = await parseMarkdown(markdown, translator);
});
const moderationAsyncData = await useFetch('/api/admin/moderation', { pick: ['blog'] });
</script>
<template>
<Page>
<NotFound v-if="!$isGranted('panel')" />
<template v-else>
<p>
<nuxt-link to="/admin">
<Icon v="user-cog" />
<T>admin.header</T>
</nuxt-link>
</p>
<h2>
<Icon v="pen-nib" />
Blog editor
</h2>
<div v-html="moderationAsyncData.data.value?.blog"></div>
<textarea v-model="content" class="form-control" rows="10"></textarea>
<template v-if="post">
<hr>
<BlogEntry class="col-12 col-sm-6 col-md-4" :post details />
</template>
<hr>
<div class="blog-post">
<Spelling v-if="parsed?.content" :text="parsed.content" />
</div>
</template>
</Page>
</template>

View File

@ -1,3 +1,57 @@
<script setup lang="ts">
import { useNuxtApp } from 'nuxt/app';
import useSimpleHead from '~/composables/useSimpleHead.ts';
import type { LocalDescriptionWithConfig } from '~/server/admin.ts';
import { longtimeCookieSetting } from '~/src/cookieSettings.ts';
import { useMainStore } from '~/store/index.ts';
const { $translator: translator, $isGranted: isGranted } = useNuxtApp();
useSimpleHead({
title: translator.translate('admin.header'),
}, translator);
const tokenCookie = useCookie('token', longtimeCookieSetting);
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
const statsAsyncData = useFetch('/api/admin/stats');
const allLocalesAsyncData = useFetch('/api/admin/all-locales');
await Promise.all([statsAsyncData, allLocalesAsyncData]);
const stats = statsAsyncData.data;
const allLocales = allLocalesAsyncData.data;
const store = useMainStore();
const adminNotifications = ref(store.user ? store.user.adminNotifications ?? 7 : 7);
watch(adminNotifications, async () => {
const res = await $fetch('/api/admin/set-notification-frequency', {
method: 'POST',
body: { frequency: adminNotifications.value },
});
await store.setToken(res.token);
});
const filterAttention = ref<boolean>(false);
const visibleLocales = computed((): Record<string, LocalDescriptionWithConfig> | null => {
if (allLocales.value === null) {
return null;
}
return Object.fromEntries(Object.entries(allLocales.value).filter(([locale, _]) => {
return isGranted('panel', locale) && statsAsyncData.data.value?.locales[locale];
}));
});
const router = useRouter();
const impersonate = async (email: string) => {
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(email)}`);
impersonatorCookie.value = tokenCookie.value;
tokenCookie.value = token;
await router.push({ name: 'user' });
setTimeout(() => window.location.reload(), 500);
};
</script>
<template>
<Page wide>
<NotFound v-if="!$isGranted('panel')" />
@ -9,10 +63,10 @@
<T>admin.header</T>
</h2>
<p v-if="stats.calculatedAt != null" class="small">
<p v-if="stats" class="small">
<em>
Stats calculated at:
<span :class="stats.calculatedAt < (new Date() - 24 * 60 * 60 * 1000) / 1000 ? `badge bg-danger text-white` : ''">
<span :class="stats.calculatedAt < (new Date().getTime() - 24 * 60 * 60 * 1000) / 1000 ? `badge bg-danger text-white` : ''">
{{ $datetime(stats.calculatedAt) }}
</span>
</em>
@ -31,34 +85,34 @@
<div class="row mb-4">
<AdminDashboardCard
v-if="$isGranted('users') || $isGranted('community')"
v-if="stats && ($isGranted('users') || $isGranted('community'))"
v-show="!filterAttention"
icon="users"
header="Users"
link="/admin/users"
:counts="[
{ count: stats._.users },
{ name: 'admins', count: stats._.admins },
{ count: stats.overall.users },
{ name: 'admins', count: stats.overall.admins },
]"
/>
<AdminDashboardCard
v-if="$isGranted('users') || $isGranted('community')"
v-show="!filterAttention || stats._.bansPending"
v-if="stats && ($isGranted('users') || $isGranted('community'))"
v-show="!filterAttention || stats.overall.bansPending"
icon="ban"
header="Pending bans"
link="/admin/pending-bans"
:counts="[
{ count: stats._.bansPending, warning: 1, danger: 16 },
{ count: stats.overall.bansPending, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="$isGranted('users') || $isGranted('community')"
v-show="!filterAttention || stats._.userReports"
v-if="stats && ($isGranted('users') || $isGranted('community'))"
v-show="!filterAttention || stats.overall.userReports"
icon="siren-on"
header="Abuse reports"
link="/admin/abuse-reports"
:counts="[
{ count: stats._.userReports, warning: 1, danger: 16 },
{ count: stats.overall.userReports, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
@ -68,21 +122,21 @@
header="Moderation rules"
/>
<AdminDashboardCard
v-if="$isGranted('code')"
v-show="!filterAttention || stats._.cardsQueue"
v-if="stats && $isGranted('code')"
v-show="!filterAttention || stats.overall.cardsQueue"
icon="id-card"
header="Cards queue"
:counts="[
{ count: stats._.cardsQueue, warning: 16, danger: 64 },
{ count: stats.overall.cardsQueue, warning: 16, danger: 64 },
]"
/>
<AdminDashboardCard
v-if="$isGranted('code')"
v-show="!filterAttention || stats._.linksQueue"
v-if="stats && $isGranted('code')"
v-show="!filterAttention || stats.overall.linksQueue"
icon="link"
header="Links queue"
:counts="[
{ count: stats._.linksQueue, warning: 64, danger: 256 },
{ count: stats.overall.linksQueue, warning: 64, danger: 256 },
]"
/>
<AdminDashboardCard
@ -132,85 +186,95 @@
/>
</div>
<template v-for="({ name, config, url, published }, locale) in visibleLocales">
<template v-for="({ name, config, url, published }, locale) in visibleLocales" :key="locale">
<h3>
{{ name }}
<small v-if="!published" class="text-muted">(not published yet)</small>
</h3>
<div class="row mb-4">
<AdminDashboardCard
v-if="stats"
v-show="!filterAttention"
:base-url="url"
icon="users"
header="Profiles"
link="/admin/profiles"
:counts="[
{ count: stats[locale].users },
{ count: stats.locales[locale].users },
]"
/>
<AdminDashboardCard
v-if="config.nouns && config.nouns.enabled && $isGranted('nouns', locale) && (stats[locale].nouns.approved > 0 || stats[locale].nouns.awaiting > 0)"
v-show="!filterAttention || stats[locale].nouns.awaiting"
v-if="stats && config.nouns && config.nouns.enabled && $isGranted('nouns', locale) && (stats.locales[locale].nouns.approved > 0 || stats.locales[locale].nouns.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].nouns.awaiting"
:base-url="url"
icon="book"
header="Dictionary"
:link="`/${config.nouns.route}`"
:counts="[
{ count: stats[locale].nouns.approved },
{ name: 'awaiting', count: stats[locale].nouns.awaiting, warning: 1, danger: 16 },
{ count: stats.locales[locale].nouns.approved },
{ name: 'awaiting', count: stats.locales[locale].nouns.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="config.inclusive && config.inclusive.enabled && $isGranted('inclusive', locale) && (stats[locale].inclusive.approved > 0 || stats[locale].inclusive.awaiting > 0)"
v-show="!filterAttention || stats[locale].inclusive.awaiting"
v-if="stats && config.inclusive && config.inclusive.enabled && $isGranted('inclusive', locale) && (stats.locales[locale].inclusive.approved > 0 || stats.locales[locale].inclusive.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].inclusive.awaiting"
:base-url="url"
icon="book-heart"
header="Inclusive"
:link="`/${config.inclusive.route}`"
:counts="[
{ count: stats[locale].inclusive.approved },
{ name: 'awaiting', count: stats[locale].inclusive.awaiting, warning: 1, danger: 16 },
{ count: stats.locales[locale].inclusive.approved },
{ name: 'awaiting', count: stats.locales[locale].inclusive.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="config.terminology && config.terminology.enabled && $isGranted('terms', locale) && (stats[locale].terms.approved > 0 || stats[locale].terms.awaiting > 0)"
v-show="!filterAttention || stats[locale].terms.awaiting"
v-if="stats && config.terminology && config.terminology.enabled && $isGranted('terms', locale) && (stats.locales[locale].terms.approved > 0 || stats.locales[locale].terms.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].terms.awaiting"
:base-url="url"
icon="flag"
header="Terminology"
:link="`/${config.terminology.route}`"
:counts="[
{ count: stats[locale].terms.approved },
{ name: 'awaiting', count: stats[locale].terms.awaiting, warning: 1, danger: 16 },
{ count: stats.locales[locale].terms.approved },
{ name: 'awaiting', count: stats.locales[locale].terms.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="config.sources && config.sources.enabled && $isGranted('sources', locale) && (stats[locale].sources.approved > 0 || stats[locale].sources.awaiting > 0)"
v-show="!filterAttention || stats[locale].sources.awaiting"
v-if="stats && config.sources && config.sources.enabled && $isGranted('sources', locale) && (stats.locales[locale].sources.approved > 0 || stats.locales[locale].sources.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].sources.awaiting"
:base-url="url"
icon="books"
header="Sources"
:link="`/${config.sources.route}`"
:counts="[
{ count: stats[locale].sources.approved },
{ name: 'awaiting', count: stats[locale].sources.awaiting, warning: 1, danger: 16 },
{ count: stats.locales[locale].sources.approved },
{ name: 'awaiting', count: stats.locales[locale].sources.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="config.names && config.names.enabled && $isGranted('names', locale) && (stats[locale].names.approved > 0 || stats[locale].names.awaiting > 0)"
v-show="!filterAttention || stats[locale].names.awaiting"
v-if="stats && config.names && config.names.enabled && $isGranted('names', locale) && (stats.locales[locale].names.approved > 0 || stats.locales[locale].names.awaiting > 0)"
v-show="!filterAttention || stats.locales[locale].names.awaiting"
:base-url="url"
icon="signature"
header="Names"
:link="`/${config.names.route}`"
:counts="[
{ count: stats[locale].names.approved },
{ name: 'awaiting', count: stats[locale].names.awaiting, warning: 1, danger: 16 },
{ count: stats.locales[locale].names.approved },
{ name: 'awaiting', count: stats.locales[locale].names.awaiting, warning: 1, danger: 16 },
]"
/>
<AdminDashboardCard
v-if="$isGranted('translations', locale) && (stats[locale].translations.missing > 0 || stats[locale].translations.awaitingApproval > 0) || $isGranted('code', locale) && stats[locale].translations.awaitingMerge > 0"
v-show="!filterAttention || stats[locale].translations.missing || stats[locale].translations.awaitingApproval || stats[locale].translations.awaitingMerge"
v-if="config.links.enabled && config.links.blog"
v-show="!filterAttention"
:base-url="url"
icon="pen-nib"
header="Blog"
>
<nuxt-link :to="{ name: 'admin-blog-editor' }" class="btn btn-sm btn-secondary text-white">Editor</nuxt-link>
</AdminDashboardCard>
<AdminDashboardCard
v-if="stats && $isGranted('translations', locale) && (stats.locales[locale].translations.missing > 0 || stats.locales[locale].translations.awaitingApproval > 0) || stats && $isGranted('code', locale) && stats.locales[locale].translations.awaitingMerge > 0"
v-show="!filterAttention || stats.locales[locale].translations.missing || stats.locales[locale].translations.awaitingApproval || stats?.locales[locale].translations.awaitingMerge"
:base-url="url"
icon="language"
header="Translations"
@ -218,7 +282,7 @@
{
name: 'missing',
link: '/admin/translations/missing',
count: stats[locale].translations.missing,
count: stats.locales[locale].translations.missing,
warning: 1,
danger: 16,
enabled: $isGranted('translations', locale),
@ -226,7 +290,7 @@
{
name: 'proposed',
link: '/admin/translations/awaiting',
count: stats[locale].translations.awaitingApproval,
count: stats.locales[locale].translations.awaitingApproval,
warning: 1,
danger: 16,
enabled: $isGranted('translations', locale),
@ -234,7 +298,7 @@
{
name: 'not merged',
link: '/admin/translations/awaiting',
count: stats[locale].translations.awaitingMerge,
count: stats.locales[locale].translations.awaitingMerge,
warning: 1,
danger: 16,
enabled: $isGranted('code', locale),
@ -246,68 +310,3 @@
</div>
</Page>
</template>
<script>
import { useNuxtApp } from 'nuxt/app';
import useConfig from '~/composables/useConfig.ts';
import useSimpleHead from '~/composables/useSimpleHead.ts';
import { longtimeCookieSetting } from '~/src/cookieSettings.ts';
import { useMainStore } from '~/store/index.ts';
export default {
async setup() {
const { $translator: translator } = useNuxtApp();
useSimpleHead({
title: translator.translate('admin.header'),
}, translator);
const tokenCookie = useCookie('token', longtimeCookieSetting);
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
const stats = useFetch('/api/admin/stats');
const allLocales = useFetch('/api/admin/all-locales');
await Promise.all([stats, allLocales]);
return {
config: useConfig(),
store: useMainStore(),
tokenCookie,
impersonatorCookie,
stats: stats.data.value,
allLocales: allLocales.data.value,
};
},
data() {
return {
adminNotifications: this.$user() ? this.$user().adminNotifications ?? 7 : 7,
filterAttention: false,
};
},
computed: {
visibleLocales() {
return Object.fromEntries(Object.entries(this.allLocales).filter(([locale, _]) => {
return this.$isGranted('panel', locale) && this.stats[locale];
}));
},
},
watch: {
async adminNotifications() {
const res = await $fetch('/api/admin/set-notification-frequency', {
method: 'POST',
body: { frequency: parseInt(this.adminNotifications) },
});
await this.store.setToken(res.token);
},
},
methods: {
async impersonate(email) {
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(email)}`);
this.impersonatorCookie = this.tokenCookie;
this.tokenCookie = token;
await this.$router.push({ name: 'user' });
setTimeout(() => window.location.reload(), 500);
},
},
};
</script>

51
server/admin.ts Normal file
View File

@ -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<LocaleDescription, 'matches'> & { config: Config };
export interface StatRow {
id: string;
locale: string;
users: number;
data: string;
}
export interface Stats {
calculatedAt: number;
overall: { users: number } & OverallStatsData;
locales: Record<string, { users: number } & LocaleStatsData>;
}
export const fetchStats = async (db: Database): Promise<Stats | null> => {
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<string, { users: number } & LocaleStatsData> = {};
for (const statsRow of await db.all<Pick<StatRow, 'locale' | 'users' | 'data'>>(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,
};
};

View File

@ -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,
}];
}));
});

View File

@ -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')),
};
});

View File

@ -0,0 +1,67 @@
import { fetchStats } from '~/server/admin.ts';
interface PublicStats {
calculatedAt: number;
overall: PublicLocaleStats;
current: Partial<PublicLocaleStats>;
}
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;
});

View File

@ -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;
});

View File

@ -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(/^<small>(\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(/<img src="([^"]+)" class="hero([^"]*)" alt="([^"]*)"/))
.filter((x) => !!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<Post[]> => {
const dir = `${rootDir}/data/blog`;

View File

@ -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<any> => {
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<Pick<StatRow, 'locale' | 'users' | 'data'>>(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<LocaleStats>;
}
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<string, LocalDescriptionWithConfig> = 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' });

View File

@ -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<boolean> => {

View File

@ -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 };
};

59
src/blog/metadata.ts Normal file
View File

@ -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(/^<small>(\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(/<img src="([^"]+)" class="hero([^"]*)" alt="([^"]*)"/))
.filter((x) => !!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 };
};

View File

@ -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<string, LocaleDescription>,
projectDir: string,
): Promise<Stats[]> => {
): Promise<LocaleStats[]> => {
const id = ulid();
const heartbeat = await checkHeartbeat();

View File

@ -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: [