diff --git a/appview/db/profile.go b/appview/db/profile.go index a21ba6a7..957fd3f6 100644 --- a/appview/db/profile.go +++ b/appview/db/profile.go @@ -4,6 +4,9 @@ import ( "database/sql" "fmt" "log" + "net/url" + "slices" + "strings" "time" "github.com/bluesky-social/indigo/atproto/syntax" @@ -337,7 +340,7 @@ func UpsertProfile(tx *sql.Tx, profile *Profile) error { ) if err != nil { - log.Println("profile_pinned_repositories") + log.Println("profile_pinned_repositories", "err", err) return err } } @@ -447,3 +450,81 @@ func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { return result, nil } + +func ValidateProfile(e Execer, profile *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 := GetAllReposByDid(e, profile.Did) + if err != nil { + log.Printf("getting repos for %s: %s", profile.Did, err) + } + + collaboratingRepos, err := CollaboratingIn(e, 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 *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 +} diff --git a/appview/ingester.go b/appview/ingester.go index 8079eb3c..05fa708d 100644 --- a/appview/ingester.go +++ b/appview/ingester.go @@ -41,6 +41,8 @@ func Ingest(d db.DbWrapper) Ingester { ingestPublicKey(&d, e) case tangled.RepoArtifactNSID: ingestArtifact(&d, e) + case tangled.ActorProfileNSID: + ingestProfile(&d, e) } return err @@ -143,7 +145,6 @@ func ingestArtifact(d *db.DbWrapper, e *models.Event) error { switch e.Commit.Operation { case models.CommitOperationCreate, models.CommitOperationUpdate: - log.Println("processing add of artifact") raw := json.RawMessage(e.Commit.Record) record := tangled.RepoArtifact{} err = json.Unmarshal(raw, &record) @@ -176,7 +177,6 @@ func ingestArtifact(d *db.DbWrapper, e *models.Event) error { err = db.AddArtifact(d, artifact) case models.CommitOperationDelete: - log.Println("processing delete of artifact") err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) } @@ -186,3 +186,94 @@ func ingestArtifact(d *db.DbWrapper, e *models.Event) error { return nil } + +func ingestProfile(d *db.DbWrapper, e *models.Event) error { + did := e.Did + var err error + + if e.Commit.RKey != "self" { + return fmt.Errorf("ingestProfile only ingests `self` record") + } + + switch e.Commit.Operation { + case models.CommitOperationCreate, models.CommitOperationUpdate: + raw := json.RawMessage(e.Commit.Record) + record := tangled.ActorProfile{} + err = json.Unmarshal(raw, &record) + if err != nil { + log.Printf("invalid record: %s", err) + return err + } + + description := "" + if record.Description != nil { + description = *record.Description + } + + includeBluesky := false + if record.Bluesky != nil { + includeBluesky = *record.Bluesky + } + + location := "" + if record.Location != nil { + location = *record.Location + } + + var links [5]string + for i, l := range record.Links { + if i < 5 { + links[i] = l + } + } + + var stats [2]db.VanityStat + for i, s := range record.Stats { + if i < 2 { + stats[i].Kind = db.VanityStatKind(s) + } + } + + var pinned [6]syntax.ATURI + for i, r := range record.PinnedRepositories { + if i < 6 { + pinned[i] = syntax.ATURI(r) + } + } + + profile := db.Profile{ + Did: did, + Description: description, + IncludeBluesky: includeBluesky, + Location: location, + Links: links, + Stats: stats, + PinnedRepos: pinned, + } + + ddb, ok := d.Execer.(*db.DB) + if !ok { + return fmt.Errorf("failed to index profile record, invalid db cast") + } + + tx, err := ddb.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction") + } + + err = db.ValidateProfile(tx, &profile) + if err != nil { + return fmt.Errorf("invalid profile record") + } + + err = db.UpsertProfile(tx, &profile) + case models.CommitOperationDelete: + err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) + } + + if err != nil { + return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err) + } + + return nil +} diff --git a/appview/pages/templates/user/profile.html b/appview/pages/templates/user/profile.html index f56bd4e8..3d80c00a 100644 --- a/appview/pages/templates/user/profile.html +++ b/appview/pages/templates/user/profile.html @@ -227,31 +227,35 @@ {{ define "profileCard" }}
-
-
+
+
{{ if .AvatarUri }} {{ end }}
-
+

{{ didOrHandle .UserDid .UserHandle }}

+ +
+ {{ block "followerFollowing" .ProfileStats }} {{ end }} +
+
+
- {{ if .Profile }} -

{{ .Profile.Description }}

+ {{ $profile := .Profile }} + {{ with .Profile }} + + {{ if .Description }} +

{{ .Description }}

{{ end }} -
- {{ i "users" "size-4" }} - {{ .ProfileStats.Followers }} followers - - {{ .ProfileStats.Following }} following + - {{ $profile := .Profile }} - {{ with .Profile }}
{{ if .Location }}
@@ -259,7 +263,6 @@ {{ .Location }}
{{ end }} - {{ if .IncludeBluesky }}
{{ i "link" "size-4" }} @@ -268,7 +271,6 @@
{{ end }} - {{ range $link := .Links }} {{ if $link }}
@@ -277,7 +279,6 @@
{{ end }} {{ end }} - {{ if not $profile.IsStatsEmpty }}
{{ range $stat := .Stats }} @@ -290,10 +291,8 @@ {{ end }}
{{ end }} -
{{ end }} - {{ if ne .FollowStatus.String "IsSelf" }} {{ template "user/fragments/follow" . }} {{ else }} @@ -303,7 +302,7 @@ hx-get="/{{ $.UserDid }}/profile/edit-bio" hx-swap="innerHTML"> {{ i "pencil" "w-4 h-4" }} - edit profile + edit {{ end }}
@@ -313,6 +312,15 @@
{{ end }} +{{ define "followerFollowing" }} +
+ {{ i "users" "size-4" }} + {{ .Followers }} followers + + {{ .Following }} following +
+{{ end }} + {{ define "ownRepos" }}
PINNED REPOS diff --git a/appview/state/profile.go b/appview/state/profile.go index 8077b3ae..df666b42 100644 --- a/appview/state/profile.go +++ b/appview/state/profile.go @@ -7,7 +7,6 @@ import ( "fmt" "log" "net/http" - "net/url" "slices" "strings" @@ -181,7 +180,7 @@ func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { profile.Stats[1].Kind = db.VanityStatKind(stat1) } - if err := s.validateProfile(profile); err != nil { + if err := db.ValidateProfile(s.db, profile); err != nil { log.Println("invalid profile", err) s.pages.Notice(w, "update-profile", err.Error()) return @@ -292,84 +291,6 @@ func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *htt 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) diff --git a/appview/state/state.go b/appview/state/state.go index 41cd3b54..7f901f48 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -63,7 +63,13 @@ func Make(config *appview.Config) (*State, error) { jc, err := jetstream.NewJetstreamClient( config.JetstreamEndpoint, "appview", - []string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID}, + []string{ + tangled.GraphFollowNSID, + tangled.FeedStarNSID, + tangled.PublicKeyNSID, + tangled.RepoArtifactNSID, + tangled.ActorProfileNSID, + }, nil, slog.Default(), wrapper,