Gitfed
bastien-mrq/gitfed/ Commits/ 22c8e9d

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.

bastien-mrq 2026-07-29 08:34 commit 22c8e9d76eb3c62509a382dd5484bbd77473c3cc parent 5467a78f45ddb07bb13c39f20482c4824e647398
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
diff --git a/cmd/gitfed-web/handlers_home.go b/cmd/gitfed-web/handlers_home.go index efc086a..e0b600a 100644 --- a/cmd/gitfed-web/handlers_home.go +++ b/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
diff --git a/cmd/gitfed-web/handlers_profile.go b/cmd/gitfed-web/handlers_profile.go new file mode 100644 index 0000000..6445e6c --- /dev/null +++ b/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
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index 5940dcc..f5ad201 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/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
diff --git a/cmd/gitfed-web/handlers_settings.go b/cmd/gitfed-web/handlers_settings.go index 2be8999..70f73ae 100644 --- a/cmd/gitfed-web/handlers_settings.go +++ b/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
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index e6bd2d0..6691b99 100644 --- a/cmd/gitfed-web/render.go +++ b/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
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 1730b1e..c79b3d3 100644 --- a/cmd/gitfed-web/routes.go +++ b/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
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index c3f1614..9fd93a0 100644 --- a/internal/admin/admin.go +++ b/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
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index b44246c..636e756 100644 --- a/internal/adminrpc/client.go +++ b/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
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 9dcae0c..baf3fa3 100644 --- a/internal/adminrpc/protocol.go +++ b/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
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 1c265d1..0d7a274 100644 --- a/internal/adminrpc/server.go +++ b/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
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index 968506e..bb9deba 100644 --- a/internal/i18n/strings_en.go +++ b/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
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index 0643ac0..a21f68c 100644 --- a/internal/i18n/strings_fr.go +++ b/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
diff --git a/internal/store/users.go b/internal/store/users.go index 4b88a99..16de991 100644 --- a/internal/store/users.go +++ b/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