diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /perm.go | |
| download | git-forge-master.tar.gz | |
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.go | 202 |
1 files changed, 202 insertions, 0 deletions
@@ -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") +} |