forked from tangled.org/core
this repo has no description

appview: ingester: ingest profile records from the firehose

authored by oppi.li and committed by Tangled 9d1eae6a 86daf8e3

Changed files
+209 -102
appview
db
pages
templates
state
+82 -1
appview/db/profile.go
··· 4 4 "database/sql" 5 5 "fmt" 6 6 "log" 7 + "net/url" 8 + "slices" 9 + "strings" 7 10 "time" 8 11 9 12 "github.com/bluesky-social/indigo/atproto/syntax" ··· 337 340 ) 338 341 339 342 if err != nil { 340 - log.Println("profile_pinned_repositories") 343 + log.Println("profile_pinned_repositories", "err", err) 341 344 return err 342 345 } 343 346 } ··· 447 450 448 451 return result, nil 449 452 } 453 + 454 + func ValidateProfile(e Execer, profile *Profile) error { 455 + // ensure description is not too long 456 + if len(profile.Description) > 256 { 457 + return fmt.Errorf("Entered bio is too long.") 458 + } 459 + 460 + // ensure description is not too long 461 + if len(profile.Location) > 40 { 462 + return fmt.Errorf("Entered location is too long.") 463 + } 464 + 465 + // ensure links are in order 466 + err := validateLinks(profile) 467 + if err != nil { 468 + return err 469 + } 470 + 471 + // ensure all pinned repos are either own repos or collaborating repos 472 + repos, err := GetAllReposByDid(e, profile.Did) 473 + if err != nil { 474 + log.Printf("getting repos for %s: %s", profile.Did, err) 475 + } 476 + 477 + collaboratingRepos, err := CollaboratingIn(e, profile.Did) 478 + if err != nil { 479 + log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 480 + } 481 + 482 + var validRepos []syntax.ATURI 483 + for _, r := range repos { 484 + validRepos = append(validRepos, r.RepoAt()) 485 + } 486 + for _, r := range collaboratingRepos { 487 + validRepos = append(validRepos, r.RepoAt()) 488 + } 489 + 490 + for _, pinned := range profile.PinnedRepos { 491 + if pinned == "" { 492 + continue 493 + } 494 + if !slices.Contains(validRepos, pinned) { 495 + return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 496 + } 497 + } 498 + 499 + return nil 500 + } 501 + 502 + func validateLinks(profile *Profile) error { 503 + for i, link := range profile.Links { 504 + if link == "" { 505 + continue 506 + } 507 + 508 + parsedURL, err := url.Parse(link) 509 + if err != nil { 510 + return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 511 + } 512 + 513 + if parsedURL.Scheme == "" { 514 + if strings.HasPrefix(link, "//") { 515 + profile.Links[i] = "https:" + link 516 + } else { 517 + profile.Links[i] = "https://" + link 518 + } 519 + continue 520 + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 521 + return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 522 + } 523 + 524 + // catch relative paths 525 + if parsedURL.Host == "" { 526 + return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 527 + } 528 + } 529 + return nil 530 + }
+93 -2
appview/ingester.go
··· 41 41 ingestPublicKey(&d, e) 42 42 case tangled.RepoArtifactNSID: 43 43 ingestArtifact(&d, e) 44 + case tangled.ActorProfileNSID: 45 + ingestProfile(&d, e) 44 46 } 45 47 46 48 return err ··· 143 145 144 146 switch e.Commit.Operation { 145 147 case models.CommitOperationCreate, models.CommitOperationUpdate: 146 - log.Println("processing add of artifact") 147 148 raw := json.RawMessage(e.Commit.Record) 148 149 record := tangled.RepoArtifact{} 149 150 err = json.Unmarshal(raw, &record) ··· 176 177 177 178 err = db.AddArtifact(d, artifact) 178 179 case models.CommitOperationDelete: 179 - log.Println("processing delete of artifact") 180 180 err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) 181 181 } 182 182 ··· 186 186 187 187 return nil 188 188 } 189 + 190 + func ingestProfile(d *db.DbWrapper, e *models.Event) error { 191 + did := e.Did 192 + var err error 193 + 194 + if e.Commit.RKey != "self" { 195 + return fmt.Errorf("ingestProfile only ingests `self` record") 196 + } 197 + 198 + switch e.Commit.Operation { 199 + case models.CommitOperationCreate, models.CommitOperationUpdate: 200 + raw := json.RawMessage(e.Commit.Record) 201 + record := tangled.ActorProfile{} 202 + err = json.Unmarshal(raw, &record) 203 + if err != nil { 204 + log.Printf("invalid record: %s", err) 205 + return err 206 + } 207 + 208 + description := "" 209 + if record.Description != nil { 210 + description = *record.Description 211 + } 212 + 213 + includeBluesky := false 214 + if record.Bluesky != nil { 215 + includeBluesky = *record.Bluesky 216 + } 217 + 218 + location := "" 219 + if record.Location != nil { 220 + location = *record.Location 221 + } 222 + 223 + var links [5]string 224 + for i, l := range record.Links { 225 + if i < 5 { 226 + links[i] = l 227 + } 228 + } 229 + 230 + var stats [2]db.VanityStat 231 + for i, s := range record.Stats { 232 + if i < 2 { 233 + stats[i].Kind = db.VanityStatKind(s) 234 + } 235 + } 236 + 237 + var pinned [6]syntax.ATURI 238 + for i, r := range record.PinnedRepositories { 239 + if i < 6 { 240 + pinned[i] = syntax.ATURI(r) 241 + } 242 + } 243 + 244 + profile := db.Profile{ 245 + Did: did, 246 + Description: description, 247 + IncludeBluesky: includeBluesky, 248 + Location: location, 249 + Links: links, 250 + Stats: stats, 251 + PinnedRepos: pinned, 252 + } 253 + 254 + ddb, ok := d.Execer.(*db.DB) 255 + if !ok { 256 + return fmt.Errorf("failed to index profile record, invalid db cast") 257 + } 258 + 259 + tx, err := ddb.Begin() 260 + if err != nil { 261 + return fmt.Errorf("failed to start transaction") 262 + } 263 + 264 + err = db.ValidateProfile(tx, &profile) 265 + if err != nil { 266 + return fmt.Errorf("invalid profile record") 267 + } 268 + 269 + err = db.UpsertProfile(tx, &profile) 270 + case models.CommitOperationDelete: 271 + err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) 272 + } 273 + 274 + if err != nil { 275 + return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err) 276 + } 277 + 278 + return nil 279 + }
+26 -18
appview/pages/templates/user/profile.html
··· 227 227 228 228 {{ define "profileCard" }} 229 229 <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 230 - <div class="grid grid-cols-3 md:grid-cols-1 gap-3 items-center"> 231 - <div id="avatar" class="col-span-1 md-col-span-full flex justify-center items-center"> 230 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 231 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 232 232 {{ if .AvatarUri }} 233 233 <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 234 234 {{ end }} 235 235 </div> 236 - <div class="col-span-2 md:col-span-full"> 236 + <div class="col-span-2"> 237 237 <p title="{{ didOrHandle .UserDid .UserHandle }}" 238 238 class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 239 239 {{ didOrHandle .UserDid .UserHandle }} 240 240 </p> 241 + 242 + <div class="md:hidden"> 243 + {{ block "followerFollowing" .ProfileStats }} {{ end }} 244 + </div> 245 + </div> 246 + <div class="col-span-3 md:col-span-full"> 241 247 <div id="profile-bio" class="text-sm"> 242 - {{ if .Profile }} 243 - <p>{{ .Profile.Description }}</p> 248 + {{ $profile := .Profile }} 249 + {{ with .Profile }} 250 + 251 + {{ if .Description }} 252 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 244 253 {{ end }} 245 254 246 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 247 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 248 - <span id="followers">{{ .ProfileStats.Followers }} followers</span> 249 - <span class="select-none after:content-['·']"></span> 250 - <span id="following">{{ .ProfileStats.Following }} following</span> 255 + <div class="hidden md:block"> 256 + {{ block "followerFollowing" $.ProfileStats }} {{ end }} 251 257 </div> 252 258 253 - {{ $profile := .Profile }} 254 - {{ with .Profile }} 255 259 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 256 260 {{ if .Location }} 257 261 <div class="flex items-center gap-2"> ··· 259 263 <span>{{ .Location }}</span> 260 264 </div> 261 265 {{ end }} 262 - 263 266 {{ if .IncludeBluesky }} 264 267 <div class="flex items-center gap-2"> 265 268 <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> ··· 268 271 </a> 269 272 </div> 270 273 {{ end }} 271 - 272 274 {{ range $link := .Links }} 273 275 {{ if $link }} 274 276 <div class="flex items-center gap-2"> ··· 277 279 </div> 278 280 {{ end }} 279 281 {{ end }} 280 - 281 282 {{ if not $profile.IsStatsEmpty }} 282 283 <div class="flex items-center justify-evenly gap-2 py-2"> 283 284 {{ range $stat := .Stats }} ··· 290 291 {{ end }} 291 292 </div> 292 293 {{ end }} 293 - 294 294 </div> 295 295 {{ end }} 296 - 297 296 {{ if ne .FollowStatus.String "IsSelf" }} 298 297 {{ template "user/fragments/follow" . }} 299 298 {{ else }} ··· 303 302 hx-get="/{{ $.UserDid }}/profile/edit-bio" 304 303 hx-swap="innerHTML"> 305 304 {{ i "pencil" "w-4 h-4" }} 306 - edit profile 305 + edit 307 306 </button> 308 307 {{ end }} 309 308 </div> 310 309 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 311 310 </div> 312 311 </div> 312 + </div> 313 + {{ end }} 314 + 315 + {{ define "followerFollowing" }} 316 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 317 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 318 + <span id="followers">{{ .Followers }} followers</span> 319 + <span class="select-none after:content-['·']"></span> 320 + <span id="following">{{ .Following }} following</span> 313 321 </div> 314 322 {{ end }} 315 323
+1 -80
appview/state/profile.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 - "net/url" 11 10 "slices" 12 11 "strings" 13 12 ··· 181 180 profile.Stats[1].Kind = db.VanityStatKind(stat1) 182 181 } 183 182 184 - if err := s.validateProfile(profile); err != nil { 183 + if err := db.ValidateProfile(s.db, profile); err != nil { 185 184 log.Println("invalid profile", err) 186 185 s.pages.Notice(w, "update-profile", err.Error()) 187 186 return ··· 290 289 291 290 s.pages.HxRedirect(w, "/"+user.Did) 292 291 return 293 - } 294 - 295 - func (s *State) validateProfile(profile *db.Profile) error { 296 - // ensure description is not too long 297 - if len(profile.Description) > 256 { 298 - return fmt.Errorf("Entered bio is too long.") 299 - } 300 - 301 - // ensure description is not too long 302 - if len(profile.Location) > 40 { 303 - return fmt.Errorf("Entered location is too long.") 304 - } 305 - 306 - // ensure links are in order 307 - err := validateLinks(profile) 308 - if err != nil { 309 - return err 310 - } 311 - 312 - // ensure all pinned repos are either own repos or collaborating repos 313 - repos, err := db.GetAllReposByDid(s.db, profile.Did) 314 - if err != nil { 315 - log.Printf("getting repos for %s: %s", profile.Did, err) 316 - } 317 - 318 - collaboratingRepos, err := db.CollaboratingIn(s.db, profile.Did) 319 - if err != nil { 320 - log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 321 - } 322 - 323 - var validRepos []syntax.ATURI 324 - for _, r := range repos { 325 - validRepos = append(validRepos, r.RepoAt()) 326 - } 327 - for _, r := range collaboratingRepos { 328 - validRepos = append(validRepos, r.RepoAt()) 329 - } 330 - 331 - for _, pinned := range profile.PinnedRepos { 332 - if pinned == "" { 333 - continue 334 - } 335 - if !slices.Contains(validRepos, pinned) { 336 - return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 337 - } 338 - } 339 - 340 - return nil 341 - } 342 - 343 - func validateLinks(profile *db.Profile) error { 344 - for i, link := range profile.Links { 345 - if link == "" { 346 - continue 347 - } 348 - 349 - parsedURL, err := url.Parse(link) 350 - if err != nil { 351 - return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 352 - } 353 - 354 - if parsedURL.Scheme == "" { 355 - if strings.HasPrefix(link, "//") { 356 - profile.Links[i] = "https:" + link 357 - } else { 358 - profile.Links[i] = "https://" + link 359 - } 360 - continue 361 - } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 362 - return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 363 - } 364 - 365 - // catch relative paths 366 - if parsedURL.Host == "" { 367 - return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 368 - } 369 - } 370 - return nil 371 292 } 372 293 373 294 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) {
+7 -1
appview/state/state.go
··· 63 63 jc, err := jetstream.NewJetstreamClient( 64 64 config.JetstreamEndpoint, 65 65 "appview", 66 - []string{tangled.GraphFollowNSID, tangled.FeedStarNSID, tangled.PublicKeyNSID, tangled.RepoArtifactNSID}, 66 + []string{ 67 + tangled.GraphFollowNSID, 68 + tangled.FeedStarNSID, 69 + tangled.PublicKeyNSID, 70 + tangled.RepoArtifactNSID, 71 + tangled.ActorProfileNSID, 72 + }, 67 73 nil, 68 74 slog.Default(), 69 75 wrapper,