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