This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / db / profile.go
15 kB 662 lines
1package db 2 3import ( 4 "database/sql" 5 "fmt" 6 "log" 7 "net/url" 8 "slices" 9 "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/appview/models" 14 "tangled.org/core/orm" 15) 16 17const TimeframeMonths = 7 18 19func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 20 timeline := models.ProfileTimeline{ 21 ByMonth: make([]models.ByMonth, TimeframeMonths), 22 } 23 now := time.Now() 24 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 25 26 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) 27 if err != nil { 28 return nil, fmt.Errorf("error getting pulls by owner did: %w", err) 29 } 30 31 // group pulls by month 32 for _, pull := range pulls { 33 monthsAgo := monthsBetween(pull.Created, now) 34 35 if monthsAgo >= TimeframeMonths { 36 // shouldn't happen; but times are weird 37 continue 38 } 39 40 idx := monthsAgo 41 items := &timeline.ByMonth[idx].PullEvents.Items 42 43 *items = append(*items, &pull) 44 } 45 46 issues, err := GetIssues( 47 e, 48 orm.FilterEq("did", forDid), 49 orm.FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 50 ) 51 if err != nil { 52 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 53 } 54 55 for _, issue := range issues { 56 monthsAgo := monthsBetween(issue.Created, now) 57 58 if monthsAgo >= TimeframeMonths { 59 // shouldn't happen; but times are weird 60 continue 61 } 62 63 idx := monthsAgo 64 items := &timeline.ByMonth[idx].IssueEvents.Items 65 66 *items = append(*items, &issue) 67 } 68 69 repos, err := GetRepos(e, orm.FilterEq("did", forDid)) 70 if err != nil { 71 return nil, fmt.Errorf("error getting all repos by did: %w", err) 72 } 73 74 for _, repo := range repos { 75 // TODO: get this in the original query; requires COALESCE because nullable 76 var sourceRepo *models.Repo 77 if repo.Source != "" { 78 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 79 if err != nil { 80 // the source repo was not found, skip this bit 81 log.Println("profile", "err", err) 82 } 83 } 84 85 monthsAgo := monthsBetween(repo.Created, now) 86 87 if monthsAgo >= TimeframeMonths { 88 // shouldn't happen; but times are weird 89 continue 90 } 91 92 idx := monthsAgo 93 94 items := &timeline.ByMonth[idx].RepoEvents 95 *items = append(*items, models.RepoEvent{ 96 Repo: &repo, 97 Source: sourceRepo, 98 }) 99 } 100 101 punchcard, err := MakePunchcard( 102 e, 103 orm.FilterEq("did", forDid), 104 orm.FilterGte("date", time.Now().AddDate(0, -TimeframeMonths, 0)), 105 ) 106 if err != nil { 107 return nil, fmt.Errorf("error getting commits by did: %w", err) 108 } 109 for _, punch := range punchcard.Punches { 110 if punch.Date.After(now) { 111 continue 112 } 113 114 monthsAgo := monthsBetween(punch.Date, now) 115 if monthsAgo >= TimeframeMonths { 116 // shouldn't happen; but times are weird 117 continue 118 } 119 120 idx := monthsAgo 121 timeline.ByMonth[idx].Commits += punch.Count 122 } 123 124 return &timeline, nil 125} 126 127func monthsBetween(from, to time.Time) int { 128 years := to.Year() - from.Year() 129 months := int(to.Month() - from.Month()) 130 return years*12 + months 131} 132 133func UpsertProfile(e *DB, profile *models.Profile) error { 134 tx, err := e.Begin() 135 if err != nil { 136 return err 137 } 138 defer tx.Rollback() 139 140 // update links 141 _, err = tx.Exec(`delete from profile_links where did = ?`, profile.Did) 142 if err != nil { 143 return err 144 } 145 // update vanity stats 146 _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) 147 if err != nil { 148 return err 149 } 150 151 // update pinned repos 152 _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) 153 if err != nil { 154 return err 155 } 156 157 includeBskyValue := 0 158 if profile.IncludeBluesky { 159 includeBskyValue = 1 160 } 161 162 _, err = tx.Exec( 163 `insert or replace into profile ( 164 did, 165 avatar, 166 description, 167 include_bluesky, 168 location, 169 pronouns, 170 preferred_handle 171 ) 172 values (?, ?, ?, ?, ?, ?, ?)`, 173 profile.Did, 174 profile.Avatar, 175 profile.Description, 176 includeBskyValue, 177 profile.Location, 178 profile.Pronouns, 179 string(profile.PreferredHandle), 180 ) 181 182 if err != nil { 183 log.Println("profile", "err", err) 184 return err 185 } 186 187 for _, link := range profile.Links { 188 if link == "" { 189 continue 190 } 191 192 _, err := tx.Exec( 193 `insert into profile_links (did, link) values (?, ?)`, 194 profile.Did, 195 link, 196 ) 197 198 if err != nil { 199 log.Println("profile_links", "err", err) 200 return err 201 } 202 } 203 204 for _, v := range profile.Stats { 205 if v.Kind == "" { 206 continue 207 } 208 209 _, err := tx.Exec( 210 `insert into profile_stats (did, kind) values (?, ?)`, 211 profile.Did, 212 v.Kind, 213 ) 214 215 if err != nil { 216 log.Println("profile_stats", "err", err) 217 return err 218 } 219 } 220 221 for _, pin := range profile.PinnedRepos { 222 if pin == "" { 223 continue 224 } 225 226 _, err := tx.Exec( 227 `insert into profile_pinned_repositories (did, pin) values (?, ?)`, 228 profile.Did, 229 pin, 230 ) 231 232 if err != nil { 233 log.Println("profile_pinned_repositories", "err", err) 234 return err 235 } 236 } 237 238 if err := tx.Commit(); err != nil { 239 return err 240 } 241 return nil 242} 243 244func DeleteProfile(tx *sql.Tx, did string) error { 245 defer tx.Rollback() 246 247 if _, err := tx.Exec(`delete from profile where did = ?`, did); err != nil { 248 return err 249 } 250 251 return tx.Commit() 252} 253 254func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 255 var conditions []string 256 var args []any 257 for _, filter := range filters { 258 conditions = append(conditions, filter.Condition()) 259 args = append(args, filter.Arg()...) 260 } 261 262 whereClause := "" 263 if conditions != nil { 264 whereClause = " where " + strings.Join(conditions, " and ") 265 } 266 267 profilesQuery := fmt.Sprintf( 268 `select 269 id, 270 did, 271 description, 272 include_bluesky, 273 location, 274 pronouns, 275 preferred_handle 276 from 277 profile 278 %s`, 279 whereClause, 280 ) 281 rows, err := e.Query(profilesQuery, args...) 282 if err != nil { 283 return nil, err 284 } 285 defer rows.Close() 286 287 profileMap := make(map[string]*models.Profile) 288 for rows.Next() { 289 var profile models.Profile 290 var includeBluesky int 291 var pronouns sql.Null[string] 292 var preferredHandle sql.Null[string] 293 294 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) 295 if err != nil { 296 return nil, err 297 } 298 299 if includeBluesky != 0 { 300 profile.IncludeBluesky = true 301 } 302 303 if pronouns.Valid { 304 profile.Pronouns = pronouns.V 305 } 306 307 if preferredHandle.Valid { 308 profile.PreferredHandle = syntax.Handle(preferredHandle.V) 309 } 310 311 profileMap[profile.Did] = &profile 312 } 313 if err = rows.Err(); err != nil { 314 return nil, err 315 } 316 317 // populate profile links 318 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 319 args = make([]any, len(profileMap)) 320 i := 0 321 for did := range profileMap { 322 args[i] = did 323 i++ 324 } 325 326 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 327 rows, err = e.Query(linksQuery, args...) 328 if err != nil { 329 return nil, err 330 } 331 defer rows.Close() 332 333 idxs := make(map[string]int) 334 for did := range profileMap { 335 idxs[did] = 0 336 } 337 for rows.Next() { 338 var link, did string 339 if err = rows.Scan(&link, &did); err != nil { 340 return nil, err 341 } 342 343 idx := idxs[did] 344 profileMap[did].Links[idx] = link 345 idxs[did] = idx + 1 346 } 347 348 pinsQuery := fmt.Sprintf("select pin, did from profile_pinned_repositories where did in (%s)", inClause) 349 rows, err = e.Query(pinsQuery, args...) 350 if err != nil { 351 return nil, err 352 } 353 defer rows.Close() 354 355 idxs = make(map[string]int) 356 for did := range profileMap { 357 idxs[did] = 0 358 } 359 for rows.Next() { 360 var pin string 361 var did string 362 if err = rows.Scan(&pin, &did); err != nil { 363 return nil, err 364 } 365 366 idx := idxs[did] 367 profileMap[did].PinnedRepos[idx] = pin 368 idxs[did] = idx + 1 369 } 370 371 return profileMap, nil 372} 373 374func GetPreferredHandle(e Execer, did string) (syntax.Handle, error) { 375 var h sql.Null[string] 376 err := e.QueryRow( 377 `select preferred_handle from profile where did = ?`, 378 did, 379 ).Scan(&h) 380 if err != nil { 381 return "", err 382 } 383 if !h.Valid || h.V == "" { 384 return "", sql.ErrNoRows 385 } 386 return syntax.Handle(h.V), nil 387} 388 389func GetDidByPreferredHandle(e Execer, handle syntax.Handle) (syntax.DID, error) { 390 var did string 391 err := e.QueryRow( 392 `select did from profile where preferred_handle = ?`, 393 string(handle), 394 ).Scan(&did) 395 if err != nil { 396 return "", err 397 } 398 return syntax.DID(did), nil 399} 400 401// whether a DID has authored any Tangled record, counts some common records towards this 402func IsTangledUser(e Execer, did string) (bool, error) { 403 profile, err := GetProfile(e, did) 404 if err != nil { 405 return false, err 406 } 407 if profile != nil { 408 return true, nil 409 } 410 411 keys, err := GetPublicKeysForDid(e, did) 412 if err != nil { 413 return false, err 414 } 415 if len(keys) > 0 { 416 return true, nil 417 } 418 419 counts := []func() (int64, error){ 420 func() (int64, error) { return CountRepos(e, orm.FilterEq("did", did)) }, 421 func() (int64, error) { return CountStrings(e, orm.FilterEq("did", did)) }, 422 func() (int64, error) { return CountStars(e, orm.FilterEq("did", did)) }, 423 } 424 for _, count := range counts { 425 n, err := count() 426 if err != nil { 427 return false, err 428 } 429 if n > 0 { 430 return true, nil 431 } 432 } 433 434 stats, err := GetFollowerFollowingCount(e, did) 435 if err != nil { 436 return false, err 437 } 438 if stats.Following > 0 { 439 return true, nil 440 } 441 442 return false, nil 443} 444 445func GetProfile(e Execer, did string) (*models.Profile, error) { 446 var profile models.Profile 447 var pronouns sql.Null[string] 448 var avatar sql.Null[string] 449 var preferredHandle sql.Null[string] 450 451 profile.Did = did 452 453 includeBluesky := 0 454 455 err := e.QueryRow( 456 `select avatar, description, include_bluesky, location, pronouns, preferred_handle from profile where did = ?`, 457 did, 458 ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) 459 if err == sql.ErrNoRows { 460 return nil, nil 461 } 462 463 if err != nil { 464 return nil, err 465 } 466 467 if includeBluesky != 0 { 468 profile.IncludeBluesky = true 469 } 470 471 if pronouns.Valid { 472 profile.Pronouns = pronouns.V 473 } 474 475 if avatar.Valid { 476 profile.Avatar = avatar.V 477 } 478 479 if preferredHandle.Valid { 480 profile.PreferredHandle = syntax.Handle(preferredHandle.V) 481 } 482 483 rows, err := e.Query(`select link from profile_links where did = ?`, did) 484 if err != nil { 485 return nil, err 486 } 487 defer rows.Close() 488 i := 0 489 for rows.Next() { 490 if err := rows.Scan(&profile.Links[i]); err != nil { 491 return nil, err 492 } 493 i++ 494 } 495 496 rows, err = e.Query(`select kind from profile_stats where did = ?`, did) 497 if err != nil { 498 return nil, err 499 } 500 defer rows.Close() 501 i = 0 502 for rows.Next() { 503 if err := rows.Scan(&profile.Stats[i].Kind); err != nil { 504 return nil, err 505 } 506 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 507 if err != nil { 508 return nil, err 509 } 510 profile.Stats[i].Value = value 511 i++ 512 } 513 514 rows, err = e.Query(`select pin from profile_pinned_repositories where did = ?`, did) 515 if err != nil { 516 return nil, err 517 } 518 defer rows.Close() 519 i = 0 520 for rows.Next() { 521 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 522 return nil, err 523 } 524 i++ 525 } 526 527 return &profile, nil 528} 529 530func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 531 query := "" 532 var args []any 533 switch stat { 534 case models.VanityStatMergedPRCount: 535 query = `select count(id) from pulls where owner_did = ? and state = ?` 536 args = append(args, did, models.PullMerged) 537 case models.VanityStatClosedPRCount: 538 query = `select count(id) from pulls where owner_did = ? and state = ?` 539 args = append(args, did, models.PullClosed) 540 case models.VanityStatOpenPRCount: 541 query = `select count(id) from pulls where owner_did = ? and state = ?` 542 args = append(args, did, models.PullOpen) 543 case models.VanityStatOpenIssueCount: 544 query = `select count(id) from issues where did = ? and open = 1` 545 args = append(args, did) 546 case models.VanityStatClosedIssueCount: 547 query = `select count(id) from issues where did = ? and open = 0` 548 args = append(args, did) 549 case models.VanityStatRepositoryCount: 550 query = `select count(id) from repos where did = ?` 551 args = append(args, did) 552 case models.VanityStatStarCount: 553 query = `select count(*) from deduped_stars s join repos r on s.subject = r.repo_did where s.subject_type = 'repo' and r.did = ?` 554 args = append(args, did) 555 case models.VanityStatNone: 556 return 0, nil 557 default: 558 return 0, fmt.Errorf("invalid vanity stat kind: %s", stat) 559 } 560 561 var result uint64 562 err := e.QueryRow(query, args...).Scan(&result) 563 if err != nil { 564 return 0, err 565 } 566 567 return result, nil 568} 569 570func ValidateProfile(e Execer, profile *models.Profile) error { 571 // ensure description is not too long 572 if len(profile.Description) > 256 { 573 return fmt.Errorf("Entered bio is too long.") 574 } 575 576 // ensure description is not too long 577 if len(profile.Location) > 40 { 578 return fmt.Errorf("Entered location is too long.") 579 } 580 581 // ensure pronouns are not too long 582 if len(profile.Pronouns) > 40 { 583 return fmt.Errorf("Entered pronouns are too long.") 584 } 585 586 if profile.PreferredHandle != "" { 587 if _, err := syntax.ParseHandle(string(profile.PreferredHandle)); err != nil { 588 return fmt.Errorf("Invalid preferred handle format.") 589 } 590 591 claimant, err := GetDidByPreferredHandle(e, profile.PreferredHandle) 592 if err == nil && string(claimant) != profile.Did { 593 return fmt.Errorf("Preferred handle is already claimed by another user.") 594 } 595 } 596 597 // ensure links are in order 598 err := validateLinks(profile) 599 if err != nil { 600 return err 601 } 602 603 repos, err := GetRepos(e, orm.FilterEq("did", profile.Did)) 604 if err != nil { 605 log.Printf("getting repos for %s: %s", profile.Did, err) 606 } 607 608 collaboratingRepos, err := CollaboratingIn(e, profile.Did) 609 if err != nil { 610 log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 611 } 612 613 // ensure all pinned repos are either own repos or collaborating repos 614 allRepos := append(repos, collaboratingRepos...) 615 616 for _, pinned := range profile.PinnedRepos { 617 if pinned == "" { 618 continue 619 } 620 matched := slices.ContainsFunc(allRepos, func(r models.Repo) bool { 621 if strings.HasPrefix(pinned, "did:") { 622 return pinned == r.RepoDid 623 } 624 return pinned == string(r.RepoAt()) 625 }) 626 if !matched { 627 return fmt.Errorf("Invalid pinned repo: `%s`, does not belong to own or collaborating repos", pinned) 628 } 629 } 630 631 return nil 632} 633 634func validateLinks(profile *models.Profile) error { 635 for i, link := range profile.Links { 636 if link == "" { 637 continue 638 } 639 640 parsedURL, err := url.Parse(link) 641 if err != nil { 642 return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 643 } 644 645 if parsedURL.Scheme == "" { 646 if strings.HasPrefix(link, "//") { 647 profile.Links[i] = "https:" + link 648 } else { 649 profile.Links[i] = "https://" + link 650 } 651 continue 652 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 653 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 654 } 655 656 // catch relative paths 657 if parsedURL.Host == "" { 658 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 659 } 660 } 661 return nil 662}