Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_repo.go
package main

import (
	"bytes"
	"html/template"
	"net/http"
	"net/url"
	gopath "path"
	"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
		if readmeLang != "" {
			readmeContent, readmeFound, err = s.ops.GetRepoReadmeLang(name, readmeLang)
		} else {
			readmeContent, readmeFound, err = s.ops.GetRepoReadme(name)
		}
		if err != nil {
			s.serverError(w, r, err)
			return
		}
		if readmeFound {
			// The README preview is always rendered markdown (every
			// readmeFile candidate is a .md) — images in it resolve to
			// /repo-raw so gifs/screenshots actually display.
			readmeHTML, err = renderRepoMarkdown(name, "", 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">
  {{if .IsMarkdown}}
  <div class="gf-readme-head">
    {{icon "file-doc"}} {{.FileName}}
    <span class="gf-readme-langs">
      {{if .Rendered}}<a href="{{.CodeURL}}" class="badge plain">{{t .Lang "repo.blob_code"}}</a> <span class="badge trusted">{{t .Lang "repo.blob_preview"}}</span>{{else}}<span class="badge trusted">{{t .Lang "repo.blob_code"}}</span> <a href="{{.RenderURL}}" class="badge plain">{{t .Lang "repo.blob_preview"}}</a>{{end}}
    </span>
  </div>
  {{end}}
  <div class="gf-readme-body markdown-body">
  {{.Content}}
  </div>
</div>
`)

// repoFileForRequest resolves the ?path= / ?branch= query the blob and raw
// views share: validates access, 404s on an unknown branch or missing file,
// and returns the file's content plus the branch it was read from. ok is
// false for every "show a 404" case; a non-nil err is a real server error.
// Content comes back as []byte via the Raw ops — the string variants mangle
// binary files over adminrpc (JSON strings can't carry invalid UTF-8).
func (s *server) repoFileForRequest(r *http.Request, name, path string) (content []byte, branch string, ok bool, err error) {
	if path == "" {
		return nil, "", false, nil
	}

	repo, err := s.ops.GetRepo(name)
	if err != nil || !s.canView(r, repo) {
		return nil, "", false, nil
	}

	branch, _, err = s.ops.GetRepoBranch(name)
	if err != nil {
		return nil, "", false, err
	}
	onDefaultBranch := true
	if requested := r.URL.Query().Get("branch"); requested != "" {
		branches, err := s.ops.ListBranches(name)
		if err != nil {
			return nil, "", false, err
		}
		if !containsBranch(branches, requested) {
			return nil, "", false, nil
		}
		onDefaultBranch = requested == branch
		branch = requested
	}

	var found bool
	if onDefaultBranch {
		content, found, err = s.ops.GetRepoFileRaw(name, path)
	} else {
		content, found, err = s.ops.GetRepoFileRawAtRef(name, branch, path)
	}
	if err != nil {
		return nil, "", false, err
	}
	return content, branch, found, nil
}

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)

	raw, branch, ok, err := s.repoFileForRequest(r, name, path)
	if err != nil {
		s.serverError(w, r, err)
		return
	}
	if !ok {
		http.NotFound(w, r)
		return
	}
	content := string(raw)

	fileName, dir := path, ""
	if i := strings.LastIndex(fileName, "/"); i >= 0 {
		dir, fileName = fileName[:i], fileName[i+1:]
	}
	lower := strings.ToLower(fileName)
	isMarkdown := strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown")
	isImage := imageExtensions[strings.ToLower(gopath.Ext(fileName))]
	showRendered := isMarkdown && r.URL.Query().Get("render") == "1"

	blobURL := "/repo-blob/" + name + "?path=" + path + "&branch=" + branch

	var rendered template.HTML
	switch {
	case isImage:
		// Images (gif included) display via the raw route instead of the
		// "binary file" fallback — /repo-raw serves the actual bytes with
		// an image content-type.
		rawURL := "/repo-raw/" + name + "?path=" + path + "&branch=" + branch
		rendered = template.HTML(`<img src="` + template.HTMLEscapeString(rawURL) + `" alt="` + template.HTMLEscapeString(fileName) + `">`)
	case showRendered:
		rendered, err = renderRepoMarkdown(name, dir, content)
	case strings.Contains(content, "\x00"):
		rendered = template.HTML(`<p class="muted">` + template.HTMLEscapeString(i18n.T(lang, "repo.binary_file")) + `</p>`)
	default:
		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
		Lang       string
		Crumbs     []crumb
		Content    template.HTML
		Flash      template.HTML
		FileName   string
		IsMarkdown bool
		Rendered   bool
		CodeURL    string
		RenderURL  string
	}{name, branch, string(lang), breadcrumbs(path), rendered, flash(r),
		fileName, isMarkdown, showRendered, blobURL, blobURL + "&render=1"})

	s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
}

// handleRepoRaw serves a file's actual bytes — what <img> tags in rendered
// markdown (and the blob view's image display) point at. Same access and
// branch validation as the blob page, but the response is the file itself,
// not an HTML page around it.
func (s *server) handleRepoRaw(w http.ResponseWriter, r *http.Request) {
	name := r.PathValue("repo")
	path := strings.Trim(r.URL.Query().Get("path"), "/")

	content, _, ok, err := s.repoFileForRequest(r, name, path)
	if err != nil {
		s.serverError(w, r, err)
		return
	}
	if !ok {
		http.NotFound(w, r)
		return
	}

	// Replace the site-wide CSP with a fully inert one: raw content is
	// whatever a pusher committed, and an SVG (or HTML served as text/plain
	// on a lenient browser) navigated to directly would otherwise run
	// scripts on gitfed's own origin. sandbox + default-src 'none' keeps a
	// hostile file from executing anything while still rendering as an
	// image; style-src lets static SVG styling keep working.
	w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")

	switch ext := strings.ToLower(gopath.Ext(path)); {
	case imageMIME[ext] != "":
		w.Header().Set("Content-Type", imageMIME[ext])
	case bytes.Contains(content, []byte{0}):
		w.Header().Set("Content-Type", "application/octet-stream")
	default:
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	}
	w.Header().Set("Content-Length", strconv.Itoa(len(content)))
	_, _ = w.Write(content)
}

// 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)
}

// renderFileContent renders a file as source code (syntax-highlighted when
// chroma knows the language, plain <pre> otherwise). Markdown deliberately
// goes through here too on the blob page — the source view is the default
// there, with rendering opt-in via ?render=1 (see handleRepoBlob).
func renderFileContent(repo, dir, filename, content string) (template.HTML, error) {
	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)
}