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
}