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
14 kB 608 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(tx *sql.Tx, profile *models.Profile) error { 134 // update links 135 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 136 if err != nil { 137 return err 138 } 139 // update vanity stats 140 _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) 141 if err != nil { 142 return err 143 } 144 145 // update pinned repos 146 _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) 147 if err != nil { 148 return err 149 } 150 151 includeBskyValue := 0 152 if profile.IncludeBluesky { 153 includeBskyValue = 1 154 } 155 156 _, err = tx.Exec( 157 `insert or replace into profile ( 158 did, 159 avatar, 160 description, 161 include_bluesky, 162 location, 163 pronouns, 164 preferred_handle 165 ) 166 values (?, ?, ?, ?, ?, ?, ?)`, 167 profile.Did, 168 profile.Avatar, 169 profile.Description, 170 includeBskyValue, 171 profile.Location, 172 profile.Pronouns, 173 string(profile.PreferredHandle), 174 ) 175 176 if err != nil { 177 log.Println("profile", "err", err) 178 return err 179 } 180 181 for _, link := range profile.Links { 182 if link == "" { 183 continue 184 } 185 186 _, err := tx.Exec( 187 `insert into profile_links (did, link) values (?, ?)`, 188 profile.Did, 189 link, 190 ) 191 192 if err != nil { 193 log.Println("profile_links", "err", err) 194 return err 195 } 196 } 197 198 for _, v := range profile.Stats { 199 if v.Kind == "" { 200 continue 201 } 202 203 _, err := tx.Exec( 204 `insert into profile_stats (did, kind) values (?, ?)`, 205 profile.Did, 206 v.Kind, 207 ) 208 209 if err != nil { 210 log.Println("profile_stats", "err", err) 211 return err 212 } 213 } 214 215 for _, pin := range profile.PinnedRepos { 216 if pin == "" { 217 continue 218 } 219 220 _, err := tx.Exec( 221 `insert into profile_pinned_repositories (did, pin) values (?, ?)`, 222 profile.Did, 223 pin, 224 ) 225 226 if err != nil { 227 log.Println("profile_pinned_repositories", "err", err) 228 return err 229 } 230 } 231 return nil 232} 233 234func DeleteProfile(tx *sql.Tx, did string) error { 235 defer tx.Rollback() 236 237 if _, err := tx.Exec(`delete from profile where did = ?`, did); err != nil { 238 return err 239 } 240 241 return tx.Commit() 242} 243 244func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 245 var conditions []string 246 var args []any 247 for _, filter := range filters { 248 conditions = append(conditions, filter.Condition()) 249 args = append(args, filter.Arg()...) 250 } 251 252 whereClause := "" 253 if conditions != nil { 254 whereClause = " where " + strings.Join(conditions, " and ") 255 } 256 257 profilesQuery := fmt.Sprintf( 258 `select 259 id, 260 did, 261 description, 262 include_bluesky, 263 location, 264 pronouns, 265 preferred_handle 266 from 267 profile 268 %s`, 269 whereClause, 270 ) 271 rows, err := e.Query(profilesQuery, args...) 272 if err != nil { 273 return nil, err 274 } 275 defer rows.Close() 276 277 profileMap := make(map[string]*models.Profile) 278 for rows.Next() { 279 var profile models.Profile 280 var includeBluesky int 281 var pronouns sql.Null[string] 282 var preferredHandle sql.Null[string] 283 284 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) 285 if err != nil { 286 return nil, err 287 } 288 289 if includeBluesky != 0 { 290 profile.IncludeBluesky = true 291 } 292 293 if pronouns.Valid { 294 profile.Pronouns = pronouns.V 295 } 296 297 if preferredHandle.Valid { 298 profile.PreferredHandle = syntax.Handle(preferredHandle.V) 299 } 300 301 profileMap[profile.Did] = &profile 302 } 303 if err = rows.Err(); err != nil { 304 return nil, err 305 } 306 307 // populate profile links 308 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 309 args = make([]any, len(profileMap)) 310 i := 0 311 for did := range profileMap { 312 args[i] = did 313 i++ 314 } 315 316 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 317 rows, err = e.Query(linksQuery, args...) 318 if err != nil { 319 return nil, err 320 } 321 defer rows.Close() 322 323 idxs := make(map[string]int) 324 for did := range profileMap { 325 idxs[did] = 0 326 } 327 for rows.Next() { 328 var link, did string 329 if err = rows.Scan(&link, &did); err != nil { 330 return nil, err 331 } 332 333 idx := idxs[did] 334 profileMap[did].Links[idx] = link 335 idxs[did] = idx + 1 336 } 337 338 pinsQuery := fmt.Sprintf("select pin, did from profile_pinned_repositories where did in (%s)", inClause) 339 rows, err = e.Query(pinsQuery, args...) 340 if err != nil { 341 return nil, err 342 } 343 defer rows.Close() 344 345 idxs = make(map[string]int) 346 for did := range profileMap { 347 idxs[did] = 0 348 } 349 for rows.Next() { 350 var pin string 351 var did string 352 if err = rows.Scan(&pin, &did); err != nil { 353 return nil, err 354 } 355 356 idx := idxs[did] 357 profileMap[did].PinnedRepos[idx] = pin 358 idxs[did] = idx + 1 359 } 360 361 return profileMap, nil 362} 363 364func GetPreferredHandle(e Execer, did string) (syntax.Handle, error) { 365 var h sql.Null[string] 366 err := e.QueryRow( 367 `select preferred_handle from profile where did = ?`, 368 did, 369 ).Scan(&h) 370 if err != nil { 371 return "", err 372 } 373 if !h.Valid || h.V == "" { 374 return "", sql.ErrNoRows 375 } 376 return syntax.Handle(h.V), nil 377} 378 379func GetDidByPreferredHandle(e Execer, handle syntax.Handle) (syntax.DID, error) { 380 var did string 381 err := e.QueryRow( 382 `select did from profile where preferred_handle = ?`, 383 string(handle), 384 ).Scan(&did) 385 if err != nil { 386 return "", err 387 } 388 return syntax.DID(did), nil 389} 390 391func GetProfile(e Execer, did string) (*models.Profile, error) { 392 var profile models.Profile 393 var pronouns sql.Null[string] 394 var avatar sql.Null[string] 395 var preferredHandle sql.Null[string] 396 397 profile.Did = did 398 399 includeBluesky := 0 400 401 err := e.QueryRow( 402 `select avatar, description, include_bluesky, location, pronouns, preferred_handle from profile where did = ?`, 403 did, 404 ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns, &preferredHandle) 405 if err == sql.ErrNoRows { 406 return nil, nil 407 } 408 409 if err != nil { 410 return nil, err 411 } 412 413 if includeBluesky != 0 { 414 profile.IncludeBluesky = true 415 } 416 417 if pronouns.Valid { 418 profile.Pronouns = pronouns.V 419 } 420 421 if avatar.Valid { 422 profile.Avatar = avatar.V 423 } 424 425 if preferredHandle.Valid { 426 profile.PreferredHandle = syntax.Handle(preferredHandle.V) 427 } 428 429 rows, err := e.Query(`select link from profile_links where did = ?`, did) 430 if err != nil { 431 return nil, err 432 } 433 defer rows.Close() 434 i := 0 435 for rows.Next() { 436 if err := rows.Scan(&profile.Links[i]); err != nil { 437 return nil, err 438 } 439 i++ 440 } 441 442 rows, err = e.Query(`select kind from profile_stats where did = ?`, did) 443 if err != nil { 444 return nil, err 445 } 446 defer rows.Close() 447 i = 0 448 for rows.Next() { 449 if err := rows.Scan(&profile.Stats[i].Kind); err != nil { 450 return nil, err 451 } 452 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 453 if err != nil { 454 return nil, err 455 } 456 profile.Stats[i].Value = value 457 i++ 458 } 459 460 rows, err = e.Query(`select pin from profile_pinned_repositories where did = ?`, did) 461 if err != nil { 462 return nil, err 463 } 464 defer rows.Close() 465 i = 0 466 for rows.Next() { 467 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 468 return nil, err 469 } 470 i++ 471 } 472 473 return &profile, nil 474} 475 476func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 477 query := "" 478 var args []any 479 switch stat { 480 case models.VanityStatMergedPRCount: 481 query = `select count(id) from pulls where owner_did = ? and state = ?` 482 args = append(args, did, models.PullMerged) 483 case models.VanityStatClosedPRCount: 484 query = `select count(id) from pulls where owner_did = ? and state = ?` 485 args = append(args, did, models.PullClosed) 486 case models.VanityStatOpenPRCount: 487 query = `select count(id) from pulls where owner_did = ? and state = ?` 488 args = append(args, did, models.PullOpen) 489 case models.VanityStatOpenIssueCount: 490 query = `select count(id) from issues where did = ? and open = 1` 491 args = append(args, did) 492 case models.VanityStatClosedIssueCount: 493 query = `select count(id) from issues where did = ? and open = 0` 494 args = append(args, did) 495 case models.VanityStatRepositoryCount: 496 query = `select count(id) from repos where did = ?` 497 args = append(args, did) 498 case models.VanityStatStarCount: 499 query = `select count(s.at_uri) from stars s join repos r on s.subject = r.repo_did where s.subject_type = 'repo' and r.did = ?` 500 args = append(args, did) 501 case models.VanityStatNone: 502 return 0, nil 503 default: 504 return 0, fmt.Errorf("invalid vanity stat kind: %s", stat) 505 } 506 507 var result uint64 508 err := e.QueryRow(query, args...).Scan(&result) 509 if err != nil { 510 return 0, err 511 } 512 513 return result, nil 514} 515 516func ValidateProfile(e Execer, profile *models.Profile) error { 517 // ensure description is not too long 518 if len(profile.Description) > 256 { 519 return fmt.Errorf("Entered bio is too long.") 520 } 521 522 // ensure description is not too long 523 if len(profile.Location) > 40 { 524 return fmt.Errorf("Entered location is too long.") 525 } 526 527 // ensure pronouns are not too long 528 if len(profile.Pronouns) > 40 { 529 return fmt.Errorf("Entered pronouns are too long.") 530 } 531 532 if profile.PreferredHandle != "" { 533 if _, err := syntax.ParseHandle(string(profile.PreferredHandle)); err != nil { 534 return fmt.Errorf("Invalid preferred handle format.") 535 } 536 537 claimant, err := GetDidByPreferredHandle(e, profile.PreferredHandle) 538 if err == nil && string(claimant) != profile.Did { 539 return fmt.Errorf("Preferred handle is already claimed by another user.") 540 } 541 } 542 543 // ensure links are in order 544 err := validateLinks(profile) 545 if err != nil { 546 return err 547 } 548 549 repos, err := GetRepos(e, orm.FilterEq("did", profile.Did)) 550 if err != nil { 551 log.Printf("getting repos for %s: %s", profile.Did, err) 552 } 553 554 collaboratingRepos, err := CollaboratingIn(e, profile.Did) 555 if err != nil { 556 log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 557 } 558 559 // ensure all pinned repos are either own repos or collaborating repos 560 allRepos := append(repos, collaboratingRepos...) 561 562 for _, pinned := range profile.PinnedRepos { 563 if pinned == "" { 564 continue 565 } 566 matched := slices.ContainsFunc(allRepos, func(r models.Repo) bool { 567 if strings.HasPrefix(pinned, "did:") { 568 return pinned == r.RepoDid 569 } 570 return pinned == string(r.RepoAt()) 571 }) 572 if !matched { 573 return fmt.Errorf("Invalid pinned repo: `%s`, does not belong to own or collaborating repos", pinned) 574 } 575 } 576 577 return nil 578} 579 580func validateLinks(profile *models.Profile) error { 581 for i, link := range profile.Links { 582 if link == "" { 583 continue 584 } 585 586 parsedURL, err := url.Parse(link) 587 if err != nil { 588 return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 589 } 590 591 if parsedURL.Scheme == "" { 592 if strings.HasPrefix(link, "//") { 593 profile.Links[i] = "https:" + link 594 } else { 595 profile.Links[i] = "https://" + link 596 } 597 continue 598 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 599 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 600 } 601 602 // catch relative paths 603 if parsedURL.Host == "" { 604 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 605 } 606 } 607 return nil 608}