krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

f92774345dbb1721f44285daac94369e864dfa85

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T03:25:26Z

owner profiles: description and website for users and orgs

Migration 0007 adds both columns to users and orgs. profile show/set
(self-service; partial updates, '' clears, website must be http(s)),
org profile for org admins, rendered on owner pages with a nofollow
website link. CLI passthroughs included.
 cmd/gitbay/main.go                               |   5 +
 e2e/profile_test.go                              |  73 +++++++++++
 internal/control/profile.go                      | 158 +++++++++++++++++++++++
 internal/httpd/web.go                            |   4 +-
 internal/store/migrations/0007_profiles.down.sql |   4 +
 internal/store/migrations/0007_profiles.up.sql   |   4 +
 internal/store/orgs.go                           |  25 ++++
 internal/web/templates/owner.html                |   2 +
 8 files changed, 274 insertions(+), 1 deletion(-)

diff --git a/cmd/gitbay/main.go b/cmd/gitbay/main.go
index a1a7b15..842db20 100644
--- a/cmd/gitbay/main.go
+++ b/cmd/gitbay/main.go
@@ -31,6 +31,10 @@ func main() {
 		mrCmd(),
 		webCmd(),
 		orgCmd(),
+		group("profile", "user and org profiles",
+			pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}),
+			pass("set", "set your profile: [--description d] [--website url]", passOpts{server: []string{"profile", "set"}}),
+		),
 		webhookCmd(),
 		remoteCmd(),
 		initCmd(),
