package main
import (
"bytes"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)
// canView reports whether the current request may see repo at all: public
// repos are open to everyone, private ones require a logged-in principal
// with at least read access. Not found and access-denied look identical to
// the caller — neither anonymous visitors nor unrelated logged-in users
// should be able to tell a private repo exists.
func (s *server) canView(r *http.Request, repo store.Repo) bool {
if repo.Public {
return true
}
sess, ok := s.currentSession(r)
if !ok {
return false
}
_, allowed, err := s.ops.CheckAccess(repo.Name, sess.Principal, store.RoleRead)
return err == nil && allowed
}
// canAdminister reports whether the current session may change repo's
// settings (visibility, topics, collaborators) or delete it.
func (s *server) canAdminister(r *http.Request, repoName string) (string, bool) {
sess, ok := s.currentSession(r)
if !ok {
return "", false
}
_, allowed, err := s.ops.CheckAccess(repoName, sess.Principal, store.RoleAdmin)
return sess.Principal, err == nil && allowed
}
// canWrite reports whether the current session may push to repo — the same
// bar for opening or merging a merge request, since either is something a
// write collaborator could already do directly over SSH (clone, merge
// locally, push back).
func (s *server) canWrite(r *http.Request, repoName string) (string, bool) {
sess, ok := s.currentSession(r)
if !ok {
return "", false
}
_, allowed, err := s.ops.CheckAccess(repoName, sess.Principal, store.RoleWrite)
return sess.Principal, err == nil && allowed
}
// crumb is one clickable segment of an in-repo path breadcrumb.
type crumb struct {
Name, Path string
Last bool
}
func breadcrumbs(path string) []crumb {
if path == "" {
return nil
}
parts := strings.Split(path, "/")
crumbs := make([]crumb, len(parts))
for i, part := range parts {
crumbs[i] = crumb{Name: part, Path: strings.Join(parts[:i+1], "/"), Last: i == len(parts)-1}
}
return crumbs
}
func parentPath(path string) string {
i := strings.LastIndex(path, "/")
if i < 0 {
return ""
}
return path[:i]
}
type treeEntryView struct {
gitexec.TreeEntry
FullPath string
}
type branchOptionView struct {
Name string
URL string
Active bool
}
// branchOptions builds the repo page's branch dropdown entries — each
// links back to the same repo/path with ?branch= set, so switching branch
// while browsing a subdirectory tries to keep you at the same relative
// path (a 404 if it doesn't exist there, same as any other bad path).
func branchOptions(name string, branches []string, path, active string) []branchOptionView {
views := make([]branchOptionView, len(branches))
for i, b := range branches {
u := "/r/" + url.PathEscape(name) + "?branch=" + url.QueryEscape(b)
if path != "" {
u += "&path=" + url.QueryEscape(path)
}
views[i] = branchOptionView{Name: b, URL: u, Active: b == active}
}
return views
}
type readmeLangOptionView struct {
Label string
URL string
Active bool
}
// readmeLangOptions builds the repo page's README language switcher — one
// entry for the main README plus one per README.<lang>.md sibling found at
// HEAD. All link back to the repo root, since these previews are only ever
// shown there (see handleRepoView's onDefaultBranch/path=="" gate).
func readmeLangOptions(name string, langs []string, active string) []readmeLangOptionView {
if len(langs) == 0 {
return nil
}
base := "/r/" + url.PathEscape(name)
views := make([]readmeLangOptionView, 0, len(langs)+1)
views = append(views, readmeLangOptionView{Label: strings.ToUpper(defaultReadmeLangLabel), URL: base, Active: active == ""})
for _, l := range langs {
views = append(views, readmeLangOptionView{
Label: strings.ToUpper(l),
URL: base + "?readme_lang=" + url.QueryEscape(l),
Active: active == l,
})
}
return views
}
// defaultReadmeLangLabel labels the main README (README.md, not a
// README.<lang>.md sibling) in the language switcher — there's no reliable
// way to detect its actual language, so it's labeled generically rather
// than guessed.
const defaultReadmeLangLabel = "orig"
// collaboratorView adds display-only context to a store.Collaborator: only
// the settings page needs to distinguish local from federated principals,
// so it's computed there rather than carried on the ACL model itself.
type collaboratorView struct {
store.Collaborator
Remote bool
}
// repoTpl renders the repo's file tree at the current path, GitLab-style:
// files/dirs at the top, README (and, root only, LICENSE) rendered directly
// below — no separate "browse files" page.
var repoTpl = newTpl("repo", `
{{.Flash}}
<div class="gf-crumbs"><a href="/explore">{{t .Lang "nav.explore"}}</a><span class="sep">/</span>{{.Repo.Name}}</div>
<div class="gf-repo-head">
<h1>{{.Repo.Name}}</h1>
{{if .Repo.Public}}<span class="badge trusted">{{t .Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t .Lang "common.private"}}</span>{{end}}
{{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}}
</div>
{{if .Repo.Description}}<p class="muted">{{.Repo.Description}}</p>{{end}}
<p class="muted">{{t .Lang "repo.owner"}}: {{if localUser .Repo.Owner .Domain}}<a href="/u/{{localUser .Repo.Owner .Domain}}">{{.Repo.Owner}}</a>{{else}}{{.Repo.Owner}}{{end}}</p>
{{if not .Empty}}
<p class="muted" style="font-size:0.84rem;">
{{t .Lang "repo.stat_commits" .CommitCount}}{{if .ContributorCount}} · {{t .Lang "repo.stat_contributors" .ContributorCount}}{{end}}{{if .DominantLanguage}} · {{.DominantLanguage}}{{end}}
</p>
{{end}}
<div class="gf-actions-row">
{{if .Branch}}
<details class="gf-branch-dropdown">
<summary class="gf-btn">{{icon "branch"}} {{.Branch}}</summary>
<div class="gf-branch-menu">
{{range .Branches}}<a href="{{.URL}}"{{if .Active}} class="active"{{end}}>{{.Name}}</a>{{end}}
</div>
</details>
{{end}}
{{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}{{if .CommitCount}} ({{.CommitCount}}){{end}}</a>{{end}}
{{if not .Empty}}<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{icon "branch"}} {{t .Lang "mr.list_title"}}</a>{{end}}
<div class="gf-actions-right">
{{if not .Empty}}
<details class="gf-branch-dropdown">
<summary class="gf-btn subtle">{{icon "download"}} {{t .Lang "repo.download"}}</summary>
<div class="gf-branch-menu">
<a href="/repo-archive/{{.Repo.Name}}?ref={{.Branch}}&format=tar.gz">{{.Branch}}.tar.gz</a>
<a href="/repo-archive/{{.Repo.Name}}?ref={{.Branch}}&format=zip">{{.Branch}}.zip</a>
</div>
</details>
{{end}}
{{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn subtle">{{icon "settings"}} {{t .Lang "nav.settings"}}</a>{{end}}
</div>
</div>
{{if .Repo.Public}}
<div class="gf-clone-block">
<div class="gf-tabs gf-clone-toggle">
<button type="button" data-tab="clone-https" class="active">HTTPS</button>
<button type="button" data-tab="clone-ssh">SSH</button>
</div>
<div id="clone-https" class="gf-tabpane gf-clone-row active">
<span class="badge plain" title="{{t .Lang "repo.https_readonly_hint"}}">{{t .Lang "repo.read_only"}}</span>
<code>{{.HTTPSCloneURL}}</code>
<button type="button" class="linklike" data-copy="{{.HTTPSCloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button>
</div>
<div id="clone-ssh" class="gf-tabpane gf-clone-row">
<code>{{.CloneURL}}</code>
<button type="button" class="linklike" data-copy="{{.CloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button>
</div>
</div>
{{else}}
<div class="gf-clone-block single">
<div class="gf-clone-row">
<span class="label">SSH</span>
<code>{{.CloneURL}}</code>
<button type="button" class="linklike" data-copy="{{.CloneURL}}" title="{{t .Lang "repo.copy_clone_url"}}" aria-label="{{t .Lang "repo.copy_clone_url"}}">{{icon "copy"}}</button>
</div>
</div>
{{end}}
{{if .Crumbs}}
<div class="gf-crumbs">
<a href="/r/{{.Repo.Name}}?branch={{.Branch}}">{{.Repo.Name}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo.Name}}?path={{.Path}}&branch={{$.Branch}}">{{.Name}}</a>{{end}}{{end}}
</div>
{{end}}
{{if .Empty}}
<div class="gf-card" style="padding: 1.5rem;">
<p class="muted" style="margin: 0 0 0.6rem;">{{t .Lang "repo.empty"}}</p>
<code>git clone {{.CloneURL}}</code>
</div>
{{else}}
<div class="gf-card">
<table class="gf-file-table">
{{if .ShowUp}}<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}&branch={{.Branch}}">..</a></td><td class="meta"></td></tr>{{end}}
{{range .Entries}}
{{if eq .Type "tree"}}
<tr><td class="icon">{{icon "folder"}}</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}&branch={{$.Branch}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.directory"}}</td></tr>
{{else}}
<tr><td class="icon">{{icon (fileIcon .Name)}}</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}&branch={{$.Branch}}">{{.Name}}</a></td><td class="meta">{{t $.Lang "repo.file"}}</td></tr>
{{end}}
{{else}}
{{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">{{t .Lang "repo.nothing_here"}}</td></tr>{{end}}
{{end}}
</table>
</div>
{{end}}
{{if .Tags}}
<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<a href="/repo-archive/{{$.Repo.Name}}?ref={{.}}&format=tar.gz" class="badge plain" title="{{t $.Lang "repo.download"}}">{{icon "download"}} {{.}}</a> {{end}}</section>
{{end}}
{{if .ReadmeHTML}}
<div class="gf-card">
<div class="gf-readme-head">
{{icon "file"}} README
{{if .ReadmeLangs}}
<span class="gf-readme-langs">
{{range .ReadmeLangs}}{{if .Active}}<span class="badge trusted">{{.Label}}</span>{{else}}<a href="{{.URL}}" class="badge plain">{{.Label}}</a>{{end}} {{end}}
</span>
{{end}}
</div>
<div class="gf-readme-body markdown-body">{{.ReadmeHTML}}</div>
</div>
{{end}}
{{if .LicenseFile}}
<div class="gf-card gf-license-card">
<span>{{icon "file-doc"}} {{if .LicenseType}}{{.LicenseType}}{{else}}{{t .Lang "repo.license"}}{{end}} ({{.LicenseFile}})</span>
<a class="gf-btn" href="/repo-blob/{{.Repo.Name}}?path={{.LicenseFile}}">{{t .Lang "repo.view_file"}}</a>
</div>
{{end}}
`)
func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
path := strings.Trim(r.URL.Query().Get("path"), "/")
lang := s.lang(r)
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
_, canAdminister := s.canAdminister(r, name)
branch, _, err := s.ops.GetRepoBranch(name)
if err != nil {
s.serverError(w, r, err)
return
}
branches, err := s.ops.ListBranches(name)
if err != nil {
s.serverError(w, r, err)
return
}
// A ?branch= naming a real branch switches the tree/blob views to it;
// anything else (including a stale link to a since-deleted branch) is
// a 404, same treatment as an unknown path — silently falling back to
// the default branch would look like it worked while quietly showing
// the wrong content.
onDefaultBranch := true
if requested := r.URL.Query().Get("branch"); requested != "" {
if !containsBranch(branches, requested) {
http.NotFound(w, r)
return
}
onDefaultBranch = requested == branch
branch = requested
}
var entries []gitexec.TreeEntry
var found bool
if onDefaultBranch {
entries, found, err = s.ops.ListRepoTree(name, path)
} else {
entries, found, err = s.ops.ListRepoTreeAtRef(name, branch, path)
}
if err != nil {
s.serverError(w, r, err)
return
}
if !found && path != "" {
// A real path that doesn't exist — an empty repo at the root is
// handled below instead of 404ing (there's a legitimate page to
// show: the clone command to get started).
http.NotFound(w, r)
return
}
views := make([]treeEntryView, len(entries))
for i, e := range entries {
full := e.Name
if path != "" {
full = path + "/" + e.Name
}
views[i] = treeEntryView{TreeEntry: e, FullPath: full}
}
// Decorative (shown as a "(N)" suffix on the Commits button) — a
// failure here shouldn't take down the whole repo page, so it's
// deliberately not treated the same as the errors above.
commitCount, _, _ := s.ops.CountCommits(name)
var readmeHTML template.HTML
var readmeLangViews []readmeLangOptionView
var licenseFile, licenseType string
var tags []string
var contributorCount int
var dominantLanguage string
if path == "" && onDefaultBranch {
// README/license previews deliberately stay tied to the default
// branch even while browsing another one's tree — there's no
// ref-aware variant of these two, and showing (say) main's README
// while the file list below is some other branch's would be more
// confusing than just not showing a README at all here.
readmeLangs, err := s.ops.ListRepoReadmeLanguages(name)
if err != nil {
s.serverError(w, r, err)
return
}
// A ?readme_lang= naming a real README.<lang>.md sibling switches
// the preview to it; anything else is a 404, same "unknown value
// 404s" treatment used for ?branch= above.
readmeLang := r.URL.Query().Get("readme_lang")
if readmeLang != "" && !containsBranch(readmeLangs, readmeLang) {
http.NotFound(w, r)
return
}
readmeLangViews = readmeLangOptions(name, readmeLangs, readmeLang)
var readmeContent string
var readmeFound bool
var readmeFile string
if readmeLang != "" {
readmeContent, readmeFound, err = s.ops.GetRepoReadmeLang(name, readmeLang)
readmeFile = "README." + readmeLang + ".md"
} else {
readmeContent, readmeFound, err = s.ops.GetRepoReadme(name)
readmeFile = "README.md"
}
if err != nil {
s.serverError(w, r, err)
return
}
if readmeFound {
readmeHTML, err = renderFileContent(name, "", readmeFile, readmeContent)
if err != nil {
s.serverError(w, r, err)
return
}
}
licenseContent, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name)
if err != nil {
s.serverError(w, r, err)
return
}
if licenseFound {
licenseFile = foundLicenseFile
licenseType = detectLicenseType(licenseContent)
}
tags, err = s.ops.ListRepoTags(name)
if err != nil {
s.serverError(w, r, err)
return
}
// Decorative stats line — same non-fatal treatment as commitCount
// above, a failure here shouldn't take down the whole page.
contributorCount, _, _ = s.ops.CountContributors(name)
dominantLanguage, _, _ = s.ops.DominantLanguage(name)
}
var buf bytes.Buffer
_ = repoTpl.Execute(&buf, struct {
Repo store.Repo
Domain string
CloneURL string
HTTPSCloneURL string
Branch string
Branches []branchOptionView
CommitCount int
Lang string
Crumbs []crumb
Entries []treeEntryView
ShowUp bool
ParentPath string
Empty bool
Tags []string
ReadmeHTML template.HTML
ReadmeLangs []readmeLangOptionView
LicenseFile string
LicenseType string
ContributorCount int
DominantLanguage string
CanAdminister bool
Flash template.HTML
}{
repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", "https://" + s.domain + "/" + name + ".git", branch,
branchOptions(name, branches, path, branch), commitCount, string(lang),
breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found,
tags, readmeHTML, readmeLangViews, licenseFile, licenseType, contributorCount, dominantLanguage, canAdminister, flash(r),
})
title := name
if path != "" {
title = path + " — " + name
}
s.render(w, r, title, "home", template.HTML(buf.String()))
}
var blobTpl = newTpl("blob", `
{{.Flash}}
<div class="gf-crumbs">
<a href="/r/{{.Repo}}?branch={{.Branch}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}&branch={{$.Branch}}">{{.Name}}</a>{{end}}{{end}}
</div>
<div class="gf-card">
<div class="gf-readme-body markdown-body">
{{.Content}}
</div>
</div>
`)
func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
path := strings.Trim(r.URL.Query().Get("path"), "/")
lang := s.lang(r)
if path == "" {
http.NotFound(w, r)
return
}
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
branch, _, err := s.ops.GetRepoBranch(name)
if err != nil {
s.serverError(w, r, err)
return
}
onDefaultBranch := true
if requested := r.URL.Query().Get("branch"); requested != "" {
branches, err := s.ops.ListBranches(name)
if err != nil {
s.serverError(w, r, err)
return
}
if !containsBranch(branches, requested) {
http.NotFound(w, r)
return
}
onDefaultBranch = requested == branch
branch = requested
}
var content string
var found bool
if onDefaultBranch {
content, found, err = s.ops.GetRepoFile(name, path)
} else {
content, found, err = s.ops.GetRepoFileAtRef(name, branch, path)
}
if err != nil {
s.serverError(w, r, err)
return
}
if !found {
http.NotFound(w, r)
return
}
var rendered template.HTML
if strings.Contains(content, "\x00") {
rendered = template.HTML(`<p class="muted">` + template.HTMLEscapeString(i18n.T(lang, "repo.binary_file")) + `</p>`)
} else {
fileName, dir := path, ""
if i := strings.LastIndex(fileName, "/"); i >= 0 {
dir, fileName = fileName[:i], fileName[i+1:]
}
rendered, err = renderFileContent(name, dir, fileName, content)
if err != nil {
s.serverError(w, r, err)
return
}
}
var buf bytes.Buffer
_ = blobTpl.Execute(&buf, struct {
Repo string
Branch string
Crumbs []crumb
Content template.HTML
Flash template.HTML
}{name, branch, breadcrumbs(path), rendered, flash(r)})
s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
}
// handleRepoArchive streams a tar.gz/zip snapshot of a branch or tag —
// ?ref= is validated against the repo's real branches and tags before it
// ever reaches a git subprocess (see gitexec.Archive's contract), same
// "unknown value 404s, no silent fallback" treatment as the branch
// dropdown's ?branch=.
func (s *server) handleRepoArchive(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
format := r.URL.Query().Get("format")
if format != "tar.gz" && format != "zip" {
http.NotFound(w, r)
return
}
ref := r.URL.Query().Get("ref")
if ref == "" {
http.NotFound(w, r)
return
}
branches, err := s.ops.ListBranches(name)
if err != nil {
s.serverError(w, r, err)
return
}
tags, err := s.ops.ListRepoTags(name)
if err != nil {
s.serverError(w, r, err)
return
}
if !containsBranch(branches, ref) && !containsBranch(tags, ref) {
http.NotFound(w, r)
return
}
data, err := s.ops.GetRepoArchive(name, ref, format)
if err != nil {
s.serverError(w, r, err)
return
}
contentType := "application/gzip"
if format == "zip" {
contentType = "application/zip"
}
// Slashes in the owner/repo name or in a branch like "feature/x" are
// cosmetic-only here — purely for the suggested filename, not passed
// to git (that already happened, safely, above).
base := strings.ReplaceAll(name, "/", "-") + "-" + strings.ReplaceAll(ref, "/", "-")
filename := base + "." + format
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
func renderFileContent(repo, dir, filename, content string) (template.HTML, error) {
if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") {
return renderRepoMarkdown(repo, dir, content)
}
if html, ok, err := highlightCode(filename, content); err != nil {
return "", err
} else if ok {
return html, nil
}
var buf bytes.Buffer
if err := plainTpl.Execute(&buf, content); err != nil {
return "", err
}
return template.HTML(buf.String()), nil
}
var plainTpl = template.Must(template.New("plain").Parse(`<pre>{{.}}</pre>`))
var repoSettingsTpl = newTpl("repo-settings", `
{{.Flash}}
<div class="gf-crumbs"><a href="/r/{{.Repo}}">{{.Repo}}</a></div>
<div class="gf-page-head"><h1>{{t .Lang "repo.settings_title"}}</h1></div>
<form class="card" method="post" action="/repo-settings/{{.Repo}}">
<strong>{{t .Lang "repo.visibility_topics"}}</strong>
<label><input type="checkbox" name="public" value="1" style="width:auto; display:inline-block;" {{if .Public}}checked{{end}}> {{t .Lang "repo.public_desc"}}</label>
<label>{{t .Lang "repo.topics_label"}}</label>
<input name="topics" value="{{.TopicsCSV}}" placeholder="cli, tooling, go">
<label>{{t .Lang "repo.description_label"}}</label>
<input name="description" value="{{.Description}}" maxlength="200" placeholder="{{t .Lang "repo.description_placeholder"}}">
<button type="submit">{{t .Lang "repo.save"}}</button>
</form>
<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">{{t .Lang "repo.collaborators_title"}}</h3>
<div class="gf-card">
<div class="gf-list-row">
<div class="gf-list-icon">{{icon "shield"}}</div>
<div class="gf-list-main">
<div class="primary">{{.Owner}}</div>
<div class="meta">{{t .Lang "role.owner"}}</div>
</div>
<span class="badge plain">{{t .Lang "role.admin"}}</span>
</div>
{{range .Collaborators}}
<div class="gf-list-row">
<div class="gf-list-icon">{{if .Remote}}{{icon "network"}}{{else}}{{icon "user"}}{{end}}</div>
<div class="gf-list-main">
<div class="primary">{{.Principal}}</div>
<div class="meta">{{if .Remote}}{{t $.Lang "repo.federated_collaborator"}}{{else}}{{t $.Lang "repo.local_collaborator"}}{{end}}</div>
</div>
<span class="badge plain">{{roleLabel $.Lang (print .Role)}}</span>
<form class="inline" method="post" action="/repo-revoke/{{$.Repo}}" data-confirm="{{t $.Lang "repo.confirm_revoke"}} {{.Principal}}?">
<input type="hidden" name="principal" value="{{.Principal}}">
<button class="linklike" type="submit" title="{{t $.Lang "repo.revoke"}}" aria-label="{{t $.Lang "repo.revoke"}}">{{icon "close"}}</button>
</form>
</div>
{{end}}
</div>
<form class="card" method="post" action="/repo-grant/{{.Repo}}">
<strong>{{t .Lang "repo.grant_collaborator"}}</strong>
<label>{{t .Lang "repo.col_principal"}}</label>
<input name="principal" required placeholder="bob@instanceb.example">
<label>{{t .Lang "repo.col_role"}}</label>
<select name="role">
<option value="read">{{t .Lang "role.read"}}</option>
<option value="write">{{t .Lang "role.write"}}</option>
<option value="admin">{{t .Lang "role.admin"}}</option>
</select>
<button type="submit">{{t .Lang "repo.grant"}}</button>
</form>
<p class="muted" style="margin-top:-0.9rem;">{{t .Lang "repo.grant_note"}}</p>
<div class="gf-danger-zone">
<strong>{{t .Lang "repo.danger_zone"}}</strong>
<p class="muted">{{t .Lang "repo.danger_zone_note"}}</p>
<form method="post" action="/repo-delete/{{.Repo}}" data-confirm="{{t .Lang "repo.confirm_delete"}} {{.Repo}}?">
<button class="danger" type="submit">{{t .Lang "repo.delete_record"}}</button>
</form>
</div>
`)
func (s *server) handleRepoSettingsForm(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
}
repo, err := s.ops.GetRepo(name)
if err != nil {
http.NotFound(w, r)
return
}
acl, err := s.ops.GetACL(name)
if err != nil && err != store.ErrNotFound {
s.serverError(w, r, err)
return
}
collaborators := make([]collaboratorView, len(acl.Collaborators))
for i, c := range acl.Collaborators {
_, domain, _ := strings.Cut(c.Principal, "@")
collaborators[i] = collaboratorView{Collaborator: c, Remote: domain != s.domain}
}
var buf bytes.Buffer
_ = repoSettingsTpl.Execute(&buf, struct {
Repo string
Owner string
Lang string
Public bool
TopicsCSV string
Description string
Collaborators []collaboratorView
Flash template.HTML
}{name, repo.Owner, string(lang), repo.Public, strings.Join(repo.Topics, ", "), repo.Description, collaborators, flash(r)})
s.render(w, r, name+" "+i18n.T(lang, "repo.settings_title"), "home", template.HTML(buf.String()))
}
// repoDescriptionMaxLen matches the settings form's maxlength — a direct
// POST bypasses that, so this is the real enforcement (same pattern as
// bioMaxLen in handlers_settings.go).
const repoDescriptionMaxLen = 200
func (s *server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
}
back := "/repo-settings/" + url.PathEscape(name)
public := r.FormValue("public") == "1"
if err := s.ops.SetRepoPublic(name, public); err != nil {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
var topics []string
for _, t := range strings.Split(r.FormValue("topics"), ",") {
if t = strings.TrimSpace(t); t != "" {
topics = append(topics, t)
}
}
if err := s.ops.SetRepoTopics(name, topics); err != nil {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
description := strings.TrimSpace(r.FormValue("description"))
if len(description) > repoDescriptionMaxLen {
description = description[:repoDescriptionMaxLen]
}
if err := s.ops.SetRepoDescription(name, description); err != nil {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_saved"), false)
}
func (s *server) handleCollabGrant(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
actor, ok := s.canAdminister(r, name)
if !ok {
http.NotFound(w, r)
return
}
back := "/repo-settings/" + url.PathEscape(name)
principal := r.FormValue("principal")
role := store.Role(r.FormValue("role"))
if err := s.ops.GrantCollaborator(name, principal, actor, role); err != nil {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
grantedMsg := i18n.T(lang, "repo.msg_granted", principal, roleLabel(lang, string(role)))
if _, domain, ok := strings.Cut(principal, "@"); ok && domain != s.domain {
// A federated grant only becomes usable once this domain's CA is
// trusted — GrantCollaborator queues that automatically, but
// (with the default whitelist policy) it needs an admin to
// actually approve it, a separate step nothing else prompts for.
// Missing it looks like a working grant followed by a confusing
// SSH "Permission denied (publickey)" for the collaborator.
if trusted, err := s.ops.ListTrustedCAs(); err == nil {
for _, t := range trusted {
if t.Domain == domain && t.Status == store.TrustPending {
redirectWithWarnMsg(w, r, back, i18n.T(lang, "repo.msg_granted_pending_trust", principal, domain))
return
}
}
}
}
redirectWithMsg(w, r, back, grantedMsg, false)
}
func (s *server) handleCollabRevoke(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
}
back := "/repo-settings/" + url.PathEscape(name)
principal := r.FormValue("principal")
if err := s.ops.RevokeCollaborator(name, principal); err != nil {
redirectWithMsg(w, r, back, err.Error(), true)
return
}
redirectWithMsg(w, r, back, i18n.T(lang, "repo.msg_revoked", principal), false)
}
func (s *server) handleRepoDelete(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
if _, ok := s.canAdminister(r, name); !ok {
http.NotFound(w, r)
return
}
if err := s.ops.DeleteRepo(name); err != nil {
redirectWithMsg(w, r, "/repo-settings/"+url.PathEscape(name), err.Error(), true)
return
}
redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "repo.msg_deleted", name), false)
}