From 86daf8e3a3304bed263606faa6aa7b9941fbad72 Mon Sep 17 00:00:00 2001 From: Akshay Date: Sun, 4 May 2025 17:53:33 +0100 Subject: [PATCH] appview: profile: introduce profile lexicon --- api/tangled/actorprofile.go | 31 ++ api/tangled/cbor_gen.go | 513 ++++++++++++++++++ appview/db/artifact.go | 16 - appview/db/db.go | 73 +++ appview/db/profile.go | 285 ++++++++++ appview/db/repos.go | 6 + appview/pages/pages.go | 26 + .../templates/user/fragments/editBio.html | 107 ++++ .../templates/user/fragments/editPins.html | 38 ++ appview/pages/templates/user/profile.html | 217 +++++--- appview/state/profile.go | 337 +++++++++++- appview/state/router.go | 8 + cmd/gen.go | 1 + flake.nix | 3 +- lexicons/actor/profile.json | 72 +++ 15 files changed, 1639 insertions(+), 94 deletions(-) create mode 100644 api/tangled/actorprofile.go create mode 100644 appview/pages/templates/user/fragments/editBio.html create mode 100644 appview/pages/templates/user/fragments/editPins.html create mode 100644 lexicons/actor/profile.json diff --git a/api/tangled/actorprofile.go b/api/tangled/actorprofile.go new file mode 100644 index 0000000..5d01787 --- /dev/null +++ b/api/tangled/actorprofile.go @@ -0,0 +1,31 @@ +// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. + +package tangled + +// schema: sh.tangled.actor.profile + +import ( + "github.com/bluesky-social/indigo/lex/util" +) + +const ( + ActorProfileNSID = "sh.tangled.actor.profile" +) + +func init() { + util.RegisterType("sh.tangled.actor.profile", &ActorProfile{}) +} // +// RECORDTYPE: ActorProfile +type ActorProfile struct { + LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"` + // bluesky: Include link to this account on Bluesky. + Bluesky *bool `json:"bluesky,omitempty" cborgen:"bluesky,omitempty"` + // description: Free-form profile description text. + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` + Links []string `json:"links,omitempty" cborgen:"links,omitempty"` + // location: Free-form location text. + Location *string `json:"location,omitempty" cborgen:"location,omitempty"` + // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. + PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` +} diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go index 0b745ba..7d0f687 100644 --- a/api/tangled/cbor_gen.go +++ b/api/tangled/cbor_gen.go @@ -3389,3 +3389,516 @@ func (t *RepoArtifact) UnmarshalCBOR(r io.Reader) (err error) { return nil } +func (t *ActorProfile) MarshalCBOR(w io.Writer) error { + if t == nil { + _, err := w.Write(cbg.CborNull) + return err + } + + cw := cbg.NewCborWriter(w) + fieldCount := 7 + + if t.Bluesky == nil { + fieldCount-- + } + + if t.Description == nil { + fieldCount-- + } + + if t.Links == nil { + fieldCount-- + } + + if t.Location == nil { + fieldCount-- + } + + if t.PinnedRepositories == nil { + fieldCount-- + } + + if t.Stats == nil { + fieldCount-- + } + + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { + return err + } + + // t.LexiconTypeID (string) (string) + if len("$type") > 1000000 { + return xerrors.Errorf("Value in field \"$type\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { + return err + } + if _, err := cw.WriteString(string("$type")); err != nil { + return err + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.actor.profile"))); err != nil { + return err + } + if _, err := cw.WriteString(string("sh.tangled.actor.profile")); err != nil { + return err + } + + // t.Links ([]string) (slice) + if t.Links != nil { + + if len("links") > 1000000 { + return xerrors.Errorf("Value in field \"links\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil { + return err + } + if _, err := cw.WriteString(string("links")); err != nil { + return err + } + + if len(t.Links) > 8192 { + return xerrors.Errorf("Slice value in field t.Links was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil { + return err + } + for _, v := range t.Links { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + + // t.Stats ([]string) (slice) + if t.Stats != nil { + + if len("stats") > 1000000 { + return xerrors.Errorf("Value in field \"stats\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil { + return err + } + if _, err := cw.WriteString(string("stats")); err != nil { + return err + } + + if len(t.Stats) > 8192 { + return xerrors.Errorf("Slice value in field t.Stats was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil { + return err + } + for _, v := range t.Stats { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + + // t.Bluesky (bool) (bool) + if t.Bluesky != nil { + + if len("bluesky") > 1000000 { + return xerrors.Errorf("Value in field \"bluesky\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil { + return err + } + if _, err := cw.WriteString(string("bluesky")); err != nil { + return err + } + + if t.Bluesky == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if err := cbg.WriteBool(w, *t.Bluesky); err != nil { + return err + } + } + } + + // t.Location (string) (string) + if t.Location != nil { + + if len("location") > 1000000 { + return xerrors.Errorf("Value in field \"location\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil { + return err + } + if _, err := cw.WriteString(string("location")); err != nil { + return err + } + + if t.Location == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Location) > 1000000 { + return xerrors.Errorf("Value in field t.Location was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Location)); err != nil { + return err + } + } + } + + // t.Description (string) (string) + if t.Description != nil { + + if len("description") > 1000000 { + return xerrors.Errorf("Value in field \"description\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { + return err + } + if _, err := cw.WriteString(string("description")); err != nil { + return err + } + + if t.Description == nil { + if _, err := cw.Write(cbg.CborNull); err != nil { + return err + } + } else { + if len(*t.Description) > 1000000 { + return xerrors.Errorf("Value in field t.Description was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil { + return err + } + if _, err := cw.WriteString(string(*t.Description)); err != nil { + return err + } + } + } + + // t.PinnedRepositories ([]string) (slice) + if t.PinnedRepositories != nil { + + if len("pinnedRepositories") > 1000000 { + return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil { + return err + } + if _, err := cw.WriteString(string("pinnedRepositories")); err != nil { + return err + } + + if len(t.PinnedRepositories) > 8192 { + return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil { + return err + } + for _, v := range t.PinnedRepositories { + if len(v) > 1000000 { + return xerrors.Errorf("Value in field v was too long") + } + + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { + return err + } + if _, err := cw.WriteString(string(v)); err != nil { + return err + } + + } + } + return nil +} + +func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { + *t = ActorProfile{} + + cr := cbg.NewCborReader(r) + + maj, extra, err := cr.ReadHeader() + if err != nil { + return err + } + defer func() { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + }() + + if maj != cbg.MajMap { + return fmt.Errorf("cbor input should be of type map") + } + + if extra > cbg.MaxLength { + return fmt.Errorf("ActorProfile: map struct too large (%d)", extra) + } + + n := extra + + nameBuf := make([]byte, 18) + for i := uint64(0); i < n; i++ { + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) + if err != nil { + return err + } + + if !ok { + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { + return err + } + continue + } + + switch string(nameBuf[:nameLen]) { + // t.LexiconTypeID (string) (string) + case "$type": + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.LexiconTypeID = string(sval) + } + // t.Links ([]string) (slice) + case "links": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Links: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Links = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Links[i] = string(sval) + } + + } + } + // t.Stats ([]string) (slice) + case "stats": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.Stats: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.Stats = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Stats[i] = string(sval) + } + + } + } + // t.Bluesky (bool) (bool) + case "bluesky": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + if maj != cbg.MajOther { + return fmt.Errorf("booleans must be major type 7") + } + + var val bool + switch extra { + case 20: + val = false + case 21: + val = true + default: + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) + } + t.Bluesky = &val + } + } + // t.Location (string) (string) + case "location": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Location = (*string)(&sval) + } + } + // t.Description (string) (string) + case "description": + + { + b, err := cr.ReadByte() + if err != nil { + return err + } + if b != cbg.CborNull[0] { + if err := cr.UnreadByte(); err != nil { + return err + } + + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.Description = (*string)(&sval) + } + } + // t.PinnedRepositories ([]string) (slice) + case "pinnedRepositories": + + maj, extra, err = cr.ReadHeader() + if err != nil { + return err + } + + if extra > 8192 { + return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra) + } + + if maj != cbg.MajArray { + return fmt.Errorf("expected cbor array") + } + + if extra > 0 { + t.PinnedRepositories = make([]string, extra) + } + + for i := 0; i < int(extra); i++ { + { + var maj byte + var extra uint64 + var err error + _ = maj + _ = extra + _ = err + + { + sval, err := cbg.ReadStringWithMax(cr, 1000000) + if err != nil { + return err + } + + t.PinnedRepositories[i] = string(sval) + } + + } + } + + default: + // Field doesn't exist on this type, so ignore it + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { + return err + } + } + } + + return nil +} diff --git a/appview/db/artifact.go b/appview/db/artifact.go index 8e93844..51aae7a 100644 --- a/appview/db/artifact.go +++ b/appview/db/artifact.go @@ -57,22 +57,6 @@ func AddArtifact(e Execer, artifact Artifact) error { return err } -type filter struct { - key string - arg any -} - -func Filter(key string, arg any) filter { - return filter{ - key: key, - arg: arg, - } -} - -func (f filter) Condition() string { - return fmt.Sprintf("%s = ?", f.key) -} - func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { var artifacts []Artifact diff --git a/appview/db/db.go b/appview/db/db.go index 0b74db7..eb19053 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + "fmt" "log" _ "github.com/mattn/go-sqlite3" @@ -231,6 +232,62 @@ func Make(dbPath string) (*DB, error) { foreign key (repo_at) references repos(at_uri) on delete cascade ); + create table if not exists profile ( + -- id + id integer primary key autoincrement, + did text not null, + + -- data + description text not null, + include_bluesky integer not null default 0, + location text, + + -- constraints + unique(did) + ); + create table if not exists profile_links ( + -- id + id integer primary key autoincrement, + did text not null, + + -- data + link text not null, + + -- constraints + foreign key (did) references profile(did) on delete cascade + ); + create table if not exists profile_stats ( + -- id + id integer primary key autoincrement, + did text not null, + + -- data + kind text not null check (kind in ( + "merged-pull-request-count", + "closed-pull-request-count", + "open-pull-request-count", + "open-issue-count", + "closed-issue-count", + "repository-count" + )), + + -- constraints + foreign key (did) references profile(did) on delete cascade + ); + create table if not exists profile_pinned_repositories ( + -- id + id integer primary key autoincrement, + did text not null, + + -- data + at_uri text not null, + + -- constraints + unique(did, at_uri), + foreign key (did) references profile(did) on delete cascade, + foreign key (at_uri) references repos(at_uri) on delete cascade + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique @@ -348,3 +405,19 @@ func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { return nil } + +type filter struct { + key string + arg any +} + +func Filter(key string, arg any) filter { + return filter{ + key: key, + arg: arg, + } +} + +func (f filter) Condition() string { + return fmt.Sprintf("%s = ?", f.key) +} diff --git a/appview/db/profile.go b/appview/db/profile.go index 3e9bd5a..a21ba6a 100644 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -1,8 +1,13 @@ package db import ( + "database/sql" "fmt" + "log" "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.sh/tangled.sh/core/api/tangled" ) type RepoEvent struct { @@ -162,3 +167,283 @@ func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { return &timeline, nil } + +type Profile struct { + // ids + ID int + Did string + + // data + Description string + IncludeBluesky bool + Location string + Links [5]string + Stats [2]VanityStat + PinnedRepos [6]syntax.ATURI +} + +func (p Profile) IsLinksEmpty() bool { + for _, l := range p.Links { + if l != "" { + return false + } + } + return true +} + +func (p Profile) IsStatsEmpty() bool { + for _, s := range p.Stats { + if s.Kind != "" { + return false + } + } + return true +} + +func (p Profile) IsPinnedReposEmpty() bool { + for _, r := range p.PinnedRepos { + if r != "" { + return false + } + } + return true +} + +type VanityStatKind string + +const ( + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" + VanityStatRepositoryCount VanityStatKind = "repository-count" +) + +func (v VanityStatKind) String() string { + switch v { + case VanityStatMergedPRCount: + return "Merged PRs" + case VanityStatClosedPRCount: + return "Closed PRs" + case VanityStatOpenPRCount: + return "Open PRs" + case VanityStatOpenIssueCount: + return "Open Issues" + case VanityStatClosedIssueCount: + return "Closed Issues" + case VanityStatRepositoryCount: + return "Repositories" + } + return "" +} + +type VanityStat struct { + Kind VanityStatKind + Value uint64 +} + +func (p *Profile) ProfileAt() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) +} + +func UpsertProfile(tx *sql.Tx, profile *Profile) error { + defer tx.Rollback() + + // update links + _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) + if err != nil { + return err + } + // update vanity stats + _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) + if err != nil { + return err + } + + // update pinned repos + _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) + if err != nil { + return err + } + + includeBskyValue := 0 + if profile.IncludeBluesky { + includeBskyValue = 1 + } + + _, err = tx.Exec( + `insert or replace into profile ( + did, + description, + include_bluesky, + location + ) + values (?, ?, ?, ?)`, + profile.Did, + profile.Description, + includeBskyValue, + profile.Location, + ) + + if err != nil { + log.Println("profile", "err", err) + return err + } + + for _, link := range profile.Links { + if link == "" { + continue + } + + _, err := tx.Exec( + `insert into profile_links (did, link) values (?, ?)`, + profile.Did, + link, + ) + + if err != nil { + log.Println("profile_links", "err", err) + return err + } + } + + for _, v := range profile.Stats { + if v.Kind == "" { + continue + } + + _, err := tx.Exec( + `insert into profile_stats (did, kind) values (?, ?)`, + profile.Did, + v.Kind, + ) + + if err != nil { + log.Println("profile_stats", "err", err) + return err + } + } + + for _, pin := range profile.PinnedRepos { + if pin == "" { + continue + } + + _, err := tx.Exec( + `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`, + profile.Did, + pin, + ) + + if err != nil { + log.Println("profile_pinned_repositories") + return err + } + } + + return tx.Commit() +} + +func GetProfile(e Execer, did string) (*Profile, error) { + var profile Profile + profile.Did = did + + includeBluesky := 0 + err := e.QueryRow( + `select description, include_bluesky, location from profile where did = ?`, + did, + ).Scan(&profile.Description, &includeBluesky, &profile.Location) + if err == sql.ErrNoRows { + profile := Profile{} + profile.Did = did + return &profile, nil + } + + if err != nil { + return nil, err + } + + if includeBluesky != 0 { + profile.IncludeBluesky = true + } + + rows, err := e.Query(`select link from profile_links where did = ?`, did) + if err != nil { + return nil, err + } + defer rows.Close() + i := 0 + for rows.Next() { + if err := rows.Scan(&profile.Links[i]); err != nil { + return nil, err + } + i++ + } + + rows, err = e.Query(`select kind from profile_stats where did = ?`, did) + if err != nil { + return nil, err + } + defer rows.Close() + i = 0 + for rows.Next() { + if err := rows.Scan(&profile.Stats[i].Kind); err != nil { + return nil, err + } + value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) + if err != nil { + return nil, err + } + profile.Stats[i].Value = value + i++ + } + + rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) + if err != nil { + return nil, err + } + defer rows.Close() + i = 0 + for rows.Next() { + if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { + return nil, err + } + i++ + } + + return &profile, nil +} + +func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { + query := "" + var args []any + switch stat { + case VanityStatMergedPRCount: + query = `select count(id) from pulls where owner_did = ? and state = ?` + args = append(args, did, PullMerged) + case VanityStatClosedPRCount: + query = `select count(id) from pulls where owner_did = ? and state = ?` + args = append(args, did, PullClosed) + case VanityStatOpenPRCount: + query = `select count(id) from pulls where owner_did = ? and state = ?` + args = append(args, did, PullOpen) + case VanityStatOpenIssueCount: + query = `select count(id) from issues where owner_did = ? and open = 1` + args = append(args, did) + case VanityStatClosedIssueCount: + query = `select count(id) from issues where owner_did = ? and open = 0` + args = append(args, did) + case VanityStatRepositoryCount: + query = `select count(id) from repos where did = ?` + args = append(args, did) + } + + var result uint64 + err := e.QueryRow(query, args...).Scan(&result) + if err != nil { + return 0, err + } + + return result, nil +} diff --git a/appview/db/repos.go b/appview/db/repos.go index 8c20144..9b9329c 100644 --- a/appview/db/repos.go +++ b/appview/db/repos.go @@ -2,9 +2,11 @@ package db import ( "database/sql" + "fmt" "time" "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.sh/tangled.sh/core/api/tangled" ) type Repo struct { @@ -23,6 +25,10 @@ type Repo struct { Source string } +func (r Repo) RepoAt() syntax.ATURI { + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) +} + func GetAllRepos(e Execer, limit int) ([]Repo, error) { var repos []Repo diff --git a/appview/pages/pages.go b/appview/pages/pages.go index f737319..a9738f2 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -317,6 +317,7 @@ type ProfilePageParams struct { CollaboratingRepos []db.Repo ProfileStats ProfileStats FollowStatus db.FollowStatus + Profile *db.Profile AvatarUri string ProfileTimeline *db.ProfileTimeline @@ -341,6 +342,31 @@ func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { return p.executePlain("user/fragments/follow", w, params) } +type EditBioParams struct { + LoggedInUser *auth.User + Profile *db.Profile +} + +func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { + return p.executePlain("user/fragments/editBio", w, params) +} + +type EditPinsParams struct { + LoggedInUser *auth.User + Profile *db.Profile + AllRepos []PinnedRepo + DidHandleMap map[string]string +} + +type PinnedRepo struct { + IsPinned bool + db.Repo +} + +func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { + return p.executePlain("user/fragments/editPins", w, params) +} + type RepoActionsFragmentParams struct { IsStarred bool RepoAt syntax.ATURI diff --git a/appview/pages/templates/user/fragments/editBio.html b/appview/pages/templates/user/fragments/editBio.html new file mode 100644 index 0000000..8cc182e --- /dev/null +++ b/appview/pages/templates/user/fragments/editBio.html @@ -0,0 +1,107 @@ +{{ define "user/fragments/editBio" }} +
+
+ {{ $description := "" }} + {{ if and .Profile .Profile.Description }} + {{ $description = .Profile.Description }} + {{ end }} + + +
+ +
+ +
+ {{ $location := "" }} + {{ if and .Profile .Profile.Location }} + {{ $location = .Profile.Location }} + {{ end }} + {{ i "map-pin" "size-4" }} + +
+
+ +
+ +
+ {{ $includeBsky := false }} + {{ if and .Profile .Profile.IncludeBluesky }} + {{ $includeBsky = true }} + {{ end }} + + +
+ + {{ $profile := .Profile }} + {{ range $idx, $s := (sequence 5) }} + {{ $link := "" }} + {{ if and $profile $profile.Links }} + {{ if lt $idx (len $profile.Links) }} + {{ $link = index $profile.Links $idx }} + {{ end }} + {{ end }} + +
+ {{ i "link" "size-4" }} + +
+ {{ end }} +
+ +
+ + {{ range $idx, $s := (sequence 2) }} + {{ $stat := "" }} + {{ if and $profile $profile.Stats }} + {{ if lt $idx (len $profile.Stats) }} + {{ $s := index $profile.Stats $idx }} + {{ $stat = $s.Kind }} + {{ end }} + {{ end }} + + {{ block "stat" (list $idx $stat) }} {{ end }} + {{ end }} +
+ +
+ + + + +
+
+{{ end }} + +{{ define "stat" }} + {{ $id := index . 0 }} + {{ $stat := index . 1 }} + +{{ end }} diff --git a/appview/pages/templates/user/fragments/editPins.html b/appview/pages/templates/user/fragments/editPins.html new file mode 100644 index 0000000..49a64b9 --- /dev/null +++ b/appview/pages/templates/user/fragments/editPins.html @@ -0,0 +1,38 @@ +{{ define "user/fragments/editPins" }} + {{ $profile := .Profile }} +
+
+

