- 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
123 lines
3.3 KiB
Go
123 lines
3.3 KiB
Go
// 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
|
|
}
|