Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_profile.go
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()))
}