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:
@@ -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/"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
.DS_Store
|
||||||
|
bun.lock*
|
||||||
@@ -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.
|
||||||
@@ -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()] },
|
||||||
|
});
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
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 |
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
@@ -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 |
@@ -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>
|
||||||
@@ -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)} />)}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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[],
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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)
|
||||||
@@ -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/)
|
||||||
@@ -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.
|
||||||
@@ -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/)
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 & 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user