at master 11 kB view raw
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, 0, 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 return &timeline, nil 102} 103 104func monthsBetween(from, to time.Time) int { 105 years := to.Year() - from.Year() 106 months := int(to.Month() - from.Month()) 107 return years*12 + months 108} 109 110func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 111 defer tx.Rollback() 112 113 // update links 114 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 115 if err != nil { 116 return err 117 } 118 // update vanity stats 119 _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) 120 if err != nil { 121 return err 122 } 123 124 // update pinned repos 125 _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) 126 if err != nil { 127 return err 128 } 129 130 includeBskyValue := 0 131 if profile.IncludeBluesky { 132 includeBskyValue = 1 133 } 134 135 _, err = tx.Exec( 136 `insert or replace into profile ( 137 did, 138 description, 139 include_bluesky, 140 location, 141 pronouns 142 ) 143 values (?, ?, ?, ?, ?)`, 144 profile.Did, 145 profile.Description, 146 includeBskyValue, 147 profile.Location, 148 profile.Pronouns, 149 ) 150 151 if err != nil { 152 log.Println("profile", "err", err) 153 return err 154 } 155 156 for _, link := range profile.Links { 157 if link == "" { 158 continue 159 } 160 161 _, err := tx.Exec( 162 `insert into profile_links (did, link) values (?, ?)`, 163 profile.Did, 164 link, 165 ) 166 167 if err != nil { 168 log.Println("profile_links", "err", err) 169 return err 170 } 171 } 172 173 for _, v := range profile.Stats { 174 if v.Kind == "" { 175 continue 176 } 177 178 _, err := tx.Exec( 179 `insert into profile_stats (did, kind) values (?, ?)`, 180 profile.Did, 181 v.Kind, 182 ) 183 184 if err != nil { 185 log.Println("profile_stats", "err", err) 186 return err 187 } 188 } 189 190 for _, pin := range profile.PinnedRepos { 191 if pin == "" { 192 continue 193 } 194 195 _, err := tx.Exec( 196 `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`, 197 profile.Did, 198 pin, 199 ) 200 201 if err != nil { 202 log.Println("profile_pinned_repositories", "err", err) 203 return err 204 } 205 } 206 207 return tx.Commit() 208} 209 210func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) { 211 var conditions []string 212 var args []any 213 for _, filter := range filters { 214 conditions = append(conditions, filter.Condition()) 215 args = append(args, filter.Arg()...) 216 } 217 218 whereClause := "" 219 if conditions != nil { 220 whereClause = " where " + strings.Join(conditions, " and ") 221 } 222 223 profilesQuery := fmt.Sprintf( 224 `select 225 id, 226 did, 227 description, 228 include_bluesky, 229 location, 230 pronouns 231 from 232 profile 233 %s`, 234 whereClause, 235 ) 236 rows, err := e.Query(profilesQuery, args...) 237 if err != nil { 238 return nil, err 239 } 240 defer rows.Close() 241 242 profileMap := make(map[string]*models.Profile) 243 for rows.Next() { 244 var profile models.Profile 245 var includeBluesky int 246 var pronouns sql.Null[string] 247 248 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 249 if err != nil { 250 return nil, err 251 } 252 253 if includeBluesky != 0 { 254 profile.IncludeBluesky = true 255 } 256 257 if pronouns.Valid { 258 profile.Pronouns = pronouns.V 259 } 260 261 profileMap[profile.Did] = &profile 262 } 263 if err = rows.Err(); err != nil { 264 return nil, err 265 } 266 267 // populate profile links 268 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 269 args = make([]any, len(profileMap)) 270 i := 0 271 for did := range profileMap { 272 args[i] = did 273 i++ 274 } 275 276 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 277 rows, err = e.Query(linksQuery, args...) 278 if err != nil { 279 return nil, err 280 } 281 defer rows.Close() 282 283 idxs := make(map[string]int) 284 for did := range profileMap { 285 idxs[did] = 0 286 } 287 for rows.Next() { 288 var link, did string 289 if err = rows.Scan(&link, &did); err != nil { 290 return nil, err 291 } 292 293 idx := idxs[did] 294 profileMap[did].Links[idx] = link 295 idxs[did] = idx + 1 296 } 297 298 pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause) 299 rows, err = e.Query(pinsQuery, args...) 300 if err != nil { 301 return nil, err 302 } 303 defer rows.Close() 304 305 idxs = make(map[string]int) 306 for did := range profileMap { 307 idxs[did] = 0 308 } 309 for rows.Next() { 310 var link syntax.ATURI 311 var did string 312 if err = rows.Scan(&link, &did); err != nil { 313 return nil, err 314 } 315 316 idx := idxs[did] 317 profileMap[did].PinnedRepos[idx] = link 318 idxs[did] = idx + 1 319 } 320 321 return profileMap, nil 322} 323 324func GetProfile(e Execer, did string) (*models.Profile, error) { 325 var profile models.Profile 326 var pronouns sql.Null[string] 327 328 profile.Did = did 329 330 includeBluesky := 0 331 332 err := e.QueryRow( 333 `select description, include_bluesky, location, pronouns from profile where did = ?`, 334 did, 335 ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 336 if err == sql.ErrNoRows { 337 profile := models.Profile{} 338 profile.Did = did 339 return &profile, nil 340 } 341 342 if err != nil { 343 return nil, err 344 } 345 346 if includeBluesky != 0 { 347 profile.IncludeBluesky = true 348 } 349 350 if pronouns.Valid { 351 profile.Pronouns = pronouns.V 352 } 353 354 rows, err := e.Query(`select link from profile_links where did = ?`, did) 355 if err != nil { 356 return nil, err 357 } 358 defer rows.Close() 359 i := 0 360 for rows.Next() { 361 if err := rows.Scan(&profile.Links[i]); err != nil { 362 return nil, err 363 } 364 i++ 365 } 366 367 rows, err = e.Query(`select kind from profile_stats where did = ?`, did) 368 if err != nil { 369 return nil, err 370 } 371 defer rows.Close() 372 i = 0 373 for rows.Next() { 374 if err := rows.Scan(&profile.Stats[i].Kind); err != nil { 375 return nil, err 376 } 377 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 378 if err != nil { 379 return nil, err 380 } 381 profile.Stats[i].Value = value 382 i++ 383 } 384 385 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) 386 if err != nil { 387 return nil, err 388 } 389 defer rows.Close() 390 i = 0 391 for rows.Next() { 392 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 393 return nil, err 394 } 395 i++ 396 } 397 398 return &profile, nil 399} 400 401func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 402 query := "" 403 var args []any 404 switch stat { 405 case models.VanityStatMergedPRCount: 406 query = `select count(id) from pulls where owner_did = ? and state = ?` 407 args = append(args, did, models.PullMerged) 408 case models.VanityStatClosedPRCount: 409 query = `select count(id) from pulls where owner_did = ? and state = ?` 410 args = append(args, did, models.PullClosed) 411 case models.VanityStatOpenPRCount: 412 query = `select count(id) from pulls where owner_did = ? and state = ?` 413 args = append(args, did, models.PullOpen) 414 case models.VanityStatOpenIssueCount: 415 query = `select count(id) from issues where did = ? and open = 1` 416 args = append(args, did) 417 case models.VanityStatClosedIssueCount: 418 query = `select count(id) from issues where did = ? and open = 0` 419 args = append(args, did) 420 case models.VanityStatRepositoryCount: 421 query = `select count(id) from repos where did = ?` 422 args = append(args, did) 423 } 424 425 var result uint64 426 err := e.QueryRow(query, args...).Scan(&result) 427 if err != nil { 428 return 0, err 429 } 430 431 return result, nil 432} 433 434func ValidateProfile(e Execer, profile *models.Profile) error { 435 // ensure description is not too long 436 if len(profile.Description) > 256 { 437 return fmt.Errorf("Entered bio is too long.") 438 } 439 440 // ensure description is not too long 441 if len(profile.Location) > 40 { 442 return fmt.Errorf("Entered location is too long.") 443 } 444 445 // ensure pronouns are not too long 446 if len(profile.Pronouns) > 40 { 447 return fmt.Errorf("Entered pronouns are too long.") 448 } 449 450 // ensure links are in order 451 err := validateLinks(profile) 452 if err != nil { 453 return err 454 } 455 456 // ensure all pinned repos are either own repos or collaborating repos 457 repos, err := GetRepos(e, 0, orm.FilterEq("did", profile.Did)) 458 if err != nil { 459 log.Printf("getting repos for %s: %s", profile.Did, err) 460 } 461 462 collaboratingRepos, err := CollaboratingIn(e, profile.Did) 463 if err != nil { 464 log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 465 } 466 467 var validRepos []syntax.ATURI 468 for _, r := range repos { 469 validRepos = append(validRepos, r.RepoAt()) 470 } 471 for _, r := range collaboratingRepos { 472 validRepos = append(validRepos, r.RepoAt()) 473 } 474 475 for _, pinned := range profile.PinnedRepos { 476 if pinned == "" { 477 continue 478 } 479 if !slices.Contains(validRepos, pinned) { 480 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 481 } 482 } 483 484 return nil 485} 486 487func validateLinks(profile *models.Profile) error { 488 for i, link := range profile.Links { 489 if link == "" { 490 continue 491 } 492 493 parsedURL, err := url.Parse(link) 494 if err != nil { 495 return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 496 } 497 498 if parsedURL.Scheme == "" { 499 if strings.HasPrefix(link, "//") { 500 profile.Links[i] = "https:" + link 501 } else { 502 profile.Links[i] = "https://" + link 503 } 504 continue 505 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 506 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 507 } 508 509 // catch relative paths 510 if parsedURL.Host == "" { 511 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 512 } 513 } 514 return nil 515}