forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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) 15 16const TimeframeMonths = 7 17 18func MakeProfileTimeline(e Execer, forDid string) (*models.ProfileTimeline, error) { 19 timeline := models.ProfileTimeline{ 20 ByMonth: make([]models.ByMonth, TimeframeMonths), 21 } 22 currentMonth := time.Now().Month() 23 timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 24 25 pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) 26 if err != nil { 27 return nil, fmt.Errorf("error getting pulls by owner did: %w", err) 28 } 29 30 // group pulls by month 31 for _, pull := range pulls { 32 pullMonth := pull.Created.Month() 33 34 if currentMonth-pullMonth >= TimeframeMonths { 35 // shouldn't happen; but times are weird 36 continue 37 } 38 39 idx := currentMonth - pullMonth 40 items := &timeline.ByMonth[idx].PullEvents.Items 41 42 *items = append(*items, &pull) 43 } 44 45 issues, err := GetIssues( 46 e, 47 FilterEq("did", forDid), 48 FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 49 ) 50 if err != nil { 51 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 52 } 53 54 for _, issue := range issues { 55 issueMonth := issue.Created.Month() 56 57 if currentMonth-issueMonth >= TimeframeMonths { 58 // shouldn't happen; but times are weird 59 continue 60 } 61 62 idx := currentMonth - issueMonth 63 items := &timeline.ByMonth[idx].IssueEvents.Items 64 65 *items = append(*items, &issue) 66 } 67 68 repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 69 if err != nil { 70 return nil, fmt.Errorf("error getting all repos by did: %w", err) 71 } 72 73 for _, repo := range repos { 74 // TODO: get this in the original query; requires COALESCE because nullable 75 var sourceRepo *models.Repo 76 if repo.Source != "" { 77 sourceRepo, err = GetRepoByAtUri(e, repo.Source) 78 if err != nil { 79 return nil, err 80 } 81 } 82 83 repoMonth := repo.Created.Month() 84 85 if currentMonth-repoMonth >= TimeframeMonths { 86 // shouldn't happen; but times are weird 87 continue 88 } 89 90 idx := currentMonth - repoMonth 91 92 items := &timeline.ByMonth[idx].RepoEvents 93 *items = append(*items, models.RepoEvent{ 94 Repo: &repo, 95 Source: sourceRepo, 96 }) 97 } 98 99 return &timeline, nil 100} 101 102func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 103 defer tx.Rollback() 104 105 // update links 106 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 107 if err != nil { 108 return err 109 } 110 // update vanity stats 111 _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) 112 if err != nil { 113 return err 114 } 115 116 // update pinned repos 117 _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) 118 if err != nil { 119 return err 120 } 121 122 includeBskyValue := 0 123 if profile.IncludeBluesky { 124 includeBskyValue = 1 125 } 126 127 _, err = tx.Exec( 128 `insert or replace into profile ( 129 did, 130 description, 131 include_bluesky, 132 location, 133 pronouns 134 ) 135 values (?, ?, ?, ?, ?)`, 136 profile.Did, 137 profile.Description, 138 includeBskyValue, 139 profile.Location, 140 profile.Pronouns, 141 ) 142 143 if err != nil { 144 log.Println("profile", "err", err) 145 return err 146 } 147 148 for _, link := range profile.Links { 149 if link == "" { 150 continue 151 } 152 153 _, err := tx.Exec( 154 `insert into profile_links (did, link) values (?, ?)`, 155 profile.Did, 156 link, 157 ) 158 159 if err != nil { 160 log.Println("profile_links", "err", err) 161 return err 162 } 163 } 164 165 for _, v := range profile.Stats { 166 if v.Kind == "" { 167 continue 168 } 169 170 _, err := tx.Exec( 171 `insert into profile_stats (did, kind) values (?, ?)`, 172 profile.Did, 173 v.Kind, 174 ) 175 176 if err != nil { 177 log.Println("profile_stats", "err", err) 178 return err 179 } 180 } 181 182 for _, pin := range profile.PinnedRepos { 183 if pin == "" { 184 continue 185 } 186 187 _, err := tx.Exec( 188 `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`, 189 profile.Did, 190 pin, 191 ) 192 193 if err != nil { 194 log.Println("profile_pinned_repositories", "err", err) 195 return err 196 } 197 } 198 199 return tx.Commit() 200} 201 202func GetProfiles(e Execer, filters ...filter) (map[string]*models.Profile, error) { 203 var conditions []string 204 var args []any 205 for _, filter := range filters { 206 conditions = append(conditions, filter.Condition()) 207 args = append(args, filter.Arg()...) 208 } 209 210 whereClause := "" 211 if conditions != nil { 212 whereClause = " where " + strings.Join(conditions, " and ") 213 } 214 215 profilesQuery := fmt.Sprintf( 216 `select 217 id, 218 did, 219 description, 220 include_bluesky, 221 location, 222 pronouns 223 from 224 profile 225 %s`, 226 whereClause, 227 ) 228 rows, err := e.Query(profilesQuery, args...) 229 if err != nil { 230 return nil, err 231 } 232 233 profileMap := make(map[string]*models.Profile) 234 for rows.Next() { 235 var profile models.Profile 236 var includeBluesky int 237 var pronouns sql.Null[string] 238 239 err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &pronouns) 240 if err != nil { 241 return nil, err 242 } 243 244 if includeBluesky != 0 { 245 profile.IncludeBluesky = true 246 } 247 248 if pronouns.Valid { 249 profile.Pronouns = pronouns.V 250 } 251 252 profileMap[profile.Did] = &profile 253 } 254 if err = rows.Err(); err != nil { 255 return nil, err 256 } 257 258 // populate profile links 259 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 260 args = make([]any, len(profileMap)) 261 i := 0 262 for did := range profileMap { 263 args[i] = did 264 i++ 265 } 266 267 linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 268 rows, err = e.Query(linksQuery, args...) 269 if err != nil { 270 return nil, err 271 } 272 idxs := make(map[string]int) 273 for did := range profileMap { 274 idxs[did] = 0 275 } 276 for rows.Next() { 277 var link, did string 278 if err = rows.Scan(&link, &did); err != nil { 279 return nil, err 280 } 281 282 idx := idxs[did] 283 profileMap[did].Links[idx] = link 284 idxs[did] = idx + 1 285 } 286 287 pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause) 288 rows, err = e.Query(pinsQuery, args...) 289 if err != nil { 290 return nil, err 291 } 292 idxs = make(map[string]int) 293 for did := range profileMap { 294 idxs[did] = 0 295 } 296 for rows.Next() { 297 var link syntax.ATURI 298 var did string 299 if err = rows.Scan(&link, &did); err != nil { 300 return nil, err 301 } 302 303 idx := idxs[did] 304 profileMap[did].PinnedRepos[idx] = link 305 idxs[did] = idx + 1 306 } 307 308 return profileMap, nil 309} 310 311func GetProfile(e Execer, did string) (*models.Profile, error) { 312 var profile models.Profile 313 var pronouns sql.Null[string] 314 315 profile.Did = did 316 317 includeBluesky := 0 318 319 err := e.QueryRow( 320 `select description, include_bluesky, location, pronouns from profile where did = ?`, 321 did, 322 ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 323 if err == sql.ErrNoRows { 324 profile := models.Profile{} 325 profile.Did = did 326 return &profile, nil 327 } 328 329 if err != nil { 330 return nil, err 331 } 332 333 if includeBluesky != 0 { 334 profile.IncludeBluesky = true 335 } 336 337 if pronouns.Valid { 338 profile.Pronouns = pronouns.V 339 } 340 341 rows, err := e.Query(`select link from profile_links where did = ?`, did) 342 if err != nil { 343 return nil, err 344 } 345 defer rows.Close() 346 i := 0 347 for rows.Next() { 348 if err := rows.Scan(&profile.Links[i]); err != nil { 349 return nil, err 350 } 351 i++ 352 } 353 354 rows, err = e.Query(`select kind from profile_stats 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.Stats[i].Kind); err != nil { 362 return nil, err 363 } 364 value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 365 if err != nil { 366 return nil, err 367 } 368 profile.Stats[i].Value = value 369 i++ 370 } 371 372 rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) 373 if err != nil { 374 return nil, err 375 } 376 defer rows.Close() 377 i = 0 378 for rows.Next() { 379 if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 380 return nil, err 381 } 382 i++ 383 } 384 385 return &profile, nil 386} 387 388func GetVanityStat(e Execer, did string, stat models.VanityStatKind) (uint64, error) { 389 query := "" 390 var args []any 391 switch stat { 392 case models.VanityStatMergedPRCount: 393 query = `select count(id) from pulls where owner_did = ? and state = ?` 394 args = append(args, did, models.PullMerged) 395 case models.VanityStatClosedPRCount: 396 query = `select count(id) from pulls where owner_did = ? and state = ?` 397 args = append(args, did, models.PullClosed) 398 case models.VanityStatOpenPRCount: 399 query = `select count(id) from pulls where owner_did = ? and state = ?` 400 args = append(args, did, models.PullOpen) 401 case models.VanityStatOpenIssueCount: 402 query = `select count(id) from issues where did = ? and open = 1` 403 args = append(args, did) 404 case models.VanityStatClosedIssueCount: 405 query = `select count(id) from issues where did = ? and open = 0` 406 args = append(args, did) 407 case models.VanityStatRepositoryCount: 408 query = `select count(id) from repos where did = ?` 409 args = append(args, did) 410 } 411 412 var result uint64 413 err := e.QueryRow(query, args...).Scan(&result) 414 if err != nil { 415 return 0, err 416 } 417 418 return result, nil 419} 420 421func ValidateProfile(e Execer, profile *models.Profile) error { 422 // ensure description is not too long 423 if len(profile.Description) > 256 { 424 return fmt.Errorf("Entered bio is too long.") 425 } 426 427 // ensure description is not too long 428 if len(profile.Location) > 40 { 429 return fmt.Errorf("Entered location is too long.") 430 } 431 432 // ensure pronouns are not too long 433 if len(profile.Pronouns) > 40 { 434 return fmt.Errorf("Entered pronouns are too long.") 435 } 436 437 // ensure links are in order 438 err := validateLinks(profile) 439 if err != nil { 440 return err 441 } 442 443 // ensure all pinned repos are either own repos or collaborating repos 444 repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 445 if err != nil { 446 log.Printf("getting repos for %s: %s", profile.Did, err) 447 } 448 449 collaboratingRepos, err := CollaboratingIn(e, profile.Did) 450 if err != nil { 451 log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 452 } 453 454 var validRepos []syntax.ATURI 455 for _, r := range repos { 456 validRepos = append(validRepos, r.RepoAt()) 457 } 458 for _, r := range collaboratingRepos { 459 validRepos = append(validRepos, r.RepoAt()) 460 } 461 462 for _, pinned := range profile.PinnedRepos { 463 if pinned == "" { 464 continue 465 } 466 if !slices.Contains(validRepos, pinned) { 467 return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 468 } 469 } 470 471 return nil 472} 473 474func validateLinks(profile *models.Profile) error { 475 for i, link := range profile.Links { 476 if link == "" { 477 continue 478 } 479 480 parsedURL, err := url.Parse(link) 481 if err != nil { 482 return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 483 } 484 485 if parsedURL.Scheme == "" { 486 if strings.HasPrefix(link, "//") { 487 profile.Links[i] = "https:" + link 488 } else { 489 profile.Links[i] = "https://" + link 490 } 491 continue 492 } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 493 return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 494 } 495 496 // catch relative paths 497 if parsedURL.Host == "" { 498 return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 499 } 500 } 501 return nil 502}