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

import (
	"bytes"
	"errors"
	"html/template"
	"net/http"
	"strings"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)

var dashboardTpl = newTpl("dashboard", `
{{.Flash}}
<div class="gf-stat-row">
  <div class="gf-stat"><div class="n">{{len .Repos}}</div><div class="l">{{t .Lang "dashboard.stat_repos"}}</div></div>
  <div class="gf-stat"><div class="n">{{.PublicCount}}</div><div class="l">{{t .Lang "dashboard.stat_public"}}</div></div>
  <div class="gf-stat"><div class="n">{{.SharedCount}}</div><div class="l">{{t .Lang "dashboard.stat_shared"}}</div></div>
</div>

<div class="gf-page-head">
  <h1>{{t .Lang "dashboard.title"}}</h1>
  <button type="button" class="gf-btn primary" data-toggle="addRepoPanel" aria-expanded="false" aria-controls="addRepoPanel">+ {{t .Lang "dashboard.add_repo"}}</button>
</div>

<div class="gf-card gf-add-repo-panel" id="addRepoPanel" hidden>
  <div class="gf-tabs">
    <button type="button" data-tab="tab-new-repo" class="active">{{t .Lang "dashboard.new_repo"}}</button>
    <button type="button" data-tab="tab-import-repo">{{t .Lang "dashboard.import_repo"}}</button>
  </div>
  <div id="tab-new-repo" class="gf-tabpane active">
    <form method="post" action="/repos">
      <label>{{t .Lang "dashboard.repo_name"}}</label>
      <div class="gf-input-group">
        <span class="prefix">{{.Username}}/</span>
        <input name="name" required placeholder="my-project" autocomplete="off">
      </div>
      <button type="submit">{{t .Lang "dashboard.create"}}</button>
    </form>
  </div>
  <div id="tab-import-repo" class="gf-tabpane gf-import-form">
    <form method="post" action="/repos/import">
      <label>{{t .Lang "dashboard.import_url_label"}}</label>
      <input name="source_url" type="url" required placeholder="https://github.com/owner/repo.git" id="importUrl" autocomplete="off">
      <label>{{t .Lang "dashboard.repo_name"}}</label>
      <div class="gf-input-group">
        <span class="prefix">{{.Username}}/</span>
        <input name="name" required placeholder="repo" id="importName" autocomplete="off">
      </div>
      <button type="submit">{{t .Lang "dashboard.import"}}</button>
    </form>
  </div>
</div>

<div class="gf-card gf-repo-list">
{{range .Repos}}
  <div class="gf-repo-row">
    <div class="gf-repo-icon">{{icon "folder"}}</div>
    <div class="gf-repo-main">
      <div class="name"><a href="/r/{{.Name}}">{{.Name}}</a></div>
      <div class="meta">{{roleLabel $.Lang .Role}}{{if .Topics}} · {{end}}{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</div>
    </div>
    {{if .Public}}<span class="badge trusted">{{t $.Lang "common.public"}}</span>{{else}}<span class="badge pending">{{t $.Lang "common.private"}}</span>{{end}}
  </div>
{{else}}
  <div class="gf-repo-row"><span class="muted">{{t .Lang "dashboard.empty"}}</span></div>
{{end}}
</div>

<div class="gf-page-head" style="margin-top:2rem;">
  <h1 style="font-size:1.1rem;">{{t .Lang "dashboard.pinned_title"}}</h1>
  <details class="gf-new-repo">
    <summary class="gf-btn" style="margin:0;">+ {{t .Lang "dashboard.pin_add"}}</summary>
    <form class="card" method="post" action="/pins" style="margin-top:0.75rem;">
      <label>{{t .Lang "dashboard.pin_input_label"}}</label>
      <input name="url" required placeholder="chez-moi.fr/alice/mon-projet">
      <button type="submit">{{t .Lang "dashboard.pin_add"}}</button>
    </form>
  </details>
</div>
<p class="muted" style="margin-top:-0.6rem;">{{t .Lang "dashboard.pinned_note"}}</p>

<div class="gf-card gf-repo-list">
{{range .Pins}}
  <div class="gf-repo-row">
    <div class="gf-repo-icon">{{icon "network"}}</div>
    <div class="gf-repo-main">
      <div class="name"><a href="https://{{.Domain}}/r/{{.Repo}}">{{.Repo}}</a></div>
      <div class="meta">{{.Domain}}</div>
    </div>
    <form class="inline" method="post" action="/pins/remove">
      <input type="hidden" name="domain" value="{{.Domain}}">
      <input type="hidden" name="repo" value="{{.Repo}}">
      <button class="linklike" type="submit" title="{{t $.Lang "dashboard.pin_remove"}}" aria-label="{{t $.Lang "dashboard.pin_remove"}}">{{icon "close"}}</button>
    </form>
  </div>
{{else}}
  <div class="gf-repo-row"><span class="muted">{{t .Lang "dashboard.pins_empty"}}</span></div>
{{end}}
</div>
`)

type dashboardRepo struct {
	store.Repo
	Role string
}