SELECT PINNED REPOS

+
+ + + + +
+
+
+ {{ range $idx, $r := .AllRepos }} +
+ + +
+ {{ end }} +
+ +
+{{ end }} diff --git a/appview/pages/templates/user/profile.html b/appview/pages/templates/user/profile.html index 0529e58..f56bd4e 100644 --- a/appview/pages/templates/user/profile.html +++ b/appview/pages/templates/user/profile.html @@ -1,22 +1,22 @@ {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} {{ define "content" }} -
-
+
+
{{ block "profileCard" . }}{{ end }}
-
+
{{ block "ownRepos" . }}{{ end }} {{ block "collaboratingRepos" . }}{{ end }}
-
+
{{ block "profileTimeline" . }}{{ end }}
{{ end }} {{ define "profileTimeline" }} -

ACTIVITY

+

ACTIVITY

{{ with .ProfileTimeline }} {{ range $idx, $byMonth := .ByMonth }} @@ -233,90 +233,157 @@ {{ end }}
-
-

- {{ didOrHandle .UserDid .UserHandle }} +

+

+ {{ didOrHandle .UserDid .UserHandle }}

-
- {{ .ProfileStats.Followers }} followers - - {{ .ProfileStats.Following }} following -
+
+ {{ if .Profile }} +

