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
This commit is contained in:
Matthias G
2026-08-01 11:31:45 +02:00
parent 7582c64709
commit 58c4cb12a8
12 changed files with 191 additions and 33 deletions
+6
View File
@@ -12,12 +12,18 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
RUN pnpm build 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 FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 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/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
COPY --from=builder --chown=nextjs:nodejs /app/docs/style-guide.md ./docs/style-guide.md COPY --from=builder --chown=nextjs:nodejs /app/docs/style-guide.md ./docs/style-guide.md
+11
View File
@@ -1,11 +1,22 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin' 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 = { const config: NextConfig = {
output: 'standalone', output: 'standalone',
outputFileTracingIncludes: { outputFileTracingIncludes: {
'/api/v1/style-guide': ['./docs/style-guide.md'], '/api/v1/style-guide': ['./docs/style-guide.md'],
'/': ['./node_modules/drizzle-orm/**'], '/': ['./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, typedRoutes: true,
experimental: { experimental: {
+10
View File
@@ -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)
}
@@ -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)
}
+29 -7
View File
@@ -1,15 +1,17 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { headers } from 'next/headers'
import Link from 'next/link' import Link from 'next/link'
import { TbBook, TbDownload } from 'react-icons/tb' import { TbBook, TbDownload } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading' import { AdminHeading } from '~/components/admin/AdminHeading'
import { ApiEndpoint } from '~/components/admin/ApiEndpoint' import { ApiEndpoint } from '~/components/admin/ApiEndpoint'
import { cellClass, headCellClass } from '~/components/admin/styles' import { cellClass, headCellClass } from '~/components/admin/styles'
import { CopyButton } from '~/components/ui/CopyButton'
import { SectionLabel } from '~/components/ui/SectionLabel' import { SectionLabel } from '~/components/ui/SectionLabel'
import { Scroller } from '~/components/ui/Scroller' import { Scroller } from '~/components/ui/Scroller'
import { blockTypes } from '~/domain/blocks' import { blockTypes } from '~/domain/blocks'
import { listActivePostTypes } from '~/data/repositories/post-types' import { listActivePostTypes } from '~/data/repositories/post-types'
import { requireAdminArea } from '~/lib/auth-guards' 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' } export const metadata: Metadata = { title: 'API - Logbuch' }
@@ -35,6 +37,12 @@ export default async function AdminApiPage() {
await requireAdminArea() await requireAdminArea()
const types = await listActivePostTypes() 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 ( return (
<div className="flex flex-col gap-12 pt-12"> <div className="flex flex-col gap-12 pt-12">
@@ -70,25 +78,39 @@ export default async function AdminApiPage() {
</p> </p>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<a <a
href="/api/v1/openapi.json" href={adminApiFilePath('openapi')}
download download
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3" className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
> >
<TbDownload aria-hidden="true" className="size-4" /> <TbDownload aria-hidden="true" className="size-4" />
openapi.json openapi.json laden
</a> </a>
<a <a
href="/api/v1/style-guide" href={adminApiFilePath('style-guide')}
download
className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3" className="inline-flex items-center gap-2 border border-rule bg-surface px-3 py-2 font-mono text-micro font-semibold uppercase tracking-label text-ink no-underline hover:border-ink-3"
> >
<TbBook aria-hidden="true" className="size-4" /> <TbBook aria-hidden="true" className="size-4" />
Redaktionsleitfaden Leitfaden laden
</a> </a>
</div> </div>
<p className="m-0 text-small"> <p className="m-0 text-small">
Beide Adressen verlangen ein Token. In Postman oder Bruno trägst du es unter Authorization als Angemeldet als Admin lädst du beide Dateien direkt herunter. Über die Schnittstelle verlangen
Bearer ein. sie ein Token, in Postman oder Bruno unter Authorization als Bearer.
</p> </p>
<div className="flex flex-col divide-y divide-rule border-y border-rule">
{files.map(file => (
<div key={file.url} className="flex flex-wrap items-center justify-between gap-3 py-3">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-display text-micro font-semibold uppercase tracking-label text-ink-3">
{file.label}
</span>
<code className="block break-all font-mono text-small text-ink">{file.url}</code>
</span>
<CopyButton value={file.url} label="Adresse kopieren" doneLabel="Kopiert" />
</div>
))}
</div>
</div> </div>
</section> </section>
+2 -8
View File
@@ -1,5 +1,5 @@
import { resolveClient } from '~/lib/api-auth' import { resolveClient } from '~/lib/api-auth'
import { openApiDocument } from '~/lib/openapi' import { openApiResponse } from '~/lib/api-docs'
import { problem } from '~/lib/problem' import { problem } from '~/lib/problem'
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -10,12 +10,6 @@ export async function GET(request: Request) {
} }
const url = new URL(request.url) const url = new URL(request.url)
const document = openApiDocument(process.env.BETTER_AUTH_URL ?? url.origin)
return new Response(JSON.stringify(document, null, '\t'), { return openApiResponse(process.env.BETTER_AUTH_URL ?? url.origin)
headers: {
'content-type': 'application/json; charset=utf-8',
'content-disposition': 'attachment; filename="logbuch-openapi.json"',
},
})
} }
+5 -8
View File
@@ -1,6 +1,5 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { resolveClient } from '~/lib/api-auth' import { resolveClient } from '~/lib/api-auth'
import { readStyleGuide, styleGuideResponse } from '~/lib/api-docs'
import { problem } from '~/lib/problem' import { problem } from '~/lib/problem'
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -10,13 +9,11 @@ export async function GET(request: Request) {
return problem(401, 'unauthorized') return problem(401, 'unauthorized')
} }
try { const text = await readStyleGuide()
const text = await readFile(join(process.cwd(), 'docs', 'style-guide.md'), 'utf8')
return new Response(text, { if (text === null) {
headers: { 'content-type': 'text/markdown; charset=utf-8' },
})
} catch {
return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.') return problem(500, 'style_guide_missing', 'docs/style-guide.md fehlt.')
} }
return styleGuideResponse(text)
} }
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" fill="#14161a" />
<g
transform="translate(4 4)"
fill="none"
stroke="#f2f1ec"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 9v12m-8 -8a8 8 0 0 0 16 0m1 0h-2m-14 0h-2" />
<path d="M9 6a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 394 B

