this repo has no description
1package state
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "strings"
11 "time"
12
13 "tangled.org/core/api/tangled"
14 "tangled.org/core/appview"
15 "tangled.org/core/appview/config"
16 "tangled.org/core/appview/db"
17 "tangled.org/core/appview/indexer"
18 "tangled.org/core/appview/mentions"
19 "tangled.org/core/appview/models"
20 "tangled.org/core/appview/notify"
21 dbnotify "tangled.org/core/appview/notify/db"
22 phnotify "tangled.org/core/appview/notify/posthog"
23 "tangled.org/core/appview/oauth"
24 "tangled.org/core/appview/pages"
25 "tangled.org/core/appview/reporesolver"
26 "tangled.org/core/appview/validator"
27 xrpcclient "tangled.org/core/appview/xrpcclient"
28 "tangled.org/core/eventconsumer"
29 "tangled.org/core/idresolver"
30 "tangled.org/core/jetstream"
31 "tangled.org/core/log"
32 tlog "tangled.org/core/log"
33 "tangled.org/core/orm"
34 "tangled.org/core/rbac"
35 "tangled.org/core/tid"
36
37 comatproto "github.com/bluesky-social/indigo/api/atproto"
38 atpclient "github.com/bluesky-social/indigo/atproto/client"
39 "github.com/bluesky-social/indigo/atproto/syntax"
40 lexutil "github.com/bluesky-social/indigo/lex/util"
41 securejoin "github.com/cyphar/filepath-securejoin"
42 "github.com/go-chi/chi/v5"
43 "github.com/posthog/posthog-go"
44)
45
46type State struct {
47 db *db.DB
48 notifier notify.Notifier
49 indexer *indexer.Indexer
50 oauth *oauth.OAuth
51 enforcer *rbac.Enforcer
52 pages *pages.Pages
53 idResolver *idresolver.Resolver
54 mentionsResolver *mentions.Resolver
55 posthog posthog.Client
56 jc *jetstream.JetstreamClient
57 config *config.Config
58 repoResolver *reporesolver.RepoResolver
59 knotstream *eventconsumer.Consumer
60 spindlestream *eventconsumer.Consumer
61 logger *slog.Logger
62 validator *validator.Validator
63}
64
65func Make(ctx context.Context, config *config.Config) (*State, error) {
66 logger := tlog.FromContext(ctx)
67
68 d, err := db.Make(ctx, config.Core.DbPath)
69 if err != nil {
70 return nil, fmt.Errorf("failed to create db: %w", err)
71 }
72
73 indexer := indexer.New(log.SubLogger(logger, "indexer"))
74 err = indexer.Init(ctx, d)
75 if err != nil {
76 return nil, fmt.Errorf("failed to create indexer: %w", err)
77 }
78
79 enforcer, err := rbac.NewEnforcer(config.Core.DbPath)
80 if err != nil {
81 return nil, fmt.Errorf("failed to create enforcer: %w", err)
82 }
83
84 res, err := idresolver.RedisResolver(config.Redis.ToURL(), config.Plc.PLCURL)
85 if err != nil {
86 logger.Error("failed to create redis resolver", "err", err)
87 res = idresolver.DefaultResolver(config.Plc.PLCURL)
88 }
89
90 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint})
91 if err != nil {
92 return nil, fmt.Errorf("failed to create posthog client: %w", err)
93 }
94
95 pages := pages.NewPages(config, res, log.SubLogger(logger, "pages"))
96 oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth"))
97 if err != nil {
98 return nil, fmt.Errorf("failed to start oauth handler: %w", err)
99 }
100 validator := validator.New(d, res, enforcer)
101
102 repoResolver := reporesolver.New(config, enforcer, d)
103
104 mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver"))
105
106 wrapper := db.DbWrapper{Execer: d}
107 jc, err := jetstream.NewJetstreamClient(
108 config.Jetstream.Endpoint,
109 "appview",
110 []string{
111 tangled.GraphFollowNSID,
112 tangled.FeedStarNSID,
113 tangled.PublicKeyNSID,
114 tangled.RepoArtifactNSID,
115 tangled.ActorProfileNSID,
116 tangled.SpindleMemberNSID,
117 tangled.SpindleNSID,
118 tangled.StringNSID,
119 tangled.RepoIssueNSID,
120 tangled.RepoIssueCommentNSID,
121 tangled.LabelDefinitionNSID,
122 tangled.LabelOpNSID,
123 },
124 nil,
125 tlog.SubLogger(logger, "jetstream"),
126 wrapper,
127 false,
128
129 // in-memory filter is inapplicalble to appview so
130 // we'll never log dids anyway.
131 false,
132 )
133 if err != nil {
134 return nil, fmt.Errorf("failed to create jetstream client: %w", err)
135 }
136
137 if err := BackfillDefaultDefs(d, res, config.Label.DefaultLabelDefs); err != nil {
138 return nil, fmt.Errorf("failed to backfill default label defs: %w", err)
139 }
140
141 ingester := appview.Ingester{
142 Db: wrapper,
143 Enforcer: enforcer,
144 IdResolver: res,
145 Config: config,
146 Logger: log.SubLogger(logger, "ingester"),
147 Validator: validator,
148 }
149 err = jc.StartJetstream(ctx, ingester.Ingest())
150 if err != nil {
151 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err)
152 }
153
154 knotstream, err := Knotstream(ctx, config, d, enforcer, posthog)
155 if err != nil {
156 return nil, fmt.Errorf("failed to start knotstream consumer: %w", err)
157 }
158 knotstream.Start(ctx)
159
160 spindlestream, err := Spindlestream(ctx, config, d, enforcer)
161 if err != nil {
162 return nil, fmt.Errorf("failed to start spindlestream consumer: %w", err)
163 }
164 spindlestream.Start(ctx)
165
166 var notifiers []notify.Notifier
167
168 // Always add the database notifier
169 notifiers = append(notifiers, dbnotify.NewDatabaseNotifier(d, res))
170
171 // Add other notifiers in production only
172 if !config.Core.Dev {
173 notifiers = append(notifiers, phnotify.NewPosthogNotifier(posthog))
174 }
175 notifiers = append(notifiers, indexer)
176 notifier := notify.NewMergedNotifier(notifiers, tlog.SubLogger(logger, "notify"))
177
178 state := &State{
179 d,
180 notifier,
181 indexer,
182 oauth,
183 enforcer,
184 pages,
185 res,
186 mentionsResolver,
187 posthog,
188 jc,
189 config,
190 repoResolver,
191 knotstream,
192 spindlestream,
193 logger,
194 validator,
195 }
196
197 return state, nil
198}
199
200func (s *State) Close() error {
201 // other close up logic goes here
202 return s.db.Close()
203}
204
205func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) {
206 w.Header().Set("Content-Type", "text/plain")
207 w.Header().Set("Cache-Control", "public, max-age=86400") // one day
208
209 robotsTxt := `User-agent: *
210Allow: /
211`
212 w.Write([]byte(robotsTxt))
213}
214
215// https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest
216const manifestJson = `{
217 "name": "tangled",
218 "description": "tightly-knit social coding.",
219 "icons": [
220 {
221 "src": "/favicon.svg",
222 "sizes": "144x144"
223 }
224 ],
225 "start_url": "/",
226 "id": "org.tangled",
227
228 "display": "standalone",
229 "background_color": "#111827",
230 "theme_color": "#111827"
231}`
232
233func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) {
234 w.Header().Set("Content-Type", "application/json")
235 w.Write([]byte(manifestJson))
236}
237
238func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) {
239 user := s.oauth.GetUser(r)
240 s.pages.TermsOfService(w, pages.TermsOfServiceParams{
241 LoggedInUser: user,
242 })
243}
244
245func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) {
246 user := s.oauth.GetUser(r)
247 s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{
248 LoggedInUser: user,
249 })
250}
251
252func (s *State) Brand(w http.ResponseWriter, r *http.Request) {
253 user := s.oauth.GetUser(r)
254 s.pages.Brand(w, pages.BrandParams{
255 LoggedInUser: user,
256 })
257}
258
259func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) {
260 if s.oauth.GetUser(r) != nil {
261 s.Timeline(w, r)
262 return
263 }
264 s.Home(w, r)
265}
266
267func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
268 user := s.oauth.GetUser(r)
269
270 // TODO: set this flag based on the UI
271 filtered := false
272
273 var userDid string
274 if user != nil {
275 userDid = user.Did
276 }
277 timeline, err := db.MakeTimeline(s.db, 50, userDid, filtered)
278 if err != nil {
279 s.logger.Error("failed to make timeline", "err", err)
280 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
281 }
282
283 repos, err := db.GetTopStarredReposLastWeek(s.db)
284 if err != nil {
285 s.logger.Error("failed to get top starred repos", "err", err)
286 s.pages.Notice(w, "topstarredrepos", "Unable to load.")
287 return
288 }
289
290 gfiLabel, err := db.GetLabelDefinition(s.db, orm.FilterEq("at_uri", s.config.Label.GoodFirstIssue))
291 if err != nil {
292 // non-fatal
293 }
294
295 s.pages.Timeline(w, pages.TimelineParams{
296 LoggedInUser: user,
297 Timeline: timeline,
298 Repos: repos,
299 GfiLabel: gfiLabel,
300 })
301}
302
303func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) {
304 user := s.oauth.GetUser(r)
305 if user == nil {
306 return
307 }
308
309 l := s.logger.With("handler", "UpgradeBanner")
310 l = l.With("did", user.Did)
311
312 regs, err := db.GetRegistrations(
313 s.db,
314 orm.FilterEq("did", user.Did),
315 orm.FilterEq("needs_upgrade", 1),
316 )
317 if err != nil {
318 l.Error("non-fatal: failed to get registrations", "err", err)
319 }
320
321 spindles, err := db.GetSpindles(
322 s.db,
323 orm.FilterEq("owner", user.Did),
324 orm.FilterEq("needs_upgrade", 1),
325 )
326 if err != nil {
327 l.Error("non-fatal: failed to get spindles", "err", err)
328 }
329
330 if regs == nil && spindles == nil {
331 return
332 }
333
334 s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{
335 Registrations: regs,
336 Spindles: spindles,
337 })
338}
339
340func (s *State) Home(w http.ResponseWriter, r *http.Request) {
341 // TODO: set this flag based on the UI
342 filtered := false
343
344 timeline, err := db.MakeTimeline(s.db, 5, "", filtered)
345 if err != nil {
346 s.logger.Error("failed to make timeline", "err", err)
347 s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.")
348 return
349 }
350
351 repos, err := db.GetTopStarredReposLastWeek(s.db)
352 if err != nil {
353 s.logger.Error("failed to get top starred repos", "err", err)
354 s.pages.Notice(w, "topstarredrepos", "Unable to load.")
355 return
356 }
357
358 s.pages.Home(w, pages.TimelineParams{
359 LoggedInUser: nil,
360 Timeline: timeline,
361 Repos: repos,
362 })
363}
364
365func (s *State) Keys(w http.ResponseWriter, r *http.Request) {
366 user := chi.URLParam(r, "user")
367 user = strings.TrimPrefix(user, "@")
368
369 if user == "" {
370 w.WriteHeader(http.StatusBadRequest)
371 return
372 }
373
374 id, err := s.idResolver.ResolveIdent(r.Context(), user)
375 if err != nil {
376 w.WriteHeader(http.StatusInternalServerError)
377 return
378 }
379
380 pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String())
381 if err != nil {
382 s.logger.Error("failed to get public keys", "err", err)
383 http.Error(w, "failed to get public keys", http.StatusInternalServerError)
384 return
385 }
386
387 if len(pubKeys) == 0 {
388 w.WriteHeader(http.StatusNoContent)
389 return
390 }
391
392 for _, k := range pubKeys {
393 key := strings.TrimRight(k.Key, "\n")
394 fmt.Fprintln(w, key)
395 }
396}
397
398func validateRepoName(name string) error {
399 // check for path traversal attempts
400 if name == "." || name == ".." ||
401 strings.Contains(name, "/") || strings.Contains(name, "\\") {
402 return fmt.Errorf("Repository name contains invalid path characters")
403 }
404
405 // check for sequences that could be used for traversal when normalized
406 if strings.Contains(name, "./") || strings.Contains(name, "../") ||
407 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
408 return fmt.Errorf("Repository name contains invalid path sequence")
409 }
410
411 // then continue with character validation
412 for _, char := range name {
413 if !((char >= 'a' && char <= 'z') ||
414 (char >= 'A' && char <= 'Z') ||
415 (char >= '0' && char <= '9') ||
416 char == '-' || char == '_' || char == '.') {
417 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
418 }
419 }
420
421 // additional check to prevent multiple sequential dots
422 if strings.Contains(name, "..") {
423 return fmt.Errorf("Repository name cannot contain sequential dots")
424 }
425
426 // if all checks pass
427 return nil
428}
429
430func stripGitExt(name string) string {
431 return strings.TrimSuffix(name, ".git")
432}
433
434func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) {
435 switch r.Method {
436 case http.MethodGet:
437 user := s.oauth.GetUser(r)
438 knots, err := s.enforcer.GetKnotsForUser(user.Did)
439 if err != nil {
440 s.pages.Notice(w, "repo", "Invalid user account.")
441 return
442 }
443
444 s.pages.NewRepo(w, pages.NewRepoParams{
445 LoggedInUser: user,
446 Knots: knots,
447 })
448
449 case http.MethodPost:
450 l := s.logger.With("handler", "NewRepo")
451
452 user := s.oauth.GetUser(r)
453 l = l.With("did", user.Did)
454
455 // form validation
456 domain := r.FormValue("domain")
457 if domain == "" {
458 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
459 return
460 }
461 l = l.With("knot", domain)
462
463 repoName := r.FormValue("name")
464 if repoName == "" {
465 s.pages.Notice(w, "repo", "Repository name cannot be empty.")
466 return
467 }
468
469 if err := validateRepoName(repoName); err != nil {
470 s.pages.Notice(w, "repo", err.Error())
471 return
472 }
473 repoName = stripGitExt(repoName)
474 l = l.With("repoName", repoName)
475
476 defaultBranch := r.FormValue("branch")
477 if defaultBranch == "" {
478 defaultBranch = "main"
479 }
480 l = l.With("defaultBranch", defaultBranch)
481
482 description := r.FormValue("description")
483
484 // ACL validation
485 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")
486 if err != nil || !ok {
487 l.Info("unauthorized")
488 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
489 return
490 }
491
492 // Check for existing repos
493 existingRepo, err := db.GetRepo(
494 s.db,
495 orm.FilterEq("did", user.Did),
496 orm.FilterEq("name", repoName),
497 )
498 if err == nil && existingRepo != nil {
499 l.Info("repo exists")
500 s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot))
501 return
502 }
503
504 // create atproto record for this repo
505 rkey := tid.TID()
506 repo := &models.Repo{
507 Did: user.Did,
508 Name: repoName,
509 Knot: domain,
510 Rkey: rkey,
511 Description: description,
512 Created: time.Now(),
513 Labels: s.config.Label.DefaultLabelDefs,
514 }
515 record := repo.AsRecord()
516
517 atpClient, err := s.oauth.AuthorizedClient(r)
518 if err != nil {
519 l.Info("PDS write failed", "err", err)
520 s.pages.Notice(w, "repo", "Failed to write record to PDS.")
521 return
522 }
523
524 atresp, err := comatproto.RepoPutRecord(r.Context(), atpClient, &comatproto.RepoPutRecord_Input{
525 Collection: tangled.RepoNSID,
526 Repo: user.Did,
527 Rkey: rkey,
528 Record: &lexutil.LexiconTypeDecoder{
529 Val: &record,
530 },
531 })
532 if err != nil {
533 l.Info("PDS write failed", "err", err)
534 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
535 return
536 }
537
538 aturi := atresp.Uri
539 l = l.With("aturi", aturi)
540 l.Info("wrote to PDS")
541
542 tx, err := s.db.BeginTx(r.Context(), nil)
543 if err != nil {
544 l.Info("txn failed", "err", err)
545 s.pages.Notice(w, "repo", "Failed to save repository information.")
546 return
547 }
548
549 // The rollback function reverts a few things on failure:
550 // - the pending txn
551 // - the ACLs
552 // - the atproto record created
553 rollback := func() {
554 err1 := tx.Rollback()
555 err2 := s.enforcer.E.LoadPolicy()
556 err3 := rollbackRecord(context.Background(), aturi, atpClient)
557
558 // ignore txn complete errors, this is okay
559 if errors.Is(err1, sql.ErrTxDone) {
560 err1 = nil
561 }
562
563 if errs := errors.Join(err1, err2, err3); errs != nil {
564 l.Error("failed to rollback changes", "errs", errs)
565 return
566 }
567 }
568 defer rollback()
569
570 client, err := s.oauth.ServiceClient(
571 r,
572 oauth.WithService(domain),
573 oauth.WithLxm(tangled.RepoCreateNSID),
574 oauth.WithDev(s.config.Core.Dev),
575 )
576 if err != nil {
577 l.Error("service auth failed", "err", err)
578 s.pages.Notice(w, "repo", "Failed to reach PDS.")
579 return
580 }
581
582 xe := tangled.RepoCreate(
583 r.Context(),
584 client,
585 &tangled.RepoCreate_Input{
586 Rkey: rkey,
587 },
588 )
589 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
590 l.Error("xrpc error", "xe", xe)
591 s.pages.Notice(w, "repo", err.Error())
592 return
593 }
594
595 err = db.AddRepo(tx, repo)
596 if err != nil {
597 l.Error("db write failed", "err", err)
598 s.pages.Notice(w, "repo", "Failed to save repository information.")
599 return
600 }
601
602 // acls
603 p, _ := securejoin.SecureJoin(user.Did, repoName)
604 err = s.enforcer.AddRepo(user.Did, domain, p)
605 if err != nil {
606 l.Error("acl setup failed", "err", err)
607 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
608 return
609 }
610
611 err = tx.Commit()
612 if err != nil {
613 l.Error("txn commit failed", "err", err)
614 http.Error(w, err.Error(), http.StatusInternalServerError)
615 return
616 }
617
618 err = s.enforcer.E.SavePolicy()
619 if err != nil {
620 l.Error("acl save failed", "err", err)
621 http.Error(w, err.Error(), http.StatusInternalServerError)
622 return
623 }
624
625 // reset the ATURI because the transaction completed successfully
626 aturi = ""
627
628 s.notifier.NewRepo(r.Context(), repo)
629 s.pages.HxLocation(w, fmt.Sprintf("/%s/%s", user.Did, repoName))
630 }
631}
632
633// this is used to rollback changes made to the PDS
634//
635// it is a no-op if the provided ATURI is empty
636func rollbackRecord(ctx context.Context, aturi string, client *atpclient.APIClient) error {
637 if aturi == "" {
638 return nil
639 }
640
641 parsed := syntax.ATURI(aturi)
642
643 collection := parsed.Collection().String()
644 repo := parsed.Authority().String()
645 rkey := parsed.RecordKey().String()
646
647 _, err := comatproto.RepoDeleteRecord(ctx, client, &comatproto.RepoDeleteRecord_Input{
648 Collection: collection,
649 Repo: repo,
650 Rkey: rkey,
651 })
652 return err
653}
654
655func BackfillDefaultDefs(e db.Execer, r *idresolver.Resolver, defaults []string) error {
656 defaultLabels, err := db.GetLabelDefinitions(e, orm.FilterIn("at_uri", defaults))
657 if err != nil {
658 return err
659 }
660 // already present
661 if len(defaultLabels) == len(defaults) {
662 return nil
663 }
664
665 labelDefs, err := models.FetchLabelDefs(r, defaults)
666 if err != nil {
667 return err
668 }
669
670 // Insert each label definition to the database
671 for _, labelDef := range labelDefs {
672 _, err = db.AddLabelDefinition(e, &labelDef)
673 if err != nil {
674 return fmt.Errorf("failed to add label definition %s: %v", labelDef.Name, err)
675 }
676 }
677
678 return nil
679}