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