+16 -10
View File
@@ -1,5 +1,6 @@
import Link from 'next/link' import Link from 'next/link'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { TbAnchor } from 'react-icons/tb'
import { entryNumber } from '~/components/ui/Plate' import { entryNumber } from '~/components/ui/Plate'
import { overviewPath } from '~/lib/routes' import { overviewPath } from '~/lib/routes'
@@ -15,19 +16,24 @@ export function Wordmark({ number, className }: WordmarkProps) {
return ( return (
<Link <Link
href={overviewPath()} href={overviewPath()}
className={`inline-flex items-baseline gap-2 no-underline ${className ?? ''}`} className={`inline-flex items-center gap-2.5 no-underline ${className ?? ''}`}
> >
<span className="font-display text-lead font-bold uppercase leading-none tracking-tight text-ink"> <span className="flex size-7 shrink-0 items-center justify-center bg-ink text-paper">
{app('name')} <TbAnchor aria-hidden="true" className="size-4" />
</span> </span>
{number !== undefined ? ( <span className="inline-flex items-baseline gap-2">
<span <span className="font-display text-lead font-bold uppercase leading-none tracking-tight text-ink">
title={nav('state', { number: entryNumber(number) })} {app('name')}
className="font-mono text-micro leading-none text-ink-3 tabular-nums"
>
{entryNumber(number)}
</span> </span>
) : null} {number !== undefined ? (
<span
title={nav('state', { number: entryNumber(number) })}
className="font-mono text-micro leading-none text-ink-3 tabular-nums"
>
{entryNumber(number)}
</span>
) : null}
</span>
</Link> </Link>
) )
} }
+46
View File
@@ -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 (
<button type="button" onClick={copy} className={`${ghostButtonClass} ${className ?? ''}`}>
{copied ? (
<TbCheck aria-hidden="true" className="size-4 shrink-0" />
) : (
<TbCopy aria-hidden="true" className="size-4 shrink-0" />
)}
{copied ? doneLabel : label}
</button>
)
}
+4
View File
@@ -12,6 +12,10 @@ export const adminClientsPath = '/admin/clients' as Route
export const adminPostTypesPath = '/admin/post-types' as Route export const adminPostTypesPath = '/admin/post-types' as Route
export const adminNewPostTypePath = '/admin/post-types/new' 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 { export function adminPostTypePath(id: string): Route {
return `/admin/post-types/${encodeURIComponent(id)}` as Route return `/admin/post-types/${encodeURIComponent(id)}` as Route
} }
+34
View File
@@ -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<string | null> {
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<string, string> = { 'content-type': 'text/markdown; charset=utf-8' }
if (download) {
headers['content-disposition'] = 'attachment; filename="logbuch-redaktionsleitfaden.md"'
}
return new Response(text, { headers })
}