Files
Yggdrasil/cmd/parser/main.go
T
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

108 lines
2.8 KiB
Go

// 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
}