Author SHA1 Message Date
Klagarge 9ffc6a14d2 fix: apply fresh-start review findings
Independent review (second attempt) passed all 7 verification steps at
19f6007, including the fresh-clone embed check, full build from clean
state (9.7s), server curl matrix (public 200, protected + all theme
sidecars 503, searchindex 404, no content in 503 bodies), and just check.

Findings applied:
- README: prerequisite Go version corrected to 1.27.1+ to match go.mod
  (a Go 1.22-1.27 user got a hard 'requires go >= 1.27.1' failure).
- CI: upload-artifact v3 -> v4 with a fallback note for older runners;
  CI now also runs the gofmt guard like 'just check'.
- Justfile: curl -fsSL so a bad download fails at curl instead of tar;
  comment clarifying that --dest-dir is resolved relative to docs/.
- AGENT.md: version-pairing trap now covers both pin locations
  (Justfile and .gitea/workflows/build.yml).
2026-10-10 19:05:34 +02:00
Klagarge 19f6007113 fix: preserve tracked dist/.gitkeep across clean cycles
Commit 6472890 accidentally deleted the committed dist/.gitkeep: the
clean-state test ran 'rm -rf dist' (which contains the tracked .gitkeep)
and the following 'git add -A' recorded the deletion. Fresh clones then
had no dist/ and 'go build'/'just check' failed before the first shiroa
run ('pattern all:dist: no matching files found').

- Justfile: 'clean' now recreates dist/.gitkeep, so the tracked file
  survives clean/build cycles.
- AGENT.md: clean-state procedure uses 'just clean' and requires a clean
  'git status' after test cycles, with the reason documented.
- Verified: with dist/ holding only .gitkeep, 'go build' and 'go vet'
  succeed (fresh-clone state).
2026-10-10 18:58:45 +02:00
Klagarge 6472890fa2 fix: self-bootstrapping shiroa, help on bare just, AGENT.md
- Justfile: pinned shiroa (v0.4.0-rc2, arch-aware) bootstrapped into
  tools/ by the 'tools' recipe; build-docs/preview-docs use it, so a
  fresh clone needs only Go and just. Bare 'just' now shows the recipe
  overview instead of starting a build.
- AGENT.md: operating rules for agents (commands, layout, security
  invariants, shiroa version-pairing trap, testing expectations).
- README: prerequisites updated (no global shiroa), bare-just behavior,
  tools/ in the layout. gitignore tools/ and docs/dist/.
2026-10-10 18:56:05 +02:00
Klagarge fc1b45058c feat: shiroa book, ACL parser, and OIDC-gated Go server
- docs/: shiroa book with three example categories (thermo, security,
  homelab); pages declare access via '#let access-groups = (...)'
- cmd/parser: scans docs/ and generates acl.json (public by default,
  skips book.typ/template.typ/partials)
- internal/acl: loads acl.json, normalizes URLs to page keys so a page's
  renderer data (*.multi.sir.in) inherits its protection; fail closed on
  malformed input
- internal/oidc: authorization-code flow client, verified ID tokens,
  groups claim from Authentik
- internal/middleware: in-memory sessions, login/logout/callback handlers,
  CSRF states, access gate + security headers
- cmd/server + embed.go: single binary embedding dist/ and acl.json;
  /searchindex.json blocked; graceful shutdown
- Justfile (build/run/parse/check/clean/preview-docs), Gitea Actions
  workflow (shiroa v0.4.0-rc2), rootless Dockerfile, README
