site: full Astro source + blog posts, Gitea Actions deploy to CT148

Replaces the placeholder repo with the asger.weirsoe.dk site source.
Adds 6 blog posts (Proxmox, Authentik/OIDC, NPMplus migration, JS injection,
ah-fail) and .gitea/workflows/deploy.yml (bun build + rsync over SSH).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Asger Geel Weirsøe
2026-06-11 19:13:47 +02:00
co-authored by Claude Opus 4.8
commit 80c820bdff
40 changed files with 1716 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
name: Build & Deploy
on:
push:
branches: [master, main]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Build site
run: bun run build
- name: Deploy dist/ to CT148 over SSH
env:
DEPLOY_HOST: 192.168.10.148
DEPLOY_USER: root
DEPLOY_PATH: /var/www/asger
run: |
set -euo pipefail
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -t ed25519 "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
rsync -az --delete \
-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=accept-new" \
dist/ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/"
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.astro/
.DS_Store
bun.lock*
+13
View File
@@ -0,0 +1,13 @@
# asger.weirsoe.dk
Personlig side (CV + blog) bygget med Astro + Bun + Tailwind. Statisk output, hostet på Proxmox (CT148, nginx), deployet via Gitea Actions.
## Udvikling
```
bun install
bun run dev # http://localhost:4321
bun run build # -> dist/
```
## Nyt blogindlæg
Læg en `.md`-fil i `src/content/blog/` med frontmatter (title, date, tags, description, draft). Push til main → bygges og deployes automatisk.
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
site: 'https://asger.xn--weirse-fya.dk',
trailingSlash: 'ignore',
integrations: [
sitemap({
changefreq: 'weekly',
priority: 0.7,
serialize(item) {
if (item.url === 'https://asger.xn--weirse-fya.dk/') item.priority = 1.0;
return item;
},
}),
],
prefetch: { prefetchAll: true, defaultStrategy: 'viewport' },
vite: { plugins: [tailwindcss()] },
});
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<radialGradient id="glow" cx="78%" cy="12%" r="60%">
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.22"/>
<stop offset="55%" stop-color="#818cf8" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0b0f17" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1200" height="630" fill="#0b0f17"/>
<rect width="1200" height="630" fill="url(#glow)"/>
<rect x="90" y="196" width="8" height="232" rx="4" fill="#38bdf8"/>
<g font-family="DejaVu Sans, sans-serif" fill="#e6e9ef">
<text x="130" y="270" font-size="92" font-weight="bold" letter-spacing="-2">Asger Weirsøe</text>
<text x="132" y="338" font-size="40" fill="#38bdf8">Technical Solution Architect · Datalog</text>
<text x="132" y="392" font-size="30" fill="#97a3b6">Proxmox · AI · Selvhosting · Softwarearkitektur</text>
</g>
<text x="130" y="556" font-family="DejaVu Sans, sans-serif" font-size="30" font-weight="bold" fill="#818cf8">asger.weirsøe.dk</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+18
View File
@@ -0,0 +1,18 @@
{
"name": "asger-weirsoe-dk",
"type": "module",
"private": true,
"scripts": {
"dev": "astro dev --host",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^5.7.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.3.0",
"@tailwindcss/vite": "^4.1.5",
"tailwindcss": "^4.1.5",
"@fontsource-variable/inter": "^5.2.5"
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

@@ -0,0 +1 @@
b68b31655cb9c1a662d53905dd0b1b06
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

+33
View File
@@ -0,0 +1,33 @@
# Asger Weirsøe
> Datalog (cand.scient.) fra Aalborg Universitet og Technical Solution Architect hos Digisense A/S. Selvhosting, Proxmox, AI og fuld-stack udvikling.
## Om
- Fulde navn: Asger Geel Weirsøe
- Nuværende rolle: Technical Solution Architect hos Digisense A/S (fra maj 2026)
- Tidligere: Teknisk Lead / Senior Konsulent hos CGI (2019–2026), Fullstack-udvikler hos BilligVVS
- Uddannelse: cand.scient. i Datalogi, Aalborg Universitet; læser HD 1. del i Erhvervsøkonomi (2025–2027)
- Ekspertise: Proxmox, selvhosting/homelab, Linux, Docker, Kubernetes, fuld-stack (Java, Go, Python, TypeScript, Angular), AI/LLM-workflows, OIDC/SSO (Authentik)
- Bopæl: Nørresundby, Danmark
## Fagområder / ekspertise
Asger arbejder med **elektronisk fakturering (e-faktura / electronic invoicing)** og e-procurement:
- **Peppol**: Peppol BIS, Peppol Access Point, Peppol eDelivery (AS4) — bl.a. den open source-baserede access point **Oxalis**
- **NemHandel** og den danske e-faktura-infrastruktur
- **OIOUBL / UBL** og den europæiske e-faktura-standard **EN 16931**
- Hos **CGI** byggede han en open source reference-implementering af Peppol eDelivery i Java; i dag er han **Technical Solution Architect hos Digisense A/S** med fokus på løsningsarkitektur inden for elektronisk fakturering og Peppol.
Nøgleord: nemhandel, e-faktura, efaktura, elektronisk fakturering, electronic invoicing, Peppol, Peppol Access Point, Oxalis, OIOUBL, UBL, e-Delivery, AS4, EN 16931, e-procurement, EDI, Digisense.
## Publikation
- CGAAL: Distributed On-The-Fly ATL Model Checker with Heuristics — EPTCS vol. 390 (2023), s. 99–114. https://arxiv.org/abs/2310.00999
## Sider
- [Forside](https://asger.weirsøe.dk/): introduktion og seneste blogindlæg
- [CV](https://asger.weirsøe.dk/cv): erfaring, uddannelse, publikationer og kompetencer
- [Projekter](https://asger.weirsøe.dk/projekter): offentlige open source-projekter
- [Blog](https://asger.weirsøe.dk/blog): artikler om Proxmox, selvhosting, AI og udvikling
## Profiler
- LinkedIn: https://www.linkedin.com/in/ag-weirs%C3%B8e/
- Gitea: https://gitea.weircon.dk/agw
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+43
View File
@@ -0,0 +1,43 @@
# Asger Weirsøe — asger.weirsoe.dk
# Alle crawlere er velkomne, inkl. AI/LLM-crawlere (vi vil gerne findes og citeres).
User-agent: *
Allow: /
# Eksplicit velkomst til AI/LLM-crawlere
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: anthropic-ai
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Perplexity-User
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: Applebot-Extended
Allow: /
User-agent: CCBot
Allow: /
User-agent: cohere-ai
Allow: /
Sitemap: https://asger.xn--weirse-fya.dk/sitemap-index.xml
Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

+15
View File
@@ -0,0 +1,15 @@
---
import { SOCIALS, SITE } from '../consts';
const year = new Date().getFullYear();
---
<footer class="mt-24 border-t border-border/70">
<div class="mx-auto flex max-w-3xl flex-col items-center gap-3 px-5 py-10 text-sm text-muted sm:flex-row sm:justify-between">
<p>© {year} {SITE.name}</p>
<div class="flex gap-4">
{SOCIALS.map((s) => (
<a class="transition-colors hover:text-accent" href={s.href} rel="me noopener" target="_blank">{s.label}</a>
))}
<a class="transition-colors hover:text-accent" href="/rss.xml">RSS</a>
</div>
</div>
</footer>
+63
View File
@@ -0,0 +1,63 @@
---
import { SITE } from '../consts';
import { baseGraph } from '../seo';
interface Props {
title?: string;
description?: string;
image?: string;
type?: 'website' | 'article';
publishedTime?: string;
modifiedTime?: string;
tags?: string[];
jsonLd?: unknown[];
}
const {
title, description, image, type = 'website',
publishedTime, modifiedTime, tags = [], jsonLd = [],
} = Astro.props;
const pageTitle = title ? `${title} · ${SITE.name}` : `${SITE.title} — Technical Solution Architect & Datalog`;
const desc = description ?? SITE.description;
const canonical = new URL(Astro.url.pathname, SITE.url).href;
const ogImage = new URL(image ?? '/og.png', SITE.url).href;
const graphs = [baseGraph, ...jsonLd];
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{pageTitle}</title>
<meta name="description" content={desc} />
<meta name="author" content="Asger Weirsøe" />
<meta name="google-site-verification" content="l5LfnJZFSkVCauhwN9pND_gvxdnZJbx3FM6fpEK8nzE" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<link rel="canonical" href={canonical} />
<!-- Open Graph -->
<meta property="og:type" content={type} />
<meta property="og:site_name" content={SITE.name} />
<meta property="og:title" content={pageTitle} />
<meta property="og:description" content={desc} />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content="da_DK" />
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={pageTitle} />
{type === 'article' && publishedTime && <meta property="article:published_time" content={publishedTime} />}
{type === 'article' && modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{type === 'article' && <meta property="article:author" content="Asger Weirsøe" />}
{type === 'article' && tags.map((t) => <meta property="article:tag" content={t} />)}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={desc} />
<meta name="twitter:image" content={ogImage} />
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="alternate" type="application/rss+xml" title={SITE.name} href="/rss.xml" />
{graphs.map((g) => <script type="application/ld+json" set:html={JSON.stringify(g)} />)}
+44
View File
@@ -0,0 +1,44 @@
---
import { NAV, SITE } from '../consts';
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
---
<header class="sticky top-0 z-50 border-b border-border/70 backdrop-blur-md bg-bg/70">
<nav class="mx-auto flex max-w-3xl items-center justify-between px-5 py-3.5">
<a href="/" class="font-bold tracking-tight text-lg">{SITE.name}<span class="text-accent">.</span></a>
<div class="flex items-center gap-1">
<ul class="flex items-center gap-1 text-sm">
{NAV.map((item) => (
<li>
<a
href={item.href}
class:list={[
'rounded-lg px-3 py-1.5 transition-colors hover:text-text',
isActive(item.href) ? 'text-text bg-surface-2' : 'text-muted',
]}
>{item.label}</a>
</li>
))}
</ul>
<button type="button" data-theme-toggle class="theme-toggle ml-1" title="Skift mellem lyst og mørkt tema" aria-label="Skift tema">
<svg class="sun-and-moon" aria-hidden="true" width="22" height="22" viewBox="0 0 24 24">
<mask class="moon" id="moon-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
</button>
</div>
</nav>
</header>
+19
View File
@@ -0,0 +1,19 @@
---
interface Props { href: string; title: string; date: Date; description?: string; tags?: string[]; }
const { href, title, date, description, tags = [] } = Astro.props;
const d = date.toLocaleDateString('da-DK', { year: 'numeric', month: 'long', day: 'numeric' });
---
<article class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
<a href={href} class="block">
<div class="text-xs text-muted">{d}</div>
<h3 class="mt-1 text-lg font-semibold tracking-tight group-hover:text-accent">{title}</h3>
{description && <p class="mt-2 text-sm text-muted">{description}</p>}
</a>
{tags.length > 0 && (
<div class="mt-3 flex flex-wrap gap-1.5">
{tags.map((t) => (
<a href={`/tags/${t}`} class="rounded-full border border-border px-2.5 py-0.5 text-xs text-muted transition-colors hover:border-accent hover:text-accent">#{t}</a>
))}
</div>
)}
</article>
+31
View File
@@ -0,0 +1,31 @@
export const SITE = {
name: 'Asger Weirsøe',
title: 'Asger Weirsøe',
description: 'Asger Weirsøe — Technical Solution Architect hos Digisense. Peppol, NemHandel, e-faktura/elektronisk fakturering, selvhosting, Proxmox og AI.',
url: 'https://asger.xn--weirse-fya.dk',
lang: 'da',
email: 'asger@weircon.dk',
location: 'Nørresundby, Danmark',
giteaUser: 'agw',
giteaBase: 'https://gitea.weircon.dk',
};
export const NAV = [
{ href: '/', label: 'Forside' },
{ href: '/cv', label: 'CV' },
{ href: '/projekter', label: 'Projekter' },
{ href: '/blog', label: 'Blog' },
];
export const SOCIALS = [
{ href: 'https://gitea.weircon.dk/agw', label: 'Gitea' },
{ href: 'https://www.linkedin.com/in/ag-weirs%C3%B8e/', label: 'LinkedIn' },
{ href: 'mailto:asger@weircon.dk', label: 'Email' },
];
// Styr hvad der vises på /projekter.
// hide: repo-navne der skjules helt. pinned: vises først (i den rækkefølge).
export const PROJECTS = {
hide: [] as string[],
pinned: [] as string[],
};
+17
View File
@@ -0,0 +1,17 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: () =>
z.object({
title: z.string(),
description: z.string().default(''),
date: z.coerce.date(),
updated: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
+172
View File
@@ -0,0 +1,172 @@
---
title: "Ah ah ah, you didn't say the magic word.. en skærmlås-prank i Rust"
description: "Hvorfor jeg droppede ANSI C til fordel for Rust, og hvordan du installerer ah-fail på Wayland, X11 og macOS."
date: 2026-06-11
tags: [rust, pam, gtklock, wayland, linux, macos]
draft: false
---
Et af mine mere fjollede sideprojekter: **ah-fail**. Taster du dit kodeord forkert
på låseskærmen, popper der en loopende animation op af **mit eget ansigt
photoshoppet ind på Dennis Nedrys krop** fra Jurassic Park, mens "ah ah ah, you
didn't say the magic word" spiller i fuld lydstyrke. Hvert forkert gæt tilføjer en
ny sprite på en tilfældig position på skærmen. Lydstyrken tvinges til 100 % på
første fejl og sættes tilbage, når du endelig låser op.
Det er en prank. Men vejen dertil lærte mig en del om, hvor følsomt
authentication-laget på et system egentlig er.
## Hvorfor jeg skrottede ANSI C
Jeg startede projektet i **ANSI C**. Det giver god mening på papiret: det her hooker
ind i **PAM**, og PAM er skrevet i C. Men der er et problem.. PAM sidder **direkte i
authentication-kritiske sti**. Min kode bliver indlæst ind i selve den proces, der
afgør, om du må komme ind på maskinen.
Og med manuel hukommelsesstyring blev det hurtigt for risikabelt. Jeg jagtede
use-after-free og små lækager i en kontekst, hvor en enkelt fejl i værste fald
**låser dig ude af dit eget system** eller svækker login. En segfault i et lille
hobby-PAM-modul er ikke bare en crash.. det er en crash i din låseskærm.
Så jeg skrev det hele om til **Rust**. Hukommelsessikkerheden er ikke et nice-to-have
her, den er hele pointen: jeg vil have compileren til at fange den slags fejl, før de
nogensinde rammer en authentication-proces. Resten af projektet blev til en **Cargo
workspace**, bygget med **Meson**, og koden, der rører PAM, blev holdt så minimal som
overhovedet muligt.
## Sådan hænger det sammen
Integrationen afhænger af din display-server, og det er her, det bliver lidt sjovt:
| Platform | Display-server | Modul | Hvordan |
|---|---|---|---|
| Linux | **Wayland** | `ahfail-module.so` | Indlæses direkte af `gtklock` via dens modul-API |
| Linux | **X11** | `libahfail_pam.so` + `ahfail-display` | PAM-hook spawner display-binæren efter hver fejlet auth |
| macOS | .. | `libahfail_pam.so` + `ahfail-display` | Samme PAM-tilgang, hooker ind i screensaver-stakken |
På **Wayland** kan jeg bruge gtklocks interne vindues-API og tegne sprites direkte
oven på låseskærmen. På **X11 og macOS** findes der ikke sådan et plugin-API, så her
gør jeg det via et **PAM-modul**: det registrerer et cleanup-callback, der fyrer ved
hver fejlet authentication, og **double-forker** en selvstændig display-binær
(`ahfail-display`), som kører helt uafhængigt af PAM-stakken. Det vigtigste:
PAM-modulet **rører aldrig dine credentials**. Det reagerer kun på, at et forsøg
fejlede.
Al den fælles logik (animation, lyd, deadzone, volumen) ligger i én delt crate,
`ahfail-ui`, så gtklock-modulet og display-binæren deler præcis samme adfærd.
## Installation: Wayland (gtklock)
På Arch henter du afhængighederne og bygger med Meson:
```bash
sudo pacman -S meson ninja rust gtk3 gstreamer gst-plugins-base \
gst-plugins-good gst-plugins-bad gtklock
meson setup builddir --prefix=/usr
meson compile -C builddir
sudo meson install -C builddir
```
`--prefix=/usr` er vigtig.. uden den defaulter Meson til `/usr/local`, og så matcher
den indbyggede sti i binæren ikke der, hvor filerne faktisk lander. Derefter starter
du bare gtklock med modulet:
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so
```
Hold dit password-felt frit for sprites med en deadzone:
```bash
gtklock -m /usr/lib/gtklock/ahfail-module.so -- --deadzone=860,440,200,200
```
## Installation: X11 (i3lock, xscreensaver, ..)
PAM-modulet virker med enhver X11-locker, der authenticator via PAM. Du tilføjer en
linje til lockerens PAM-service-fil (fx `/etc/pam.d/i3lock` eller
`/etc/pam.d/xscreensaver`).
Her er **den fælde der kostede mig mest tid**: `ahfail`-linjen skal stå **før**
`auth include system-auth`. Står den efter, så betyder PAM's interne flow-kontrol
inde i `system-auth` (`pam_faillock` med `[default=die]`), at mit modul aldrig
bliver nået ved et fejlet forsøg.. det bliver kun kaldt ved succes, hvilket er det
stik modsatte af, hvad jeg vil.
Stien til modulet afhænger af distro:
```bash
# Arch / standard (--prefix=/usr)
#%PAM-1.0
auth optional /usr/lib/ahfail/libahfail_pam.so
auth include system-auth
```
```bash
# Debian / Ubuntu (multiarch)
#%PAM-1.0
auth optional /usr/lib/x86_64-linux-gnu/ahfail/libahfail_pam.so
auth include system-auth
```
```bash
# Fedora / RHEL (multilib)
#%PAM-1.0
auth optional /usr/lib64/ahfail/libahfail_pam.so
auth include system-auth
```
Den fulde sti er nødvendig.. `$(libdir)/ahfail` ligger ikke i PAM's
standard-søgesti.
## Installation: macOS
Den nemme vej er **Homebrew**:
```bash
brew tap agw/ahfail https://gitea.weircon.dk/agw/homebrew-ahfail
brew install ahfail
```
Bagefter printer Homebrew den ene PAM-linje, du skal tilføje til
`/etc/pam.d/screensaverui` (macOS 13 og nyere) eller `/etc/pam.d/screensaver`
(macOS 12 og ældre). Den manuelle ét-linjes-rettelse er det eneste trin tilbage, og
opgradering er bare `brew upgrade ahfail`.
Vil du hellere bygge fra kilde, klarer install-scriptet dependencies, build og
PAM-konfiguration på én gang:
```bash
git clone https://gitea.weircon.dk/agw/gtk-ahfail.git
cd gtk-ahfail
bash scripts/install-macos.sh
```
Et lille Apple Silicon-gotcha jeg ramte: Homebrew bruger `/opt/homebrew` som prefix
på ARM, men `/usr/local` på Intel. Den indbyggede default-sti skal sættes ved
build-tid (`-Dlibdir`), ellers peger PAM-modulet på den forkerte placering på den
ene af de to.
## En ærlig advarsel
Det her hooker ind i PAM og/eller din lockers plugin-API. **PAM sidder i
authentication-kritiske sti**.. en fejl i modulet kan i værste fald låse dig ude
eller svække dit login. Jeg giver ingen garantier. Installerer du det, ændrer du en
sikkerhedskritisk komponent i dit system og påtager dig selv ansvaret. Det er
fjollet i sin natur, men det skal behandles seriøst.
## Hvad der ikke virker (endnu)
På **swaylock** og **hyprlock** fyrer PAM-modulet fint ved fejlede forsøg, men
animationen bliver ikke vist. Waylands session-lock-protokol
(`ext-session-lock-v1`) tillader kun lockeren selv at tegne, så compositoren
undertrykker min sprite, og kun lyden spiller. Fuld support kræver, at swaylock og
hyprlock eksponerer et plugin-API ligesom gtklock. Indtil da er det Wayland via
gtklock, eller X11.
## Koden
Hele projektet ligger offentligt på
[gitea.weircon.dk/agw/gtk-ahfail](https://gitea.weircon.dk/agw/gtk-ahfail).
Spriten er mit ansigt på Nedrys krop, men du kan nemt bytte den ud med dit eget
PNG-sekvens i `assets/` og bygge om. God fornøjelse.. og husk din deadzone.
@@ -0,0 +1,84 @@
---
title: "Authentik og OIDC: de faldgruber der koster en weekend"
description: "redirect_uri-mismatch, X-Forwarded-Proto, sub vs. email og de andre OIDC-fælder jeg er faldet i med Authentik."
date: 2026-06-01
tags: [authentik, oidc, sso, selfhost]
draft: false
---
Jeg kører **Authentik** som central identitetsudbyder i mit homelab, så jeg kan
logge ind ét sted og få **SSO** på tværs af alle mine tjenester via **OIDC**. Det
fungerer rigtig godt .. lige indtil det ikke gør. Her er de faldgruber jeg selv
er faldet i, og hvordan jeg slap ud af dem igen.
## redirect_uri stemmer ikke
Den klassiker. Du logger ind, Authentik sender dig tilbage, og applikationen råber
`redirect_uri_mismatch`. I næsten alle tilfælde er det en af tre ting:
- En **trailing slash** der er med det ene sted og ikke det andet.
- **http kontra https** .. appen bygger callback-URL'en med `http://`, mens
Authentik forventer `https://`.
- Et **ekstra query-parameter** der får regex'en for tilladte redirect-URI'er til
at fejle.
Callback-URL'en i appen skal matche en af redirect-URI'erne i Authentik helt
nøjagtigt. Jeg holder én regel: kopiér URL'en, lim den ind begge steder, og rør
den ikke med håndkraft.
## X-Forwarded-Proto er din ven
Den med http/https er næsten altid en proxy der ikke fortæller appen, at den
oprindelige forbindelse var krypteret. Hvis din reverse proxy ikke sætter
`X-Forwarded-Proto: https`, så genererer appen sine egne URL'er med `http://`, og
så er redirect-mismatchen i hus. Sørg for at headeren er sat, så appen bygger
korrekte redirect-URI'er. Det er en af de hyppigste årsager til OAuth-fejl med
Authentik bag fx Portainer.
## Bland ikke ID-token og access-token sammen
OIDC giver dig to tokens, og de har hver sin opgave:
- **ID-tokenet** beviser *hvem* brugeren er. Det skal bruges til at oprette
sessionen i din OIDC-klient.
- **Access-tokenet** giver adgang til *API'er*. Det skal sendes med i
`Authorization`-headeren til din backend.
Sender du et ID-token til et API, har det forkert `aud` (audience). Bruger du et
access-token til at identificere sessionen, mangler det en defineret
claim-struktur. En af de mest udbredte OIDC-bugs er netop at bytte rundt på de to.
## Bind sessionen til `sub`, ikke email
Det er fristende at bruge email som den unikke nøgle for en bruger. Lad være.
**Email kan ændre sig**, mens `sub` er det stabile id. Binder du brugerkontoen til
email, så bryder alt den dag nogen skifter adresse. Brug `sub`.
## Hold ID-tokenet småt og brug /userinfo
Smid ikke alle claims ind i ID-tokenet via store scopes. Hold det lille, og hent
de rige profildata fra **/userinfo**-endpointet når du har brug for dem.
ID-tokenet indeholder ikke nødvendigvis alle claims for de scopes du beder om, men
/userinfo har det fulde sæt.
## PKCE på alt
Brug **PKCE** på alle flows, både offentlige og fortrolige klienter. Mange public
clients springer det stadig over, selvom det i dag reelt er et krav for dem og
stærkt anbefalet generelt. Det koster ingenting at slå til, og det lukker en hel
klasse af angreb.
## Når en opgradering vender op og ned på det
En sidste advarsel: efter en opgradering fra Authentik 2025.12.1 til 2025.12.2
begyndte OAuth via en **Proxy Provider** at fejle med `redirect_uri_mismatch`,
fordi Authentik byggede callback'en med applikationens domæne i stedet for sit
eget. Pointen: når noget pludselig knækker uden du har rørt din config, så kig på
changelog'en før du river håret ud.
## Kilder og videre læsning
- [Authentik #19883: redirect_uri via Proxy Provider](https://github.com/goauthentik/authentik/issues/19883)
- [Fix OAuth login med Authentik i Portainer](https://oneuptime.com/blog/post/2026-03-20-portainer-oauth-authentik/view)
- [Authentik #3445: forward auth redirect URI](https://github.com/goauthentik/authentik/issues/3445)
- [Okta: OIDC primer (sub, claims, tokens)](https://developer.okta.com/blog/2017/07/25/oidc-primer-part-1)
@@ -0,0 +1,74 @@
---
title: "Authentik bag en reverse proxy: forward auth, outposts og 502-fælden"
description: "Forward auth eller proxy provider? Og hvorfor store auth-headers giver 502 i nginx, når Authentik sidder bagved."
date: 2026-06-05
tags: [authentik, reverse-proxy, oidc, nginx]
draft: false
---
Når du sætter **Authentik** bag en reverse proxy, skal du tidligt træffe et valg:
skal proxyen selv håndtere applikationstrafikken og kun spørge Authentik
*"må den her bruger?"*, eller skal Authentik stå helt foran? De to modeller hedder
**forward auth** og **proxy provider**, og de løser hver sit problem.
## Forward auth kontra proxy provider
Med **forward auth** bruger du din eksisterende reverse proxy til selve trafikken,
og Authentiks **outpost** bliver kun spurgt om authentication og authorization.
Det er den model jeg foretrækker, fordi min proxy i forvejen styrer TLS, routing
og certifikater .. Authentik skal bare sige god eller ej.
Med en **proxy provider** lader du Authentik proxye trafikken hele vejen. Det er
enklere for enkeltstående apps, men du giver afkald på noget kontrol over edge'en.
For at bruge forward auth vælger du en af forward-auth-tilstandene på proxy
provideren og konfigurerer din reverse proxy til at sende auth-tjek til outposten.
Vælg **domain-level** hvis du vil dele login på tværs af mange subdomæner, og
**single application** hvis det kun gælder én app.
## 502-fælden: for store auth-headers
Her er den der koster timer. Alt virker, men en gang imellem .. eller for nogle
brugere .. svarer appen med **502 Bad Gateway**. Det er ofte fordi auth-proxyen
sender **meget store response-headers** tilbage (tænk mange grupper-claims eller
lange tokens), og nginx' default-buffere er for små til at rumme dem.
Løsningen er at skrue op for buffer-størrelserne i nginx:
```nginx
proxy_buffers 8 16k;
proxy_buffer_size 32k;
```
Justér tallene op hvis du har mange grupper på din bruger. Det er ikke en
Authentik-bug, det er nginx der nægter at videresende en header den synes er for
stor.
## WebSockets og HTTP/1.1 er et krav
Authentik kommunikerer med sine **outposts over WebSockets**, og din reverse proxy
**skal** kunne tale HTTP/1.1 eller nyere. Kører proxyen i en gammel
HTTP/1.0-tilstand, eller mangler den `Upgrade`- og `Connection`-headerne, så får
outposten aldrig forbindelse, og din UI viser bare `502 authentik starting...` i
en uendelighed.
## Sørg for at proto-headeren er sat
Det samme råd som med ren OIDC gælder her: din proxy skal sende
`X-Forwarded-Proto`, `X-Forwarded-Host` og `X-Forwarded-For` videre. Gør den ikke
det, bygger Authentik forkerte redirect-URI'er, og du ender i den klassiske
`redirect_uri`-fejl .. nu bare med et ekstra lag proxy at fejlsøge igennem.
## Min anbefaling
Start med **forward auth** hvis du allerede har en reverse proxy du er glad for.
Hold proxyens buffere store nok fra dag ét, tjek at WebSockets går igennem, og
send dine forwarded-headers korrekt. Så er du fri for langt de fleste af de fejl
jeg har brugt aftener på.
## Kilder og videre læsning
- [Authentik docs: Forward auth](https://docs.goauthentik.io/add-secure-apps/providers/proxy/forward_auth/)
- [Authentik docs: Reverse proxy](https://docs.goauthentik.io/install-config/reverse-proxy/)
- [Authentik #2247: 502 "authentik starting..."](https://github.com/goauthentik/authentik/issues/2247)
- [Authentik #18445: nginx-proxy-manager setup](https://github.com/goauthentik/authentik/discussions/18445)
@@ -0,0 +1,165 @@
---
title: "Injicér JavaScript på alle dine sider med NPMplus og sub_filter"
description: "Sådan sprøjter du et script ind i HTML for hver app bag din reverse proxy med nginx sub_filter og lidt Lua .. uden at røre den enkelte app."
date: 2026-06-08
tags: [npmplus, nginx, sub-filter, reverse-proxy, selfhost, matomo]
draft: false
---
Jeg ville have **Matomo-analytics** på tværs af alle mine selvhostede apps uden at
skulle ind og redigere hver enkelt app's HTML. Nogle af dem kan jeg slet ikke rette
i.. de er tredjeparts-images. Løsningen sidder ét sted: i min reverse proxy.
**NPMplus** bygger på OpenResty, så jeg har både **`sub_filter`** og **Lua** til
rådighed, og dermed kan jeg injicere et `<script>` ind i ethvert HTML-svar, der
passerer igennem proxyen.
Her er hele opskriften, inklusive de to fælder der koster tid: **gzip** og **CSP**.
## Den korte version
Kernen er to ting: find `</head>` i svaret og sæt mit script ind lige før det.
```nginx
# Slå upstream-komprimering fra, ellers ser sub_filter kun gzippede bytes
proxy_set_header Accept-Encoding "";
sub_filter '</head>' '<script src="/_inject/injector.js" defer></script></head>';
sub_filter_once on;
sub_filter_types text/html;
```
I NPMplus lægger du det enten på den enkelte proxy host under **Advanced > Custom
Nginx Configuration**, eller globalt i `custom_nginx/server_proxy.conf`, så det
gælder *alle* hosts på én gang. Det sidste er det jeg gør.
## Fælde 1: gzip gør sub_filter usynlig
Det her er den klassiske aha-fejl. Din upstream-app svarer som regel **gzippet**,
og `sub_filter` arbejder på den rå svar-body. Får den komprimerede bytes, finder
den aldrig `</head>`, og der sker .. ingenting. Ingen fejl, bare tavshed.
Løsningen er linjen `proxy_set_header Accept-Encoding "";`. Den fortæller upstream
at du ikke vil have komprimering, så nginx får ren HTML at arbejde på. Husk den,
ellers leder du efter spøgelser.
`sub_filter_types text/html;` sikrer samtidig, at vi kun rører HTML og ikke
forsøger at rode i JSON, billeder eller dine JavaScript-filer.
## Fælde 2: CSP blokerer dit tredjepartsscript
Mange apps sender en **Content-Security-Policy**, der kun tillader scripts fra
deres eget origin. Injicerer du et `<script src="https://assets.example.dk/...">`,
bliver det blokeret af browseren. Tricket er at gøre scriptet **same-origin**: lad
proxyen servere det fra en sti på selve domænet og hente det bagved fra din
asset-host.
```nginx
# Servér injector-scriptet same-origin, så CSP ikke blokerer det
location = /_inject/injector.js {
proxy_pass https://assets.example.dk/injector.js?origin=$host;
proxy_set_header Host assets.example.dk;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# SNI når vi henter over HTTPS
proxy_ssl_server_name on;
proxy_ssl_name assets.example.dk;
proxy_buffering off;
add_header Cache-Control "public, max-age=60";
}
```
Nu peger mit injicerede script på `/_inject/injector.js` på *samme* domæne som
siden selv, og CSP er glad. Bag kulissen henter proxyen den rigtige fil fra
`assets.example.dk` og sender `?origin=$host` med, så scriptet ved hvilken side det
kører på.
## Gør injektionen dynamisk med Lua
Hvis du injicerer på *alle* hosts, så opstår der ét problem: du kommer også til at
injicere ind i din **analytics-host selv**, og i asset-hosten der serverer scriptet.
Det vil du ikke. Med et `set_by_lua_block` kan jeg bygge script-strengen dynamisk og
returnere en tom streng for de hosts, der skal holdes fri:
```nginx
set_by_lua_block $inject_script {
local host = ngx.var.host or ""
-- Injicér intet på asset- og analytics-hosten (undgår loops)
if host == "assets.example.dk" or host == "matomo.example.dk" then
return ""
end
return "<script src='/_inject/injector.js' defer></script>"
}
sub_filter '</head>' '$inject_script</head>';
sub_filter_once on;
sub_filter_types text/html;
```
`sub_filter` kan godt indsætte en nginx-variabel, så `$inject_script` bliver til
enten et script-tag eller ingenting, afhængigt af hvilken host der svarer.
## Bonus: en first-party cookie på tværs af subdomæner
Vil du følge den samme besøgende på tværs af `app1.example.dk`,
`app2.example.dk` osv., så kan du sætte en **first-party cookie på todomænet** med
`header_filter_by_lua_block`. Pointen er at sætte den på `.example.dk`, så den
deles, og kun på rigtige sidevisninger (ikke baggrundskald), så du undgår en
Set-Cookie-løkke:
```nginx
header_filter_by_lua_block {
local cookie = ngx.var.http_cookie or ""
local vid = cookie:match("VID=([^;]+)")
local is_new = false
if not vid or vid == "" then
vid = ngx.var.request_id
is_new = true
end
-- Sæt kun cookien på spritny besøgende eller ægte HTML-sideload
local accept = ngx.var.http_accept or ""
local is_html = string.find(accept, "text/html")
if is_new or is_html then
local secure = (ngx.var.scheme == "https") and "; Secure" or ""
local sc = "VID=" .. vid ..
"; Domain=.example.dk; Path=/; Max-Age=157680000; HttpOnly; SameSite=Lax" ..
secure
local existing = ngx.header["Set-Cookie"]
if existing == nil then
ngx.header["Set-Cookie"] = sc
elseif type(existing) == "table" then
table.insert(existing, sc)
ngx.header["Set-Cookie"] = existing
else
ngx.header["Set-Cookie"] = { existing, sc }
end
end
}
```
`Max-Age=157680000` er fem år. Bemærk `HttpOnly`.. cookien er kun til server-side
sammenkædning, ikke til JavaScript.
## Et par advarsler
- **Hold dig til dine egne sider.** At injicere scripts i HTML, du ikke ejer, er
præcis den slags man bør lade være med. Det her er til *mine* selvhostede apps.
- **`sub_filter_once on`** sikrer, at vi kun rammer det første `</head>`. Slå det
fra, hvis du bevidst vil ramme flere forekomster, men det er sjældent det du vil.
- **Test med `curl -H "Accept-Encoding: identity"`** og kig efter dit tag, hvis det
ikke dukker op i browseren. Så ved du hurtigt, om det er gzip eller CSP, der
driller.
Når det først sidder globalt i `server_proxy.conf`, får hver ny app bag proxyen
analytics gratis, uden at jeg rører appen. Det er hele pointen med at gøre det i
edge-laget.
## Kilder og videre læsning
- [nginx: ngx_http_sub_module (sub_filter)](https://nginx.org/en/docs/http/ngx_http_sub_module.html)
- [OpenResty: set_by_lua_block / header_filter_by_lua_block](https://github.com/openresty/lua-nginx-module#set_by_lua_block)
- [ZoeyVid/NPMplus på GitHub](https://github.com/ZoeyVid/NPMplus)
+85
View File
@@ -0,0 +1,85 @@
---
title: "Fra Nginx Proxy Manager til NPMplus: en migrering uden vej tilbage"
description: "Hvad der gik godt, hvad der brød, og hvorfor backup ikke er til forhandling, når du skifter fra NPM til NPMplus."
date: 2026-06-07
tags: [npmplus, nginx-proxy-manager, reverse-proxy, selfhost]
draft: false
---
I årevis har min edge kørt på **Nginx Proxy Manager** (NPM). Det er en fremragende
lille proxy med en pæn UI, men jeg ville have CrowdSec, moderne TLS-defaults og en
del andre forbedringer. Det fører naturligt til **NPMplus**, en forbedret fork af
NPM. Migreringen er for det meste smertefri, men der er nogle fælder du skal kende.
## Der er ingen vej tilbage
Det vigtigste først: **du kan ikke migrere tilbage** til den oprindelige NPM
bagefter. Tag en fuld backup før du rører ved noget, så du har muligheden for at
rulle tilbage hvis det går galt. Jeg snapshotter hele containeren først .. det er
fem sekunders arbejde og redder din aften.
## Formularerne har ændret adfærd
NPMplus har ændret mange af formularerne, så efter migreringen skal du gennemgå
**hver eneste proxy host** og tjekke at indstillingerne stadig er korrekte. Antag
ikke at alt bare flytter med 1:1. Hos mig var de fleste hosts fine, men et par
stykker skulle rettes i hånden.
## Let's Encrypt-mountet skal fjernes igen
Det her er den fælde folk hænger fast i. Under migreringen monterer du dit gamle
`letsencrypt`-bibliotek ind, så NPMplus kan importere dine eksisterende
certifikater. Men migreringen **fortsætter ikke** før du fjerner det mount igen.
Du får en besked i stil med:
> All certbot certs have been copied, please remove the /etc/letsencrypt
> mountpoint and redeploy to continue the migration!
Certifikaterne er flyttet til `/data`. Fjern `/etc/letsencrypt`-mountet, redeploy
din compose-fil, og så kører den videre.
## Intet admin-password i loggen
Du leder forgæves efter et nyt admin-password i opstartsloggen. Det kommer ikke,
fordi **dine eksisterende NPM-credentials følger med**. Log ind med det gamle
brugernavn og kodeord.
## Proxy gennem proxy? Skift til https
Hvis du proxyer NPMplus gennem en anden NPM(plus)-instans, så husk at skifte
schemet fra `http` til **`https`**. Det er en lille ting, men den giver en bøvlet
fejl hvis du overser den.
## CrowdSec ser intet uden logs
Vil du have **CrowdSec** med, så er der to ting der ofte spænder ben:
- Sæt `LOGROTATE=true`. Uden den skrives access-logs aldrig til disk, og så har
CrowdSec ingenting at analysere.
- CrowdSec-konfigurationen peger på **container-stier** (fx
`/opt/npmplus/nginx/logs`), ikke host-stier, selvom dine bind mounts mapper
anderledes.
Sidder du bag **Cloudflare**, så sæt `TRUST_CLOUDFLARE=true`, ellers ser CrowdSec
kun Cloudflares IP'er som kilde til al trafik, og så banlyser den i praksis hele
internettet på én gang.
## HTTP/3 kræver UDP 443
NPMplus kan **HTTP/3**, men det kræver at du forwarder **UDP 443**, ikke kun TCP.
Enten åbner du porten i din firewall, eller også slår du funktionen fra. Glemmer
du det, virker siderne stadig (de falder bare tilbage til HTTP/2), så det er nemt
at overse.
## Var det det værd?
Ja. NPMplus er en solid drop-in-erstatning .. den velkendte UI, alle proxy hosts
og certifikater migrerede rent, og oveni fik jeg CrowdSec og bedre defaults. Bare
husk backuppen, for der er som sagt ingen vej tilbage.
## Kilder og videre læsning
- [ZoeyVid/NPMplus på GitHub](https://github.com/ZoeyVid/NPMplus)
- [Walkthrough: NPM til NPMplus med CrowdSec](https://trevorbotha.net/tech-related-blog/migrating-from-nginx-proxy-manager-to-npmplus-with-crowdsec-a-complete-walkthrough/)
- [NPMplus migration discussion #964](https://github.com/ZoeyVid/NPMplus/discussions/964)
- [Nginx Proxy Manager vs. NPMplus](https://www.virtualizationhowto.com/2025/09/nginx-proxy-manager-vs-npmplus-which-one-is-better-for-your-home-lab/)
+22
View File
@@ -0,0 +1,22 @@
---
title: "Mit Proxmox-hjemmelab"
description: "Et kig på opbygningen: reverse proxy, SSO, CI/CD og en masse containere."
date: 2026-06-09
tags: [proxmox, selfhost, homelab]
draft: false
---
Mit hjemmelab kører på **Proxmox VE** med en blanding af LXC-containere og enkelte
virtuelle maskiner. Et par af hjørnestenene:
## Edge & sikkerhed
Al indgående trafik går gennem en **reverse proxy** med automatiske Let's Encrypt-certifikater,
og brugere logger ind centralt via **SSO (OIDC)**.
## Kode & CI/CD
Jeg hoster min egen **Gitea** med tilhørende Actions-runnere, så projekter bygges og
deployes automatisk ved push — inklusive den her side.
## Resten
Medieservere, databaser, dokument-redigering, overvågning og en lang række småtjenester.
Mere om de enkelte dele i kommende indlæg.
+63
View File
@@ -0,0 +1,63 @@
---
title: "Proxmox LXC i praksis: de fejl jeg selv lavede"
description: "Unprivileged kontra privileged, uid-mapping til nobody, og hvorfor Docker i en unprivileged LXC driller på nye kerner."
date: 2026-05-26
tags: [proxmox, lxc, homelab, selfhost]
draft: false
---
Næsten alt i mit homelab kører i **LXC-containere** på **Proxmox VE** frem for
fulde virtuelle maskiner. De er lette, starter på et øjeblik og deler kernen med
værten. Men der er et par steder hvor jeg har lært det på den hårde måde.
## Unprivileged kontra privileged
Proxmox defaulter til **unprivileged** containere i GUI'en, og det er den rigtige
default. Internetvendte tjenester som webservere og reverse proxies bør køre
unprivileged af sikkerhedshensyn. DNS, proxies, overvågning og lette tjenester er
perfekte LXC-kandidater.
**Privileged** containere bør du kun bruge til isolerede homelabs uden
internetvendte tjenester, til NFS-servere hvor uid-mapping bliver for besværligt,
eller til hurtige testmiljøer. Min tommelfingerregel: unprivileged medmindre noget
tvinger mig til andet.
## Alt bliver til `nobody`
Den første væg man render ind i med unprivileged containere: hver fil og mappe
mappes til **`nobody`** (uid 65534). Det giver bøvl så snart du har restriktive
rettigheder, eller vil skrive filer med et bestemt uid/gid. Især når du monterer
**netværksshares** ind, introducerer uid-shiftet en hel del kompleksitet du skal
forstå før det spiller.
Løsningen er ikke at give op og køre privileged. Det er at lære `lxc.idmap` at
kende og mappe de specifikke uid'er du har brug for. Det tager en eftermiddag at
forstå, og så har du det som værktøj resten af tiden.
## Docker i en unprivileged LXC er en fælde lige nu
Det her bed mig for nylig. De fleste guides til **Docker i LXC** online er
forældede .. de antager kerne 5.x og runc 1.1.x. På moderne Proxmox med
**kerne 6.14+** og **runc 1.2+** fejler de opskrifter før du overhovedet når frem
til de gamle workarounds. Unprivileged Docker er reelt brudt på nye kerner på
grund af runc's per-namespace sysctl-skrivning og Dockers AppArmor-probe.
Næsten enhver fungerende opsætning i forummerne involverer enten
**kerneændringer på værten**, at slå **AppArmor** helt fra, eller begge dele. Mit
råd: vil du køre Docker, så kør det i en **VM** i stedet, eller i en bevidst
**privileged** LXC du holder isoleret. Det er ikke kampen værd at presse det ind i
en unprivileged container på en ny kerne.
## LXC kontra VM
Jeg vælger LXC til langt det meste: reverse proxy, databaser, statiske websites,
små API'er. Jeg vælger en **VM** når jeg har brug for en anden kerne, fuld
isolation, eller netop Docker-tunge workloads. Reglen er enkel: container når jeg
kan, VM når jeg skal.
## Kilder og videre læsning
- [Proxmox wiki: Unprivileged LXC containers](https://pve.proxmox.com/wiki/Unprivileged_LXC_containers)
- [Docker on Proxmox LXC: what actually works](https://dev.to/mattercoder/docker-on-proxmox-lxc-what-actually-works-and-why-unprivileged-doesnt-45km)
- [Privileged vs. unprivileged LXC](https://proxmoxpulse.com/articles/privileged-vs-unprivileged-lxc-containers-proxmox/)
- [Proxmox: LXC containers vs. VMs](https://homelabstarter.com/proxmox-lxc-vs-vm/)
+18
View File
@@ -0,0 +1,18 @@
---
title: "Velkommen til min nye side"
description: "Hvad siden er, og hvad du kan forvente at finde her."
date: 2026-06-10
tags: [meta, selfhost]
draft: false
---
Velkommen! Det her er min nye personlige side — bygget med **Astro** på **Bun**,
hostet på min egen Proxmox-infrastruktur, og deployet automatisk via Gitea Actions.
Her kommer jeg til at skrive om det jeg roder med:
- **Selvhosting & Proxmox** — hvordan jeg driver mit hjemmelab.
- **AI & automatisering** — at bygge ting med Claude Code og LLM'er.
- **Udvikling** — Bun, TypeScript, Django og alt derimellem.
Siden opdateres løbende. God fornøjelse.
+51
View File
@@ -0,0 +1,51 @@
---
import { ClientRouter } from 'astro:transitions';
import Head from '../components/Head.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { SITE } from '../consts';
import '../styles/global.css';
interface Props {
title?: string;
description?: string;
image?: string;
type?: 'website' | 'article';
publishedTime?: string;
modifiedTime?: string;
tags?: string[];
jsonLd?: unknown[];
}
const { title, description, image, type, publishedTime, modifiedTime, tags, jsonLd } = Astro.props;
---
<!doctype html>
<html lang={SITE.lang}>
<head>
<script is:inline>
(() => {
const apply = () => {
const t = localStorage.getItem('theme') ||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.dataset.theme = t;
};
apply();
document.addEventListener('astro:after-swap', apply);
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-theme-toggle]');
if (!btn) return;
const next = document.documentElement.dataset.theme === 'light' ? 'dark' : 'light';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
})();
</script>
<Head {title} {description} {image} {type} {publishedTime} {modifiedTime} {tags} {jsonLd} />
<ClientRouter />
</head>
<body class="min-h-screen flex flex-col antialiased">
<Header />
<main class="mx-auto w-full max-w-3xl flex-1 px-5 py-12">
<slot />
</main>
<Footer />
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="404">
<div class="py-20 text-center">
<h1 class="text-6xl font-bold tracking-tight">404</h1>
<p class="mt-4 text-muted">Siden findes ikke.</p>
<a href="/" class="mt-8 inline-block rounded-xl bg-accent px-5 py-2.5 font-medium text-bg">Til forsiden</a>
</div>
</Base>
+64
View File
@@ -0,0 +1,64 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { SITE } from '../../consts';
import { blogPosting } from '../../seo';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const d = post.data.date.toLocaleDateString('da-DK', { year: 'numeric', month: 'long', day: 'numeric' });
const url = new URL(`/blog/${post.id}`, SITE.url).href;
const published = post.data.date.toISOString();
const modified = (post.data.updated ?? post.data.date).toISOString();
const ld = blogPosting({
url, headline: post.data.title, description: post.data.description,
datePublished: published, dateModified: modified,
tags: post.data.tags, image: new URL('/og.png', SITE.url).href,
});
---
<Base
title={post.data.title}
description={post.data.description}
type="article"
publishedTime={published}
modifiedTime={modified}
tags={post.data.tags}
jsonLd={[ld]}
>
<a href="/blog" class="text-sm text-muted hover:text-accent">← Tilbage til blog</a>
<article class="mt-4">
<header class="border-b border-border pb-6">
<div class="text-sm text-muted">{d}</div>
<h1 class="mt-2 text-4xl font-bold tracking-tight">{post.data.title}</h1>
{post.data.tags.length > 0 && (
<div class="mt-4 flex flex-wrap gap-1.5">
{post.data.tags.map((t) => (
<a href={`/tags/${t}`} class="rounded-full border border-border px-2.5 py-0.5 text-xs text-muted hover:border-accent hover:text-accent">#{t}</a>
))}
</div>
)}
</header>
<div class="prose mt-8"><Content /></div>
</article>
<section class="mt-16 border-t border-border pt-8">
<h2 class="text-2xl font-bold tracking-tight">Kommentarer</h2>
<comentario-comments class="mt-6 block" live-update="true"></comentario-comments>
</section>
<script is:inline>
(function () {
if (!window.__comentarioLoaded) {
window.__comentarioLoaded = true;
var s = document.createElement('script');
s.src = 'https://comentario.weircon.dk/comentario.js';
s.defer = true;
document.head.appendChild(s);
}
})();
</script>
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
---
<Base title="Blog" description="Indlæg om selvhosting, Proxmox, AI og udvikling.">
<div class="flex items-baseline justify-between">
<h1 class="text-4xl font-bold tracking-tight">Blog</h1>
<a href="/tags" class="text-sm text-accent hover:underline">Tags</a>
</div>
<div class="mt-8 grid gap-4">
{posts.map((p) => (
<PostCard href={`/blog/${p.id}`} title={p.data.title} date={p.data.date} description={p.data.description} tags={p.data.tags} />
))}
{posts.length === 0 && <p class="text-muted">Ingen indlæg endnu.</p>}
</div>
</Base>
+170
View File
@@ -0,0 +1,170 @@
---
import Base from '../layouts/Base.astro';
import { SITE } from '../consts';
const erhverv = [
{
org: 'Digisense A/S', sted: 'Danmark', roller: [
{ titel: 'Technical Solution Architect', naar: 'maj 2026 – nu', detaljer: ['Teknisk arkitektur og løsningsdesign', 'Bygger bro mellem kunder og udviklingsteams', 'Teknisk overblik og rådgivning'] },
],
},
{
org: 'CGI', sted: 'Aalborg', roller: [
{ titel: 'Teknisk Lead', naar: 'aug. 2023 – maj 2026', detaljer: ['Teknisk overblik og arkitektur', 'Tæt kundesamarbejde om teknisk leverance', 'Kontaktperson og rådgiver for kunden', 'Formidler kundens behov til udviklingsteamet'] },
{ titel: 'Senior Konsulent', naar: 'apr. 2023 – maj 2026', detaljer: ['Buddy for ny udvikler', 'Afholdt installationsvejledning via webinar'] },
{ titel: 'Konsulent', naar: 'aug. 2022 – apr. 2023', detaljer: ['OpenSource reference-implementering (Java, Peppol eDelivery)', 'Løsningsarkitekt'] },
{ titel: 'Studentermedhjælper', naar: 'aug. 2020 – aug. 2022', detaljer: ['Kode-ansvarlig for en regelmotor (Java, Drools)', 'DevOps (Docker, docker-compose, Linux)'] },
{ titel: 'Konsulent', naar: 'jun. 2019 – aug. 2020', detaljer: ['Frontend (Angular 8, TypeScript)', 'Backend (GoLang, Java, Python)'] },
],
},
{
org: 'BilligVVS', sted: 'Egaa', roller: [
{ titel: 'Fullstack IT-udvikler', naar: 'sep. 2018 – jun. 2019', detaljer: ['Hjemmeside (Python, TypeScript)', 'Backend (Python, Movex)', 'Webcrawler (Python)', 'BI (Python, chart.js)'] },
],
},
];
const uddannelse = [
{ hvad: 'HD 1. del — Erhvervsøkonomi', hvor: 'Aalborg Universitet', naar: '2025 – 2027 (i gang)', note: 'Forretningsforståelse, erhvervsøkonomi, regnskab og finansiering.' },
{ hvad: 'Kandidat i Datalogi (cand.scient.)', hvor: 'Aalborg Universitet', naar: '2020 – 2022', note: 'Speciale: Thread-Fuzz — mutation-based behavioural blackbox fuzzing of Thread.' },
{ hvad: 'Bachelor i Datalogi (B.Sc.)', hvor: 'Aalborg Universitet', naar: '2015 – 2018', note: '' },
{ hvad: 'Almen studentereksamen (STX)', hvor: 'Espergærde Gymnasium og HF', naar: '2012 – 2015', note: '' },
{ hvad: 'Udvekslingsår', hvor: 'Lincoln High School, Ypsilanti, Michigan (USA)', naar: '2011 – 2012', note: '' },
];
const frivilligt = [
{ hvad: 'LUNARK', naar: '2020', note: 'Notifikationssystem i C# og React.' },
{ hvad: 'Kirkens Korshær', naar: '2020 – nu', note: 'Køkkenhjælper hver jul og nytår.' },
{ hvad: 'AAU Campus', naar: '2016 – 2022', note: 'Tutor og F-Klub-frivillig.' },
];
const primaere = ['Java', 'Python', 'GoLang', 'TypeScript', 'JavaScript', 'Angular', 'Docker', 'Linux', 'Proxmox', 'PostgreSQL', 'Jenkins', 'Git / GitLab', 'BI', 'Agile'];
const sekundaere = ['C#', 'C++', 'C', 'Kubernetes', 'AWS', 'RabbitMQ', 'Datadog', 'XeLaTeX'];
const sprog = ['Dansk — modersmål', 'Engelsk — flydende (tale & skrift)'];
---
<Base title="CV" description="Asger Weirsøe — datalog og Technical Solution Architect. Erfaring, uddannelse og publikationer.">
<h1 class="text-4xl font-bold tracking-tight">CV</h1>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted">
<span>{SITE.location}</span>
<a class="text-accent hover:underline" href={`mailto:${SITE.email}`}>{SITE.email}</a>
<a class="text-accent hover:underline" href="https://www.linkedin.com/in/ag-weirs%C3%B8e/">LinkedIn</a>
</div>
<p class="mt-6 max-w-2xl text-muted">
Passioneret datalog med solid baggrund fra Aalborg Universitet. Min karriere startede i
detail og gik via fuld-stack udvikling hos BilligVVS til CGI — og i dag er jeg Technical
Solution Architect hos Digisense A/S. Jeg forener teknisk dybde med ledelse og trives med
at have det tekniske overblik og facilitere samarbejdet mellem kunder og udviklingsteams.
</p>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Fagområder</h2>
<p class="mt-4 max-w-2xl text-muted">
Jeg arbejder med <strong class="text-text">elektronisk fakturering</strong> (e-faktura / electronic
invoicing) og e-procurement. Hos CGI byggede jeg en open source reference-implementering af
<strong class="text-text">Peppol eDelivery</strong> i Java, og i dag rådgiver jeg som Technical
Solution Architect hos Digisense A/S om løsningsarkitektur på området — bl.a.
<strong class="text-text">Peppol</strong> (BIS, Access Point og eDelivery/AS4, herunder den open
source-baserede access point <strong class="text-text">Oxalis</strong>),
<strong class="text-text">NemHandel</strong>, <strong class="text-text">OIOUBL/UBL</strong> og den
europæiske e-faktura-standard EN 16931.
</p>
<div class="mt-4 flex flex-wrap gap-2">
{['Peppol', 'Peppol Access Point', 'Oxalis', 'NemHandel', 'e-faktura', 'Electronic invoicing', 'OIOUBL / UBL', 'e-Delivery (AS4)', 'EN 16931', 'e-procurement'].map((k) => (
<span class="rounded-full border border-border bg-surface/60 px-3 py-1 text-sm">{k}</span>
))}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Erhvervserfaring</h2>
<div class="mt-4 space-y-4">
{erhverv.map((e) => (
<div class="rounded-2xl border border-border bg-surface/60 p-5">
<h3 class="font-semibold">{e.org} <span class="font-normal text-muted">· {e.sted}</span></h3>
<div class="mt-3 space-y-3">
{e.roller.map((r) => (
<div>
<div class="flex flex-wrap items-baseline justify-between gap-2">
<span class="font-medium text-text">{r.titel}</span>
<span class="text-xs text-muted">{r.naar}</span>
</div>
<ul class="mt-1 list-disc pl-5 text-sm text-muted">
{r.detaljer.map((d) => <li>{d}</li>)}
</ul>
</div>
))}
</div>
</div>
))}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Uddannelse</h2>
<div class="mt-4 space-y-4">
{uddannelse.map((u) => (
<div class="rounded-2xl border border-border bg-surface/60 p-5">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="font-semibold">{u.hvad}</h3><span class="text-xs text-muted">{u.naar}</span>
</div>
<p class="text-sm text-muted">{u.hvor}</p>
{u.note && <p class="mt-1 text-sm text-muted/80">{u.note}</p>}
</div>
))}
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Publikationer</h2>
<div class="mt-4 rounded-2xl border border-border bg-surface/60 p-5">
<a href="https://arxiv.org/abs/2310.00999" target="_blank" rel="noopener" class="font-medium hover:text-accent">
CGAAL: Distributed On-The-Fly ATL Model Checker with Heuristics
</a>
<p class="mt-1 text-sm text-muted">
Carlsen, Frydenskov, Jensen, Rasmussen, Sørensen, <span class="text-text">Weirsøe</span>, Jensen &amp; Larsen —
<em>EPTCS</em>, vol. 390 (2023), s. 99–114. <span class="text-muted/80">arXiv:2310.00999</span>
</p>
</div>
</section>
<section class="mt-10">
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Frivilligt arbejde</h2>
<div class="mt-4 space-y-4">
{frivilligt.map((f) => (
<div class="rounded-2xl border border-border bg-surface/60 p-5">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h3 class="font-semibold">{f.hvad}</h3><span class="text-xs text-muted">{f.naar}</span>
</div>
<p class="mt-1 text-sm text-muted">{f.note}</p>
</div>
))}
</div>
</section>
<section class="mt-10 grid gap-4 sm:grid-cols-2">
<div>
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Primære kompetencer</h2>
<div class="mt-4 flex flex-wrap gap-2">
{primaere.map((k) => <span class="rounded-full border border-border bg-surface/60 px-3 py-1 text-sm">{k}</span>)}
</div>
<h2 class="mt-8 text-sm font-semibold uppercase tracking-widest text-accent">Sekundære</h2>
<div class="mt-4 flex flex-wrap gap-2">
{sekundaere.map((k) => <span class="rounded-full border border-border bg-surface/40 px-3 py-1 text-sm text-muted">{k}</span>)}
</div>
</div>
<div>
<h2 class="text-sm font-semibold uppercase tracking-widest text-accent">Sprog</h2>
<ul class="mt-4 space-y-2 text-sm">
{sprog.map((s) => <li class="rounded-xl border border-border bg-surface/60 px-3 py-2">{s}</li>)}
</ul>
<h2 class="mt-8 text-sm font-semibold uppercase tracking-widest text-accent">Interesser</h2>
<p class="mt-4 text-sm text-muted">
Selvhosting og drift: jeg har gennem mange år bygget og vedligeholdt et homelab med
<a class="text-accent hover:underline" href="https://www.proxmox.com/" target="_blank" rel="noopener"> Proxmox</a>,
bag SSO via <a class="text-accent hover:underline" href="https://goauthentik.io/" target="_blank" rel="noopener">Authentik</a>.
Det har ført til bidrag i open source-projekter som Qutebrowser og Stregsystemet (IT-studerendes betalingssystem på AAU).
</p>
</div>
</section>
</Base>
+66
View File
@@ -0,0 +1,66 @@
---
import { getCollection } from 'astro:content';
import { Image } from 'astro:assets';
import Base from '../layouts/Base.astro';
import PostCard from '../components/PostCard.astro';
import portrait from '../assets/asger.png';
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.slice(0, 3);
const highlights = [
{ title: 'Arkitektur & e-fakturering', body: 'Technical Solution Architect hos Digisense A/S — løsningsarkitektur inden for elektronisk fakturering, Peppol, Oxalis og NemHandel.' },
{ title: 'Selvhostet infrastruktur', body: 'Et Proxmox-homelab med ~50 LXC/VM’er bag SSO (Authentik): reverse proxy, CI/CD, medier og meget mere.' },
{ title: 'AI & fuld-stack', body: 'Fra Java, Go og Python til Bun og TypeScript — og LLM-drevne workflows med Claude Code.' },
];
---
<Base>
<section class="grid items-center gap-8 py-6 sm:grid-cols-[1fr_auto]">
<div>
<p class="font-medium text-accent">Hej, jeg er</p>
<h1 class="mt-1 text-5xl font-bold tracking-tight sm:text-6xl">Asger Weirsøe</h1>
<p class="mt-5 max-w-xl text-lg text-muted">
Datalog (cand.scient.) fra Aalborg Universitet og Technical Solution Architect hos Digisense A/S.
Jeg kombinerer teknisk dybde med ledelse — og i fritiden drifter jeg mit eget
Proxmox-homelab og bygger projekter med AI.
</p>
<div class="mt-7 flex flex-wrap gap-3">
<a href="/cv" class="rounded-xl bg-accent px-5 py-2.5 font-medium text-bg transition-transform hover:-translate-y-0.5">Se mit CV</a>
<a href="/projekter" class="rounded-xl border border-border px-5 py-2.5 font-medium transition-colors hover:border-accent">Projekter</a>
<a href="/blog" class="rounded-xl border border-border px-5 py-2.5 font-medium transition-colors hover:border-accent">Blog</a>
</div>
</div>
<Image
src={portrait}
alt="Asger Weirsøe"
width={260}
densities={[1, 2]}
class="mx-auto h-44 w-44 rounded-2xl border border-border object-cover shadow-xl sm:h-56 sm:w-56"
loading="eager"
/>
</section>
<section class="mt-14 grid gap-4 sm:grid-cols-3">
{highlights.map((h) => (
<div class="rounded-2xl border border-border bg-surface/60 p-5">
<h2 class="font-semibold tracking-tight">{h.title}</h2>
<p class="mt-2 text-sm text-muted">{h.body}</p>
</div>
))}
</section>
{posts.length > 0 && (
<section class="mt-16">
<div class="flex items-baseline justify-between">
<h2 class="text-2xl font-bold tracking-tight">Seneste indlæg</h2>
<a href="/blog" class="text-sm text-accent hover:underline">Alle indlæg →</a>
</div>
<div class="mt-5 grid gap-4">
{posts.map((p) => (
<PostCard href={`/blog/${p.id}`} title={p.data.title} date={p.data.date} description={p.data.description} tags={p.data.tags} />
))}
</div>
</section>
)}
</Base>
+49
View File
@@ -0,0 +1,49 @@
---
import Base from '../layouts/Base.astro';
import { SITE, PROJECTS } from '../consts';
type Repo = { name: string; description: string | null; html_url: string; language: string | null; stars_count: number; updated_at: string; fork: boolean; archived: boolean; private: boolean; };
let repos: Repo[] = [];
let error = false;
try {
const res = await fetch(`${SITE.giteaBase}/api/v1/users/${SITE.giteaUser}/repos?limit=50&sort=updated`, {
headers: { 'Accept': 'application/json' },
});
if (res.ok) {
const hide = new Set(PROJECTS.hide.map((s) => s.toLowerCase()));
const data = (await res.json()) as Repo[];
repos = data
.filter((r) => !r.fork && !r.archived && !r.private && !hide.has(r.name.toLowerCase()));
// pinned først (i opsat rækkefølge), resten efter senest opdateret
const order = (n: string) => {
const i = PROJECTS.pinned.findIndex((p) => p.toLowerCase() === n.toLowerCase());
return i === -1 ? Infinity : i;
};
repos.sort((a, b) => {
const oa = order(a.name), ob = order(b.name);
if (oa !== ob) return oa - ob;
return +new Date(b.updated_at) - +new Date(a.updated_at);
});
} else { error = true; }
} catch { error = true; }
---
<Base title="Projekter" description="Udvalgte offentlige projekter fra min Gitea.">
<h1 class="text-4xl font-bold tracking-tight">Projekter</h1>
<p class="mt-3 text-muted">Hentet live fra min <a class="text-accent hover:underline" href={`${SITE.giteaBase}/${SITE.giteaUser}`}>Gitea</a>.</p>
{error && <p class="mt-8 rounded-xl border border-border bg-surface/60 p-5 text-muted">Kunne ikke hente repositories lige nu.</p>}
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{repos.map((r) => (
<a href={r.html_url} target="_blank" rel="noopener"
class="group rounded-2xl border border-border bg-surface/60 p-5 transition-colors hover:border-accent/50">
<div class="flex items-center justify-between">
<h3 class="font-semibold tracking-tight group-hover:text-accent">{r.name}</h3>
{r.stars_count > 0 && <span class="text-xs text-muted">★ {r.stars_count}</span>}
</div>
<p class="mt-2 line-clamp-3 text-sm text-muted">{r.description || 'Ingen beskrivelse.'}</p>
{r.language && <div class="mt-3 text-xs text-accent">{r.language}</div>}
</a>
))}
</div>
</Base>
+20
View File
@@ -0,0 +1,20 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import { SITE } from '../consts';
export async function GET(context) {
const posts = (await getCollection('blog', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
return rss({
title: SITE.title,
description: SITE.description,
site: context.site,
items: posts.map((p) => ({
title: p.data.title,
description: p.data.description,
pubDate: p.data.date,
link: `/blog/${p.id}/`,
})),
customData: `<language>da-dk</language>`,
});
}
+24
View File
@@ -0,0 +1,24 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PostCard from '../../components/PostCard.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => !data.draft);
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: { posts: posts.filter((p) => p.data.tags.includes(tag)).sort((a, b) => b.data.date.getTime() - a.data.date.getTime()), tag },
}));
}
const { posts, tag } = Astro.props;
---
<Base title={`#${tag}`} description={`Indlæg om ${tag}.`}>
<a href="/tags" class="text-sm text-muted hover:text-accent">← Alle tags</a>
<h1 class="mt-3 text-4xl font-bold tracking-tight">#{tag}</h1>
<div class="mt-8 grid gap-4">
{posts.map((p) => (
<PostCard href={`/blog/${p.id}`} title={p.data.title} date={p.data.date} description={p.data.description} tags={p.data.tags} />
))}
</div>
</Base>
+20
View File
@@ -0,0 +1,20 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const counts = new Map<string, number>();
for (const p of posts) for (const t of p.data.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
const tags = [...counts.entries()].sort((a, b) => b[1] - a[1]);
---
<Base title="Tags" description="Alle emner på bloggen.">
<h1 class="text-4xl font-bold tracking-tight">Tags</h1>
<div class="mt-8 flex flex-wrap gap-2">
{tags.map(([t, n]) => (
<a href={`/tags/${t}`} class="rounded-full border border-border bg-surface/60 px-3.5 py-1.5 text-sm transition-colors hover:border-accent hover:text-accent">
#{t} <span class="text-muted">({n})</span>
</a>
))}
{tags.length === 0 && <p class="text-muted">Ingen tags endnu.</p>}
</div>
</Base>
+66
View File
@@ -0,0 +1,66 @@
import { SITE } from './consts';
export const personId = `${SITE.url}/#person`;
export const websiteId = `${SITE.url}/#website`;
// Canonical Person entity — referenced by @id from every other entity.
export const person = {
'@type': 'Person',
'@id': personId,
name: 'Asger Geel Weirsøe',
alternateName: 'Asger Weirsøe',
url: `${SITE.url}/`,
image: `${SITE.url}/asger.png`,
jobTitle: 'Technical Solution Architect',
worksFor: { '@type': 'Organization', name: 'Digisense A/S' },
alumniOf: { '@type': 'CollegeOrUniversity', name: 'Aalborg Universitet' },
knowsAbout: [
'Peppol', 'Peppol Access Point', 'Peppol BIS', 'Peppol eDelivery', 'Oxalis',
'NemHandel', 'Elektronisk fakturering', 'E-faktura', 'Electronic invoicing',
'OIOUBL', 'UBL', 'e-Delivery (AS4)', 'EN 16931', 'E-procurement', 'EDI',
'Softwarearkitektur', 'Løsningsarkitektur', 'Proxmox', 'Selvhosting',
'Kunstig intelligens', 'Linux', 'DevOps', 'OIDC', 'SSO', 'Fuld-stack udvikling', 'Docker',
],
sameAs: [
'https://www.linkedin.com/in/ag-weirs%C3%B8e/',
'https://gitea.weircon.dk/agw',
'https://arxiv.org/abs/2310.00999',
],
description:
'Datalog (cand.scient.) fra Aalborg Universitet og Technical Solution Architect hos Digisense A/S. Arbejder med elektronisk fakturering og e-procurement — Peppol (Access Point, eDelivery/AS4, Oxalis), NemHandel og OIOUBL — samt selvhosting, Proxmox og AI.',
};
export const website = {
'@type': 'WebSite',
'@id': websiteId,
url: `${SITE.url}/`,
name: SITE.name,
description: SITE.description,
inLanguage: 'da-DK',
publisher: { '@id': personId },
};
// Default graph present on every page (Google de-dupes by @id).
export const baseGraph = { '@context': 'https://schema.org', '@graph': [website, person] };
export function blogPosting(opts: {
url: string; headline: string; description: string;
datePublished: string; dateModified: string; tags: string[]; image: string;
}) {
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: opts.headline,
description: opts.description,
url: opts.url,
mainEntityOfPage: opts.url,
datePublished: opts.datePublished,
dateModified: opts.dateModified,
inLanguage: 'da-DK',
image: opts.image,
keywords: opts.tags.join(', '),
author: { '@id': personId },
publisher: { '@id': personId },
isPartOf: { '@id': websiteId },
};
}
+88
View File
@@ -0,0 +1,88 @@
@import "tailwindcss";
@import "@fontsource-variable/inter";
@theme {
--color-bg: #0b0f17;
--color-surface: #121a27;
--color-surface-2: #182232;
--color-border: #243044;
--color-text: #e6e9ef;
--color-muted: #97a3b6;
--color-accent: #38bdf8;
--color-accent-2: #818cf8;
--font-sans: "Inter Variable", ui-sans-serif, system-ui, -apple-system, sans-serif;
}
/* ---------- light theme ---------- */
html { color-scheme: dark; }
html[data-theme="light"] {
color-scheme: light;
--color-bg: #f6f8fb;
--color-surface: #ffffff;
--color-surface-2: #eef2f8;
--color-border: #dde4ee;
--color-text: #0f1729;
--color-muted: #54617a;
--color-accent: #0284c7;
--color-accent-2: #4f46e5;
}
html { scroll-behavior: smooth; }
body { background-color: var(--color-bg); color: var(--color-text); }
/* smooth cross-fade when toggling theme (no flash on load: theme is set pre-paint) */
body, header, a, span, div, p, h1, h2, h3, li, ul, input, button, svg, section, article {
transition: background-color .35s ease, border-color .35s ease, color .35s ease;
}
::selection { background: color-mix(in srgb, var(--color-accent) 35%, transparent); }
a { color: inherit; }
/* subtle top-glow background */
body::before {
content: "";
position: fixed; inset: 0; z-index: -1; pointer-events: none;
background:
radial-gradient(60rem 30rem at 70% -10%, color-mix(in srgb, var(--color-accent) 12%, transparent), transparent 70%),
radial-gradient(50rem 30rem at 10% 0%, color-mix(in srgb, var(--color-accent-2) 10%, transparent), transparent 70%);
}
/* ---------- animated theme toggle (sun ⇄ moon) ---------- */
.theme-toggle {
display: inline-grid; place-items: center;
width: 2.25rem; height: 2.25rem; border-radius: 0.75rem;
color: var(--color-muted); background: transparent; cursor: pointer;
border: 1px solid transparent;
}
.theme-toggle:hover { color: var(--color-text); background: var(--color-surface-2); }
.theme-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.sun-and-moon > :is(.moon, .sun, .sun-beams) { transform-origin: center; }
.sun-and-moon > :is(.moon, .sun) { fill: currentColor; }
.sun-and-moon > .sun-beams { stroke: currentColor; stroke-width: 2px; }
.sun-and-moon > .sun { transition: transform .5s cubic-bezier(.5, 1.25, .75, 1.25); }
.sun-and-moon > .sun-beams { transition: transform .5s cubic-bezier(.5, 1.5, .75, 1.25), opacity .5s cubic-bezier(.25, 0, .3, 1); }
.sun-and-moon .moon > circle { transition: transform .25s cubic-bezier(0, 0, 0, 1); }
@supports (cx: 1) { .sun-and-moon .moon > circle { transition: cx .25s cubic-bezier(0, 0, 0, 1); } }
[data-theme="dark"] .sun-and-moon > .sun { transform: scale(1.75); transition-timing-function: cubic-bezier(.25, 0, .3, 1); transition-duration: .25s; }
[data-theme="dark"] .sun-and-moon > .sun-beams { opacity: 0; transform: rotateZ(-25deg); transition-duration: .15s; }
[data-theme="dark"] .sun-and-moon > .moon > circle { transform: translateX(-7px); transition-delay: .25s; transition-duration: .5s; }
@supports (cx: 1) { [data-theme="dark"] .sun-and-moon > .moon > circle { cx: 17; transform: translateX(0); } }
@media (prefers-reduced-motion: reduce) { .sun-and-moon * { transition: none !important; } }
/* ---------- markdown prose ---------- */
.prose { color: var(--color-text); line-height: 1.75; font-size: 1.05rem; }
.prose > * + * { margin-top: 1.1em; }
.prose h2 { font-size: 1.6rem; font-weight: 700; margin-top: 2em; letter-spacing: -0.01em; }
.prose h3 { font-size: 1.25rem; font-weight: 700; margin-top: 1.6em; }
.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
.prose code { background: var(--color-surface-2); padding: 0.15em 0.4em; border-radius: 0.35rem; font-size: 0.9em; }
.prose pre { background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: 0.75rem; padding: 1rem 1.1rem; overflow-x: auto; }
.prose pre code { background: none; padding: 0; }
.prose blockquote { border-left: 3px solid var(--color-accent); padding-left: 1rem; color: var(--color-muted); font-style: italic; }
.prose ul { list-style: disc; padding-left: 1.4em; }
.prose ol { list-style: decimal; padding-left: 1.4em; }
.prose img { border-radius: 0.75rem; border: 1px solid var(--color-border); }
.prose hr { border-color: var(--color-border); }
.prose table { width: 100%; border-collapse: collapse; }
.prose th, .prose td { border: 1px solid var(--color-border); padding: 0.5rem 0.7rem; text-align: left; }
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"]
}