aboutsummaryrefslogtreecommitdiff
path: root/perm.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /perm.go
downloadgit-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'perm.go')
-rw-r--r--perm.go202
1 files changed, 202 insertions, 0 deletions
diff --git a/perm.go b/perm.go
new file mode 100644
index 0000000..a915a95
--- /dev/null
+++ b/perm.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+// Repo is one entry in repos.json, keyed by {section}/{repo} or {repo}.
+type Repo struct {
+ Desc string `json:"desc"`
+ Visibility string `json:"visibility"`
+ Issues string `json:"issues"`
+ Wiki string `json:"wiki"`
+ Labels []string `json:"labels"`
+ Grants map[string]string `json:"grants"`
+}
+
+// visibility governs reading everything; issues and wiki each govern writing
+// to that feature.
+const (
+ visPublic = "public"
+ visPrivate = "private"
+
+ featDisabled = "disabled"
+ featMembers = "members"
+ featUsers = "users"
+
+ grantRead = "read"
+ grantWrite = "write"
+ grantAdmin = "admin"
+)
+
+// orphanRepo is how a directory with no entry is served: private, both
+// features off.
+func orphanRepo() Repo {
+ return Repo{
+ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled,
+ Labels: []string{}, Grants: map[string]string{},
+ }
+}
+
+// clone deep copies, so a writer rebuilding grants is never seen half done.
+func (r Repo) clone() Repo {
+ r.Labels = append([]string(nil), r.Labels...)
+ grants := make(map[string]string, len(r.Grants))
+ for who, level := range r.Grants {
+ grants[who] = level
+ }
+ r.Grants = grants
+ return r
+}
+
+// A section or repository name. The first character rules out . and .., so a
+// path built from these cannot traverse.
+var repoNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
+
+// The forge's own route names, refused in both components: /infra/settings is
+// the settings of infra.
+var reservedNames = map[string]bool{
+ "login": true, "logout": true, "settings": true, "admin": true,
+ "setup": true, "healthz": true, "set-password": true,
+ "cgit.css": true, "cgit.png": true, "favicon.ico": true,
+ "logo": true, "avatar": true, "reset": true, "confirm-email": true, "oidc": true,
+ "issues": true, "wiki": true,
+}
+
+// cgit's routes, refused in the second component: /infra/log is the log of
+// infra. cgit's command table, less info and objects, which it refuses itself.
+var cgitNames = map[string]bool{
+ "about": true, "atom": true, "blame": true, "blob": true, "commit": true,
+ "diff": true, "log": true, "ls_cache": true, "patch": true, "plain": true,
+ "rawdiff": true, "refs": true, "repolist": true, "snapshot": true,
+ "stats": true, "summary": true, "tag": true, "tree": true,
+}
+
+// Suffixes the tree gives meaning to.
+var reservedSuffixes = []string{".git", ".issues", ".wiki"}
+
+// validRepoPath checks a path from a URL or repos.json: one or two components.
+func validRepoPath(path string) bool {
+ parts := strings.Split(path, "/")
+ if len(parts) > 2 {
+ return false
+ }
+ for i, part := range parts {
+ if !repoNameRE.MatchString(part) || reservedNames[part] {
+ return false
+ }
+ if i == 1 && cgitNames[part] {
+ return false
+ }
+ for _, suffix := range reservedSuffixes {
+ if strings.HasSuffix(part, suffix) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// Viewer is whoever is asking. The zero value is an anonymous visitor.
+type Viewer struct {
+ Name string // empty when signed out
+ Admin bool // the global admin flag
+}
+
+func (v Viewer) SignedIn() bool { return v.Name != "" }
+
+// Grant is the viewer's level on the repository, or "" for none. A global
+// admin holds admin everywhere.
+func (v Viewer) Grant(repo Repo) string {
+ if v.Admin {
+ return grantAdmin
+ }
+ if v.Name == "" {
+ return ""
+ }
+ return repo.Grants[v.Name]
+}
+
+// CanRead covers code, browsing, cloning, issues, and wiki alike.
+func (v Viewer) CanRead(repo Repo) bool {
+ return repo.Visibility == visPublic || v.Grant(repo) != ""
+}
+
+// CanWrite is push to {repo}.git alone; issues and wiki have their own knobs.
+func (v Viewer) CanWrite(repo Repo) bool {
+ g := v.Grant(repo)
+ return g == grantWrite || g == grantAdmin
+}
+
+// CanAdmin is this repository's settings and grants.
+func (v Viewer) CanAdmin(repo Repo) bool { return v.Grant(repo) == grantAdmin }
+
+// CanIssues and CanWiki are writing; reading is visibility's alone.
+func (v Viewer) CanIssues(repo Repo) bool { return v.canFeature(repo, repo.Issues) }
+func (v Viewer) CanWiki(repo Repo) bool { return v.canFeature(repo, repo.Wiki) }
+
+func (v Viewer) canFeature(repo Repo, knob string) bool {
+ if !v.CanRead(repo) {
+ return false
+ }
+ switch knob {
+ case featMembers:
+ return v.Grant(repo) != ""
+ case featUsers:
+ return v.SignedIn()
+ }
+ return false
+}
+
+// validate checks one repos.json record. Refused rather than coerced: a
+// coerced permission is one nobody knows they have.
+func (r Repo) validate(path string) []error {
+ var errs []error
+ fail := func(format string, a ...any) {
+ errs = append(errs, fmt.Errorf(format, a...))
+ }
+
+ if !validRepoPath(path) {
+ fail("%q is not a usable repository path", path)
+ }
+ if r.Visibility != visPublic && r.Visibility != visPrivate {
+ fail("%s: visibility %q is neither public nor private", path, r.Visibility)
+ }
+ for _, knob := range []struct{ name, value string }{{"issues", r.Issues}, {"wiki", r.Wiki}} {
+ switch knob.value {
+ case featDisabled, featMembers:
+ case featUsers:
+ if r.Visibility == visPrivate {
+ fail("%s: %s is users on a private repository, so an account that cannot read it could write to it", path, knob.name)
+ }
+ default:
+ fail("%s: %s %q is not disabled, members, or users", path, knob.name, knob.value)
+ }
+ }
+ for name, level := range r.Grants {
+ if !accountNameRE.MatchString(name) {
+ fail("%s: %q is not a usable account name", path, name)
+ }
+ if level != grantRead && level != grantWrite && level != grantAdmin {
+ fail("%s: grant %q for %s is not read, write, or admin", path, level, name)
+ }
+ }
+ // Every label is a checkbox on every issue form.
+ if len(r.Labels) > maxLabels {
+ fail("%s: %d labels, and %d is all a repository may have", path, len(r.Labels), maxLabels)
+ }
+ for _, label := range r.Labels {
+ if !validLabel(label) {
+ fail("%s: label %q is empty, too long, or has a line break in it", path, label)
+ }
+ }
+ return errs
+}
+
+const maxLabels = 64
+
+func validLabel(label string) bool {
+ return label != "" && len(label) <= 32 && !strings.ContainsAny(label, "\r\n")
+}