func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)

	all, err := s.ops.ListRepos()
	if err != nil {
		s.serverError(w, r, err)
		return
	}
	var mine []dashboardRepo
	publicCount, sharedCount := 0, 0
	for _, repo := range all {
		if repo.Owner == sess.Principal {
			mine = append(mine, dashboardRepo{Repo: repo, Role: "owner"})
		} else if role, ok, err := s.ops.CheckAccess(repo.Name, sess.Principal, store.RoleRead); err == nil && ok {
			mine = append(mine, dashboardRepo{Repo: repo, Role: string(role)})
			sharedCount++
		} else {
			continue
		}
		if repo.Public {
			publicCount++
		}
	}

	pins, err := s.ops.ListPinnedRepos(sess.Principal)
	if err != nil {
		s.serverError(w, r, err)
		return
	}

	var buf bytes.Buffer
	_ = dashboardTpl.Execute(&buf, struct {
		Repos                    []dashboardRepo
		Pins                     []store.PinnedRepo
		Username, Lang           string
		PublicCount, SharedCount int
		Flash                    template.HTML
	}{mine, pins, sess.Username, string(lang), publicCount, sharedCount, flash(r)})
	s.render(w, r, i18n.T(lang, "dashboard.title"), "dashboard", template.HTML(buf.String()))
}

// maxReposPerUser caps how many repos one account may own, so a single
// logged-in user can't exhaust disk by mass-creating repos.
const maxReposPerUser = 100

// resolveOwnRepoName validates and normalizes a repo name the way every
// self-service repo-creation path does (new repo, one-shot import): keep it
// inside the creator's own namespace, and check they're under the
// per-account quota. The returned error's message is already translated.
func (s *server) resolveOwnRepoName(sess store.Session, lang i18n.Lang, rawName string) (string, error) {
	name := strings.Trim(strings.TrimSpace(rawName), "/")

	// Keep self-service repos inside the creator's own namespace: a bare name
	// is prefixed with the username; a "namespace/name" form must use the
	// user's own namespace. This blocks squatting another user's prefix.
	if name == "" {
		return "", errors.New(i18n.T(lang, "dashboard.msg_name_required"))
	}
	if strings.Contains(name, "/") {
		if !strings.HasPrefix(name, sess.Username+"/") {
			return "", errors.New(i18n.T(lang, "dashboard.msg_bad_namespace"))
		}
	} else {
		name = sess.Username + "/" + name
	}

	repos, err := s.ops.ListRepos()
	if err != nil {
		return "", err
	}
	owned := 0
	for _, repo := range repos {
		if repo.Owner == sess.Principal {
			owned++
		}
	}
	if owned >= maxReposPerUser {
		return "", errors.New(i18n.T(lang, "dashboard.msg_quota"))
	}
	return name, nil
}

func (s *server) handleCreateRepo(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)

	name, err := s.resolveOwnRepoName(sess, lang, r.FormValue("name"))
	if err != nil {
		redirectWithMsg(w, r, "/dashboard", err.Error(), true)
		return
	}
	if err := s.ops.CreateRepo(name, sess.Username); err != nil {
		redirectWithMsg(w, r, "/dashboard", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_created", name), false)
}

func (s *server) handleImportRepo(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)

	name, err := s.resolveOwnRepoName(sess, lang, r.FormValue("name"))
	if err != nil {
		redirectWithMsg(w, r, "/dashboard", err.Error(), true)
		return
	}
	sourceURL := strings.TrimSpace(r.FormValue("source_url"))
	if sourceURL == "" {
		redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_import_url_required"), true)
		return
	}
	if err := s.ops.ImportRepo(name, sourceURL, sess.Username); err != nil {
		redirectWithMsg(w, r, "/dashboard", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/repo-settings/"+name, i18n.T(lang, "dashboard.msg_imported", name), false)
}

// maxPinsPerUser is a sanity cap, not a meaningful security boundary — pins
// grant no access, they're just bookmarks — but an unbounded list would
// still be an easy way to bloat one user's slice of the store.
const maxPinsPerUser = 200

func (s *server) handleAddPin(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)

	domain, repo, ok := parsePinInput(r.FormValue("url"))
	if !ok {
		redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_pin_invalid"), true)
		return
	}
	if existing, err := s.ops.ListPinnedRepos(sess.Principal); err == nil && len(existing) >= maxPinsPerUser {
		redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_pin_quota"), true)
		return
	}
	if err := s.ops.PinRepo(sess.Principal, domain, repo, ""); err != nil {
		redirectWithMsg(w, r, "/dashboard", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/dashboard", i18n.T(lang, "dashboard.msg_pinned", repo), false)
}

func (s *server) handleRemovePin(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	if err := s.ops.UnpinRepo(sess.Principal, r.FormValue("domain"), r.FormValue("repo")); err != nil {
		s.serverError(w, r, err)
		return
	}
	http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

// parsePinInput accepts anything from a bare "domain/owner/repo" to a full
// URL someone pasted straight out of their browser's address bar,
// including gitfed's own "/r/" repo-view prefix if that's what they copied.
func parsePinInput(s string) (domain, repo string, ok bool) {
	s = strings.TrimSpace(s)
	s = strings.TrimPrefix(s, "https://")
	s = strings.TrimPrefix(s, "http://")
	s = strings.TrimSuffix(s, "/")
	s = strings.TrimSuffix(s, ".git")

	domain, rest, found := strings.Cut(s, "/")
	rest = strings.TrimPrefix(rest, "r/")
	if !found || domain == "" || rest == "" {
		return "", "", false
	}
	return domain, rest, true
}