{{ .Profile.Description }}

+ {{ end }} - {{ if ne .FollowStatus.String "IsSelf" }} - {{ template "user/fragments/follow" . }} - {{ end }} +
+ {{ i "users" "size-4" }} + {{ .ProfileStats.Followers }} followers + + {{ .ProfileStats.Following }} following +
+ + {{ $profile := .Profile }} + {{ with .Profile }} +
+ {{ if .Location }} +
+ {{ i "map-pin" "size-4" }} + {{ .Location }} +
+ {{ end }} + + {{ if .IncludeBluesky }} + + {{ end }} + + {{ range $link := .Links }} + {{ if $link }} +
+ {{ i "link" "size-4" }} + {{ $link }} +
+ {{ end }} + {{ end }} + + {{ if not $profile.IsStatsEmpty }} +
+ {{ range $stat := .Stats }} + {{ if $stat.Kind }} +
+ {{ $stat.Value }} + {{ $stat.Kind.String }} +
+ {{ end }} + {{ end }} +
+ {{ end }} + +
+ {{ end }} + + {{ if ne .FollowStatus.String "IsSelf" }} + {{ template "user/fragments/follow" . }} + {{ else }} + + {{ end }} +
+
{{ end }} {{ define "ownRepos" }} -

REPOS

-
- {{ range .Repos }} -
- - {{ if .Description }} -
- {{ .Description }} -
- {{ end }} -
- - {{ if .RepoStats.StarCount }} -
- {{ i "star" "w-3 h-3 fill-current" }} - {{ .RepoStats.StarCount }} -
- {{ end }} +
+ PINNED REPOS + {{ if and .LoggedInUser (eq .LoggedInUser.Did .UserDid) }} + + {{ end }} +
+
+ {{ range .Repos }} +
+ + {{ if .Description }} +
+ {{ .Description }} +
+ {{ end }} +
+ {{ if .RepoStats.StarCount }} +
+ {{ i "star" "w-3 h-3 fill-current" }} + {{ .RepoStats.StarCount }}
-
- {{ else }} -

