- 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
108 lines
2.8 KiB
Go
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
|
|
}
|