@@ -314,6 +318,7 @@ func orgCmd() *cobra.Command {
 		pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
 		pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
 		pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
+		pass("profile", "show or set an org profile: <org> [--description d] [--website url]", passOpts{server: []string{"org", "profile"}}),
 		group("members", "manage members",
 			pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
 			pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
diff --git a/e2e/profile_test.go b/e2e/profile_test.go
new file mode 100644
index 0000000..8a29ebf
--- /dev/null
+++ b/e2e/profile_test.go
@@ -0,0 +1,73 @@
+package e2e
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestOwnerProfiles(t *testing.T) {
+	inst := startInstance(t)
+	aliceKey := inst.newKey(t, "alice")
+	bobKey := inst.newKey(t, "bob")
+	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
+	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
+
+	// Self-service user profile; website validated.
+	if _, errOut, code := inst.ssh(t, aliceKey, "",
+		"profile", "set", "--description", "'builds small tools'", "--website", "https://alice.example"); code != 0 {
+		t.Fatalf("profile set: %s", errOut)
+	}
+	if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "gopher://nope"); code != 2 {
+		t.Fatal("bad website scheme accepted")
+	}
+	out, _, _ := inst.ssh(t, bobKey, "", "profile", "show", "alice", "--json")
+	if !strings.Contains(out, `"description":"builds small tools"`) || !strings.Contains(out, `"website":"https://alice.example"`) {
+		t.Fatalf("profile show: %s", out)
+	}
+
+	// Partial update leaves the other field untouched; empty clears.
+	if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--description", "'tinkerer'"); code != 0 {
+		t.Fatal("partial set failed")
+	}
+	out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
+	if !strings.Contains(out, "tinkerer") || !strings.Contains(out, "alice.example") {
+		t.Fatalf("partial update clobbered website: %s", out)
+	}
+	if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--website", "''"); code != 0 {
+		t.Fatal("clear failed")
+	}
+	out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "--json")
+	if strings.Contains(out, "alice.example") {
+		t.Fatalf("website not cleared: %s", out)
+	}
+
+	// Org profile: admin sets, member cannot; no flags shows.
+	if _, _, code := inst.ssh(t, aliceKey, "", "org", "create", "workshop"); code != 0 {
+		t.Fatal("org create failed")
+	}
+	if _, _, code := inst.ssh(t, aliceKey, "", "org", "members", "add", "workshop", "bob"); code != 0 {
+		t.Fatal("member add failed")
+	}
+	if _, errOut, code := inst.ssh(t, aliceKey, "",
+		"org", "profile", "workshop", "--description", "'where things get made'", "--website", "https://workshop.example"); code != 0 {
+		t.Fatalf("org profile set: %s", errOut)
+	}
+	if _, _, code := inst.ssh(t, bobKey, "", "org", "profile", "workshop", "--description", "hax"); code != 4 {
+		t.Fatal("member set org profile")
+	}
+	out, _, _ = inst.ssh(t, bobKey, "", "org", "profile", "workshop")
+	if !strings.Contains(out, "where things get made") {
+		t.Fatalf("org profile show: %s", out)
+	}
+
+	// Owner pages render description and website link.
+	status, body := inst.get(t, "/alice")
+	if status != 200 || !strings.Contains(body, "tinkerer") {
+		t.Fatalf("user page profile: %d", status)
+	}
+	status, body = inst.get(t, "/workshop")
+	if status != 200 || !strings.Contains(body, "where things get made") ||
+		!strings.Contains(body, `href="https://workshop.example"`) {
+		t.Fatalf("org page profile: %d\n%s", status, body)
+	}
+}
diff --git a/internal/control/profile.go b/internal/control/profile.go
new file mode 100644
index 0000000..9903cbb
--- /dev/null
+++ b/internal/control/profile.go
@@ -0,0 +1,158 @@
+package control
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"strings"
+
+	"gitbay.org/gitbay/internal/protocol"
+	"gitbay.org/gitbay/internal/store"
+)
+
+func init() {
+	register(Command{Path: []string{"profile", "show"},
+		Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow})
+	register(Command{Path: []string{"profile", "set"},
+		Summary: "set your profile: profile set [--description <d>] [--website <url>] ('' clears)", Run: runProfileSet})
+	register(Command{Path: []string{"org", "profile"},
+		Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>]", Run: runOrgProfile})
+}
+
+// parseProfileFlags pulls --description/--website out of args; a flag given
+// with an empty value clears the field, an absent flag leaves it untouched.
+func parseProfileFlags(args []string) (rest []string, desc, site *string, err error) {
+	for i := 0; i < len(args); i++ {
+		switch args[i] {
+		case "--description", "--website":
+			if i+1 >= len(args) {
+				return nil, nil, nil, fmt.Errorf("%s requires a value", args[i])
+			}
+			v := args[i+1]
+			if args[i] == "--description" {
+				desc = &v
+			} else {
+				site = &v
+			}
+			i++
+		default:
+			rest = append(rest, args[i])
+		}
+	}
+	return rest, desc, site, nil
+}
+
+func validateWebsite(url string) error {
+	if url == "" || strings.HasPrefix(url, "https://") || strings.HasPrefix(url, "http://") {
+		return nil
+	}
+	return errors.New("website must start with https:// or http://")
+}
+
+func applyProfile(p store.Profile, desc, site *string) (store.Profile, error) {
+	if desc != nil {
+		d, _, _ := strings.Cut(strings.TrimSpace(*desc), "\n")
+		if len(d) > 256 {
+			d = d[:256]
+		}
+		p.Description = d
+	}
+	if site != nil {
+		s := strings.TrimSpace(*site)
+		if err := validateWebsite(s); err != nil {
+			return p, err
+		}
+		p.Website = s
+	}
+	return p, nil
+}
+
+type profileOut struct {
+	Name        string `json:"name"`
+	Kind        string `json:"kind"`
+	Description string `json:"description,omitempty"`
+	Website     string `json:"website,omitempty"`
+}
+
+func emitProfile(c *Ctx, d profileOut) int {
+	return c.emit(d, func(w io.Writer) {
+		fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind)
+		if d.Description != "" {
+			fmt.Fprintf(w, "%s\n", d.Description)
+		}
+		if d.Website != "" {
+			fmt.Fprintf(w, "%s\n", d.Website)
+		}
+	})
+}
+
+func runProfileShow(c *Ctx, args []string) int {
+	name := c.User.Username
+	if len(args) == 1 {
+		name = args[0]
+	} else if len(args) > 1 {
+		return c.fail(protocol.ExitUsage, "usage: profile show [name]")
+	}
+	kind, id := "", int64(0)
+	if u, err := c.Store.UserByUsername(name); err == nil {
+		kind, id = "user", u.ID
+	} else if o, err := c.Store.OrgByName(name); err == nil {
+		kind, id = "org", o.ID
+	} else {
+		return c.fail(protocol.ExitNotFound, "no user or organization %q", name)
+	}
+	p, err := c.Store.OwnerProfile(kind, id)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return emitProfile(c, profileOut{name, kind, p.Description, p.Website})
+}
+
+func runProfileSet(c *Ctx, args []string) int {
+	rest, desc, site, err := parseProfileFlags(args)
+	if err != nil || len(rest) != 0 {
+		return c.fail(protocol.ExitUsage, "usage: profile set [--description <d>] [--website <url>]")
+	}
+	if desc == nil && site == nil {
+		return c.fail(protocol.ExitUsage, "nothing to set: pass --description and/or --website")
+	}
+	p, err := c.Store.OwnerProfile("user", c.User.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	p, err = applyProfile(p, desc, site)
+	if err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return emitProfile(c, profileOut{c.User.Username, "user", p.Description, p.Website})
+}
+
+func runOrgProfile(c *Ctx, args []string) int {
+	rest, desc, site, err := parseProfileFlags(args)
+	if err != nil || len(rest) != 1 {
+		return c.fail(protocol.ExitUsage, "usage: org profile <org> [--description <d>] [--website <url>]")
+	}
+	name := rest[0]
+	if desc == nil && site == nil {
+		return runProfileShow(c, []string{name})
+	}
+	org, code := orgAdmin(c, name)
+	if code >= 0 {
+		return code
+	}
+	p, err := c.Store.OwnerProfile("org", org.ID)
+	if err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	p, err = applyProfile(p, desc, site)
+	if err != nil {
+		return c.fail(protocol.ExitUsage, "%v", err)
+	}
+	if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
+		return c.fail(protocol.ExitFailure, "%v", err)
+	}
+	return emitProfile(c, profileOut{org.Name, "org", p.Description, p.Website})
+}
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 4fbd299..bf2e53c 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -181,6 +181,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 		http.NotFound(w, r)
 		return
 	}
