Add public profile pages
GitLab-style /u/{username}: Markdown bio (editable from Settings → Profile), the user's public repos, join date, and recent activity merged across those repos. Repo owners become clickable links to their profile on the repo page and /explore when the owner is a local account.
13 files changed
+275 −15
M
cmd/gitfed-web/handlers_home.go
+6 −6
A
cmd/gitfed-web/handlers_profile.go
+141 −0
M
cmd/gitfed-web/handlers_repo.go
+1 −1
M
cmd/gitfed-web/handlers_settings.go
+34 −5
M
cmd/gitfed-web/render.go
+22 −3
M
cmd/gitfed-web/routes.go
+2 −0
M
internal/admin/admin.go
+12 −0
M
internal/adminrpc/client.go
+4 −0
M
internal/adminrpc/protocol.go
+6 −0
M
internal/adminrpc/server.go
+7 −0
M
internal/i18n/strings_en.go
+14 −0
M
internal/i18n/strings_fr.go
+14 −0
M
internal/store/users.go
+12 −0
cmd/gitfed-web/handlers_home.go
@@ -42,7 +42,7 @@ var exploreTpl = newTpl("explore", `
<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> <span class="owner">— {{.Owner}}</span></div>
+ <div class="name"><a href="/r/{{.Name}}">{{.Name}}</a> <span class="owner">— {{if localUser .Owner $.Domain}}<a href="/u/{{localUser .Owner $.Domain}}">{{.Owner}}</a>{{else}}{{.Owner}}{{end}}</span></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>
@@ -97,11 +97,11 @@ func (s *server) handleExplore(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_ = exploreTpl.Execute(&buf, struct {
- Repos []store.Repo
- AllTopics []string
- Topic, Query, Lang string
- Flash template.HTML
- }{visible, allTopics, topic, query, string(lang), flash(r)})
+ Repos []store.Repo
+ AllTopics []string
+ Topic, Query, Lang, Domain string
+ Flash template.HTML
+ }{visible, allTopics, topic, query, string(lang), s.domain, flash(r)})
s.render(w, r, i18n.T(lang, "explore.title"), "explore", template.HTML(buf.String()))
}
cmd/gitfed-web/handlers_profile.go
@@ -0,0 +1,141 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+ "sort"
+
+ "gitfed/internal/gitexec"
+ "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
+}
+
+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}}
+
+<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
+ Flash template.HTML
+ }{
+ user.Username, string(lang), initials(user.Username), user.CreatedAt.Format("2006-01-02"),
+ user.IsAdmin, bioHTML, repos, activity, flash(r),
+ })
+ s.render(w, r, user.Username, "", template.HTML(buf.String()))
+}
cmd/gitfed-web/handlers_repo.go
@@ -104,7 +104,7 @@ var repoTpl = newTpl("repo", `
{{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>
-<p class="muted">{{t .Lang "repo.owner"}}: {{.Repo.Owner}}</p>
+<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>
<div class="gf-actions-row">
{{if .Branch}}<span class="gf-btn">{{icon "branch"}} {{.Branch}}</span>{{end}}
cmd/gitfed-web/handlers_settings.go
@@ -28,6 +28,14 @@ var settingsTpl = newTpl("settings", `
</div>
</div>
<p class="muted">{{t .Lang "settings.profile_note"}}</p>
+
+ <form class="card" method="post" action="/settings/profile">
+ <strong>{{t .Lang "settings.bio_title"}}</strong>
+ <label>{{t .Lang "settings.bio_label"}}</label>
+ <textarea name="bio" rows="8" maxlength="4000" placeholder="{{t .Lang "settings.bio_placeholder"}}">{{.Bio}}</textarea>
+ <button type="submit">{{t .Lang "repo.save"}}</button>
+ </form>
+ <p class="muted">{{t .Lang "settings.public_profile_hint"}} <a href="/u/{{.Username}}">{{t .Lang "settings.view_public_profile"}}</a></p>
</div>
<div id="tab-keys" class="gf-tabpane {{if eq .ActiveTab "keys"}}active{{end}}">
@@ -106,14 +114,35 @@ func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) {
var buf bytes.Buffer
_ = settingsTpl.Execute(&buf, struct {
- Username, Principal, ActiveTab, Lang, Initials string
- IsAdmin bool
- Keys []keyView
- Flash template.HTML
- }{sess.Username, sess.Principal, settingsTab(r), string(lang), initials(sess.Username), sess.IsAdmin, keys, flash(r)})
+ Username, Principal, ActiveTab, Lang, Initials, Bio string
+ IsAdmin bool
+ Keys []keyView
+ Flash template.HTML
+ }{sess.Username, sess.Principal, settingsTab(r), string(lang), initials(sess.Username), user.Bio, sess.IsAdmin, keys, flash(r)})
s.render(w, r, i18n.T(lang, "settings.title"), "settings", template.HTML(buf.String()))
}
+// bioMaxLen matches mrTextMaxLen (handlers_merge_requests.go) — same
+// free-text-field budget used throughout the app. The HTML maxlength on the
+// textarea is a UX hint only; this is the real enforcement, since a direct
+// POST bypasses the former.
+const bioMaxLen = 4000
+
+func (s *server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
+ sess, _ := s.currentSession(r)
+ lang := s.lang(r)
+ bio := r.FormValue("bio")
+ if len(bio) > bioMaxLen {
+ redirectWithMsg(w, r, "/settings?tab=profile", i18n.T(lang, "settings.msg_bio_too_long"), true)
+ return
+ }
+ if err := s.ops.SetUserBio(sess.Username, bio); err != nil {
+ redirectWithMsg(w, r, "/settings?tab=profile", err.Error(), true)
+ return
+ }
+ redirectWithMsg(w, r, "/settings?tab=profile", i18n.T(lang, "settings.msg_bio_saved"), false)
+}
+
func (s *server) handleAddOwnKey(w http.ResponseWriter, r *http.Request) {
sess, _ := s.currentSession(r)
lang := s.lang(r)
cmd/gitfed-web/render.go
@@ -26,6 +26,18 @@ var commonFuncs = template.FuncMap{
"t": func(lang, key string, args ...any) string { return i18n.T(i18n.Lang(lang), key, args...) },
"icon": icon,
"roleLabel": func(lang, role string) string { return roleLabel(i18n.Lang(lang), role) },
+ "localUser": localUser,
+}
+
+// localUser returns the bare username if principal ("user@domain") belongs
+// to this instance's own domain, or "" if it's a federated principal from
+// elsewhere — only local accounts have a /u/{username} profile page here.
+func localUser(principal, domain string) string {
+ suffix := "@" + domain
+ if !strings.HasSuffix(principal, suffix) {
+ return ""
+ }
+ return strings.TrimSuffix(principal, suffix)
}
func newTpl(name, src string) *template.Template {
@@ -359,7 +371,7 @@ const shellHeadSrc = `<!doctype html>
/* ---------- dashboard / settings / admin ---------- */
.gf-page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
- .gf-page-head h1 { font-size: 1.35rem; margin: 0; }
+ .gf-page-head h1, .gf-page-head h2 { font-size: 1.35rem; margin: 0; }
.gf-page-head .count { font-family: var(--mono); font-size: 0.82rem; color: var(--text-faint); }
.gf-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; }
@@ -504,6 +516,12 @@ const shellHeadSrc = `<!doctype html>
.gf-profile-card .who .admin-tag { color: var(--accent); font-weight: 600; }
.gf-profile-card .principal { font-family: var(--mono); font-size: 0.8rem; color: var(--text-faint); margin-top: 0.15rem; }
+ .gf-profile-page-head { display: flex; align-items: center; gap: 1rem; margin: 0.5rem 0 1.5rem; }
+ .gf-profile-page-head .avatar { width: 64px; height: 64px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; flex-shrink: 0; }
+ .gf-profile-page-head h1 { font-size: 1.4rem; margin: 0; }
+ .gf-profile-page-head h1 .admin-tag { color: var(--accent); font-weight: 600; }
+ .gf-profile-page-head p { margin: 0.2rem 0 0; }
+
.gf-list-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.85rem 1.1rem; border-bottom: 1px solid var(--border); }
.gf-list-row:last-child { border-bottom: none; }
.gf-list-row:hover { background: var(--surface-2); }
@@ -549,8 +567,9 @@ const shellHeadSrc = `<!doctype html>
form.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; margin: 1rem 0; }
form.card.narrow { max-width: 480px; }
form.card label { display: block; font-size: 0.85rem; color: var(--text-dim); margin-top: 0.6rem; }
- input, select { width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; margin-top: 0.25rem; background: var(--canvas); border: 1px solid var(--border-strong); color: var(--text); border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
- input:focus, select:focus { outline: none; border-color: var(--accent); }
+ input, select, textarea { width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; margin-top: 0.25rem; background: var(--canvas); border: 1px solid var(--border-strong); color: var(--text); border-radius: 6px; font-size: 0.9rem; font-family: inherit; }
+ textarea { resize: vertical; line-height: 1.5; }
+ input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
button, .gf-btn { margin-top: 0.8rem; padding: 0.45rem 0.9rem; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--text); font-weight: 600; font-size: 0.86rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; }
button:hover, .gf-btn:hover { background: var(--surface-3); }
button[type="submit"]:not(.linklike), .gf-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
cmd/gitfed-web/routes.go
@@ -18,6 +18,7 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("GET /search", s.handleSearch)
mux.HandleFunc("GET /security", s.handleSecurity)
mux.HandleFunc("GET /explore", s.handleExplore)
+ mux.HandleFunc("GET /u/{username}", s.handleUserProfile)
mux.HandleFunc("GET /lang/{lang}", s.handleSetLang)
// Anonymous read-only git-over-HTTPS for public repos (clone/fetch
@@ -46,6 +47,7 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("POST /repo-mr-comment/{repo...}", s.requireLogin(s.handleMRComment))
mux.HandleFunc("GET /settings", s.requireLogin(s.handleSettings))
+ mux.HandleFunc("POST /settings/profile", s.requireLogin(s.handleUpdateProfile))
mux.HandleFunc("POST /settings/keys/add", s.requireLogin(s.handleAddOwnKey))
mux.HandleFunc("POST /settings/keys/remove", s.requireLogin(s.handleRemoveOwnKey))
mux.HandleFunc("POST /settings/password", s.requireLogin(s.handleChangePassword))
internal/admin/admin.go
@@ -32,6 +32,7 @@ type Ops interface {
RemoveUserKey(username, pubKeyAuthorized string) error
DeleteUser(username string) error
SetUserAdmin(username string, isAdmin bool) error
+ SetUserBio(username, bio string) error
SetPassword(username, newPassword string) error
VerifyPassword(username, password string) (isAdmin bool, ok bool, err error)
@@ -226,6 +227,17 @@ func (a *Admin) SetUserAdmin(username string, isAdmin bool) error {
return a.Store.SetUserAdmin(username, isAdmin)
}
+// bioMaxLen matches mrTextMaxLen (cmd/gitfed-web) — same free-text-field
+// budget used throughout the app.
+const bioMaxLen = 4000
+
+func (a *Admin) SetUserBio(username, bio string) error {
+ if len(bio) > bioMaxLen {
+ return fmt.Errorf("admin: bio must be at most %d characters", bioMaxLen)
+ }
+ return a.Store.SetUserBio(username, bio)
+}
+
func (a *Admin) ListRepos() ([]store.Repo, error) {
return a.Store.ListRepos()
}
internal/adminrpc/client.go
@@ -200,6 +200,10 @@ func (c *Client) SetUserAdmin(username string, isAdmin bool) error {
return c.call(methodSetUserAdmin, setAdminArgs{Username: username, IsAdmin: isAdmin}, nil)
}
+func (c *Client) SetUserBio(username, bio string) error {
+ return c.call(methodSetUserBio, setBioArgs{Username: username, Bio: bio}, nil)
+}
+
func (c *Client) SetPassword(username, newPassword string) error {
return c.call(methodSetPassword, setPasswordArgs{Username: username, Password: newPassword}, nil)
}
internal/adminrpc/protocol.go
@@ -39,6 +39,7 @@ const (
methodGetRepoBranch = "GetRepoBranch"
methodGetUser = "GetUser"
methodSetUserAdmin = "SetUserAdmin"
+ methodSetUserBio = "SetUserBio"
methodSetPassword = "SetPassword"
methodVerifyPassword = "VerifyPassword"
methodCreateSession = "CreateSession"
@@ -180,6 +181,11 @@ type setAdminArgs struct {
IsAdmin bool `json:"is_admin"`
}
+type setBioArgs struct {
+ Username string `json:"username"`
+ Bio string `json:"bio"`
+}
+
type setPasswordArgs struct {
Username string `json:"username"`
Password string `json:"password"`
internal/adminrpc/server.go
@@ -240,6 +240,13 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
}
return nil, s.ops.SetUserAdmin(a.Username, a.IsAdmin)
+ case methodSetUserBio:
+ var a setBioArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ return nil, s.ops.SetUserBio(a.Username, a.Bio)
+
case methodSetPassword:
var a setPasswordArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/i18n/strings_en.go
@@ -28,6 +28,13 @@ var en = map[string]string{
"explore.empty_title": "No public repositories match",
"explore.empty_hint": "Try a different topic or search, or clear the filter.",
+ // ---------- profile ----------
+ "profile.joined": "Joined",
+ "profile.repos_title": "Public repositories",
+ "profile.no_repos": "No public repositories yet.",
+ "profile.activity_title": "Recent activity",
+ "profile.no_activity": "No recent activity.",
+
// ---------- landing ----------
"landing.title": "",
"landing.kicker": "self-hosted & federated git",
@@ -176,6 +183,13 @@ var en = map[string]string{
"settings.msg_key_removed": "key removed",
"settings.msg_wrong_password": "current password is incorrect",
"settings.msg_password_changed": "password changed",
+ "settings.bio_title": "Public bio",
+ "settings.bio_label": "Shown on your public profile page — Markdown supported.",
+ "settings.bio_placeholder": "A few words about you or what you work on…",
+ "settings.public_profile_hint": "Anyone can view your public profile, even without an account.",
+ "settings.view_public_profile": "View public profile",
+ "settings.msg_bio_saved": "bio saved",
+ "settings.msg_bio_too_long": "bio is too long",
// ---------- auth ----------
"auth.login": "Log in",
internal/i18n/strings_fr.go
@@ -28,6 +28,13 @@ var fr = map[string]string{
"explore.empty_title": "Aucun dépôt public ne correspond",
"explore.empty_hint": "Essayez un autre sujet ou une autre recherche, ou effacez le filtre.",
+ // ---------- profile ----------
+ "profile.joined": "Inscrit·e depuis",
+ "profile.repos_title": "Dépôts publics",
+ "profile.no_repos": "Aucun dépôt public pour l'instant.",
+ "profile.activity_title": "Activité récente",
+ "profile.no_activity": "Aucune activité récente.",
+
// ---------- landing ----------
"landing.title": "",
"landing.kicker": "git auto-hébergé & fédéré",
@@ -176,6 +183,13 @@ var fr = map[string]string{
"settings.msg_key_removed": "clé retirée",
"settings.msg_wrong_password": "mot de passe actuel incorrect",
"settings.msg_password_changed": "mot de passe changé",
+ "settings.bio_title": "Bio publique",
+ "settings.bio_label": "Affichée sur votre page de profil publique — Markdown pris en charge.",
+ "settings.bio_placeholder": "Quelques mots sur vous ou ce sur quoi vous travaillez…",
+ "settings.public_profile_hint": "N'importe qui peut voir votre profil public, même sans compte.",
+ "settings.view_public_profile": "Voir le profil public",
+ "settings.msg_bio_saved": "bio enregistrée",
+ "settings.msg_bio_too_long": "la bio est trop longue",
// ---------- auth ----------
"auth.login": "Se connecter",
internal/store/users.go
@@ -16,6 +16,7 @@ type User struct {
PubKeys []string `json:"pub_keys"` // authorized_keys-format entries
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
+ Bio string `json:"bio,omitempty"` // markdown, shown on the public /u/{username} profile page
}
func (s *Store) CreateUser(u User) error {
@@ -106,6 +107,17 @@ func (s *Store) SetUserAdmin(username string, isAdmin bool) error {
})
}
+func (s *Store) SetUserBio(username, bio string) error {
+ return s.db.Update(func(tx *bolt.Tx) error {
+ var u User
+ if err := getJSON(tx, bucketUsers, username, &u); err != nil {
+ return err
+ }
+ u.Bio = bio
+ return putJSON(tx, bucketUsers, username, u)
+ })
+}
+
// FindUserByKey returns the user owning the given authorized_keys-format key.
func (s *Store) FindUserByKey(pubKey string) (User, error) {
var found User