- legacy docs moved to legacy_docs/; .idea untracked
2026-10-10 18:42:40 +02:00
Klagarge 3601def1f6 chore: move legacy documentation to legacy_docs 2026-10-10 18:29:20 +02:00
Klagarge 1292597e09 chore: setup go module and structure 2026-10-10 13:57:15 +02:00
59 changed files with 1536 additions and 910 deletions

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
.git
.delta
.idea
.vscode
docs
legacy_docs
+17
View File
@@ -0,0 +1,17 @@
# Server configuration. Copy to .env (gitignored), export or source it.
# Address the server listens on.
LISTEN_ADDR=:8080
# Session lifetime.
SESSION_TTL=12h
# Set to false only when testing locally over plain http.
COOKIE_SECURE=false
# OIDC (Authentik). Unset: public pages still work, protected pages answer 503.
OIDC_ISSUER_URL=https://auth.example.com/application/o/yggdrasil/
OIDC_CLIENT_ID=yggdrasil
OIDC_CLIENT_SECRET=change-me
OIDC_REDIRECT_URL=http://localhost:8080/callback
# Comma/space separated scopes. The groups claim must be covered by a scope
# mapping on the provider for group checks to work.
# OIDC_SCOPES=openid profile email groups
+37 -60
View File
@@ -1,71 +1,48 @@
name: Build site
name: build
on:
- push
- workflow_dispatch
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
build:
# NOTE: must match your act_runner labels (e.g. ubuntu-latest, ubuntu-24.04).
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
- name: Bootstrap pinned toolchain
run: ./scripts/bootstrap.sh
- name: Build site
run: ./scripts/calepin.sh compile site public
# The official upload-artifact@v4 refuses to run on Gitea (it detects a
# non-GitHub server and aborts with GHESNotSupportedError). v3 uses the
# artifact API that Gitea implements. On Gitea >= 1.22 with a current
# act_runner, christopherhx/gitea-upload-artifact@v4 is a drop-in
# replacement if you want the v4 API.
- name: Upload site artifact
uses: actions/upload-artifact@v3
- name: Set up Go
uses: actions/setup-go@v5
with:
name: public
path: public/
go-version-file: go.mod
deploy:
needs: build
# Publish only from main; move the gate to publish from another branch.
if: github.ref == 'refs/heads/main'
# Write access for the automatic Actions token to push the pages branch.
permissions:
contents: write
# NOTE: must match your act_runner labels (e.g. ubuntu-latest, ubuntu-24.04).
runs-on: ubuntu-latest
steps:
- name: Download site artifact
uses: actions/download-artifact@v3
with:
name: public
path: public
- name: Check artifact layout
run: test -f public/index.html
# Publishing interfaces with prod; it never runs under local act tests
# (act sets env.ACT, the Gitea runner does not). The force-push to the
# `pages` branch is what triggers the git-pages webhook.
- name: Publish to pages branch
if: ${{ !env.ACT }}
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# shiroa bundles its own Typst compiler, so no separate typst install.
- name: Install shiroa
run: |
if [ -z "$GITEA_TOKEN" ]; then
echo "::error::Missing GITEA_TOKEN (the automatic Actions token)."
exit 1
fi
cd public
git init -q -b pages .
git config user.name "Yggdrasil CI"
git config user.email "ci@noreply.kb28.ch"
git add -A
git commit -q -m "Publish site from ${GITHUB_SHA}"
repo_host="${GITHUB_SERVER_URL#*://}"
repo_host="${repo_host%/}"
git push -q --force "https://${GITHUB_REPOSITORY_OWNER}:${GITEA_TOKEN}@${repo_host}/${GITHUB_REPOSITORY}.git" pages
curl -sSL -o /tmp/shiroa.tar.gz \
https://github.com/Myriad-Dreamin/shiroa/releases/download/v0.4.0-rc2/shiroa-x86_64-unknown-linux-musl.tar.gz
tar -xzf /tmp/shiroa.tar.gz -C /tmp
install -m 755 /tmp/shiroa-x86_64-unknown-linux-musl/shiroa /usr/local/bin/shiroa
shiroa --version
- name: Generate acl.json
run: go run ./cmd/parser -in docs -out acl.json
- name: Build the book
run: shiroa build docs --dest-dir ../dist
- name: Test and build the server
run: |
go vet ./...
go test ./...
test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; }
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o yggdrasil-server ./cmd/server
# upload-artifact v4 needs a reasonably current Gitea/act_runner;
# fall back to v3 if your runner is too old to serve v4 artifacts.
- name: Upload server artifact
uses: actions/upload-artifact@v4
with:
name: yggdrasil-server-linux-amd64
path: yggdrasil-server
+19 -8
View File
@@ -1,10 +1,21 @@
# Generated site and pinned toolchain
public/
# build outputs (dist is regenerated by shiroa; .gitkeep keeps go:embed happy)
/dist/*
!/dist/.gitkeep
/server
yggdrasil-server
# secrets
.env
# editors
.idea/
.vscode/
# delta workspace
/.delta/
# pinned toolchain downloads (shiroa), managed by the Justfile
tools/
# Calepin build artifacts
.calepin/
.calepin-entry.*
# IDE
.idea/
# shiroa serve default output
docs/dist/
-17
View File
@@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Docsify" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="start http://localhost:3000 ; docsify serve " />
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/.idea/runConfigurations/run.sh" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="powershell.exe" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="false" />
<envs />
<method v="2" />
</configuration>
</component>
+79 -19
View File
@@ -1,20 +1,80 @@
# Agent Rules for Yggdrasil Migration
# AGENT.md — operating rules for agents working on Yggdrasil
* **Commit Policy**: Small conventional commits. One logical change per
commit, committed only once that step is verified and working. End every
commit message with a blank line followed by an
`Assisted-by: Delta:zai-glm-5.3` footer.
* **Layout**: Sources live in `site/` (`calepin.toml` + `.typ` pages),
generated output in `public/` (gitignored), pinned binaries in `tools/`
(gitignored), helper scripts in `scripts/`, and the old Docsify
documentation stays untouched in `legacy_docs/`.
* **Toolchain**: `calepin` (0.0.61) and `typst` (0.15.1) are pinned in
`scripts/bootstrap.sh` and downloaded into `tools/` with checksum
verification. The build must never depend on global installs. CI installs
the same pinned versions by calling the same scripts.
* **Verification**: Before committing, run a fresh cycle: `rm -rf tools`,
then `just clean`, then `just build` — the build must succeed with no
calepin on `PATH`. Verify the served site (`just preview`): home 200,
category page 200, sub-page 200, missing page shows the 404 page,
`sitemap.xml` present. After every test/clean cycle `git status` must stay
clean.
Personal knowledge base: Typst sources (`docs/`, a shiroa book) compiled to
a static site, embedded into a Go binary that enforces per-page OIDC group
permissions (Authentik). README.md is the human-facing documentation; this
file is the working rulebook. Follow it; when it conflicts with the user,
the user wins.
## Commands
- `just` — shows the command overview (bare `just` never builds anything).
- `just build` — parse ACL, compile the book, build `./server`.
- `just run` — build everything, then serve on :8080.
- `just check` — go vet, go test, gofmt. Run it before every commit.
- `just clean` — removes `dist/` and `server` (keeps `tools/` and
restores the committed `dist/.gitkeep`).
- The shiroa toolchain is pinned and self-bootstrapped into `tools/` by the
`tools` recipe. Never assume a global shiroa/typst install exists; do not
install shiroa globally; do not bump the pin casually (see Traps).
## Layout
- `docs/` — shiroa book. `book.typ` lists every built page; files not listed
there are not part of the site. Categories are `=` part headers in the
summary, pages are `# chapter("path.typ")[Title]` entries. Final URL =
source path with `.typ` replaced by `.html`.
- `cmd/parser` — build step: scans `docs/` for `#let access-groups = (..)`
tags and emits `acl.json`. Untagged pages are public.
- `cmd/server` + `embed.go` (repo root) — `//go:embed` of `dist/` and
`acl.json` into a single binary.
- `internal/acl` — ACL loading and page-key normalization (security critical).
- `internal/oidc` — OIDC authorization-code client.
- `internal/middleware` — sessions, login/callback/logout, access gate.
- `legacy_docs/` — frozen previous site. Never modify or migrate it unless
the user asks explicitly.
## Security invariants — do not break
- Fail closed: a malformed `acl.json` aborts startup; protected pages with
no configured IdP answer 503, never the content.
- Page keys: shiroa pages keep their rendered content in sibling
`<page>.<theme>.multi.sir.in` files fetched by client-side JS.
`acl.PageKey` reduces any URL to a page key so those files inherit the
page's protection. If the shiroa output format changes, re-verify this
mapping: content in any new sidecar file must be covered or blocked.
- `/searchindex.json` contains the text of protected pages: blocked at the
router, always.
- Session cookies: HttpOnly, SameSite=Lax, Secure unless COOKIE_SECURE=false.
OAuth2 states are random and single use; post-login redirect targets are
sanitized (open-redirect guard).
## Traps
- Shiroa version pairing: the CLI and the `@preview/shiroa:*` typst package
must match (CLI v0.4.0-rc2 pairs with package 0.4.0; the v0.3.0 release
CLI rejects 0.4.0 books). The pin lives in two places: the Justfile
`shiroa-version` and `.gitea/workflows/build.yml`. Bump all of them
together with the package import and rebuild.
- Typst call syntax: no space between `)` and the trailing content block —
`#chapter("x.typ")[Title]`, not `#chapter("x.typ") [Title]`.
- `dist/` and `acl.json` are embedded at Go build time; there is no runtime
reload. After changing docs or ACL, rebuild the binary.
- `go build` needs `dist/` and `acl.json` to exist: fresh clones carry
`dist/.gitkeep` and a committed `acl.json` for exactly this reason.
Never gitignore those two.
## Testing expectations
- Test what the user runs, not a proxy for it: from a clean state
(`rm -rf tools && just clean`), `just build` and `just run` must succeed
with no global shiroa on PATH. Test the bare `just` output too. After
any clean/test cycle, `git status` must be clean: `dist/.gitkeep` is
tracked, and committing a worktree where it is missing breaks fresh
clones (`go:embed` needs `dist/`).
- Server behavior: curl a public page (200), a protected page (503 without
OIDC env; it must never serve protected content), and `/searchindex.json`
(404). Extend the unit tests in `internal/acl` and `internal/middleware`
whenever touching those paths.
- A live OIDC round trip needs a real IdP and is not testable here. Say
that explicitly when reporting what was verified.
+16
View File
@@ -0,0 +1,16 @@
# Rootless image of the Yggdrasil server.
# Build after `just parse build-docs` so that dist/ and acl.json are current:
# just parse build-docs && docker build -t yggdrasil .
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
+44 -19
View File
@@ -1,27 +1,52 @@
# Show the available recipes
# Yggdrasil: Typst documentation behind an authenticated Go server.
# Pinned shiroa toolchain, self-managed under tools/ (no global install).
# The CLI and the @preview/shiroa package version must match (see AGENT.md).
shiroa-version := "v0.4.0-rc2"
shiroa-target := if arch() == "x86_64" { "x86_64-unknown-linux-musl" } else if arch() == "aarch64" { "aarch64-unknown-linux-musl" } else { error("unsupported architecture " + arch()) }
shiroa-dir := "tools/shiroa"
shiroa-bin := shiroa-dir + "/shiroa-" + shiroa-target + "/shiroa"
shiroa-url := "https://github.com/Myriad-Dreamin/shiroa/releases/download/" + shiroa-version + "/shiroa-" + shiroa-target + ".tar.gz"
# Show the available commands.
default:
@just --list
@just --list
# Bootstrap the pinned calepin + typst binaries into tools/
# Build everything: acl.json, the static site, and the server binary.
build: parse build-docs build-server
# Scan docs/ for access-groups tags and regenerate acl.json.
parse:
go run ./cmd/parser -in docs -out acl.json
# Download the pinned shiroa into tools/ if missing.
tools:
./scripts/bootstrap.sh
@mkdir -p {{shiroa-dir}}
@test -x {{shiroa-bin}} || { echo "Downloading shiroa {{shiroa-version}} ({{shiroa-target}})..."; curl -fsSL -o {{shiroa-dir}}/shiroa.tar.gz {{shiroa-url}}; tar -xzf {{shiroa-dir}}/shiroa.tar.gz -C {{shiroa-dir}}; rm {{shiroa-dir}}/shiroa.tar.gz; {{shiroa-bin}} --version; }
# Compile the site into public/
build: tools
./scripts/calepin.sh compile site public
# Compile the Typst book into dist/.
# (--dest-dir is resolved relative to the book directory, docs/, hence ../dist)
build-docs: tools
{{shiroa-bin}} build docs --dest-dir ../dist
# Build, then serve the site locally
preview: build
./scripts/calepin.sh serve public
# Build the server binary (embeds dist/ and acl.json).
build-server:
CGO_ENABLED=0 go build -trimpath -o server ./cmd/server
# Rebuild on change and serve the result
watch: tools
./scripts/calepin.sh watch site public --serve
# Rebuild everything and run the server locally.
run: build
./server
# Remove generated output and build caches
# Live-preview the book without authentication (shiroa dev server).
preview-docs: tools
{{shiroa-bin}} serve docs
# Run static checks and tests.
check:
go vet ./...
go test ./...
@test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; }
# Remove build artifacts (keeps tools/ and the committed dist/.gitkeep).
clean:
./scripts/clean.sh
# Run the CI workflow locally with act (requires act + docker)
ci:
act push -W .gitea/workflows/build.yml -j build --artifact-server-path /tmp/act-artifacts
rm -rf dist server && mkdir -p dist && touch dist/.gitkeep
+179 -67
View File
@@ -1,88 +1,200 @@
# Yggdrasil
Personal knowledge base built as a static website with
[Calepin](https://vincentarelbundock.github.io/calepin/), a Typst-based static
site generator.
A personal knowledge base written in [Typst](https://typst.app), compiled to
a static site by [shiroa](https://github.com/Myriad-Dreamin/shiroa), and
served by a small Go binary that embeds the whole site and gates protected
pages behind OIDC login (Authentik) with per-page group permissions.
## Layout
No search, no database, no Node.js: one binary, one configuration.
- `site/` — website sources: `calepin.toml` + `.typ` pages
- `site/themes/yggdrasil/` — local theme extending the bundled `calepin` theme (scoped sidebars, academic-styled landing)
- `public/` — generated site (gitignored)
- `tools/` — pinned `calepin` and `typst` binaries (gitignored, downloaded on demand)
- `scripts/` — helper scripts used by the Justfile and CI
- `legacy_docs/` — the old Docsify documentation, kept unmodified for reference
## How it works
```mermaid
flowchart TD
A["docs/*.typ (Typst sources)"] -->|"just build-docs (shiroa)"| B["dist/ (static site)"]
A -->|"just parse (cmd/parser, access-groups tags)"| C["acl.json"]
B -->|"go build (//go:embed)"| E["server binary"]
C --> E
E --> F{"request /page.html"}
F -->|"public page"| G["serve page"]
F -->|"protected page"| H{"session + groups ok?"}
H -->|"no session"| I["redirect to /login, then Authentik"]
H -->|"not authorized"| J["403"]
H -->|"authorized"| G
```
- Pages are written in Typst under `docs/` and listed in `docs/book.typ`.
- A page can declare who may read it with a tag:
`#let access-groups = ("family", "admin")`. Pages without a tag are public.
- At build time, `cmd/parser` scans `docs/`, extracts the tags and generates
`acl.json` mapping final page URLs to allowed groups.
- The Go server embeds both `dist/` and `acl.json`. For every request it
reduces the URL to a *page key* and looks it up in the ACL: public pages
are served directly; protected pages require a session whose groups include
at least one of the allowed groups.
- Users authenticate with OIDC. The server reads the `groups` claim of the
verified ID token to build the session.
## Repository layout
```
docs/ Typst sources (shiroa book)
book.typ book metadata + chapter list (the sidebar)
template.typ page layout (from the shiroa minimal template)
index.typ home page (public)
thermo/, security/, homelab/ example categories
cmd/parser/ generates acl.json from access-groups tags
cmd/server/ the web server
internal/acl/ ACL loading, page-key normalization, group checks
internal/oidc/ OIDC client (authorization code flow)
internal/middleware/ sessions, login/logout/callback, access gate
dist/ compiled site (generated, embedded at build time)
tools/ pinned shiroa binary (downloaded by `just tools`)
acl.json generated ACL (embedded at build time)
legacy_docs/ the old documentation base (kept for manual migration)
.gitea/workflows/ Gitea Actions CI
```
## Quick start
`just` is the only prerequisite — the toolchain is downloaded and
checksum-verified into `tools/` on first use (no global installs, no cargo):
Prerequisites: Go 1.27.1+ (the version required by `go.mod`) and
[just](https://github.com/casey/just). The
pinned shiroa toolchain is downloaded automatically into `tools/` on the
first build (needs `curl` + `tar` and network access); nothing is
installed globally.
just # list recipes
just tools # download the pinned toolchain into tools/
just build # compile site/ into public/
just preview # build, then serve the site locally
just watch # rebuild on change and serve
just clean # remove public/ and build caches
just ci # run the CI workflow locally with act (needs act + docker)
```sh
just # show the available commands
just run # parse ACL, compile the book, build and start the server
```
## Adding a page
Then open http://localhost:8080. The home page and public pages render;
protected pages (`/thermo/fluids.html`, `/security/ssh.html`,
`/homelab/backup.html` in the example book) return **503** until OIDC is
configured.
Create `site/<category>/<page>.typ`:
For pure writing previews without authentication, use `just preview-docs`.
## Writing documentation
The book structure mirrors the URL structure: `docs/<path>.typ` is compiled
to `/<path>.html`. To add a page:
1. Create the file, e.g. `docs/security/wireguard.typ`, starting with:
```typst
#import "../template.typ": project
#show: project
= WireGuard
Your content...
```
2. Reference it in `docs/book.typ`. A `=` heading creates a category
(part) in the sidebar; each `- #chapter(...)` is a page:
```typst
= Security
- #chapter("security/ssh.typ", section: "4")[SSH hardening]
- #chapter("security/wireguard.typ", section: "5")[WireGuard]
```
3. Only pages listed in `book.typ` are built. Keep `section:` numbers
consistent (sub-chapters like `"4.1"` nest under `"4"`).
To restrict a page, add the tag on the first line of its file:
```typst
#set document(title: [Page title])
#metadata((title: "Page title", translation_key: "category-page")) <website-metadata>
#title()
= Page title
Content…
// Only readable by members of these groups.
#let access-groups = ("family", "admin")
```
and reference it in `site/calepin.toml`:
- The groups are compared against the `groups` claim of the OIDC ID token
(in Authentik: your user's group memberships).
- A page without the tag is public.
- Everything served *for* a protected page is protected too: the shiroa
renderer fetches page content from `*.multi.sir.in` files, and those
reduce to the same ACL entry as the page itself.
- The search index (`searchindex.json`) would also contain protected text,
so the server never serves it.
```toml
[[sidebar.section.item]]
target = "<category>/<page>.typ"
## Configuration
Environment variables (see `.env.example`):
| Variable | Default | Meaning |
|---------------------|-----------------------------|---------|
| `LISTEN_ADDR` | `:8080` | listen address |
| `SESSION_TTL` | `12h` | session lifetime |
| `COOKIE_SECURE` | `true` | `Secure` cookie flag; use `false` only on plain-http local tests |
| `OIDC_ISSUER_URL` | (unset) | e.g. `https://auth.example.com/application/o/yggdrasil/` |
| `OIDC_CLIENT_ID` | (unset) | OAuth2 client id |
| `OIDC_CLIENT_SECRET`| (unset) | OAuth2 client secret |
| `OIDC_REDIRECT_URL` | (unset) | e.g. `https://docs.example.com/callback` |
| `OIDC_SCOPES` | `openid profile email groups` | requested scopes |
Without the `OIDC_*` variables the server runs in public-only mode:
public pages are served, protected pages answer 503. The server refuses to
start on a malformed `acl.json` or an unreachable OIDC issuer (fail closed).
### Setting up Authentik
1. **Applications -> Providers -> Create**: type *OAuth2/OpenID Connect*.
2. Authorization flow: *implicit/explicit consent* (default is fine).
3. Redirect URI: exactly `https://your-docs-host/callback`.
4. Client type: *Confidential*; note the client id and secret.
5. **Scope mappings**: make sure the issued ID token contains a `groups`
claim. Recent Authentik versions ship a scope mapping that adds
`groups`; otherwise create a *Property Mapping (Scope)* with e.g.
`return {"groups": [g.name for g in user.ak_groups.all()]}` and add it
to the provider.
6. Note the issuer URL shown on the provider page
(`.../application/o/<slug>/`) and set `OIDC_ISSUER_URL` to it.
7. Create groups in **Directory -> Groups** (e.g. `family`, `admin`) whose
names match the `access-groups` in your pages, and add users to them.
The redirect after login returns the user to the page they originally
requested. Logout is available at `/logout`.
## Build and CI
Local commands (see `Justfile`): `just build`, `just run`, `just parse`,
`just check`, `just clean`, `just preview-docs`. Bare `just` prints the
command overview. The pinned shiroa lives in `tools/` (gitignored) and is
downloaded on demand.
`.gitea/workflows/build.yml` (Gitea Actions, not GitLab CI) runs on push to
`main` and on pull requests: installs shiroa, regenerates `acl.json`,
compiles the book, runs `go vet`/`go test`, builds the static binary and
uploads it as an artifact.
A rootless Docker/Podman image is available:
```sh
just parse build-docs
docker build -t yggdrasil .
docker run --rm -p 8080:8080 --env-file .env yggdrasil
```
Sidebar labels come from the page's `#metadata(...)` line, not from the config.
Note that the binary embeds the site at build time: publishing new or
edited pages always requires rebuilding and redeploying the server.
Every page also renders a printable PDF twin (`pdf = true` in
`calepin.toml`, selectable in the page's view picker). Add `pdf: false` to a
page's `#metadata(...)` to skip its PDF, as `404.typ` does.
## Security notes and limitations
## Adding a category
- Sessions live in memory: they vanish on restart and do not scale past
one instance (users simply log in again).
- Page *titles* and URLs of protected pages are visible in the sidebar of
public pages (that is inherent to a static site). Page *content* is not:
neither the HTML nor the renderer data files can be fetched without
authorization.
- Directory listing is enabled (file names only, e.g. `/thermo/`).
- Logout is a GET request (`/logout`), like most documentation sites.
- No search is implemented (`searchindex.json` is explicitly blocked).
- Static assets referenced by pages (theme CSS/JS, fonts, renderer wasm)
are public; they contain no page content.
1. Create `site/<category>/` with an `index.typ`, using
`#metadata((title: "Overview", ...))` for its sidebar label.
2. Add a `[[sidebar.section]]` named after the category in `calepin.toml`,
listing the index page and its sub-pages.
3. Add a `[[menus.main]]` entry with the category's `index.typ` as `target`
and the category name as `label` (e.g. `Home`, `IT`), so it appears in the
top navigation bar. The `label` is required here because the index page's
metadata title is `Overview`.
## Legacy documentation
Pages inside a category show only that category's sidebar section — the
local theme (`site/themes/yggdrasil/partials/site-nav-sidebar.html`) filters
the sidebar to the section containing the current page. Pages outside every
category (landing page, `404`, standalone pages) fall back to the full
sidebar.
## Deployment
`just build` produces a fully static site in `public/`: HTML pages with
printable PDF twins, plus `sitemap.xml`, `robots.txt`, and `llms.txt`. The
Gitea Actions workflow in
`.gitea/workflows/build.yml` builds the site on every push with the same
pinned toolchain and uploads `public/` as an artifact.
Pushes to `main` are also published: the `deploy` job force-pushes `public/`
to the repo's `pages` branch using the automatic Actions token (no secrets to
configure). A Gitea webhook on the repo then notifies the self-hosted
[git-pages](https://codeberg.org/git-pages/git-pages) server, which clones
the `pages` branch and serves the site. The push step is gated with
`if: ${{ !env.ACT }}` so local `act` runs never publish, and
`workflow_dispatch` enables manual publishes from the Gitea UI.
The previous documentation base lives in `legacy_docs/` and is kept only
for reference; migrate pages into `docs/` manually, then delete the folder.
+12
View File
@@ -0,0 +1,12 @@
{
"/homelab/backup.html": [
"family"
],
"/security/ssh.html": [
"admin"
],
"/thermo/fluids.html": [
"family",
"admin"
]
}
+107
View File
@@ -0,0 +1,107 @@
// Command parser scans a directory of Typst sources (a shiroa book) for
// access-group tags and generates the acl.json consumed by the server.
//
// A page declares its access groups with a Typst let binding at the top of
// the file:
//
// #let access-groups = ("family", "admin")
//
// Pages without a tag are public. The generated acl.json maps the final URL
// of each page (e.g. "/thermo/fluids.html") to its authorized groups.
package main
import (
"encoding/json"
"flag"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"regexp"
"strings"
)
// Matches: #let access-groups = ("group1", "group2")
var accessGroupsRe = regexp.MustCompile(`(?m)^\s*#let\s+access-groups\s*=\s*\(([^)]*)\)`)
// Files that are never pages: book metadata, page layout, and partials
// (files starting with "_") imported by other pages.
func isSkipped(base string) bool {
return base == "book.typ" || base == "template.typ" || strings.HasPrefix(base, "_")
}
func main() {
inDir := flag.String("in", "docs", "input directory containing the shiroa book (.typ sources)")
outFile := flag.String("out", "acl.json", "output acl.json file")
flag.Parse()
acl := make(map[string][]string)
err := filepath.WalkDir(*inDir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
base := d.Name()
if d.IsDir() {
// Skip build output and hidden directories.
if p != *inDir && (base == "dist" || strings.HasPrefix(base, ".")) {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(base) != ".typ" || isSkipped(base) {
return nil
}
content, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("reading %s: %w", p, err)
}
groups := extractGroups(content)
if len(groups) == 0 {
return nil // public page, no rule needed
}
relPath, err := filepath.Rel(*inDir, p)
if err != nil {
return err
}
// docs/<path>.typ compiles to dist/<path>.html, served at /<path>.html
urlPath := "/" + strings.TrimSuffix(filepath.ToSlash(relPath), ".typ") + ".html"
acl[urlPath] = groups
log.Printf("protected %s -> %s", urlPath, strings.Join(groups, ", "))
return nil
})
if err != nil {
log.Fatalf("scanning %s: %v", *inDir, err)
}
out, err := json.MarshalIndent(acl, "", " ")
if err != nil {
log.Fatalf("encoding acl: %v", err)
}
out = append(out, '\n')
if err := os.WriteFile(*outFile, out, 0o644); err != nil {
log.Fatalf("writing %s: %v", *outFile, err)
}
fmt.Printf("generated %s (%d protected pages)\n", *outFile, len(acl))
}
func extractGroups(content []byte) []string {
m := accessGroupsRe.FindSubmatch(content)
if m == nil {
return nil
}
var groups []string
for _, part := range strings.Split(string(m[1]), ",") {
part = strings.TrimSpace(part)
part = strings.Trim(part, "\"'")
if part != "" {
groups = append(groups, part)
}
}
return groups
}
+120
View File
@@ -0,0 +1,120 @@
// Command server is the Yggdrasil web server: a single static binary that
// embeds the compiled documentation (dist/) and its access control list
// (acl.json), serves public pages directly, and gates protected pages
// behind OIDC authentication (Authentik).
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/Klagarge/yggdrasil"
"github.com/Klagarge/yggdrasil/internal/acl"
"github.com/Klagarge/yggdrasil/internal/middleware"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
func main() {
listenAddr := envOr("LISTEN_ADDR", ":8080")
sessionTTL := envDuration("SESSION_TTL", 12*time.Hour)
cookieSecure := envOr("COOKIE_SECURE", "true") == "true"
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
list, err := acl.Load(yggdrasil.ACLData())
if err != nil {
log.Fatalf("refusing to start: %v", err) // fail closed
}
sessions := middleware.NewSessionStore(sessionTTL)
go sessions.StartJanitor(ctx, 10*time.Minute)
auth := setupOIDC(ctx)
dist, err := yggdrasil.DistFS()
if err != nil {
log.Fatalf("embedded dist: %v", err)
}
gate := middleware.NewAccessControl(list, sessions, auth, cookieSecure)
mux := http.NewServeMux()
mux.HandleFunc("GET /login", gate.Login)
mux.HandleFunc("GET /callback", gate.Callback)
mux.HandleFunc("GET /logout", gate.Logout)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok\n"))
})
// The search index contains the text of protected pages: never serve it.
mux.HandleFunc("/searchindex.json", http.NotFound)
mux.Handle("/", gate.Wrap(http.FileServerFS(dist)))
srv := &http.Server{
Addr: listenAddr,
Handler: gate.SecurityHeaders(mux),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
log.Printf("listening on %s (%d protected pages)", listenAddr, len(list))
select {
case err := <-errCh:
if !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
case <-ctx.Done():
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
// setupOIDC builds the OIDC client from the environment. Without OIDC
// configuration the server still serves public pages; protected pages
// answer 503 instead of being exposed.
func setupOIDC(ctx context.Context) *oidc.Authenticator {
cfg, err := oidc.LoadConfigFromEnv()
if err != nil {
log.Printf("OIDC disabled (%v) - protected pages will answer 503", err)
return nil
}
auth, err := oidc.NewAuthenticator(ctx, cfg)
if err != nil {
log.Fatalf("OIDC setup: %v", err)
}
log.Printf("OIDC enabled (issuer: %s)", cfg.IssuerURL)
return auth
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
log.Printf("invalid %s %q, using %s", key, v, def)
}
return def
}
View File
Whitespace-only changes.
+22
View File
@@ -0,0 +1,22 @@
#import "@preview/shiroa:0.4.0": *
#show: book
#book-meta(
title: "Yggdrasil",
authors: ("Klagarge",),
summary: [
#prefix-chapter("index.typ")[Home]
= Thermodynamics
- #chapter("thermo/heat-pumps.typ", section: "1")[Heat pumps]
- #chapter("thermo/fluids.typ", section: "2")[Fluid mechanics]
= Security
- #chapter("security/opsec.typ", section: "3")[Opsec basics]
- #chapter("security/ssh.typ", section: "4")[SSH hardening]
= Homelab
- #chapter("homelab/self-hosting.typ", section: "5")[Self-hosting]
- #chapter("homelab/backup.typ", section: "6")[Backups]
],
)
+11
View File
@@ -0,0 +1,11 @@
// Access control: only members of these Authentik groups can read this page.
// Delete this line to make the page public.
#let access-groups = ("family",)
#import "../template.typ": project
#show: project
= Backups
Protected page placeholder about backups: the 3-2-1 rule, restic,
off-site replication...
+7
View File
@@ -0,0 +1,7 @@
#import "../template.typ": project
#show: project
= Self-hosting
Public page placeholder about self-hosting: Docker/Podman, reverse
proxies, DNS, VLANs...
+17
View File
@@ -0,0 +1,17 @@
#import "template.typ": project
#show: project
= Yggdrasil
Welcome to the Yggdrasil knowledge base, built with Typst, Shiroa and a Go
server.
*How it works:*
- Pages are written in Typst under `docs/` and compiled to static HTML.
- A Go server embeds the HTML and an access-control list (`acl.json`).
- Pages tagged with `#let access-groups = ("family", "admin")` are only
visible to authenticated users in those groups; everything else is
public.
Browse the categories in the sidebar to get started.
+7
View File
@@ -0,0 +1,7 @@
#import "../template.typ": project
#show: project
= Opsec basics
Public page placeholder about operational security: threat models,
password managers, 2FA...
+11
View File
@@ -0,0 +1,11 @@
// Access control: only members of these Authentik groups can read this page.
// Delete this line to make the page public.
#let access-groups = ("admin",)
#import "../template.typ": project
#show: project
= SSH hardening
Protected page placeholder about SSH: key management, ciphers,
session hardening, bastion hosts...
+44
View File
@@ -0,0 +1,44 @@
#import "@preview/shiroa:0.4.0": get-page-width, is-html-target, is-pdf-target, is-web-target, templates, x-target
#import templates: *
// Metadata
#let page-width = get-page-width()
#let is-html-target = is-html-target() // target.starts-with("html")
#let is-pdf-target = is-pdf-target() // target.starts-with("pdf")
#let is-web-target = is-web-target() // target.starts-with("web")
#let project(body) = {
// set web/pdf page properties
set page(
width: page-width,
// for a website, we don't need pagination.
height: auto,
) if is-pdf-target or is-web-target
// remove margins for web target
set page(margin: (
// reserved beautiful top margin
top: 20pt,
// Typst is setting the page's bottom to the baseline of the last line of text. So bad :(.
bottom: 0.5em,
// remove rest margins.
rest: 0pt,
)) if is-web-target
let web-theme = if x-target.starts-with("html") and not x-target.starts-with("html-wrapper") {
"starlight"
} else {
"mdbook"
}
show: if web-theme == "starlight" {
import "@preview/shiroa-starlight:0.4.0": starlight
starlight.with(include "book.typ")
} else if web-theme == "mdbook" {
import "@preview/shiroa-mdbook:0.4.0": mdbook
mdbook.with(include "book.typ")
} else {
panic("Unknown web theme: " + web-theme)
}
body
}
+11
View File
@@ -0,0 +1,11 @@
// Access control: only members of these Authentik groups can read this page.
// Delete this line to make the page public.
#let access-groups = ("family", "admin")
#import "../template.typ": project
#show: project
= Fluid mechanics
Protected page placeholder about fluid mechanics: statics, dynamics,
Bernoulli's principle, pressure losses...
+7
View File
@@ -0,0 +1,7 @@
#import "../template.typ": project
#show: project
= Heat pumps
Public page placeholder about heat pumps: COP, seasonal performance,
refrigerant cycles...
+26
View File
@@ -0,0 +1,26 @@
// Package yggdrasil embeds the build artifacts: the static site compiled by
// shiroa (dist/) and the access control list (acl.json). Embedding them at
// the repository root lets //go:embed see both directories relative to this
// file; `go build` requires acl.json and dist/ to exist (see the Justfile).
package yggdrasil
import (
"embed"
"io/fs"
)
//go:embed all:dist
var distFS embed.FS
//go:embed acl.json
var aclData []byte
// DistFS returns the embedded static site.
func DistFS() (fs.FS, error) {
return fs.Sub(distFS, "dist")
}
// ACLData returns the embedded acl.json.
func ACLData() []byte {
return aclData
}
+10
View File
@@ -0,0 +1,10 @@
module github.com/Klagarge/yggdrasil
go 1.27.1
require (
github.com/coreos/go-oidc/v3 v3.21.0
golang.org/x/oauth2 v0.37.0
)
require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+6
View File
@@ -0,0 +1,6 @@
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
+122
View File
@@ -0,0 +1,122 @@
// Package acl loads the access control list generated by cmd/parser and
// answers authorization questions for the HTTP middleware.
//
// acl.json maps the URL of each protected page ("/thermo/fluids.html") to
// the groups allowed to read it. URLs absent from the list are public.
//
// Keys are normalized to a "page key": the URL path reduced to the page it
// belongs to, e.g. "/thermo/fluids.html" and "/thermo/fluids.html" both
// become "/thermo/fluids". The shiroa renderer fetches the page content
// from companion files such as "/thermo/fluids.light.multi.sir.in", which
// reduce to the same key, so a protected page's content cannot be fetched
// directly while the page itself is protected.
package acl
import (
"encoding/json"
"fmt"
"path"
"strings"
)
// ACL maps page keys ("/thermo/fluids") to the groups allowed to read them.
type ACL map[string][]string
// Load parses acl.json and normalizes its keys. A malformed file is an error:
// the caller must refuse to start rather than serve protected pages as
// public (fail closed).
func Load(data []byte) (ACL, error) {
var raw map[string][]string
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parsing acl.json: %w", err)
}
acl := make(ACL, len(raw))
for urlPath, groups := range raw {
if len(groups) == 0 {
continue
}
key, err := PageKey(urlPath)
if err != nil {
return nil, fmt.Errorf("acl entry %q: %w", urlPath, err)
}
// Merge entries that reduce to the same page (e.g. a page and
// its directory index): require the union of their groups.
acl[key] = appendUnique(acl[key], groups...)
}
return acl, nil
}
// PageKey reduces a URL path to the page it belongs to.
//
// "/thermo/fluids.html" -> "/thermo/fluids"
// "/thermo/fluids.light.multi.sir.in" -> "/thermo/fluids"
// "/" -> "/index"
// "/thermo/" -> "/thermo/index"
func PageKey(urlPath string) (string, error) {
if !strings.HasPrefix(urlPath, "/") {
return "", fmt.Errorf("not an absolute path")
}
// A request for a directory serves that directory's index page.
if urlPath != "/" && strings.HasSuffix(urlPath, "/") {
urlPath += "index.html"
}
p := path.Clean(urlPath)
if p == "/" {
return "/index", nil
}
dir, base := path.Split(p)
if i := strings.Index(base, "."); i >= 0 {
base = base[:i]
}
if base == "" {
base = "index"
}
return path.Join(dir, base), nil
}
// GroupsFor returns the union of the groups required by any of the given
// page keys, and whether at least one of them is protected.
func (a ACL) GroupsFor(keys ...string) (required []string, protected bool) {
seen := make(map[string]struct{})
for _, k := range keys {
for _, g := range a[k] {
if _, dup := seen[g]; !dup {
seen[g] = struct{}{}
required = append(required, g)
}
protected = true
}
}
return required, protected
}
// Allows reports whether the user's groups include at least one of the
// required groups.
func Allows(userGroups, required []string) bool {
for _, u := range userGroups {
for _, r := range required {
if u == r {
return true
}
}
}
return false
}
func appendUnique(dst []string, groups ...string) []string {
for _, g := range groups {
if !contains(dst, g) {
dst = append(dst, g)
}
}
return dst
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+67
View File
@@ -0,0 +1,67 @@
package acl
import "testing"
func TestPageKey(t *testing.T) {
cases := []struct{ in, want string }{
{"/", "/index"},
{"/index.html", "/index"},
{"/thermo/fluids.html", "/thermo/fluids"},
// The renderer fetches content from theme variants; they must map
// to the same page or the ACL would be bypassable.
{"/thermo/fluids.light.multi.sir.in", "/thermo/fluids"},
{"/thermo/fluids.ayu.multi.sir.in", "/thermo/fluids"},
// Directory requests resolve to the directory index page.
{"/thermo/", "/thermo/index"},
{"/thermo", "/thermo"},
// Path traversal is neutralized before matching.
{"/thermo/../security/ssh.html", "/security/ssh"},
}
for _, c := range cases {
if got, err := PageKey(c.in); err != nil {
t.Errorf("PageKey(%q) error: %v", c.in, err)
} else if got != c.want {
t.Errorf("PageKey(%q) = %q, want %q", c.in, got, c.want)
}
}
if _, err := PageKey("relative.html"); err == nil {
t.Error("PageKey must reject relative paths")
}
}
func TestLoadAndCheck(t *testing.T) {
// Keys in acl.json are final page URLs; Load normalizes them.
data := []byte(`{
"/thermo/fluids.html": ["family", "admin"],
"/index.html": ["admin"],
"/public.html": ["family"]
}`)
a, err := Load(data)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got, _ := a.GroupsFor("/thermo/fluids"); len(got) != 2 {
t.Errorf("expected 2 groups for /thermo/fluids, got %v", got)
}
if _, protected := a.GroupsFor("/thermo/fluids"); !protected {
t.Error("/thermo/fluids should be protected")
}
if _, protected := a.GroupsFor("/thermo/heat-pumps"); protected {
t.Error("/thermo/heat-pumps should be public")
}
if !Allows([]string{"family"}, []string{"family", "admin"}) {
t.Error("family should be allowed")
}
if Allows([]string{"guest"}, []string{"family", "admin"}) {
t.Error("guest should be denied")
}
if Allows(nil, []string{"admin"}) {
t.Error("no groups should never be allowed on a protected page")
}
}
func TestLoadMalformed(t *testing.T) {
if _, err := Load([]byte("not json")); err == nil {
t.Fatal("malformed acl.json must be an error (fail closed)")
}
}
+89
View File
@@ -0,0 +1,89 @@
// Package middleware implements the access-control layer of the server:
// OIDC login/logout, cookie sessions, and the gate that decides for every
// request whether its URL may be served to the caller.
package middleware
import (
"net/http"
"net/url"
"sync"
"time"
"github.com/Klagarge/yggdrasil/internal/acl"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
const (
sessionCookie = "ygg_session"
stateTTL = 10 * time.Minute
)
// AccessControl bundles everything the gate needs.
type AccessControl struct {
ACL acl.ACL
Sessions *SessionStore
Auth *oidc.Authenticator // nil: OIDC disabled
CookieSecure bool
stateMu sync.Mutex
states map[string]loginState // pending OIDC flows, keyed by state
}
// NewAccessControl wires the gate. auth may be nil (OIDC disabled): public
// pages keep working and protected pages answer 503.
func NewAccessControl(a acl.ACL, sessions *SessionStore, auth *oidc.Authenticator, cookieSecure bool) *AccessControl {
return &AccessControl{
ACL: a,
Sessions: sessions,
Auth: auth,
CookieSecure: cookieSecure,
states: make(map[string]loginState),
}
}
// Wrap returns a handler that serves next only when the request is allowed.
//
// The request path is reduced to a page key (see acl.PageKey) and looked up
// in the ACL. Public pages pass through; protected pages require a session
// whose groups include at least one of the groups required by the ACL.
func (ac *AccessControl) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, err := acl.PageKey(r.URL.Path)
if err != nil {
http.Error(w, "invalid request path", http.StatusBadRequest)
return
}
required, protected := ac.ACL.GroupsFor(key)
if !protected {
next.ServeHTTP(w, r)
return
}
if ac.Auth == nil {
http.Error(w, "this page is protected, but the server has no OIDC configuration",
http.StatusServiceUnavailable)
return
}
sess, ok := ac.session(r)
if !ok {
http.Redirect(w, r, "/login?target="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
if !acl.Allows(sess.Groups, required) {
http.Error(w, "403 Forbidden: your account does not have access to this page.",
http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// SecurityHeaders sets conservative defaults on every response.
func (ac *AccessControl) SecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
+141
View File
@@ -0,0 +1,141 @@
package middleware
import (
"net/http"
"strings"
"time"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
// loginState remembers where a pending OIDC flow must return.
type loginState struct {
target string
expires time.Time
}
// session reads the session cookie and returns the session if valid.
func (ac *AccessControl) session(r *http.Request) (*Session, bool) {
c, err := r.Cookie(sessionCookie)
if err != nil || c.Value == "" {
return nil, false
}
return ac.Sessions.Get(c.Value)
}
func (ac *AccessControl) setSessionCookie(w http.ResponseWriter, id string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: id,
Path: "/",
HttpOnly: true,
Secure: ac.CookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(ac.Sessions.TTL() / time.Second),
})
}
func (ac *AccessControl) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: ac.CookieSecure,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// Login redirects to the OIDC provider, remembering where to return.
func (ac *AccessControl) Login(w http.ResponseWriter, r *http.Request) {
if ac.Auth == nil {
http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable)
return
}
target := sanitizeTarget(r.URL.Query().Get("target"))
state := randomID()
ac.saveState(state, target)
http.Redirect(w, r, ac.Auth.AuthCodeURL(state), http.StatusFound)
}
// Callback finishes the OIDC flow: it verifies the provider's response and
// starts a session.
func (ac *AccessControl) Callback(w http.ResponseWriter, r *http.Request) {
if ac.Auth == nil {
http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable)
return
}
target, ok := ac.takeState(r.URL.Query().Get("state"))
if !ok {
http.Error(w, "unknown or expired login state; restart the login", http.StatusUnauthorized)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
token, err := ac.Auth.Exchange(r.Context(), code)
if err != nil {
http.Error(w, "token exchange failed", http.StatusBadGateway)
return
}
idToken, err := ac.Auth.VerifyIDToken(r.Context(), token)
if err != nil {
http.Error(w, "invalid identity token", http.StatusUnauthorized)
return
}
profile, err := oidc.ProfileFromToken(idToken)
if err != nil {
http.Error(w, "unreadable identity token claims", http.StatusUnauthorized)
return
}
ac.setSessionCookie(w, ac.Sessions.Create(profile))
http.Redirect(w, r, target, http.StatusFound)
}
// Logout ends the session.
func (ac *AccessControl) Logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
ac.Sessions.Delete(c.Value)
}
ac.clearSessionCookie(w)
http.Redirect(w, r, "/", http.StatusFound)
}
func (ac *AccessControl) saveState(state, target string) {
ac.stateMu.Lock()
now := time.Now()
for k, v := range ac.states { // opportunistic purge
if now.After(v.expires) {
delete(ac.states, k)
}
}
ac.states[state] = loginState{target: target, expires: now.Add(stateTTL)}
ac.stateMu.Unlock()
}
func (ac *AccessControl) takeState(state string) (string, bool) {
if state == "" {
return "", false
}
ac.stateMu.Lock()
defer ac.stateMu.Unlock()
s, ok := ac.states[state]
if ok {
delete(ac.states, state) // states are single use
}
if !ok || time.Now().After(s.expires) {
return "", false
}
return s.target, true
}
// sanitizeTarget only accepts local redirect targets (open-redirect guard).
func sanitizeTarget(t string) string {
if t == "" || !strings.HasPrefix(t, "/") || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "/\\") {
return "/"
}
return t
}
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"testing"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
func TestSanitizeTarget(t *testing.T) {
cases := map[string]string{
"": "/",
"/thermo/fluids.html": "/thermo/fluids.html",
"/a b/c.html?x=1": "/a b/c.html?x=1",
"//evil.example.com": "/",
"/\\evil.example.com": "/",
"https://evil.example.com": "/",
"relative.html": "/",
}
for in, want := range cases {
if got := sanitizeTarget(in); got != want {
t.Errorf("sanitizeTarget(%q) = %q, want %q", in, got, want)
}
}
}
func TestStateRoundTrip(t *testing.T) {
ac := NewAccessControl(nil, NewSessionStore(0), nil, false)
ac.saveState("s1", "/thermo/fluids.html")
target, ok := ac.takeState("s1")
if !ok || target != "/thermo/fluids.html" {
t.Fatalf("state round trip failed: %q %v", target, ok)
}
// States are single use.
if _, ok := ac.takeState("s1"); ok {
t.Error("state must be single use")
}
// Unknown states are rejected (CSRF guard).
if _, ok := ac.takeState("nope"); ok {
t.Error("unknown state must be rejected")
}
}
func TestSessionExpiry(t *testing.T) {
store := NewSessionStore(0) // zero TTL: expires immediately
id := store.Create(oidc.Profile{Subject: "sub", Email: "user@example.com", Groups: []string{"family"}})
if _, ok := store.Get(id); ok {
t.Error("expired session must not validate")
}
}
+101
View File
@@ -0,0 +1,101 @@
package middleware
import (
"context"
"crypto/rand"
"encoding/base64"
"sync"
"time"
"github.com/Klagarge/yggdrasil/internal/oidc"
)
// Session is an authenticated user.
type Session struct {
Subject string
Email string
Name string
Groups []string
Expires time.Time
}
// SessionStore keeps sessions in memory. Sessions do not survive server
// restarts; users simply log in again.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
ttl time.Duration
}
// NewSessionStore creates a store with the given session lifetime.
func NewSessionStore(ttl time.Duration) *SessionStore {
return &SessionStore{
sessions: make(map[string]*Session),
ttl: ttl,
}
}
// TTL reports the session lifetime.
func (s *SessionStore) TTL() time.Duration { return s.ttl }
// Create registers a new session and returns its ID.
func (s *SessionStore) Create(p oidc.Profile) string {
id := randomID()
s.mu.Lock()
s.sessions[id] = &Session{
Subject: p.Subject,
Email: p.Email,
Name: p.Name,
Groups: p.Groups,
Expires: time.Now().Add(s.ttl),
}
s.mu.Unlock()
return id
}
// Get returns the session for id if it exists and has not expired.
func (s *SessionStore) Get(id string) (*Session, bool) {
s.mu.RLock()
sess, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || time.Now().After(sess.Expires) {
return nil, false
}
return sess, true
}
// Delete removes a session.
func (s *SessionStore) Delete(id string) {
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
// StartJanitor purges expired sessions periodically until ctx is done.
// Call it in a goroutine.
func (s *SessionStore) StartJanitor(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
s.mu.Lock()
for id, sess := range s.sessions {
if now.After(sess.Expires) {
delete(s.sessions, id)
}
}
s.mu.Unlock()
}
}
}
func randomID() string {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
panic(err) // crypto/rand does not fail on supported platforms
}
return base64.RawURLEncoding.EncodeToString(b[:])
}
+118
View File
@@ -0,0 +1,118 @@
// Package oidc implements the OIDC client (authorization code flow) used to
// authenticate users. It is the only place that talks to the identity
// provider (e.g. Authentik).
package oidc
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// Config is the OIDC client configuration, read from the environment by
// LoadConfigFromEnv.
type Config struct {
ClientID string
ClientSecret string
IssuerURL string
RedirectURL string
Scopes string
}
// LoadConfigFromEnv reads the OIDC_* variables. It returns an error when
// OIDC is not (fully) configured; the caller decides whether that is fatal.
func LoadConfigFromEnv() (*Config, error) {
cfg := &Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Scopes: os.Getenv("OIDC_SCOPES"),
}
if cfg.Scopes == "" {
cfg.Scopes = "openid profile email groups"
}
if cfg.ClientID == "" || cfg.IssuerURL == "" || cfg.RedirectURL == "" {
return nil, errors.New("OIDC_CLIENT_ID, OIDC_ISSUER_URL and OIDC_REDIRECT_URL must be set")
}
return cfg, nil
}
// Authenticator drives the authorization code flow and verifies ID tokens.
type Authenticator struct {
oauth2 oauth2.Config
verifier *oidc.IDTokenVerifier
}
// NewAuthenticator discovers the provider at the issuer URL. It fails fast
// when the issuer is unreachable so that a misconfigured server never
// starts serving protected content.
func NewAuthenticator(ctx context.Context, cfg *Config) (*Authenticator, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("querying OIDC provider %s: %w", cfg.IssuerURL, err)
}
return &Authenticator{
oauth2: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: strings.Fields(cfg.Scopes),
},
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
}, nil
}
// AuthCodeURL returns the URL the user is redirected to for login.
func (a *Authenticator) AuthCodeURL(state string) string {
return a.oauth2.AuthCodeURL(state)
}
// Exchange trades an authorization code for tokens.
func (a *Authenticator) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
return a.oauth2.Exchange(ctx, code)
}
// VerifyIDToken extracts and verifies the ID token issued in the code
// exchange. Signature, issuer, audience and expiry are checked.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token in OAuth2 token response")
}
return a.verifier.Verify(ctx, rawIDToken)
}
// Profile holds the user attributes used for sessions and logging.
type Profile struct {
Subject string
Email string
Name string
Groups []string
}
// ProfileFromToken reads user attributes from a verified ID token. A missing
// groups claim yields nil groups, which denies every protected page
// (fail closed).
func ProfileFromToken(idToken *oidc.IDToken) (Profile, error) {
var claims struct {
Name string `json:"name"`
Email string `json:"email"`
Groups []string `json:"groups"`
}
if err := idToken.Claims(&claims); err != nil {
return Profile{}, fmt.Errorf("reading ID token claims: %w", err)
}
return Profile{
Subject: idToken.Subject,
Email: claims.Email,
Name: claims.Name,
Groups: claims.Groups,
}, nil
}
View File
File renamed without changes.
+33
View File
@@ -0,0 +1,33 @@
<header>
<h1>Yggdrasil</h1>
</header>
Yggdrasil is a collections of notes around IT, OT and other geeks stuff
<br>
<br>
<br>
# [Bachelor in embedded system](/infotro/)
Here notes I take during my bachelor in embedded systems
Mainly some topics:
- [PTR - Programmation Temps Réel](/infotro/PTR/)
- [SDi - Système Distribué](/infotro/SDi/)
- [Eln - Électronique](/infotro/Eln/)
- [SEm - Systèmes Embarqué](/infotro/SEm/)
# [Master of science in engineering in embedded system](/MSE/)
Here note I take during my Master of science un engineering with embedded system as orientation
Mainly some topics:
- [SRE - Software Reverse Engineering](/MSE/SRE/)
# [Embedded Systems](/Embed/)
Here some note specific about some Embedded System
- [STM32](/Embed/STM32/)
- [nRF](/Embed/nRF/)
# [Working environment at HES-SO](/HES-SO/)
Here some note about the working environment at HES-SO
- [OneDrive - Linux](/HES-SO/OneDrive.md)
- [Teams - Linux](/HES-SO/Teams.md)
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
# Bootstrap the pinned toolchain (Calepin + Typst) into tools/.
# Idempotent: skips downloads when the binaries are already in place.
set -euo pipefail
# Pinned toolchain -- bump both binaries together and re-verify checksums.
calepin_version="0.0.61"
typst_version="0.15.1"
# Typst publishes no .sha256 assets; this digest comes from the official
# release metadata (GitHub asset digest of typst-x86_64-unknown-linux-musl.tar.xz).
typst_sha256="a6d077d0a95eed5a2eba715b2dae06be954f624ccbf85758a03f389ded33118c"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tools="$root/tools"
if [ "$(uname -m)" != "x86_64" ]; then
echo "error: unsupported architecture $(uname -m), pinned binaries are x86_64" >&2
exit 1
fi
mkdir -p "$tools"
if [ ! -x "$tools/calepin" ]; then
name="calepin-x86_64-unknown-linux-gnu.tar.xz"
base="https://github.com/vincentarelbundock/calepin/releases/download/v$calepin_version"
curl -fsSLo "$tools/$name" "$base/$name"
curl -fsSLo "$tools/$name.sha256" "$base/$name.sha256"
(cd "$tools" && sha256sum -c "$name.sha256")
tar -xJf "$tools/$name" -C "$tools" --strip-components=1 "calepin-x86_64-unknown-linux-gnu/calepin"
rm "$tools/$name" "$tools/$name.sha256"
fi
if [ ! -x "$tools/typst" ]; then
name="typst-x86_64-unknown-linux-musl.tar.xz"
curl -fsSLo "$tools/$name" "https://github.com/typst/typst/releases/download/v$typst_version/$name"
echo "$typst_sha256 $name" > "$tools/$name.sha256"
(cd "$tools" && sha256sum -c "$name.sha256")
tar -xJf "$tools/$name" -C "$tools" --strip-components=1 "typst-x86_64-unknown-linux-musl/typst"
rm "$tools/$name" "$tools/$name.sha256"
fi
"$tools/calepin" --version
"$tools/typst" --version
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Run the pinned Calepin CLI from tools/, bootstrapping the toolchain on demand.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [ ! -x "$root/tools/calepin" ]; then
"$root/scripts/bootstrap.sh"
fi
export PATH="$root/tools:$PATH"
exec "$root/tools/calepin" "$@"
-9
View File
@@ -1,9 +0,0 @@
#!/usr/bin/env bash
# Remove generated site output and build caches. Never touches tracked files.
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
rm -rf "$root/public"
find "$root/site" -name '.calepin' -prune -exec rm -rf {} +
find "$root/site" -type f -name '.calepin-entry.*' -delete
-6
View File
@@ -1,6 +0,0 @@
#set document(title: [Not found])
#metadata((pdf: false)) <website-metadata>
#title()
The requested page does not exist.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-43
View File
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="83.380997mm"
height="83.36158mm"
viewBox="0 0 83.380997 83.36158"
version="1.1"
id="svg5"
xml:space="preserve"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="yggdrasil.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="1.0544998"
inkscape:cx="-142.7217"
inkscape:cy="163.58467"
inkscape:window-width="1918"
inkscape:window-height="1008"
inkscape:window-x="953"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" /><defs
id="defs2" /><g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-48.417524,-42.311191)"><path
style="fill:#000000;fill-opacity:1;stroke-width:0.264583"
d="m 84.403651,125.38487 c -11.277707,-1.62161 -21.59067,-7.82705 -28.163642,-16.94643 -2.120561,-2.94208 -4.8935,-8.42917 -5.950044,-11.773956 C 48.523252,91.071465 47.926554,83.146337 48.845694,77.482192 49.93327,70.780068 52.639578,64.396111 56.667168,59.031973 67.423912,44.70563 85.766835,38.878817 102.96132,44.326174 c 3.29203,1.042943 7.26188,2.961672 10.21602,4.937663 19.27665,12.893945 24.40963,38.757842 11.51548,58.023873 -6.09124,9.10134 -15.84844,15.57084 -26.688341,17.69569 -3.690904,0.72349 -10.054808,0.91134 -13.600828,0.40147 z m 10.989334,-6.5207 c 2.686786,-0.37701 2.725391,-0.43473 1.345679,-2.01195 -1.435587,-1.64109 -3.538548,-2.98982 -4.661781,-2.98982 -1.068729,0 -3.863835,1.97691 -5.118653,3.6203 -0.511633,0.67006 -0.930243,1.31456 -0.930243,1.43221 0,0.48213 5.791144,0.45075 9.364998,-0.0507 z m -12.308406,-2.23288 c 0.491178,-0.94989 1.416942,-2.30977 2.057257,-3.02195 0.716828,-0.79729 1.054034,-1.36297 0.877507,-1.47207 -0.458722,-0.28351 -3.691126,0.13469 -5.353923,0.69268 -1.772833,0.5949 -6.014517,2.51905 -6.014517,2.72835 0,0.38049 6.053839,2.73968 7.14375,2.78394 0.228987,0.009 0.774661,-0.71448 1.289926,-1.71095 z m 21.922391,-0.66565 c 1.32429,-0.61183 2.17999,-1.15009 2.03713,-1.28142 -0.89744,-0.82506 -6.75815,-2.40932 -8.912889,-2.40932 h -0.802899 l 1.313889,1.38925 c 0.722638,0.76408 1.653769,1.90284 2.069189,2.53057 0.8887,1.34293 0.89352,1.34267 4.29558,-0.22908 z m -31.746628,-4.6207 c 5.042327,-1.65497 9.255499,-3.75495 11.033473,-5.49945 2.135584,-2.09538 2.630556,-3.61771 2.64709,-8.141381 0.01273,-3.481216 -0.01109,-3.637899 -0.781344,-5.14404 -0.652878,-1.276601 -1.026305,-1.672767 -2.095314,-2.222897 -2.0984,-1.079873 -4.794002,-0.902705 -11.52452,0.757452 -4.376931,1.079619 -5.851818,1.33617 -7.672917,1.334672 -2.419566,-0.0021 -5.635238,-0.651939 -7.782001,-1.572895 -0.71182,-0.305369 -1.356009,-0.493427 -1.43153,-0.417904 -0.198855,0.198855 0.419965,2.868422 1.19789,5.167654 1.894982,5.600809 5.871765,11.438149 10.391401,15.253069 1.091406,0.92123 2.072997,1.67616 2.181314,1.67762 0.108316,0.002 1.834723,-0.5349 3.836458,-1.1919 z m 41.755458,-2.57577 c 3.98707,-4.04651 6.77652,-8.5431 8.41183,-13.559894 0.65571,-2.011585 1.53407,-5.73677 1.38728,-5.883558 -0.0378,-0.03778 -0.77417,0.217977 -1.63644,0.568348 -1.97533,0.802648 -4.85026,1.391135 -7.91402,1.619967 -2.63428,0.196755 -5.34063,-0.171648 -9.83252,-1.338458 -5.10034,-1.324859 -7.486541,-1.144257 -8.407312,0.636318 -0.522383,1.010179 -1.01409,5.134496 -0.808157,6.778633 0.867765,6.928104 5.122759,11.319124 12.960829,13.375194 2.98126,0.78204 2.85796,0.82843 5.83851,-2.19655 z M 69.067334,88.753522 c 0.785339,-0.318225 1.947269,-0.951582 2.582067,-1.407459 l 1.154177,-0.828866 -0.552597,-0.588214 c -0.762039,-0.811154 -3.235689,-1.870228 -5.140703,-2.200955 -1.678927,-0.291475 -10.077076,-1.086461 -11.477263,-1.086461 -0.82324,0 -0.825862,0.0046 -0.825862,1.434907 0,1.131462 0.135254,1.595649 0.639572,2.194997 0.761174,0.904605 2.866943,2.092999 4.902132,2.766528 2.08043,0.688499 6.688349,0.538147 8.718477,-0.284477 z m 50.863986,-1.442054 c 1.25709,-0.374959 2.95373,-0.977217 3.77031,-1.338347 l 1.48469,-0.6566 V 83.08766 c 0,-1.225873 -0.18263,-3.286255 -0.40585,-4.578623 -0.43601,-2.52445 -1.17016,-5.39247 -1.38034,-5.39247 -0.0709,0 -0.81844,0.418835 -1.66114,0.930746 -0.90567,0.550169 -2.3141,1.821365 -3.44446,3.108854 -4.16097,4.73936 -7.11814,6.862702 -11.69519,8.397521 -0.98226,0.329382 -1.78594,0.678349 -1.78594,0.775486 0,0.247123 2.192,1.008276 4.23551,1.470744 2.7024,0.611582 7.99014,0.374246 10.88241,-0.48845 z M 83.761875,83.983222 c 1.482519,-0.243309 2.266112,-0.981247 2.266112,-2.134079 0,-0.40359 -0.478081,-1.688976 -1.062403,-2.856413 -1.828768,-3.653772 -5.014299,-6.267254 -9.742194,-7.992721 -1.196372,-0.436624 -2.320827,-0.793859 -2.498788,-0.793859 -0.177961,0 -0.619081,0.557051 -0.980265,1.23789 -0.85316,1.608223 -4.006178,4.820484 -5.692392,5.79935 -1.256203,0.729242 -1.476359,0.767323 -4.365626,0.75515 -1.944292,-0.0082 -3.589412,-0.165622 -4.557125,-0.436092 l -1.514417,-0.423272 -0.168301,0.89712 c -0.288931,1.540134 -0.178452,1.602422 3.212515,1.811239 7.690604,0.473594 9.811887,0.810062 14.217736,2.25515 6.282295,2.060549 7.990334,2.35563 10.885148,1.880537 z m 21.718565,-1.70688 c 3.55447,-1.135328 7.08989,-3.616397 9.42867,-6.616799 1.95582,-2.509107 3.72573,-4.128383 5.42887,-4.966837 0.92034,-0.453081 1.67334,-0.938086 1.67334,-1.077789 0,-0.30966 -1.98551,-4.112838 -2.50079,-4.790185 -0.34453,-0.452895 -0.55038,-0.305629 -2.9696,2.124496 -2.56587,2.577443 -5.413,4.708495 -7.40044,5.539176 -0.56089,0.234431 -2.24501,0.770331 -3.7425,1.190889 -4.54688,1.276956 -8.651044,3.452133 -9.458039,5.01269 -0.675886,1.307018 -0.367083,3.395645 0.617138,4.17408 0.767993,0.60742 6.325171,0.24016 8.923351,-0.589721 z M 90.412503,75.838005 c 0.403259,-0.403259 0.435912,-0.894781 0.294405,-4.431771 -0.183579,-4.588598 -0.714886,-7.121012 -2.229903,-10.62857 -1.904016,-4.4081Line truncated
id="path304" /></g></svg>

Before

Width:  |  Height:  |  Size: 8.7 KiB

-91
View File
@@ -1,91 +0,0 @@
title = "Yggdrasil"
description = "Personal knowledge base."
base-url = "https://yggdrasil.kb28.ch"
theme = "themes/yggdrasil"
pdf = true
minify = true
search = "pagefind"
logo = "assets/yggdrasil.svg"
favicon = "assets/yggdrasil.ico"
[robots]
enabled = false
[pages]
include = ["tags.typ"]
exclude = ["drafts/**"]
[static]
include = [
"assets/**",
"CNAME"
]
exclude = ["drafts/**"]
[menus]
main = [
{ label = "Home", target = "home/index.typ" },
{ label = "IT", target = "it/index.typ" },
{ label = "OT", target = "ot/index.typ" },
{ label = "Security", target = "security/index.typ" },
{ label = "Medias", target = "medias/index.typ" },
]
social = [
{ label = "{icon:simple-icons:gitea}", target = "https://git.kb28.ch/Klagarge/Yggdrasil", aria-label = "Gitea" },
]
[footer]
item = [
{ label = "© 2026" },
{ label = "@Klagarge", target = "https://git.kb28.ch/Klagarge" },
{ label = "@Fastium", target = "https://github.com/Fastium" },
]
[sidebar]
fold = true
[[sidebar.section]]
title = "Home"
item = [
{ target = "home/index.typ" },
{ glob = "home/*" }
]
[[sidebar.section]]
title = "IT"
item = [
{ target = "it/index.typ" },
{ glob = "it/*" }
]
[[sidebar.section]]
title = "OT"
item = [
{ target = "ot/index.typ" },
{ glob = "ot/*" }
]
[[sidebar.section]]
title = "Security"
item = [
{ target = "security/index.typ" },
{ glob = "security/*" }
]
[[sidebar.section]]
title = "Medias"
target = "medias/index.typ"
item = [
# { glob = "medias/films/*.typ" },
# { glob = "medias/series/*.typ" },
# { glob = "medias/books/*.typ" },
{ target = "medias/index.typ" },
{ glob = "medias/*.typ" },
]
section = [
{ title = "Films", item = [{ glob = "medias/films/*.typ" }] },
{ title = "Series", item = [{ glob = "medias/series/*.typ" }] },
{ title = "Books", item = [{ glob = "medias/books/*.typ" }] },
]
-6
View File
@@ -1,6 +0,0 @@
#set document(title: [Cooking])
#metadata((title: "Cooking", translation_key: "home-cooking")) <website-metadata>
#title()
Placeholder page — the real content comes later.
-6
View File
@@ -1,6 +0,0 @@
#set document(title: [Gardening])
#metadata((title: "Gardening", translation_key: "home-gardening")) <website-metadata>
#title()
Placeholder page — the real content comes later.
-7
View File
@@ -1,7 +0,0 @@
#set document(title: [Home])
#metadata((title: "Overview", translation_key: "home-index")) <website-metadata>
#title()
Placeholder page for the home category: cooking, washing clothes, gardening.
The real content comes later.
-6
View File
@@ -1,6 +0,0 @@
#set document(title: [Washing clothes])
#metadata((title: "Washing clothes", translation_key: "home-washing-clothes")) <website-metadata>
#title()
Placeholder page — the real content comes later.
-7
View File
@@ -1,7 +0,0 @@
#set document(title: [Yggdrasil])
#metadata((title: "Home", translation_key: "home", layout: "layouts/site-landing.html")) <website-metadata>
#title()
Welcome to Yggdrasil, my personal knowledge base.
Pick a master category from the menu
-7
View File
@@ -1,7 +0,0 @@
#set document(title: [IT])
#metadata((title: "Overview", translation_key: "it-index")) <website-metadata>
#title()
Placeholder page for the IT category.
The real content comes later.
-11
View File
@@ -1,11 +0,0 @@
#set document(title: [Le Grand Cahier])
#metadata((
translation_key: "le_grand_cahier",
date: "2026-10-10",
author: "Rémi Heredero",
pdf: true
))<website-metadata>
#title()
This book is awesome!
-10
View File
@@ -1,10 +0,0 @@
#set document(title: [H2G2])
#metadata((
translation_key: "h2g2",
date: "2026-10-10",
author: "Rémi Heredero",
pdf: true
))<website-metadata>
#title()
One of my favourites films!
-43
View File
@@ -1,43 +0,0 @@
#set document(title: [Medias])
#metadata((
title: "Overview",
translation_key: "medias-overview",
date: "2026-10-10",
tags: ("medias", "films", "series", "books"),
author: "Rémi Heredero",
summary: "My very opinionned ideas about somes films, series and books",
draft: false,
pdf: false
))<website-metadata>
#title()
Placeholder page for the medias category.
The real content comes later.
#import "@preview/calepin:0.1.0" as calepin
#show: calepin.document
== Films
#let posts = calepin.pages().filter(p => p.path.starts-with("medias/films/")).filter(p => not p.meta.at("draft", default: false)).sorted(key: p => p.meta.at("date", default: "")).rev()
#for post in posts [
- #link(post.href)[#post.title] \
#post.meta.at("summary", default: [])
]
== Series
#let posts = calepin.pages().filter(p => p.path.starts-with("medias/series/")).filter(p => not p.meta.at("draft", default: false)).sorted(key: p => p.meta.at("date", default: "")).rev()
#for post in posts [
- #link(post.href)[#post.title] \
#post.meta.at("summary", default: [])
]
== Books
#let posts = calepin.pages().filter(p => p.path.starts-with("medias/books/")).filter(p => not p.meta.at("draft", default: false)).sorted(key: p => p.meta.at("date", default: "")).rev()
#for post in posts [
- #link(post.href)[#post.title] \
#post.meta.at("summary", default: [])
]
-12
View File
@@ -1,12 +0,0 @@
#set document(title: [Plex])
#metadata((
translation_key: "plex",
date: "2026-10-10",
author: "Rémi Heredero",
summary: "Plex is a media server",
pdf: true
))<website-metadata>
#title()
I think I'll switch to Jellyfin not in so long time
-17
View File
@@ -1,17 +0,0 @@
#set document(title: [The Expanse])
#metadata((
translation_key: "the_expanse",
date: "2026-10-10",
author: "Rémi Heredero",
summary: " - S01: 9/10
- S02: 9/10
- S03: 8/10
- S04: 8/10
- S05: 8/10
- S06: 8/10",
pdf: true
))<website-metadata>
#title()
I love hard science!
-7
View File
@@ -1,7 +0,0 @@
#set document(title: [OT])
#metadata((title: "Overview", translation_key: "ot-index")) <website-metadata>
#title()
Placeholder page for the OT category.
The real content comes later.
-7
View File
@@ -1,7 +0,0 @@
#set document(title: [Security])
#metadata((title: "Overview", translation_key: "security")) <website-metadata>
#title()
Placeholder page for the security category.
The real content comes later.
-6
View File
@@ -1,6 +0,0 @@
#set document(title: [SSH])
#metadata((title: "SSH", translation_key: "security-ssh")) <website-metadata>
#title()
Placeholder page — the real content comes later.
-23
View File
@@ -1,23 +0,0 @@
#import "/.calepin/calepin.typ" as calepin
#show: calepin.document
#let taxonomy(field) = {
let pages = calepin.pages()
.filter(page => page.meta.at(field, default: ()) != ())
let terms = pages
.map(page => page.meta.at(field))
.flatten()
.dedup()
.sorted(key: term => lower(term))
for term in terms {
heading(term, level: 2)
for page in pages.filter(
page => term in page.meta.at(field)
) [
- #link(page.href)[#page.title]
]
}
}
#taxonomy("tags")
@@ -1,293 +0,0 @@
{% set calepin_theme_storage_key = "calepin-website-theme" %}
{% set calepin_theme_param = "theme" %}
{% include "partials/site-head.html" %}
<style>
/*
* Academic theme styling, applied to the landing page only.
* Copied from the bundled academic theme (css/50_main.css, calepin 0.0.61).
*/
:root {
--calepin-page-width: calc(var(--calepin-content-width) + var(--calepin-margin-gap) + var(--calepin-margin-width));
--calepin-margin-width: 16rem;
}
.academic-topbar {
transition: transform 180ms ease;
will-change: transform;
}
.academic-topbar.is-hidden {
transform: translateY(-100%);
}
.academic-document-controls {
display: flex;
justify-content: flex-end;
width: min(100% - 2rem, var(--calepin-content-width));
margin: 1rem auto 0;
}
.academic-nav {
display: flex;
align-items: center;
justify-content: space-evenly;
width: min(100% - 2rem, var(--calepin-page-width));
margin-inline: auto;
}
.academic-nav .calepin-site-brand {
font-size: 1.2rem;
}
.academic-menu {
display: contents;
}
.academic-nav .calepin-site-language-picker {
width: auto;
min-width: 7rem;
margin: 0;
padding: 0.3rem 1.8rem 0.3rem 0.6rem;
font-size: 0.85rem;
}
.academic-page {
width: min(100% - 2rem, var(--calepin-content-width));
margin-inline: auto;
padding-block: 3.2rem 4rem;
}
.academic-main {
width: min(100%, var(--calepin-content-width));
}
.academic-page.has-toc {
width: min(100% - 2rem, var(--calepin-page-width));
}
/* Academic only positions the shared TOC in its sidenote-style margin. */
.academic-page .calepin-website-toc {
float: right;
clear: right;
width: var(--calepin-margin-width);
margin: 0.3rem 0 1.5rem var(--calepin-margin-gap);
}
.academic-document-main {
width: min(100% - 2rem, var(--calepin-content-width));
margin-inline: auto;
padding-block: 2.4rem 4rem;
}
body:has(.academic-document-main .calepin-sidenote) .academic-document-main,
body:has(.academic-document-main .calepin-sidefigure) .academic-document-main {
width: min(100% - 2rem, var(--calepin-page-width));
}
.academic-document-main > :not(.fullwidth):not(.full-width):not(.calepin-figure-grid) {
max-width: var(--calepin-content-width);
}
.academic-document-main > .calepin-figure-width {
margin-inline: 0 auto !important;
max-width: var(--calepin-content-width) !important;
}
.calepin-sidenote,
.calepin-sidefigure {
display: block;
float: right;
clear: right;
width: var(--calepin-margin-width);
color: var(--calepin-color-muted);
font-size: 0.84rem;
line-height: 1.45;
scroll-margin-top: calc(var(--calepin-topbar-height) + 1rem);
}
.calepin-sidenote {
margin: 0.25rem calc(-1 * (var(--calepin-margin-width) + var(--calepin-margin-gap))) 1rem var(--calepin-margin-gap);
}
.calepin-sidenote p,
.calepin-sidefigure figcaption {
margin: 0 0 0.7rem;
line-height: inherit;
}
.calepin-sidenote-numbered::before {
content: "[" attr(data-sidenote-number) "]";
display: inline-block;
margin-right: 0.28rem;
color: var(--calepin-color-accent);
font-size: 0.82em;
font-weight: 600;
line-height: 1;
}
/* In-text marker for a numbered sidenote. The number is emitted by the sidenote
element; CSS only renders it as a superscript. */
[role="doc-noteref"] {
color: var(--calepin-color-accent);
font-size: 0.75em;
font-weight: 600;
vertical-align: super;
line-height: 0;
text-decoration: none;
}
/* A figure, table, or code output placed in the margin. */
.calepin-sidefigure {
margin: 0.4rem 0 1rem var(--calepin-margin-gap);
}
.calepin-sidefigure figure,
.calepin-sidefigure img,
.calepin-sidefigure svg,
.calepin-sidefigure table {
width: 100%;
margin: 0;
}
.calepin-sidefigure table {
font-size: 0.8rem;
}
.calepin-site-footer {
width: min(100% - 2rem, var(--calepin-page-width));
margin: 2.25rem auto 0;
padding-top: 1.3rem;
border-top: 1px solid var(--calepin-color-border);
color: var(--calepin-color-muted);
font-size: 0.86rem;
}
.calepin-site-footer-inner {
width: min(100%, var(--calepin-page-width));
}
.calepin-site-footer-links {
margin: 0;
padding: 0;
list-style: none;
list-style-type: none;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.6rem 1rem;
}
.calepin-site-footer-links li {
list-style: none;
}
.academic-landing {
width: min(100% - 2rem, var(--calepin-page-width));
margin-inline: auto;
padding-block: 3rem 5rem;
}
.academic-landing .landing-hero {
display: grid;
align-content: center;
justify-items: start;
gap: 1rem;
max-width: var(--calepin-content-width);
min-height: min(52vh, 32rem);
border-bottom: 1px solid var(--calepin-color-border);
}
.academic-landing .landing-hero h1,
.academic-landing .landing-hero h2 {
margin: 0;
}
@media (max-width: calc(39rem + 16rem + 2rem + 2rem)) {
.calepin-sidenote,
.calepin-sidefigure {
display: block;
float: none;
width: auto;
margin: 0.75rem 0 1.2rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--calepin-color-border);
border-radius: var(--calepin-radius-sm);
background: var(--calepin-surface-muted);
}
.academic-page .calepin-website-toc {
display: none;
}
.academic-page.has-toc {
width: min(100% - 2rem, var(--calepin-content-width));
}
}
@media (max-width: 48rem) {
.academic-nav {
position: relative;
}
.academic-nav-toggle {
display: inline-grid;
margin-left: auto;
}
.academic-menu {
position: absolute;
inset: calc(var(--calepin-topbar-height) - 0.35rem) 0 auto auto;
display: none;
width: max-content;
max-width: calc(100vw - 2rem);
padding: 0.75rem;
border: 1px solid var(--calepin-color-border);
border-radius: var(--calepin-radius-sm);
background: var(--calepin-color-background);
box-shadow: var(--calepin-shadow-card);
}
.academic-menu.is-open {
display: grid;
gap: 0.25rem;
justify-items: stretch;
}
.academic-menu a {
display: flex;
min-height: 2.35rem;
padding: 0.25rem 0.2rem;
border-bottom: 0;
}
.academic-page,
.academic-document-controls,
.academic-document-main,
.academic-landing {
width: min(100% - 1.25rem, var(--calepin-content-width));
}
.academic-page,
.academic-document-main,
.academic-landing {
padding-block: 2rem 3rem;
}
}
@media (prefers-reduced-motion: reduce) {
.academic-topbar {
transition: none;
}
}
</style>
{{ doc.body_open }}
{% set show_site_sidebar = false %}
{% include "partials/site-nav.html" %}
<main class="academic-landing calepin-landing calepin-content" data-pagefind-body>
{{ doc.body }}
</main>
{% include "partials/site-footer.html" %}
{% include "partials/site-search.html" %}
{% include "partials/theme-scripts.html" %}
{{ doc.body_close }}
@@ -1,47 +0,0 @@
<aside id="calepin-website-sidebar" class="calepin-website-sidebar">
<nav class="calepin-website-mobile-drawer-nav" aria-label="Site navigation">
<p><strong>Site</strong></p>
<ul>
{% for item in site.menus.main %}
{% include "partials/site-nav-item.html" %}
{% endfor %}
{% for item in site.menus.social %}
{% include "partials/site-nav-item.html" %}
{% endfor %}
</ul>
<div class="calepin-website-mobile-drawer-actions" aria-label="Site controls">
{% set calepin_theme_toggle_storage_key = "calepin-website-theme" %}
{% set calepin_theme_toggle_param = "theme" %}
{% include "partials/theme-toggle-button.html" %}
{% include "partials/site-language-picker.html" %}
{% if site.page_source or site.page_pdf %}
<select class="calepin-website-view-mode" aria-label="Choose page view" data-calepin-view-mode>
<option value="rendered">HTML</option>
{% if site.page_source %}<option value="source">Source</option>{% endif %}
{% if site.page_pdf %}<option value="pdf">PDF</option>{% endif %}
</select>
{% endif %}
</div>
</nav>
<nav aria-label="Documentation">
{#- Scope the sidebar to the current master category: render only
the active section(s). Pages outside every section (landing,
404, standalone pages) fall back to the full sidebar. -#}
{% set active_sections = site.sidebar_sections | selectattr("active") | list %}
{% if active_sections %}
{% for section in active_sections %}
{% include "partials/site-sidebar-section.html" %}
{% endfor %}
{% elif site.sidebar_sections %}
{% for section in site.sidebar_sections %}
{% include "partials/site-sidebar-section.html" %}
{% endfor %}
{% elif site.sidebar %}
<ul>
{% for item in site.sidebar %}
{% include "partials/site-sidebar-item.html" %}
{% endfor %}
</ul>
{% endif %}
</nav>
</aside>
-1
View File
@@ -1 +0,0 @@
extends = "calepin"