From 58c4cb12a8123f0dd7aa5125c8cde18ba0704f41 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Sat, 1 Aug 2026 11:31:45 +0200 Subject: [PATCH] Fix sharp in container, add admin downloads and anchor mark - ship libvips with the runtime image so POST /api/v1/media stops failing - admin downloads openapi.json and the style guide via session, no token - copy rows for both API addresses - anchor mark in the wordmark and a favicon --- Dockerfile | 6 +++ next.config.ts | 11 +++++ src/app/admin/api/files/openapi/route.ts | 10 +++++ src/app/admin/api/files/style-guide/route.ts | 14 ++++++ src/app/admin/api/page.tsx | 36 ++++++++++++--- src/app/api/v1/openapi.json/route.ts | 10 +---- src/app/api/v1/style-guide/route.ts | 13 +++--- src/app/icon.svg | 14 ++++++ src/components/layout/Wordmark.tsx | 26 ++++++----- src/components/ui/CopyButton.tsx | 46 ++++++++++++++++++++ src/lib/admin-routes.ts | 4 ++ src/lib/api-docs.ts | 34 +++++++++++++++ 12 files changed, 191 insertions(+), 33 deletions(-) create mode 100644 src/app/admin/api/files/openapi/route.ts create mode 100644 src/app/admin/api/files/style-guide/route.ts create mode 100644 src/app/icon.svg create mode 100644 src/components/ui/CopyButton.tsx create mode 100644 src/lib/api-docs.ts diff --git a/Dockerfile b/Dockerfile index 317384f..a5f98e8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,12 +12,18 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm build +FROM base AS sharp +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +RUN mkdir -p /out && cp -RL node_modules/.pnpm/@img+* /out/ + FROM base AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=sharp --chown=nextjs:nodejs /out ./node_modules/.pnpm/ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle COPY --from=builder --chown=nextjs:nodejs /app/docs/style-guide.md ./docs/style-guide.md diff --git a/next.config.ts b/next.config.ts index 5954a8c..029f584 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,11 +1,22 @@ import type { NextConfig } from 'next' import createNextIntlPlugin from 'next-intl/plugin' +const sharpNative = [ + './node_modules/.pnpm/@img+sharp-libvips-*/**', + './node_modules/.pnpm/@img+sharp-linux*/**', + './node_modules/@img/**', +] + const config: NextConfig = { output: 'standalone', outputFileTracingIncludes: { '/api/v1/style-guide': ['./docs/style-guide.md'], '/': ['./node_modules/drizzle-orm/**'], + '/api/v1/media': sharpNative, + '/admin/media': sharpNative, + '/admin/media/[slug]': sharpNative, + '/admin/entries/[id]': sharpNative, + '/admin/entries/new': sharpNative, }, typedRoutes: true, experimental: { diff --git a/src/app/admin/api/files/openapi/route.ts b/src/app/admin/api/files/openapi/route.ts new file mode 100644 index 0000000..d62173d --- /dev/null +++ b/src/app/admin/api/files/openapi/route.ts @@ -0,0 +1,10 @@ +import { openApiResponse } from '~/lib/api-docs' +import { requireAdminArea } from '~/lib/auth-guards' + +export async function GET(request: Request) { + await requireAdminArea() + + const url = new URL(request.url) + + return openApiResponse(process.env.BETTER_AUTH_URL ?? url.origin) +} diff --git a/src/app/admin/api/files/style-guide/route.ts b/src/app/admin/api/files/style-guide/route.ts new file mode 100644 index 0000000..94fb4c7 --- /dev/null +++ b/src/app/admin/api/files/style-guide/route.ts @@ -0,0 +1,14 @@ +import { readStyleGuide, styleGuideResponse } from '~/lib/api-docs' +import { requireAdminArea } from '~/lib/auth-guards' + +export async function GET() { + await requireAdminArea() + + const text = await readStyleGuide() + + if (text === null) { + return new Response('docs/style-guide.md fehlt.', { status: 500 }) + } + + return styleGuideResponse(text, true) +} diff --git a/src/app/admin/api/page.tsx b/src/app/admin/api/page.tsx index 84268f1..4494e89 100644 --- a/src/app/admin/api/page.tsx +++ b/src/app/admin/api/page.tsx @@ -1,15 +1,17 @@ import type { Metadata } from 'next' +import { headers } from 'next/headers' import Link from 'next/link' import { TbBook, TbDownload } from 'react-icons/tb' import { AdminHeading } from '~/components/admin/AdminHeading' import { ApiEndpoint } from '~/components/admin/ApiEndpoint' import { cellClass, headCellClass } from '~/components/admin/styles' +import { CopyButton } from '~/components/ui/CopyButton' import { SectionLabel } from '~/components/ui/SectionLabel' import { Scroller } from '~/components/ui/Scroller' import { blockTypes } from '~/domain/blocks' import { listActivePostTypes } from '~/data/repositories/post-types' import { requireAdminArea } from '~/lib/auth-guards' -import { adminClientsPath } from '~/lib/admin-routes' +import { adminApiFilePath, adminClientsPath } from '~/lib/admin-routes' export const metadata: Metadata = { title: 'API - Logbuch' } @@ -35,6 +37,12 @@ export default async function AdminApiPage() { await requireAdminArea() const types = await listActivePostTypes() + const host = (await headers()).get('host') + const origin = process.env.BETTER_AUTH_URL ?? (host ? `https://${host}` : '') + const files = [ + { label: 'OpenAPI', url: `${origin}/api/v1/openapi.json` }, + { label: 'Leitfaden', url: `${origin}/api/v1/style-guide` }, + ] return (
@@ -70,25 +78,39 @@ export default async function AdminApiPage() {

- Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als - Bearer ein. + Angemeldet als Admin lädst du beide Dateien direkt herunter. Über die Schnittstelle verlangen + sie ein Token, in Postman oder Bruno unter Authorization als Bearer.

+
+ {files.map(file => ( +
+ + + {file.label} + + {file.url} + + +
+ ))} +
diff --git a/src/app/api/v1/openapi.json/route.ts b/src/app/api/v1/openapi.json/route.ts index a69e005..b889afd 100644 --- a/src/app/api/v1/openapi.json/route.ts +++ b/src/app/api/v1/openapi.json/route.ts @@ -1,5 +1,5 @@ import { resolveClient } from '~/lib/api-auth' -import { openApiDocument } from '~/lib/openapi' +import { openApiResponse } from '~/lib/api-docs' import { problem } from '~/lib/problem' export async function GET(request: Request) { @@ -10,12 +10,6 @@ export async function GET(request: Request) { } const url = new URL(request.url) - const document = openApiDocument(process.env.BETTER_AUTH_URL ?? url.origin) - return new Response(JSON.stringify(document, null, '\t'), { - headers: { - 'content-type': 'application/json; charset=utf-8', - 'content-disposition': 'attachment; filename="logbuch-openapi.json"', - }, - }) + return openApiResponse(process.env.BETTER_AUTH_URL ?? url.origin) } diff --git a/src/app/api/v1/style-guide/route.ts b/src/app/api/v1/style-guide/route.ts index 121567c..407b82c 100644 --- a/src/app/api/v1/style-guide/route.ts +++ b/src/app/api/v1/style-guide/route.ts @@ -1,6 +1,5 @@ -import { readFile } from 'node:fs/promises' -import { join } from 'node:path' import { resolveClient } from '~/lib/api-auth' +import { readStyleGuide, styleGuideResponse } from '~/lib/api-docs' import { problem } from '~/lib/problem' export async function GET(request: Request) { @@ -10,13 +9,11 @@ export async function GET(request: Request) { return problem(401, 'unauthorized') } - try { - const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8') + const text = await readStyleGuide() - return new Response(text, { - headers: { 'content-type': 'text/markdown; charset=utf-8' }, - }) - } catch { + if (text === null) { return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.') } + + return styleGuideResponse(text) } diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..e79352c --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/components/layout/Wordmark.tsx b/src/components/layout/Wordmark.tsx index af90c6b..2546ef3 100644 --- a/src/components/layout/Wordmark.tsx +++ b/src/components/layout/Wordmark.tsx @@ -1,5 +1,6 @@ import Link from 'next/link' import { useTranslations } from 'next-intl' +import { TbAnchor } from 'react-icons/tb' import { entryNumber } from '~/components/ui/Plate' import { overviewPath } from '~/lib/routes' @@ -15,19 +16,24 @@ export function Wordmark({ number, className }: WordmarkProps) { return ( - - {app('name')} + + - {number !== undefined ? ( - - {entryNumber(number)} + + + {app('name')} - ) : null} + {number !== undefined ? ( + + {entryNumber(number)} + + ) : null} + ) } diff --git a/src/components/ui/CopyButton.tsx b/src/components/ui/CopyButton.tsx new file mode 100644 index 0000000..29424ca --- /dev/null +++ b/src/components/ui/CopyButton.tsx @@ -0,0 +1,46 @@ +'use client' + +import { useEffect, useState } from 'react' +import { TbCheck, TbCopy } from 'react-icons/tb' +import { ghostButtonClass } from '~/components/admin/styles' + +export type CopyButtonProps = { + value: string + label: string + doneLabel: string + className?: string +} + +export function CopyButton({ value, label, doneLabel, className }: CopyButtonProps) { + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!copied) { + return + } + + const timer = setTimeout(() => setCopied(false), 2000) + + return () => clearTimeout(timer) + }, [copied]) + + async function copy() { + try { + await navigator.clipboard.writeText(value) + setCopied(true) + } catch { + setCopied(false) + } + } + + return ( + + ) +} diff --git a/src/lib/admin-routes.ts b/src/lib/admin-routes.ts index 89917d3..abdaa9d 100644 --- a/src/lib/admin-routes.ts +++ b/src/lib/admin-routes.ts @@ -12,6 +12,10 @@ export const adminClientsPath = '/admin/clients' as Route export const adminPostTypesPath = '/admin/post-types' as Route export const adminNewPostTypePath = '/admin/post-types/new' as Route +export function adminApiFilePath(file: 'openapi' | 'style-guide'): Route { + return `/admin/api/files/${file}` as Route +} + export function adminPostTypePath(id: string): Route { return `/admin/post-types/${encodeURIComponent(id)}` as Route } diff --git a/src/lib/api-docs.ts b/src/lib/api-docs.ts new file mode 100644 index 0000000..6b5ba01 --- /dev/null +++ b/src/lib/api-docs.ts @@ -0,0 +1,34 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { openApiDocument } from './openapi' + +export function openApiBody(origin: string): string { + return JSON.stringify(openApiDocument(origin), null, '\t') +} + +export function openApiResponse(origin: string): Response { + return new Response(openApiBody(origin), { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': 'attachment; filename="logbuch-openapi.json"', + }, + }) +} + +export async function readStyleGuide(): Promise { + try { + return await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8') + } catch { + return null + } +} + +export function styleGuideResponse(text: string, download = false): Response { + const headers: Record = { 'content-type': 'text/markdown; charset=utf-8' } + + if (download) { + headers['content-disposition'] = 'attachment; filename="logbuch-redaktionsleitfaden.md"' + } + + return new Response(text, { headers }) +}