+	profile, _ := s.st.OwnerProfile(kind, ownerID)
 
 	all, err := s.st.ListReposForOwner(kind, ownerID)
 	if err != nil {
@@ -202,10 +203,11 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 		Viewer  string
 		Owner   string
 		Kind    string
+		Profile store.Profile
 		Repos   []describedRepo
 		Members []store.OrgMember
 		Orgs    []store.OrgMember
-	}{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs})
+	}{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs})
 }
 
 func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/store/migrations/0007_profiles.down.sql b/internal/store/migrations/0007_profiles.down.sql
new file mode 100644
index 0000000..f923429
--- /dev/null
+++ b/internal/store/migrations/0007_profiles.down.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users DROP COLUMN description;
+ALTER TABLE users DROP COLUMN website;
+ALTER TABLE orgs DROP COLUMN description;
+ALTER TABLE orgs DROP COLUMN website;
diff --git a/internal/store/migrations/0007_profiles.up.sql b/internal/store/migrations/0007_profiles.up.sql
new file mode 100644
index 0000000..62ff27c
--- /dev/null
+++ b/internal/store/migrations/0007_profiles.up.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users ADD COLUMN description TEXT NOT NULL DEFAULT '';
+ALTER TABLE users ADD COLUMN website TEXT NOT NULL DEFAULT '';
+ALTER TABLE orgs ADD COLUMN description TEXT NOT NULL DEFAULT '';
+ALTER TABLE orgs ADD COLUMN website TEXT NOT NULL DEFAULT '';
diff --git a/internal/store/orgs.go b/internal/store/orgs.go
index 06ba8fa..92c6bdc 100644
--- a/internal/store/orgs.go
+++ b/internal/store/orgs.go
@@ -215,3 +215,28 @@ func (s *Store) RenameOrg(orgID int64, newName string) error {
 	}
 	return tx.Commit()
 }
+
+// Profile is the presentational half of a user or org.
+type Profile struct {
+	Description string
+	Website     string
+}
+
+// OwnerProfile reads the profile for kind "user" or "org".
+func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
+	table := map[string]string{"user": "users", "org": "orgs"}[kind]
+	var p Profile
+	err := s.DB.QueryRow(
+		"SELECT description, website FROM "+table+" WHERE id = ?", id).
+		Scan(&p.Description, &p.Website)
+	return p, err
+}
+
+// SetOwnerProfile updates the profile for kind "user" or "org".
+func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
+	table := map[string]string{"user": "users", "org": "orgs"}[kind]
+	_, err := s.DB.Exec(
+		"UPDATE "+table+" SET description = ?, website = ? WHERE id = ?",
+		p.Description, p.Website, id)
+	return err
+}
diff --git a/internal/web/templates/owner.html b/internal/web/templates/owner.html
index 708db69..c842af0 100644
--- a/internal/web/templates/owner.html
+++ b/internal/web/templates/owner.html
@@ -1,6 +1,8 @@
 {{define "title"}}{{.Owner}} · {{.Site}}{{end}}
 {{define "content"}}
 <h1>{{.Owner}} <span class="badge badge-unsigned">{{.Kind}}</span></h1>
+{{if .Profile.Description}}<p class="desc">{{.Profile.Description}}</p>{{end}}
+{{if .Profile.Website}}<p class="crumbs"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}}
 {{if .Orgs}}<p class="crumbs">member of: {{range .Orgs}}<a href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
 {{if .Members}}<p class="crumbs">members: {{range .Members}}<a href="/{{.Username}}">{{.Username}}</a> ({{.Role}}) {{end}}</p>{{end}}
 <table>