Monorepo for Tangled tangled.org
1package state 2 3import ( 4 "context" 5 "fmt" 6 "log" 7 "log/slog" 8 "net/http" 9 "strings" 10 "time" 11 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 securejoin "github.com/cyphar/filepath-securejoin" 15 "github.com/go-chi/chi/v5" 16 "github.com/posthog/posthog-go" 17 "tangled.sh/tangled.sh/core/api/tangled" 18 "tangled.sh/tangled.sh/core/appview" 19 "tangled.sh/tangled.sh/core/appview/cache" 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 "tangled.sh/tangled.sh/core/appview/config" 22 "tangled.sh/tangled.sh/core/appview/db" 23 "tangled.sh/tangled.sh/core/appview/notify" 24 "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 "tangled.sh/tangled.sh/core/eventconsumer" 29 "tangled.sh/tangled.sh/core/idresolver" 30 "tangled.sh/tangled.sh/core/jetstream" 31 "tangled.sh/tangled.sh/core/knotclient" 32 tlog "tangled.sh/tangled.sh/core/log" 33 "tangled.sh/tangled.sh/core/rbac" 34 "tangled.sh/tangled.sh/core/tid" 35) 36 37type State struct { 38 db *db.DB 39 notifier notify.Notifier 40 oauth *oauth.OAuth 41 enforcer *rbac.Enforcer 42 pages *pages.Pages 43 sess *session.SessionStore 44 idResolver *idresolver.Resolver 45 posthog posthog.Client 46 jc *jetstream.JetstreamClient 47 config *config.Config 48 repoResolver *reporesolver.RepoResolver 49 knotstream *eventconsumer.Consumer 50 spindlestream *eventconsumer.Consumer 51} 52 53func Make(ctx context.Context, config *config.Config) (*State, error) { 54 d, err := db.Make(config.Core.DbPath) 55 if err != nil { 56 return nil, fmt.Errorf("failed to create db: %w", err) 57 } 58 59 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 60 if err != nil { 61 return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 } 63 64 res, err := idresolver.RedisResolver(config.Redis.ToURL()) 65 if err != nil { 66 log.Printf("failed to create redis resolver: %v", err) 67 res = idresolver.DefaultResolver() 68 } 69 70 pgs := pages.NewPages(config, res) 71 72 cache := cache.New(config.Redis.Addr) 73 sess := session.New(cache) 74 75 oauth := oauth.NewOAuth(config, sess) 76 77 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 78 if err != nil { 79 return nil, fmt.Errorf("failed to create posthog client: %w", err) 80 } 81 82 repoResolver := reporesolver.New(config, enforcer, res, d) 83 84 wrapper := db.DbWrapper{d} 85 jc, err := jetstream.NewJetstreamClient( 86 config.Jetstream.Endpoint, 87 "appview", 88 []string{ 89 tangled.GraphFollowNSID, 90 tangled.FeedStarNSID, 91 tangled.PublicKeyNSID, 92 tangled.RepoArtifactNSID, 93 tangled.ActorProfileNSID, 94 tangled.SpindleMemberNSID, 95 tangled.SpindleNSID, 96 tangled.StringNSID, 97 }, 98 nil, 99 slog.Default(), 100 wrapper, 101 false, 102 103 // in-memory filter is inapplicalble to appview so 104 // we'll never log dids anyway. 105 false, 106 ) 107 if err != nil { 108 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 109 } 110 111 ingester := appview.Ingester{ 112 Db: wrapper, 113 Enforcer: enforcer, 114 IdResolver: res, 115 Config: config, 116 Logger: tlog.New("ingester"), 117 } 118 err = jc.StartJetstream(ctx, ingester.Ingest()) 119 if err != nil { 120 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 121 } 122 123 knotstream, err := Knotstream(ctx, config, d, enforcer, posthog) 124 if err != nil { 125 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err) 126 } 127 knotstream.Start(ctx) 128 129 spindlestream, err := Spindlestream(ctx, config, d, enforcer) 130 if err != nil { 131 return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err) 132 } 133 spindlestream.Start(ctx) 134 135 var notifiers []notify.Notifier 136 if !config.Core.Dev { 137 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 138 } 139 notifier := notify.NewMergedNotifier(notifiers...) 140 141 state := &State{ 142 d, 143 notifier, 144 oauth, 145 enforcer, 146 pgs, 147 sess, 148 res, 149 posthog, 150 jc, 151 config, 152 repoResolver, 153 knotstream, 154 spindlestream, 155 } 156 157 return state, nil 158} 159 160func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 161 w.Header().Set("Content-Type", "image/svg+xml") 162 w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 163 w.Header().Set("ETag", `"favicon-svg-v1"`) 164 165 if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 166 w.WriteHeader(http.StatusNotModified) 167 return 168 } 169 170 s.pages.Favicon(w) 171} 172 173func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 174 user := s.oauth.GetUser(r) 175 s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 176 LoggedInUser: user, 177 }) 178} 179 180func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 181 user := s.oauth.GetUser(r) 182 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 183 LoggedInUser: user, 184 }) 185} 186 187func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 188 user := s.oauth.GetUser(r) 189 190 timeline, err := db.MakeTimeline(s.db) 191 if err != nil { 192 log.Println(err) 193 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 194 } 195 196 s.pages.Timeline(w, pages.TimelineParams{ 197 LoggedInUser: user, 198 Timeline: timeline, 199 }) 200} 201 202func (s *State) TopStarredReposLastWeek(w http.ResponseWriter, r *http.Request) { 203 repos, err := db.GetTopStarredReposLastWeek(s.db) 204 if err != nil { 205 log.Println(err) 206 s.pages.Notice(w, "topstarredrepos", "Unable to load.") 207 return 208 } 209 210 s.pages.TopStarredReposLastWeek(w, pages.TopStarredReposLastWeekParams{ 211 Repos: repos, 212 }) 213} 214 215func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 216 user := chi.URLParam(r, "user") 217 user = strings.TrimPrefix(user, "@") 218 219 if user == "" { 220 w.WriteHeader(http.StatusBadRequest) 221 return 222 } 223 224 id, err := s.idResolver.ResolveIdent(r.Context(), user) 225 if err != nil { 226 w.WriteHeader(http.StatusInternalServerError) 227 return 228 } 229 230 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 231 if err != nil { 232 w.WriteHeader(http.StatusNotFound) 233 return 234 } 235 236 if len(pubKeys) == 0 { 237 w.WriteHeader(http.StatusNotFound) 238 return 239 } 240 241 for _, k := range pubKeys { 242 key := strings.TrimRight(k.Key, "\n") 243 w.Write([]byte(fmt.Sprintln(key))) 244 } 245} 246 247func validateRepoName(name string) error { 248 // check for path traversal attempts 249 if name == "." || name == ".." || 250 strings.Contains(name, "/") || strings.Contains(name, "\\") { 251 return fmt.Errorf("Repository name contains invalid path characters") 252 } 253 254 // check for sequences that could be used for traversal when normalized 255 if strings.Contains(name, "./") || strings.Contains(name, "../") || 256 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 257 return fmt.Errorf("Repository name contains invalid path sequence") 258 } 259 260 // then continue with character validation 261 for _, char := range name { 262 if !((char >= 'a' && char <= 'z') || 263 (char >= 'A' && char <= 'Z') || 264 (char >= '0' && char <= '9') || 265 char == '-' || char == '_' || char == '.') { 266 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 267 } 268 } 269 270 // additional check to prevent multiple sequential dots 271 if strings.Contains(name, "..") { 272 return fmt.Errorf("Repository name cannot contain sequential dots") 273 } 274 275 // if all checks pass 276 return nil 277} 278 279func stripGitExt(name string) string { 280 return strings.TrimSuffix(name, ".git") 281} 282 283func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 284 switch r.Method { 285 case http.MethodGet: 286 user := s.oauth.GetUser(r) 287 knots, err := s.enforcer.GetKnotsForUser(user.Did) 288 if err != nil { 289 s.pages.Notice(w, "repo", "Invalid user account.") 290 return 291 } 292 293 s.pages.NewRepo(w, pages.NewRepoParams{ 294 LoggedInUser: user, 295 Knots: knots, 296 }) 297 298 case http.MethodPost: 299 user := s.oauth.GetUser(r) 300 301 domain := r.FormValue("domain") 302 if domain == "" { 303 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 304 return 305 } 306 307 repoName := r.FormValue("name") 308 if repoName == "" { 309 s.pages.Notice(w, "repo", "Repository name cannot be empty.") 310 return 311 } 312 313 if err := validateRepoName(repoName); err != nil { 314 s.pages.Notice(w, "repo", err.Error()) 315 return 316 } 317 318 repoName = stripGitExt(repoName) 319 320 defaultBranch := r.FormValue("branch") 321 if defaultBranch == "" { 322 defaultBranch = "main" 323 } 324 325 description := r.FormValue("description") 326 327 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 328 if err != nil || !ok { 329 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 330 return 331 } 332 333 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 334 if err == nil && existingRepo != nil { 335 s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 336 return 337 } 338 339 secret, err := db.GetRegistrationKey(s.db, domain) 340 if err != nil { 341 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 342 return 343 } 344 345 client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 346 if err != nil { 347 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 348 return 349 } 350 351 rkey := tid.TID() 352 repo := &db.Repo{ 353 Did: user.Did, 354 Name: repoName, 355 Knot: domain, 356 Rkey: rkey, 357 Description: description, 358 } 359 360 xrpcClient, err := s.oauth.AuthorizedClient(r) 361 if err != nil { 362 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 363 return 364 } 365 366 createdAt := time.Now().Format(time.RFC3339) 367 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 368 Collection: tangled.RepoNSID, 369 Repo: user.Did, 370 Rkey: rkey, 371 Record: &lexutil.LexiconTypeDecoder{ 372 Val: &tangled.Repo{ 373 Knot: repo.Knot, 374 Name: repoName, 375 CreatedAt: createdAt, 376 Owner: user.Did, 377 }}, 378 }) 379 if err != nil { 380 log.Printf("failed to create record: %s", err) 381 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 382 return 383 } 384 log.Println("created repo record: ", atresp.Uri) 385 386 tx, err := s.db.BeginTx(r.Context(), nil) 387 if err != nil { 388 log.Println(err) 389 s.pages.Notice(w, "repo", "Failed to save repository information.") 390 return 391 } 392 defer func() { 393 tx.Rollback() 394 err = s.enforcer.E.LoadPolicy() 395 if err != nil { 396 log.Println("failed to rollback policies") 397 } 398 }() 399 400 resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 401 if err != nil { 402 s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 403 return 404 } 405 406 switch resp.StatusCode { 407 case http.StatusConflict: 408 s.pages.Notice(w, "repo", "A repository with that name already exists.") 409 return 410 case http.StatusInternalServerError: 411 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 412 case http.StatusNoContent: 413 // continue 414 } 415 416 err = db.AddRepo(tx, repo) 417 if err != nil { 418 log.Println(err) 419 s.pages.Notice(w, "repo", "Failed to save repository information.") 420 return 421 } 422 423 // acls 424 p, _ := securejoin.SecureJoin(user.Did, repoName) 425 err = s.enforcer.AddRepo(user.Did, domain, p) 426 if err != nil { 427 log.Println(err) 428 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 429 return 430 } 431 432 err = tx.Commit() 433 if err != nil { 434 log.Println("failed to commit changes", err) 435 http.Error(w, err.Error(), http.StatusInternalServerError) 436 return 437 } 438 439 err = s.enforcer.E.SavePolicy() 440 if err != nil { 441 log.Println("failed to update ACLs", err) 442 http.Error(w, err.Error(), http.StatusInternalServerError) 443 return 444 } 445 446 s.notifier.NewRepo(r.Context(), repo) 447 448 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 449 return 450 } 451}