This user does not have any repos yet.

- {{ end }} -
+ {{ end }} +
+
+ {{ else }} +

This user does not have any repos yet.

+ {{ end }} +
+{{ end }} -

COLLABORATING ON

+{{ define "collaboratingRepos" }} + {{ if gt (len .CollaboratingRepos) 0 }} +

COLLABORATING ON

- {{ range .CollaboratingRepos }} -
-
- - {{ index $.DidHandleMap .Did }}/{{ .Name }} - + {{ range .CollaboratingRepos }} +
+ + {{ if .Description }} +
+ {{ .Description }}
- {{ if .Description }} -
- {{ .Description }} + {{ end }} +
+ + {{ if .RepoStats.StarCount }} +
+ {{ i "star" "w-3 h-3 fill-current" }} + {{ .RepoStats.StarCount }}
{{ end }} -
- - {{ if .RepoStats.StarCount }} -
- {{ i "star" "w-3 h-3 fill-current" }} - {{ .RepoStats.StarCount }} -
- {{ end }} -
- {{ else }} -

This user is not collaborating.

- {{ end }} +
+ {{ else }} +

This user is not collaborating.

+ {{ end }}
+ {{ end }} {{ end }} diff --git a/appview/state/profile.go b/appview/state/profile.go index f26fe04..8077b3a 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -7,9 +7,16 @@ import ( "fmt" "log" "net/http" + "net/url" + "slices" + "strings" + comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" + lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/go-chi/chi/v5" + "tangled.sh/tangled.sh/core/api/tangled" "tangled.sh/tangled.sh/core/appview/db" "tangled.sh/tangled.sh/core/appview/pages" ) @@ -27,16 +34,43 @@ func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { return } + profile, err := db.GetProfile(s.db, ident.DID.String()) + if err != nil { + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) + } + repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) if err != nil { log.Printf("getting repos for %s: %s", ident.DID.String(), err) } + // filter out ones that are pinned + pinnedRepos := []db.Repo{} + for i, r := range repos { + // if this is a pinned repo, add it + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { + pinnedRepos = append(pinnedRepos, r) + } + + // if there are no saved pins, add the first 4 repos + if profile.IsPinnedReposEmpty() && i < 4 { + pinnedRepos = append(pinnedRepos, r) + } + } + collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) if err != nil { log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) } + pinnedCollaboratingRepos := []db.Repo{} + for _, r := range collaboratingRepos { + // if this is a pinned repo, add it + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { + pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) + } + } + timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) if err != nil { log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) @@ -87,12 +121,13 @@ func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { LoggedInUser: loggedInUser, UserDid: ident.DID.String(), UserHandle: ident.Handle.String(), - Repos: repos, - CollaboratingRepos: collaboratingRepos, + Repos: pinnedRepos, + CollaboratingRepos: pinnedCollaboratingRepos, ProfileStats: pages.ProfileStats{ Followers: followers, Following: following, }, + Profile: profile, FollowStatus: db.FollowStatus(followStatus), DidHandleMap: didHandleMap, AvatarUri: profileAvatarUri, @@ -107,3 +142,301 @@ func (s *State) GetAvatarUri(handle string) string { signature := hex.EncodeToString(h.Sum(nil)) return fmt.Sprintf("%s/%s/%s", s.config.AvatarHost, signature, handle) } + +func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + + err := r.ParseForm() + if err != nil { + log.Println("invalid profile update form", err) + s.pages.Notice(w, "update-profile", "Invalid form.") + return + } + + profile, err := db.GetProfile(s.db, user.Did) + if err != nil { + log.Printf("getting profile data for %s: %s", user.Did, err) + } + + profile.Description = r.FormValue("description") + profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" + profile.Location = r.FormValue("location") + + var links [5]string + for i := range 5 { + iLink := r.FormValue(fmt.Sprintf("link%d", i)) + links[i] = iLink + } + profile.Links = links + + // Parse stats (exactly 2) + stat0 := r.FormValue("stat0") + stat1 := r.FormValue("stat1") + + if stat0 != "" { + profile.Stats[0].Kind = db.VanityStatKind(stat0) + } + + if stat1 != "" { + profile.Stats[1].Kind = db.VanityStatKind(stat1) + } + + if err := s.validateProfile(profile); err != nil { + log.Println("invalid profile", err) + s.pages.Notice(w, "update-profile", err.Error()) + return + } + + s.updateProfile(profile, w, r) + return +} + +func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + + err := r.ParseForm() + if err != nil { + log.Println("invalid profile update form", err) + s.pages.Notice(w, "update-profile", "Invalid form.") + return + } + + profile, err := db.GetProfile(s.db, user.Did) + if err != nil { + log.Printf("getting profile data for %s: %s", user.Did, err) + } + + i := 0 + var pinnedRepos [6]syntax.ATURI + for key, values := range r.Form { + if i >= 6 { + log.Println("invalid pin update form", err) + s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") + return + } + if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { + aturi, err := syntax.ParseATURI(values[0]) + if err != nil { + log.Println("invalid profile update form", err) + s.pages.Notice(w, "update-profile", "Invalid form.") + return + } + pinnedRepos[i] = aturi + i++ + } + } + profile.PinnedRepos = pinnedRepos + + s.updateProfile(profile, w, r) + return +} + +func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + log.Println("failed to start transaction", err) + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") + return + } + + client, _ := s.auth.AuthorizedClient(r) + + // yeah... lexgen dose not support syntax.ATURI in the record for some reason, + // nor does it support exact size arrays + var pinnedRepoStrings []string + for _, r := range profile.PinnedRepos { + pinnedRepoStrings = append(pinnedRepoStrings, r.String()) + } + + var vanityStats []string + for _, v := range profile.Stats { + vanityStats = append(vanityStats, string(v.Kind)) + } + + ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", tangled.ActorProfileNSID, user.Did, "self") + var cid *string + if ex != nil { + cid = ex.Cid + } + + _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ + Collection: tangled.ActorProfileNSID, + Repo: user.Did, + Rkey: "self", + Record: &lexutil.LexiconTypeDecoder{ + Val: &tangled.ActorProfile{ + Bluesky: &profile.IncludeBluesky, + Description: &profile.Description, + Links: profile.Links[:], + Location: &profile.Location, + PinnedRepositories: pinnedRepoStrings, + Stats: vanityStats[:], + }}, + SwapRecord: cid, + }) + if err != nil { + log.Println("failed to update profile", err) + s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") + return + } + + err = db.UpsertProfile(tx, profile) + if err != nil { + log.Println("failed to update profile", err) + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") + return + } + + s.pages.HxRedirect(w, "/"+user.Did) + return +} + +func (s *State) validateProfile(profile *db.Profile) error { + // ensure description is not too long + if len(profile.Description) > 256 { + return fmt.Errorf("Entered bio is too long.") + } + + // ensure description is not too long + if len(profile.Location) > 40 { + return fmt.Errorf("Entered location is too long.") + } + + // ensure links are in order + err := validateLinks(profile) + if err != nil { + return err + } + + // ensure all pinned repos are either own repos or collaborating repos + repos, err := db.GetAllReposByDid(s.db, profile.Did) + if err != nil { + log.Printf("getting repos for %s: %s", profile.Did, err) + } + + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.Did) + if err != nil { + log.Printf("getting collaborating repos for %s: %s", profile.Did, err) + } + + var validRepos []syntax.ATURI + for _, r := range repos { + validRepos = append(validRepos, r.RepoAt()) + } + for _, r := range collaboratingRepos { + validRepos = append(validRepos, r.RepoAt()) + } + + for _, pinned := range profile.PinnedRepos { + if pinned == "" { + continue + } + if !slices.Contains(validRepos, pinned) { + return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) + } + } + + return nil +} + +func validateLinks(profile *db.Profile) error { + for i, link := range profile.Links { + if link == "" { + continue + } + + parsedURL, err := url.Parse(link) + if err != nil { + return fmt.Errorf("Invalid URL '%s': %v\n", link, err) + } + + if parsedURL.Scheme == "" { + if strings.HasPrefix(link, "//") { + profile.Links[i] = "https:" + link + } else { + profile.Links[i] = "https://" + link + } + continue + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) + } + + // catch relative paths + if parsedURL.Host == "" { + return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) + } + } + return nil +} + +func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + + profile, err := db.GetProfile(s.db, user.Did) + if err != nil { + log.Printf("getting profile data for %s: %s", user.Did, err) + } + + s.pages.EditBioFragment(w, pages.EditBioParams{ + LoggedInUser: user, + Profile: profile, + }) +} + +func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { + user := s.auth.GetUser(r) + + profile, err := db.GetProfile(s.db, user.Did) + if err != nil { + log.Printf("getting profile data for %s: %s", user.Did, err) + } + + repos, err := db.GetAllReposByDid(s.db, user.Did) + if err != nil { + log.Printf("getting repos for %s: %s", user.Did, err) + } + + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) + if err != nil { + log.Printf("getting collaborating repos for %s: %s", user.Did, err) + } + + allRepos := []pages.PinnedRepo{} + + for _, r := range repos { + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) + allRepos = append(allRepos, pages.PinnedRepo{ + IsPinned: isPinned, + Repo: r, + }) + } + for _, r := range collaboratingRepos { + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) + allRepos = append(allRepos, pages.PinnedRepo{ + IsPinned: isPinned, + Repo: r, + }) + } + + var didsToResolve []string + for _, r := range allRepos { + didsToResolve = append(didsToResolve, r.Did) + } + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) + didHandleMap := make(map[string]string) + for _, identity := range resolvedIds { + if !identity.Handle.IsInvalidHandle() { + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) + } else { + didHandleMap[identity.DID.String()] = identity.DID.String() + } + } + + s.pages.EditPinsFragment(w, pages.EditPinsParams{ + LoggedInUser: user, + Profile: profile, + AllRepos: allRepos, + DidHandleMap: didHandleMap, + }) +} diff --git a/appview/state/router.go b/appview/state/router.go index dcd5fce..1879990 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -54,6 +54,14 @@ func (s *State) UserRouter() http.Handler { r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { r.Get("/", s.ProfilePage) + r.Route("/profile", func(r chi.Router) { + r.Use(middleware.AuthMiddleware(s.auth)) + r.Get("/edit-bio", s.EditBioFragment) + r.Get("/edit-pins", s.EditPinsFragment) + r.Post("/bio", s.UpdateProfileBio) + r.Post("/pins", s.UpdateProfilePins) + }) + r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { r.Get("/", s.RepoIndex) r.Get("/commits/{ref}", s.RepoLog) diff --git a/cmd/gen.go b/cmd/gen.go index 9d9f421..c7df284 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -27,6 +27,7 @@ func main() { tangled.RepoPullStatus{}, tangled.RepoPullComment{}, tangled.RepoArtifact{}, + tangled.ActorProfile{}, ); err != nil { panic(err) } diff --git a/flake.nix b/flake.nix index a4b133a..ebcb756 100644 --- a/flake.nix +++ b/flake.nix @@ -163,6 +163,7 @@ cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ ''; + CGO_ENABLED=1; }; }); apps = forAllSystems (system: let @@ -171,7 +172,7 @@ pkgs.writeShellScriptBin "run" '' TANGLED_DEV=true ${pkgs.air}/bin/air -c /dev/null \ - -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ + -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ -build.bin "./out/${name}.out" \ -build.stop_on_error "true" \ -build.include_ext "go" diff --git a/lexicons/actor/profile.json b/lexicons/actor/profile.json new file mode 100644 index 0000000..1b5e502 --- /dev/null +++ b/lexicons/actor/profile.json @@ -0,0 +1,72 @@ +{ + "lexicon": 1, + "id": "sh.tangled.actor.profile", + "defs": { + "main": { + "type": "record", + "description": "A declaration of a Tangled account profile.", + "key": "literal:self", + "record": { + "type": "object", + "properties": { + "required": [ + "bluesky", + ], + "description": { + "type": "string", + "description": "Free-form profile description text.", + "maxGraphemes": 256, + "maxLength": 2560 + }, + "links": { + "type": "array", + "minLength": 0, + "maxLength": 5, + "items": { + "type": "string", + "description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.", + "format": "uri" + } + }, + "stats": { + "type": "array", + "minLength": 0, + "maxLength": 2, + "items": { + "type": "string", + "description": "Vanity stats.", + "enum": [ + "merged-pull-request-count", + "closed-pull-request-count", + "open-pull-request-count", + "open-issue-count", + "closed-issue-count", + "repository-count" + ] + } + }, + "bluesky": { + "type": "boolean", + "description": "Include link to this account on Bluesky." + }, + "location": { + "type": "string", + "description": "Free-form location text.", + "maxGraphemes": 40, + "maxLength": 400 + }, + "pinnedRepositories": { + "type": "array", + "description": "Any ATURI, it is up to appviews to validate these fields.", + "minLength": 0, + "maxLength": 6, + "items": { + "type": "string", + "format": "at-uri" + } + } + } + } + } + } +} -- 2.51.2