package main
import (
"bytes"
"html/template"
"net/http"
"sort"
gossh "golang.org/x/crypto/ssh"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)
// profileRecentCommitsPerRepo/profileRecentActivityMax bound how much work a
// profile page does: a handful of commits from each of the user's public
// repos, merged and capped, rather than a full history scan.
const (
profileRecentCommitsPerRepo = 5
profileRecentActivityMax = 10
)
// profileActivity is one commit surfaced in a profile's recent-activity
// list, tagged with which repo it came from since commits alone don't carry
// that context.
type profileActivity struct {
Repo string
Commit gitexec.Commit
}
// sshFingerprints turns a user's stored authorized_keys-format public keys
// into their SHA256 fingerprints (the standard `ssh-keygen -lf` format) for
// display on the *public* profile page — the fingerprint alone lets a
// visitor verify "is this the same key I already trust" without the page
// ever handing out the full public key material.
func sshFingerprints(pubKeys []string) []string {
fps := make([]string, 0, len(pubKeys))
for _, k := range pubKeys {
key, _, _, _, err := gossh.ParseAuthorizedKey([]byte(k))
if err != nil {
continue // a key that fails to parse just doesn't get shown
}
fps = append(fps, gossh.FingerprintSHA256(key))
}
return fps
}
var profileTpl = newTpl("profile", `
{{.Flash}}
<div class="gf-profile-page-head">
<div class="avatar">{{.Initials}}</div>
<div>
<h1>{{.Username}}{{if .IsAdmin}} — <span class="admin-tag">{{t .Lang "role.admin"}}</span>{{end}}</h1>
<p class="muted">{{t .Lang "profile.joined"}} {{.JoinedAt}}</p>
</div>
</div>
{{if .BioHTML}}
<div class="gf-card">
<div class="gf-readme-body markdown-body">{{.BioHTML}}</div>
</div>
{{end}}
{{if .KeyFingerprints}}
<div class="gf-page-head"><h2>{{t .Lang "profile.keys_title"}}</h2></div>
<div class="gf-card" style="padding:1rem 1.2rem;">
<p class="muted" style="margin:0 0 0.6rem;">{{t .Lang "profile.keys_hint"}}</p>
{{range .KeyFingerprints}}<div class="muted" style="font-family:var(--mono); font-size:0.84rem; margin:0.2rem 0;">{{icon "key"}} {{.}}</div>{{end}}
</div>
{{end}}
<div class="gf-page-head"><h2>{{t .Lang "profile.repos_title"}}</h2><span class="count">{{len .Repos}} {{t .Lang "explore.repo_count"}}</span></div>
<div class="gf-card">
{{if .Repos}}
<div class="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>
{{if .Topics}}<div class="topics">{{range .Topics}}<a href="/explore?topic={{.}}">{{.}}</a>{{end}}</div>{{end}}
</div>
<span class="badge trusted">{{t $.Lang "common.public"}}</span>
</div>
{{end}}
</div>
{{else}}
<div class="gf-empty"><p class="muted">{{t .Lang "profile.no_repos"}}</p></div>
{{end}}
</div>
<div class="gf-page-head"><h2>{{t .Lang "profile.activity_title"}}</h2></div>
<div class="gf-card gf-commit-list">
{{range .Activity}}
<a class="gf-commit-row" href="/repo-commit/{{.Repo}}?hash={{.Commit.Hash}}">
<div class="gf-commit-main">
<div class="subject">{{.Commit.Subject}}</div>
<div class="meta">{{.Repo}} · {{.Commit.Date.Local.Format "2006-01-02 15:04"}}</div>
</div>
<code class="gf-commit-hash">{{.Commit.ShortHash}}</code>
</a>
{{else}}
<div class="gf-commit-row"><span class="muted">{{t .Lang "profile.no_activity"}}</span></div>
{{end}}
</div>
`)
func (s *server) handleUserProfile(w http.ResponseWriter, r *http.Request) {
username := r.PathValue("username")
lang := s.lang(r)
user, err := s.ops.GetUser(username)
if err != nil {
http.NotFound(w, r)
return
}
principal := username + "@" + s.domain
allRepos, err := s.ops.ListRepos()
if err != nil {
s.serverError(w, r, err)
return
}
var repos []store.Repo
for _, repo := range allRepos {
if repo.Owner == principal && repo.Public {
repos = append(repos, repo)
}
}
var activity []profileActivity
for _, repo := range repos {
commits, found, err := s.ops.ListCommits(repo.Name, profileRecentCommitsPerRepo)
if err != nil || !found {
continue
}
for _, c := range commits {
activity = append(activity, profileActivity{Repo: repo.Name, Commit: c})
}
}
sort.Slice(activity, func(i, j int) bool { return activity[i].Commit.Date.After(activity[j].Commit.Date) })
if len(activity) > profileRecentActivityMax {
activity = activity[:profileRecentActivityMax]
}
var bioHTML template.HTML
if user.Bio != "" {
bioHTML, err = renderMarkdown(user.Bio)
if err != nil {
s.serverError(w, r, err)
return
}
}
var buf bytes.Buffer
_ = profileTpl.Execute(&buf, struct {
Username, Lang, Initials, JoinedAt string
IsAdmin bool
BioHTML template.HTML
Repos []store.Repo
Activity []profileActivity
KeyFingerprints []string
Flash template.HTML
}{
user.Username, string(lang), initials(user.Username), user.CreatedAt.Format("2006-01-02"),
user.IsAdmin, bioHTML, repos, activity, sshFingerprints(user.PubKeys), flash(r),
})
s.render(w, r, user.Username, "", template.HTML(buf.String()))
}