···2828 "tangled.sh/tangled.sh/core/appview/pages"2929 "tangled.sh/tangled.sh/core/appview/pages/markup"3030 "tangled.sh/tangled.sh/core/appview/reporesolver"3131+ xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"3132 "tangled.sh/tangled.sh/core/eventconsumer"3233 "tangled.sh/tangled.sh/core/idresolver"3334 "tangled.sh/tangled.sh/core/knotclient"···14541453 })1455145414561455 case http.MethodPost:14561456+ l := rp.logger.With("handler", "ForkRepo")1457145714581458- knot := r.FormValue("knot")14591459- if knot == "" {14581458+ targetKnot := r.FormValue("knot")14591459+ if targetKnot == "" {14601460 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")14611461 return14621462 }14631463+ l = l.With("targetKnot", targetKnot)1463146414641464- ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")14651465+ ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create")14651466 if err != nil || !ok {14661467 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")14671468 return14681469 }1469147014701470- forkName := fmt.Sprintf("%s", f.Name)14711471-14711471+ // choose a name for a fork14721472+ forkName := f.Name14721473 // this check is *only* to see if the forked repo name already exists14731474 // in the user's account.14741475 existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name)···14861483 // repo with this name already exists, append random string14871484 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))14881485 }14891489- client, err := rp.oauth.ServiceClient(14901490- r,14911491- oauth.WithService(knot),14921492- oauth.WithLxm(tangled.RepoForkNSID),14931493- oauth.WithDev(rp.config.Core.Dev),14941494- )14861486+ l = l.With("forkName", forkName)1495148714961496- if err != nil {14971497- log.Printf("error creating client for knot server: %v", err)14981498- rp.pages.Notice(w, "repo", "Failed to connect to knot server.")14991499- return15001500- }15011501-15021502- var uri string14881488+ uri := "https"15031489 if rp.config.Core.Dev {15041490 uri = "http"15051505- } else {15061506- uri = "https"15071491 }14921492+15081493 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name)14941494+ l = l.With("cloneUrl", forkSourceUrl)14951495+15091496 sourceAt := f.RepoAt().String()1510149714981498+ // create an atproto record for this fork15111499 rkey := tid.TID()15121500 repo := &db.Repo{15131501 Did: user.Did,15141502 Name: forkName,15151515- Knot: knot,15031503+ Knot: targetKnot,15161504 Rkey: rkey,15171505 Source: sourceAt,15181506 }1519150715201520- tx, err := rp.db.BeginTx(r.Context(), nil)15211521- if err != nil {15221522- log.Println(err)15231523- rp.pages.Notice(w, "repo", "Failed to save repository information.")15241524- return15251525- }15261526- defer func() {15271527- tx.Rollback()15281528- err = rp.enforcer.E.LoadPolicy()15291529- if err != nil {15301530- log.Println("failed to rollback policies")15311531- }15321532- }()15331533-15341534- err = tangled.RepoFork(15351535- r.Context(),15361536- client,15371537- &tangled.RepoFork_Input{15381538- Did: user.Did,15391539- Name: &forkName,15401540- Source: forkSourceUrl,15411541- },15421542- )15431543-15441544- if err != nil {15451545- xe, err := xrpcerr.Unmarshal(err.Error())15461546- if err != nil {15471547- log.Println(err)15481548- rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")15491549- return15501550- }15511551-15521552- log.Println(xe.Error())15531553- rp.pages.Notice(w, "repo", fmt.Sprintf("Failed to create repository on knot server: %s.", xe.Message))15541554- return15551555- }15561556-15571508 xrpcClient, err := rp.oauth.AuthorizedClient(r)15581509 if err != nil {15591559- log.Println("failed to get authorized client", err)15601560- rp.pages.Notice(w, "repo", "Failed to create repository.")15101510+ l.Error("failed to create xrpcclient", "err", err)15111511+ rp.pages.Notice(w, "repo", "Failed to fork repository.")15611512 return15621513 }15631514···15301573 }},15311574 })15321575 if err != nil {15331533- log.Printf("failed to create record: %s", err)15761576+ l.Error("failed to write to PDS", "err", err)15341577 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")15351578 return15361579 }15371537- log.Println("created repo record: ", atresp.Uri)15801580+15811581+ aturi := atresp.Uri15821582+ l = l.With("aturi", aturi)15831583+ l.Info("wrote to PDS")15841584+15851585+ tx, err := rp.db.BeginTx(r.Context(), nil)15861586+ if err != nil {15871587+ l.Info("txn failed", "err", err)15881588+ rp.pages.Notice(w, "repo", "Failed to save repository information.")15891589+ return15901590+ }15911591+15921592+ // The rollback function reverts a few things on failure:15931593+ // - the pending txn15941594+ // - the ACLs15951595+ // - the atproto record created15961596+ rollback := func() {15971597+ err1 := tx.Rollback()15981598+ err2 := rp.enforcer.E.LoadPolicy()15991599+ err3 := rollbackRecord(context.Background(), aturi, xrpcClient)16001600+16011601+ // ignore txn complete errors, this is okay16021602+ if errors.Is(err1, sql.ErrTxDone) {16031603+ err1 = nil16041604+ }16051605+16061606+ if errs := errors.Join(err1, err2, err3); errs != nil {16071607+ l.Error("failed to rollback changes", "errs", errs)16081608+ return16091609+ }16101610+ }16111611+ defer rollback()16121612+16131613+ client, err := rp.oauth.ServiceClient(16141614+ r,16151615+ oauth.WithService(targetKnot),16161616+ oauth.WithLxm(tangled.RepoCreateNSID),16171617+ oauth.WithDev(rp.config.Core.Dev),16181618+ )16191619+ if err != nil {16201620+ l.Error("could not create service client", "err", err)16211621+ rp.pages.Notice(w, "repo", "Failed to connect to knot server.")16221622+ return16231623+ }16241624+16251625+ err = tangled.RepoCreate(16261626+ r.Context(),16271627+ client,16281628+ &tangled.RepoCreate_Input{16291629+ Rkey: rkey,16301630+ Source: &forkSourceUrl,16311631+ },16321632+ )16331633+ if err := xrpcclient.HandleXrpcErr(err); err != nil {16341634+ rp.pages.Notice(w, "repo", err.Error())16351635+ return16361636+ }1538163715391638 err = db.AddRepo(tx, repo)15401639 if err != nil {···1601158816021589 // acls16031590 p, _ := securejoin.SecureJoin(user.Did, forkName)16041604- err = rp.enforcer.AddRepo(user.Did, knot, p)15911591+ err = rp.enforcer.AddRepo(user.Did, targetKnot, p)16051592 if err != nil {16061593 log.Println(err)16071594 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")···16221609 return16231610 }1624161116121612+ // reset the ATURI because the transaction completed successfully16131613+ aturi = ""16141614+16151615+ rp.notifier.NewRepo(r.Context(), repo)16251616 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))16261626- return16271617 }16181618+}16191619+16201620+// this is used to rollback changes made to the PDS16211621+//16221622+// it is a no-op if the provided ATURI is empty16231623+func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {16241624+ if aturi == "" {16251625+ return nil16261626+ }16271627+16281628+ parsed := syntax.ATURI(aturi)16291629+16301630+ collection := parsed.Collection().String()16311631+ repo := parsed.Authority().String()16321632+ rkey := parsed.RecordKey().String()16331633+16341634+ _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{16351635+ Collection: collection,16361636+ Repo: repo,16371637+ Rkey: rkey,16381638+ })16391639+ return err16281640}1629164116301642func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
+89-38
appview/state/state.go
···2233import (44 "context"55+ "database/sql"66+ "errors"57 "fmt"68 "log"79 "log/slog"···1210 "time"13111412 comatproto "github.com/bluesky-social/indigo/api/atproto"1313+ "github.com/bluesky-social/indigo/atproto/syntax"1514 lexutil "github.com/bluesky-social/indigo/lex/util"1615 securejoin "github.com/cyphar/filepath-securejoin"1716 "github.com/go-chi/chi/v5"···2825 "tangled.sh/tangled.sh/core/appview/pages"2926 posthogService "tangled.sh/tangled.sh/core/appview/posthog"3027 "tangled.sh/tangled.sh/core/appview/reporesolver"2828+ xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient"3129 "tangled.sh/tangled.sh/core/eventconsumer"3230 "tangled.sh/tangled.sh/core/idresolver"3331 "tangled.sh/tangled.sh/core/jetstream"···5248 repoResolver *reporesolver.RepoResolver5349 knotstream *eventconsumer.Consumer5450 spindlestream *eventconsumer.Consumer5151+ logger *slog.Logger5552}56535754func Make(ctx context.Context, config *config.Config) (*State, error) {···157152 repoResolver,158153 knotstream,159154 spindlestream,155155+ slog.Default(),160156 }161157162158 return state, nil···297291 })298292299293 case http.MethodPost:300300- user := s.oauth.GetUser(r)294294+ l := s.logger.With("handler", "NewRepo")301295296296+ user := s.oauth.GetUser(r)297297+ l = l.With("did", user.Did)298298+ l = l.With("handle", user.Handle)299299+300300+ // form validation302301 domain := r.FormValue("domain")303302 if domain == "" {304303 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")305304 return306305 }306306+ l = l.With("knot", domain)307307308308 repoName := r.FormValue("name")309309 if repoName == "" {···321309 s.pages.Notice(w, "repo", err.Error())322310 return323311 }324324-325312 repoName = stripGitExt(repoName)313313+ l = l.With("repoName", repoName)326314327315 defaultBranch := r.FormValue("branch")328316 if defaultBranch == "" {329317 defaultBranch = "main"330318 }319319+ l = l.With("defaultBranch", defaultBranch)331320332321 description := r.FormValue("description")333322323323+ // ACL validation334324 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create")335325 if err != nil || !ok {326326+ l.Info("unauthorized")336327 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")337328 return338329 }339330331331+ // Check for existing repos340332 existingRepo, err := db.GetRepo(s.db, user.Did, repoName)341333 if err == nil && existingRepo != nil {342334 l.Info("repo exists")···348332 return349333 }350334351351- client, err := s.oauth.ServiceClient(352352- r,353353- oauth.WithService(domain),354354- oauth.WithLxm(tangled.RepoCreateNSID),355355- oauth.WithDev(s.config.Core.Dev),356356- )357357-358358- if err != nil {359359- s.pages.Notice(w, "repo", "Failed to connect to knot server.")360360- return361361- }362362-335335+ // create atproto record for this repo363336 rkey := tid.TID()364337 repo := &db.Repo{365338 Did: user.Did,···360355361356 xrpcClient, err := s.oauth.AuthorizedClient(r)362357 if err != nil {358358+ l.Info("PDS write failed", "err", err)363359 s.pages.Notice(w, "repo", "Failed to write record to PDS.")364360 return365361 }···379373 }},380374 })381375 if err != nil {382382- log.Printf("failed to create record: %s", err)376376+ l.Info("PDS write failed", "err", err)383377 s.pages.Notice(w, "repo", "Failed to announce repository creation.")384378 return385379 }386386- log.Println("created repo record: ", atresp.Uri)380380+381381+ aturi := atresp.Uri382382+ l = l.With("aturi", aturi)383383+ l.Info("wrote to PDS")387384388385 tx, err := s.db.BeginTx(r.Context(), nil)389386 if err != nil {390390- log.Println(err)387387+ l.Info("txn failed", "err", err)391388 s.pages.Notice(w, "repo", "Failed to save repository information.")392389 return393390 }394394- defer func() {395395- tx.Rollback()396396- err = s.enforcer.E.LoadPolicy()397397- if err != nil {398398- log.Println("failed to rollback policies")391391+392392+ // The rollback function reverts a few things on failure:393393+ // - the pending txn394394+ // - the ACLs395395+ // - the atproto record created396396+ rollback := func() {397397+ err1 := tx.Rollback()398398+ err2 := s.enforcer.E.LoadPolicy()399399+ err3 := rollbackRecord(context.Background(), aturi, xrpcClient)400400+401401+ // ignore txn complete errors, this is okay402402+ if errors.Is(err1, sql.ErrTxDone) {403403+ err1 = nil399404 }400400- }()405405+406406+ if errs := errors.Join(err1, err2, err3); errs != nil {407407+ l.Error("failed to rollback changes", "errs", errs)408408+ return409409+ }410410+ }411411+ defer rollback()412412+413413+ client, err := s.oauth.ServiceClient(414414+ r,415415+ oauth.WithService(domain),416416+ oauth.WithLxm(tangled.RepoCreateNSID),417417+ oauth.WithDev(s.config.Core.Dev),418418+ )419419+ if err != nil {420420+ l.Error("service auth failed", "err", err)421421+ s.pages.Notice(w, "repo", "Failed to reach PDS.")422422+ return423423+ }401424402425 xe := tangled.RepoCreate(403426 r.Context(),···436401 },437402 )438403 if err != nil {439439- xe, err := xrpcerr.Unmarshal(err.Error())440440- if err != nil {441441- log.Println(err)442442- s.pages.Notice(w, "repo", "Failed to create repository on knot server.")443443- return444444- }445445-446446- log.Println(xe.Error())447447- s.pages.Notice(w, "repo", fmt.Sprintf("Failed to create repository on knot server: %s.", xe.Message))404404+ l.Error("xrpc request failed", "err", err)405405+ s.pages.Notice(w, "repo", fmt.Sprintf("Failed to create repository on knot server: %s.", err.Error()))448406 return449407 }450408451409 err = db.AddRepo(tx, repo)452410 if err != nil {453453- log.Println(err)411411+ l.Error("db write failed", "err", err)454412 s.pages.Notice(w, "repo", "Failed to save repository information.")455413 return456414 }···452424 p, _ := securejoin.SecureJoin(user.Did, repoName)453425 err = s.enforcer.AddRepo(user.Did, domain, p)454426 if err != nil {455455- log.Println(err)427427+ l.Error("acl setup failed", "err", err)456428 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")457429 return458430 }459431460432 err = tx.Commit()461433 if err != nil {462462- log.Println("failed to commit changes", err)434434+ l.Error("txn commit failed", "err", err)463435 http.Error(w, err.Error(), http.StatusInternalServerError)464436 return465437 }466438467439 err = s.enforcer.E.SavePolicy()468440 if err != nil {469469- log.Println("failed to update ACLs", err)441441+ l.Error("acl save failed", "err", err)470442 http.Error(w, err.Error(), http.StatusInternalServerError)471443 return472444 }473445474474- s.notifier.NewRepo(r.Context(), repo)446446+ // reset the ATURI because the transaction completed successfully447447+ aturi = ""475448449449+ s.notifier.NewRepo(r.Context(), repo)476450 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName))477477- return478451 }452452+}453453+454454+// this is used to rollback changes made to the PDS455455+//456456+// it is a no-op if the provided ATURI is empty457457+func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error {458458+ if aturi == "" {459459+ return nil460460+ }461461+462462+ parsed := syntax.ATURI(aturi)463463+464464+ collection := parsed.Collection().String()465465+ repo := parsed.Authority().String()466466+ rkey := parsed.RecordKey().String()467467+468468+ _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{469469+ Collection: collection,470470+ Repo: repo,471471+ Rkey: rkey,472472+ })473473+ return err479474}
+8-5
knotserver/xrpc/router.go
knotserver/xrpc/xrpc.go
···31313232func (x *Xrpc) Router() http.Handler {3333 r := chi.NewRouter()3434+3435 r.Group(func(r chi.Router) {3536 r.Use(x.ServiceAuth.VerifyServiceAuth)36373738 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch)3839 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo)3939- r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo)4040- r.Post("/"+tangled.RepoForkNSID, x.ForkRepo)4140 r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus)4241 r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync)4343-4442 r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef)4545-4643 r.Post("/"+tangled.RepoMergeNSID, x.Merge)4747- r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)4844 })4545+4646+ // merge check is an open endpoint4747+ //4848+ // TODO: should we constrain this more?4949+ // - we can calculate on PR submit/resubmit/gitRefUpdate etc.5050+ // - use ETags on clients to keep requests to a minimum5151+ r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck)4952 return r5053}5154