diff --git a/knotclient/signer.go b/knotclient/signer.go --- a/knotclient/signer.go +++ b/knotclient/signer.go @@ -7,8 +7,6 @@ "encoding/hex" "encoding/json" "fmt" - "io" - "log" "net/http" "net/url" "time" @@ -103,41 +101,6 @@ } return s.client.Do(req) -} - -func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { - const ( - Method = "GET" - ) - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) - - req, err := s.newRequest(Method, endpoint, nil) - if err != nil { - return nil, err - } - - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - - var result types.RepoLanguageResponse - if resp.StatusCode != http.StatusOK { - log.Println("failed to calculate languages", resp.Status) - return &types.RepoLanguageResponse{}, nil - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - err = json.Unmarshal(body, &result) - if err != nil { - return nil, err - } - - return &result, nil } func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { diff --git a/knotclient/unsigned.go b/knotclient/unsigned.go --- a/knotclient/unsigned.go +++ b/knotclient/unsigned.go @@ -248,3 +248,38 @@ return &formatPatchResponse, nil } + +func (s *UnsignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { + const ( + Method = "GET" + ) + endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) + + req, err := s.newRequest(Method, endpoint, nil, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + + var result types.RepoLanguageResponse + if resp.StatusCode != http.StatusOK { + log.Println("failed to calculate languages", resp.Status) + return &types.RepoLanguageResponse{}, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(body, &result) + if err != nil { + return nil, err + } + + return &result, nil +} diff --git a/knotserver/handler.go b/knotserver/handler.go --- a/knotserver/handler.go +++ b/knotserver/handler.go @@ -1,239 +1,298 @@ package knotserver import ( + "compress/gzip" "context" + "crypto/sha256" + "encoding/json" + "errors" "fmt" - "log/slog" + "log" "net/http" - "runtime/debug" + "net/url" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/gliderlabs/ssh" "github.com/go-chi/chi/v5" - "tangled.sh/tangled.sh/core/idresolver" - "tangled.sh/tangled.sh/core/jetstream" - "tangled.sh/tangled.sh/core/knotserver/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" "tangled.sh/tangled.sh/core/knotserver/db" - "tangled.sh/tangled.sh/core/knotserver/xrpc" - tlog "tangled.sh/tangled.sh/core/log" - "tangled.sh/tangled.sh/core/notifier" - "tangled.sh/tangled.sh/core/rbac" + "tangled.sh/tangled.sh/core/knotserver/git" "tangled.sh/tangled.sh/core/types" ) -type Handle struct { - c *config.Config - db *db.DB - jc *jetstream.JetstreamClient - e *rbac.Enforcer - l *slog.Logger - n *notifier.Notifier - resolver *idresolver.Resolver +func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) } -func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { - r := chi.NewRouter() +func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") - h := Handle{ - c: c, - db: db, - e: e, - l: l, - jc: jc, - n: n, - resolver: idresolver.DefaultResolver(), + capabilities := map[string]any{ + "pull_requests": map[string]any{ + "format_patch": true, + "patch_submissions": true, + "branch_submissions": true, + "fork_submissions": true, + }, + "xrpc": true, } - err := e.AddKnot(rbac.ThisServer) + jsonData, err := json.Marshal(capabilities) if err != nil { - return nil, fmt.Errorf("failed to setup enforcer: %w", err) + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) + return } - // configure owner - if err = h.configureOwner(); err != nil { - return nil, err - } - h.l.Info("owner set", "did", h.c.Server.Owner) - h.jc.AddDid(h.c.Server.Owner) - - // configure known-dids in jetstream consumer - dids, err := h.db.GetAllDids() - if err != nil { - return nil, fmt.Errorf("failed to get all dids: %w", err) - } - for _, d := range dids { - jc.AddDid(d) - } - - err = h.jc.StartJetstream(ctx, h.processMessages) - if err != nil { - return nil, fmt.Errorf("failed to start jetstream: %w", err) - } - - r.Get("/", h.Index) - r.Get("/capabilities", h.Capabilities) - r.Get("/version", h.Version) - r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(h.c.Server.Owner)) - }) - r.Route("/{did}", func(r chi.Router) { - // Repo routes - r.Route("/{name}", func(r chi.Router) { - r.Route("/collaborator", func(r chi.Router) { - r.Use(h.VerifySignature) - r.Post("/add", h.AddRepoCollaborator) - }) - - r.Route("/languages", func(r chi.Router) { - r.With(h.VerifySignature) - r.Get("/", h.RepoLanguages) - r.Get("/{ref}", h.RepoLanguages) - }) - - r.Get("/", h.RepoIndex) - r.Get("/info/refs", h.InfoRefs) - r.Post("/git-upload-pack", h.UploadPack) - r.Post("/git-receive-pack", h.ReceivePack) - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects - - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) - - r.Route("/merge", func(r chi.Router) { - r.With(h.VerifySignature) - r.Post("/", h.Merge) - r.Post("/check", h.MergeCheck) - }) - - r.Route("/tree/{ref}", func(r chi.Router) { - r.Get("/", h.RepoIndex) - r.Get("/*", h.RepoTree) - }) - - r.Route("/blob/{ref}", func(r chi.Router) { - r.Get("/*", h.Blob) - }) - - r.Route("/raw/{ref}", func(r chi.Router) { - r.Get("/*", h.BlobRaw) - }) - - r.Get("/log/{ref}", h.Log) - r.Get("/archive/{file}", h.Archive) - r.Get("/commit/{ref}", h.Diff) - r.Get("/tags", h.Tags) - r.Route("/branches", func(r chi.Router) { - r.Get("/", h.Branches) - r.Get("/{branch}", h.Branch) - r.Route("/default", func(r chi.Router) { - r.Get("/", h.DefaultBranch) - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) - }) - }) - }) - }) - - // xrpc apis - r.Mount("/xrpc", h.XrpcRouter()) - - // Create a new repository. - r.Route("/repo", func(r chi.Router) { - r.Use(h.VerifySignature) - r.Delete("/", h.RemoveRepo) - r.Route("/fork", func(r chi.Router) { - r.Post("/", h.RepoFork) - r.Post("/sync/*", h.RepoForkSync) - r.Get("/sync/*", h.RepoForkAheadBehind) - }) - }) - - r.Route("/member", func(r chi.Router) { - r.Use(h.VerifySignature) - r.Put("/add", h.AddMember) - }) - - // Socket that streams git oplogs - r.Get("/events", h.Events) - - // Health check. Used for two-way verification with appview. - r.With(h.VerifySignature).Get("/health", h.Health) - - // All public keys on the knot. - r.Get("/keys", h.Keys) - - return r, nil + w.Write(jsonData) } -func (h *Handle) XrpcRouter() http.Handler { - logger := tlog.New("knots") +func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) + l := h.l.With("path", path, "handler", "RepoIndex") + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) - serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) - - xrpc := &xrpc.Xrpc{ - Config: h.c, - Db: h.db, - Ingester: h.jc, - Enforcer: h.e, - Logger: logger, - Notifier: h.n, - Resolver: h.resolver, - ServiceAuth: serviceAuth, - } - return xrpc.Router() -} - -// version is set during build time. -var version string - -func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { - if version == "" { - info, ok := debug.ReadBuildInfo() - if !ok { - http.Error(w, "failed to read build info", http.StatusInternalServerError) + gr, err := git.Open(path, ref) + if err != nil { + plain, err2 := git.PlainOpen(path) + if err2 != nil { + l.Error("opening repo", "error", err2.Error()) + notFound(w) return } + branches, _ := plain.Branches() - var modVer string - for _, mod := range info.Deps { - if mod.Path == "tangled.sh/tangled.sh/knotserver" { - version = mod.Version - break + log.Println(err) + + if errors.Is(err, plumbing.ErrReferenceNotFound) { + resp := types.RepoIndexResponse{ + IsEmpty: true, + Branches: branches, } - } - - if modVer == "" { - version = "unknown" + writeJSON(w, resp) + return + } else { + l.Error("opening repo", "error", err.Error()) + notFound(w) + return } } - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - fmt.Fprintf(w, "knotserver/%s", version) + var ( + commits []*object.Commit + total int + branches []types.Branch + files []types.NiceTree + tags []object.Tag + ) + + var wg sync.WaitGroup + errorsCh := make(chan error, 5) + + wg.Add(1) + go func() { + defer wg.Done() + cs, err := gr.Commits(0, 60) + if err != nil { + errorsCh <- fmt.Errorf("commits: %w", err) + return + } + commits = cs + }() + + wg.Add(1) + go func() { + defer wg.Done() + t, err := gr.TotalCommits() + if err != nil { + errorsCh <- fmt.Errorf("calculating total: %w", err) + return + } + total = t + }() + + wg.Add(1) + go func() { + defer wg.Done() + bs, err := gr.Branches() + if err != nil { + errorsCh <- fmt.Errorf("fetching branches: %w", err) + return + } + branches = bs + }() + + wg.Add(1) + go func() { + defer wg.Done() + ts, err := gr.Tags() + if err != nil { + errorsCh <- fmt.Errorf("fetching tags: %w", err) + return + } + tags = ts + }() + + wg.Add(1) + go func() { + defer wg.Done() + fs, err := gr.FileTree(r.Context(), "") + if err != nil { + errorsCh <- fmt.Errorf("fetching filetree: %w", err) + return + } + files = fs + }() + + wg.Wait() + close(errorsCh) + + // show any errors + for err := range errorsCh { + l.Error("loading repo", "error", err.Error()) + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + + rtags := []*types.TagReference{} + for _, tag := range tags { + var target *object.Tag + if tag.Target != plumbing.ZeroHash { + target = &tag + } + tr := types.TagReference{ + Tag: target, + } + + tr.Reference = types.Reference{ + Name: tag.Name, + Hash: tag.Hash.String(), + } + + if tag.Message != "" { + tr.Message = tag.Message + } + + rtags = append(rtags, &tr) + } + + var readmeContent string + var readmeFile string + for _, readme := range h.c.Repo.Readme { + content, _ := gr.FileContent(readme) + if len(content) > 0 { + readmeContent = string(content) + readmeFile = readme + } + } + + if ref == "" { + mainBranch, err := gr.FindMainBranch() + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + l.Error("finding main branch", "error", err.Error()) + return + } + ref = mainBranch + } + + resp := types.RepoIndexResponse{ + IsEmpty: false, + Ref: ref, + Commits: commits, + Description: getDescription(path), + Readme: readmeContent, + ReadmeFileName: readmeFile, + Files: files, + Branches: branches, + Tags: rtags, + TotalCommits: total, + } + + writeJSON(w, resp) } -func (h *Handle) configureOwner() error { - cfgOwner := h.c.Server.Owner +func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { + treePath := chi.URLParam(r, "*") + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) - rbacDomain := "thisserver" + l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) - existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) + gr, err := git.Open(path, ref) if err != nil { - return err + notFound(w) + return } - switch len(existing) { - case 0: - // no owner configured, continue - case 1: - // find existing owner - existingOwner := existing[0] + files, err := gr.FileTree(r.Context(), treePath) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + l.Error("file tree", "error", err.Error()) + return + } - // no ownership change, this is okay - if existingOwner == h.c.Server.Owner { - break - } + resp := types.RepoTreeResponse{ + Ref: ref, + Parent: treePath, + Description: getDescription(path), + DotDot: filepath.Dir(treePath), + Files: files, + } - // remove existing owner - err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) - if err != nil { - return nil + writeJSON(w, resp) +} + +func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { + treePath := chi.URLParam(r, "*") + ref := chi.URLParam(r, "ref") + ref, _ = url.PathUnescape(ref) + + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) + + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) + gr, err := git.Open(path, ref) + if err != nil { + notFound(w) + return + } + + contents, err := gr.RawContent(treePath) + if err != nil { + writeError(w, err.Error(), http.StatusBadRequest) + l.Error("file content", "error", err.Error()) + return + } + + mimeType := http.DetectContentType(contents) + + // exception for svg + if filepath.Ext(treePath) == ".svg" { + mimeType = "image/svg+xml" + } + + contentHash := sha256.Sum256(contents) + eTag := fmt.Sprintf("\"%x\"", contentHash) + + // allow image, video, and text/plain files to be served directly + switch { + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { + w.WriteHeader(http.StatusNotModified) + return } + w.Header().Set("ETag", eTag) + + case strings.HasPrefix(mimeType, "text/plain"): + w.Header().Set("Cache-Control", "public, no-cache") + default: l.Error("attempted to serve disallowed file type", "mimetype", mimeType) writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) diff --git a/knotserver/ingester.go b/knotserver/ingester.go --- a/knotserver/ingester.go +++ b/knotserver/ingester.go @@ -8,7 +8,6 @@ "net/http" "net/url" "path/filepath" - "slices" "strings" comatproto "github.com/bluesky-social/indigo/api/atproto" @@ -103,32 +102,16 @@ l = l.With("target_branch", record.TargetBranch) if record.Source == nil { - reason := "not a branch-based pull request" - l.Info("ignoring pull record", "reason", reason) - return fmt.Errorf("ignoring pull record: %s", reason) + return fmt.Errorf("ignoring pull record: not a branch-based pull request") } if record.Source.Repo != nil { - reason := "fork based pull" - l.Info("ignoring pull record", "reason", reason) - return fmt.Errorf("ignoring pull record: %s", reason) - } - - allDids, err := h.db.GetAllDids() - if err != nil { - return err - } - - // presently: we only process PRs from collaborators for pipelines - if !slices.Contains(allDids, did) { - reason := "not a known did" - l.Info("rejecting pull record", "reason", reason) - return fmt.Errorf("rejected pull record: %s, %s", reason, did) + return fmt.Errorf("ignoring pull record: fork based pull") } repoAt, err := syntax.ParseATURI(record.TargetRepo) if err != nil { - return err + return fmt.Errorf("failed to parse ATURI: %w", err) } // resolve this aturi to extract the repo record @@ -144,35 +127,33 @@ resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) if err != nil { - return err + return fmt.Errorf("failed to resolver repo: %w", err) } repo := resp.Value.Val.(*tangled.Repo) if repo.Knot != h.c.Server.Hostname { - reason := "not this knot" - l.Info("rejecting pull record", "reason", reason) - return fmt.Errorf("rejected pull record: %s", reason) + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) } didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) if err != nil { - return err + return fmt.Errorf("failed to construct relative repo path: %w", err) } repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) if err != nil { - return err + return fmt.Errorf("failed to construct absolute repo path: %w", err) } gr, err := git.Open(repoPath, record.Source.Branch) if err != nil { - return err + return fmt.Errorf("failed to open git repository: %w", err) } workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) if err != nil { - return err + return fmt.Errorf("failed to open workflow directory: %w", err) } var pipeline workflow.RawPipeline @@ -215,7 +196,7 @@ cp := compiler.Compile(compiler.Parse(pipeline)) eventJson, err := json.Marshal(cp) if err != nil { - return err + return fmt.Errorf("failed to marshal pipeline event: %w", err) } // do not run empty pipelines diff --git a/knotserver/middleware.go b/knotserver/middleware.go deleted file mode 100644 --- a/knotserver/middleware.go +++ /dev/null @@ -1,53 +0,0 @@ -package knotserver - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "net/http" - "time" -) - -func (h *Handle) VerifySignature(next http.Handler) http.Handler { - if h.c.Server.Dev { - return next - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - signature := r.Header.Get("X-Signature") - if signature == "" || !h.verifyHMAC(signature, r) { - writeError(w, "signature verification failed", http.StatusForbidden) - return - } - next.ServeHTTP(w, r) - }) -} - -func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { - secret := h.c.Server.Secret - timestamp := r.Header.Get("X-Timestamp") - if timestamp == "" { - return false - } - - // Verify that the timestamp is not older than a minute - reqTime, err := time.Parse(time.RFC3339, timestamp) - if err != nil { - return false - } - if time.Since(reqTime) > time.Minute { - return false - } - - message := r.Method + r.URL.Path + timestamp - - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(message)) - expectedMAC := mac.Sum(nil) - - signatureBytes, err := hex.DecodeString(signature) - if err != nil { - return false - } - - return hmac.Equal(signatureBytes, expectedMAC) -} diff --git a/knotserver/routes.go b/knotserver/routes.go --- a/knotserver/routes.go +++ b/knotserver/routes.go @@ -1,1245 +1,207 @@ package knotserver import ( - "compress/gzip" "context" - "crypto/sha256" - "encoding/json" - "errors" "fmt" - "log" + "log/slog" "net/http" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "time" + "runtime/debug" - securejoin "github.com/cyphar/filepath-securejoin" - "github.com/gliderlabs/ssh" "github.com/go-chi/chi/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" - "tangled.sh/tangled.sh/core/hook" + "tangled.sh/tangled.sh/core/idresolver" + "tangled.sh/tangled.sh/core/jetstream" + "tangled.sh/tangled.sh/core/knotserver/config" "tangled.sh/tangled.sh/core/knotserver/db" - "tangled.sh/tangled.sh/core/knotserver/git" - "tangled.sh/tangled.sh/core/patchutil" + "tangled.sh/tangled.sh/core/knotserver/xrpc" + tlog "tangled.sh/tangled.sh/core/log" + "tangled.sh/tangled.sh/core/notifier" "tangled.sh/tangled.sh/core/rbac" - "tangled.sh/tangled.sh/core/types" + "tangled.sh/tangled.sh/core/xrpc/serviceauth" ) -func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) +type Handle struct { + c *config.Config + db *db.DB + jc *jetstream.JetstreamClient + e *rbac.Enforcer + l *slog.Logger + n *notifier.Notifier + resolver *idresolver.Resolver } -func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") +func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { + r := chi.NewRouter() - capabilities := map[string]any{ - "pull_requests": map[string]any{ - "format_patch": true, - "patch_submissions": true, - "branch_submissions": true, - "fork_submissions": true, - }, + h := Handle{ + c: c, + db: db, + e: e, + l: l, + jc: jc, + n: n, + resolver: idresolver.DefaultResolver(), } - jsonData, err := json.Marshal(capabilities) + err := e.AddKnot(rbac.ThisServer) if err != nil { - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) - return + return nil, fmt.Errorf("failed to setup enforcer: %w", err) } - w.Write(jsonData) + // configure owner + if err = h.configureOwner(); err != nil { + return nil, err + } + h.l.Info("owner set", "did", h.c.Server.Owner) + h.jc.AddDid(h.c.Server.Owner) + + // configure known-dids in jetstream consumer + dids, err := h.db.GetAllDids() + if err != nil { + return nil, fmt.Errorf("failed to get all dids: %w", err) + } + for _, d := range dids { + jc.AddDid(d) + } + + err = h.jc.StartJetstream(ctx, h.processMessages) + if err != nil { + return nil, fmt.Errorf("failed to start jetstream: %w", err) + } + + r.Get("/", h.Index) + r.Get("/capabilities", h.Capabilities) + r.Get("/version", h.Version) + r.Get("/owner", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(h.c.Server.Owner)) + }) + r.Route("/{did}", func(r chi.Router) { + // Repo routes + r.Route("/{name}", func(r chi.Router) { + + r.Route("/languages", func(r chi.Router) { + r.Get("/", h.RepoLanguages) + r.Get("/{ref}", h.RepoLanguages) + }) + + r.Get("/", h.RepoIndex) + r.Get("/info/refs", h.InfoRefs) + r.Post("/git-upload-pack", h.UploadPack) + r.Post("/git-receive-pack", h.ReceivePack) + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects + + r.Route("/tree/{ref}", func(r chi.Router) { + r.Get("/", h.RepoIndex) + r.Get("/*", h.RepoTree) + }) + + r.Route("/blob/{ref}", func(r chi.Router) { + r.Get("/*", h.Blob) + }) + + r.Route("/raw/{ref}", func(r chi.Router) { + r.Get("/*", h.BlobRaw) + }) + + r.Get("/log/{ref}", h.Log) + r.Get("/archive/{file}", h.Archive) + r.Get("/commit/{ref}", h.Diff) + r.Get("/tags", h.Tags) + r.Route("/branches", func(r chi.Router) { + r.Get("/", h.Branches) + r.Get("/{branch}", h.Branch) + r.Get("/default", h.DefaultBranch) + }) + }) + }) + + // xrpc apis + r.Mount("/xrpc", h.XrpcRouter()) + + // Socket that streams git oplogs + r.Get("/events", h.Events) + + // All public keys on the knot. + r.Get("/keys", h.Keys) + + return r, nil } -func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - l := h.l.With("path", path, "handler", "RepoIndex") - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) +func (h *Handle) XrpcRouter() http.Handler { + logger := tlog.New("knots") - gr, err := git.Open(path, ref) - if err != nil { - plain, err2 := git.PlainOpen(path) - if err2 != nil { - l.Error("opening repo", "error", err2.Error()) - notFound(w) + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) + + xrpc := &xrpc.Xrpc{ + Config: h.c, + Db: h.db, + Ingester: h.jc, + Enforcer: h.e, + Logger: logger, + Notifier: h.n, + Resolver: h.resolver, + ServiceAuth: serviceAuth, + } + return xrpc.Router() +} + +// version is set during build time. +var version string + +func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { + if version == "" { + info, ok := debug.ReadBuildInfo() + if !ok { + http.Error(w, "failed to read build info", http.StatusInternalServerError) return } - branches, _ := plain.Branches() - log.Println(err) - - if errors.Is(err, plumbing.ErrReferenceNotFound) { - resp := types.RepoIndexResponse{ - IsEmpty: true, - Branches: branches, + var modVer string + for _, mod := range info.Deps { + if mod.Path == "tangled.sh/tangled.sh/knotserver" { + version = mod.Version + break } - writeJSON(w, resp) - return - } else { - l.Error("opening repo", "error", err.Error()) - notFound(w) - return + } + + if modVer == "" { + version = "unknown" } } - var ( - commits []*object.Commit - total int - branches []types.Branch - files []types.NiceTree - tags []object.Tag - ) - - var wg sync.WaitGroup - errorsCh := make(chan error, 5) - - wg.Add(1) - go func() { - defer wg.Done() - cs, err := gr.Commits(0, 60) - if err != nil { - errorsCh <- fmt.Errorf("commits: %w", err) - return - } - commits = cs - }() - - wg.Add(1) - go func() { - defer wg.Done() - t, err := gr.TotalCommits() - if err != nil { - errorsCh <- fmt.Errorf("calculating total: %w", err) - return - } - total = t - }() - - wg.Add(1) - go func() { - defer wg.Done() - bs, err := gr.Branches() - if err != nil { - errorsCh <- fmt.Errorf("fetching branches: %w", err) - return - } - branches = bs - }() - - wg.Add(1) - go func() { - defer wg.Done() - ts, err := gr.Tags() - if err != nil { - errorsCh <- fmt.Errorf("fetching tags: %w", err) - return - } - tags = ts - }() - - wg.Add(1) - go func() { - defer wg.Done() - fs, err := gr.FileTree(r.Context(), "") - if err != nil { - errorsCh <- fmt.Errorf("fetching filetree: %w", err) - return - } - files = fs - }() - - wg.Wait() - close(errorsCh) - - // show any errors - for err := range errorsCh { - l.Error("loading repo", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - rtags := []*types.TagReference{} - for _, tag := range tags { - var target *object.Tag - if tag.Target != plumbing.ZeroHash { - target = &tag - } - tr := types.TagReference{ - Tag: target, - } - - tr.Reference = types.Reference{ - Name: tag.Name, - Hash: tag.Hash.String(), - } - - if tag.Message != "" { - tr.Message = tag.Message - } - - rtags = append(rtags, &tr) - } - - var readmeContent string - var readmeFile string - for _, readme := range h.c.Repo.Readme { - content, _ := gr.FileContent(readme) - if len(content) > 0 { - readmeContent = string(content) - readmeFile = readme - } - } - - if ref == "" { - mainBranch, err := gr.FindMainBranch() - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("finding main branch", "error", err.Error()) - return - } - ref = mainBranch - } - - resp := types.RepoIndexResponse{ - IsEmpty: false, - Ref: ref, - Commits: commits, - Description: getDescription(path), - Readme: readmeContent, - ReadmeFileName: readmeFile, - Files: files, - Branches: branches, - Tags: rtags, - TotalCommits: total, - } - - writeJSON(w, resp) - return + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "knotserver/%s", version) } -func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { - treePath := chi.URLParam(r, "*") - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) +func (h *Handle) configureOwner() error { + cfgOwner := h.c.Server.Owner - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) + rbacDomain := "thisserver" - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.Open(path, ref) + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) if err != nil { - notFound(w) - return + return err } - files, err := gr.FileTree(r.Context(), treePath) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("file tree", "error", err.Error()) - return - } + switch len(existing) { + case 0: + // no owner configured, continue + case 1: + // find existing owner + existingOwner := existing[0] - resp := types.RepoTreeResponse{ - Ref: ref, - Parent: treePath, - Description: getDescription(path), - DotDot: filepath.Dir(treePath), - Files: files, - } - - writeJSON(w, resp) - return -} - -func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { - treePath := chi.URLParam(r, "*") - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.Open(path, ref) - if err != nil { - notFound(w) - return - } - - contents, err := gr.RawContent(treePath) - if err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - l.Error("file content", "error", err.Error()) - return - } - - mimeType := http.DetectContentType(contents) - - // exception for svg - if filepath.Ext(treePath) == ".svg" { - mimeType = "image/svg+xml" - } - - contentHash := sha256.Sum256(contents) - eTag := fmt.Sprintf("\"%x\"", contentHash) - - // allow image, video, and text/plain files to be served directly - switch { - case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): - if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { - w.WriteHeader(http.StatusNotModified) - return + // no ownership change, this is okay + if existingOwner == h.c.Server.Owner { + break } - w.Header().Set("ETag", eTag) - case strings.HasPrefix(mimeType, "text/plain"): - w.Header().Set("Cache-Control", "public, no-cache") - + // remove existing owner + err = h.e.RemoveKnotOwner(rbacDomain, existingOwner) + if err != nil { + return nil + } default: - l.Error("attempted to serve disallowed file type", "mimetype", mimeType) - writeError(w, "only image, video, and text files can be accessed directly", http.StatusForbidden) - return + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) } - w.Header().Set("Content-Type", mimeType) - w.Write(contents) -} - -func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { - treePath := chi.URLParam(r, "*") - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.Open(path, ref) - if err != nil { - notFound(w) - return - } - - var isBinaryFile bool = false - contents, err := gr.FileContent(treePath) - if errors.Is(err, git.ErrBinaryFile) { - isBinaryFile = true - } else if errors.Is(err, object.ErrFileNotFound) { - notFound(w) - return - } else if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - bytes := []byte(contents) - // safe := string(sanitize(bytes)) - sizeHint := len(bytes) - - resp := types.RepoBlobResponse{ - Ref: ref, - Contents: string(bytes), - Path: treePath, - IsBinary: isBinaryFile, - SizeHint: uint64(sizeHint), - } - - h.showFile(resp, w, l) -} - -func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { - name := chi.URLParam(r, "name") - file := chi.URLParam(r, "file") - - l := h.l.With("handler", "Archive", "name", name, "file", file) - - // TODO: extend this to add more files compression (e.g.: xz) - if !strings.HasSuffix(file, ".tar.gz") { - notFound(w) - return - } - - ref := strings.TrimSuffix(file, ".tar.gz") - - unescapedRef, err := url.PathUnescape(ref) - if err != nil { - notFound(w) - return - } - - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-") - - // This allows the browser to use a proper name for the file when - // downloading - filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) - setContentDisposition(w, filename) - setGZipMIME(w) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.Open(path, unescapedRef) - if err != nil { - notFound(w) - return - } - - gw := gzip.NewWriter(w) - defer gw.Close() - - prefix := fmt.Sprintf("%s-%s", name, safeRefFilename) - err = gr.WriteTar(gw, prefix) - if err != nil { - // once we start writing to the body we can't report error anymore - // so we are only left with printing the error. - l.Error("writing tar file", "error", err.Error()) - return - } - - err = gw.Flush() - if err != nil { - // once we start writing to the body we can't report error anymore - // so we are only left with printing the error. - l.Error("flushing?", "error", err.Error()) - return - } -} - -func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - - l := h.l.With("handler", "Log", "ref", ref, "path", path) - - gr, err := git.Open(path, ref) - if err != nil { - notFound(w) - return - } - - // Get page parameters - page := 1 - pageSize := 30 - - if pageParam := r.URL.Query().Get("page"); pageParam != "" { - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { - page = p - } - } - - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { - pageSize = ps - } - } - - // convert to offset/limit - offset := (page - 1) * pageSize - limit := pageSize - - commits, err := gr.Commits(offset, limit) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("fetching commits", "error", err.Error()) - return - } - - total := len(commits) - - resp := types.RepoLogResponse{ - Commits: commits, - Ref: ref, - Description: getDescription(path), - Log: true, - Total: total, - Page: page, - PerPage: pageSize, - } - - writeJSON(w, resp) - return -} - -func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - l := h.l.With("handler", "Diff", "ref", ref) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.Open(path, ref) - if err != nil { - notFound(w) - return - } - - diff, err := gr.Diff() - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("getting diff", "error", err.Error()) - return - } - - resp := types.RepoCommitResponse{ - Ref: ref, - Diff: diff, - } - - writeJSON(w, resp) - return -} - -func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - l := h.l.With("handler", "Refs") - - gr, err := git.Open(path, "") - if err != nil { - notFound(w) - return - } - - tags, err := gr.Tags() - if err != nil { - // Non-fatal, we *should* have at least one branch to show. - l.Warn("getting tags", "error", err.Error()) - } - - rtags := []*types.TagReference{} - for _, tag := range tags { - var target *object.Tag - if tag.Target != plumbing.ZeroHash { - target = &tag - } - tr := types.TagReference{ - Tag: target, - } - - tr.Reference = types.Reference{ - Name: tag.Name, - Hash: tag.Hash.String(), - } - - if tag.Message != "" { - tr.Message = tag.Message - } - - rtags = append(rtags, &tr) - } - - resp := types.RepoTagsResponse{ - Tags: rtags, - } - - writeJSON(w, resp) - return -} - -func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - - gr, err := git.PlainOpen(path) - if err != nil { - notFound(w) - return - } - - branches, _ := gr.Branches() - - resp := types.RepoBranchesResponse{ - Branches: branches, - } - - writeJSON(w, resp) - return -} - -func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - branchName := chi.URLParam(r, "branch") - branchName, _ = url.PathUnescape(branchName) - - l := h.l.With("handler", "Branch") - - gr, err := git.PlainOpen(path) - if err != nil { - notFound(w) - return - } - - ref, err := gr.Branch(branchName) - if err != nil { - l.Error("getting branch", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - commit, err := gr.Commit(ref.Hash()) - if err != nil { - l.Error("getting commit object", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - defaultBranch, err := gr.FindMainBranch() - isDefault := false - if err != nil { - l.Error("getting default branch", "error", err.Error()) - // do not quit though - } else if defaultBranch == branchName { - isDefault = true - } - - resp := types.RepoBranchResponse{ - Branch: types.Branch{ - Reference: types.Reference{ - Name: ref.Name().Short(), - Hash: ref.Hash().String(), - }, - Commit: commit, - IsDefault: isDefault, - }, - } - - writeJSON(w, resp) - return -} - -func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "Keys") - - switch r.Method { - case http.MethodGet: - keys, err := h.db.GetAllPublicKeys() - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("getting public keys", "error", err.Error()) - return - } - - data := make([]map[string]any, 0) - for _, key := range keys { - j := key.JSON() - data = append(data, j) - } - writeJSON(w, data) - return - - case http.MethodPut: - pk := db.PublicKey{} - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) - if err != nil { - writeError(w, "invalid pubkey", http.StatusBadRequest) - } - - if err := h.db.AddPublicKey(pk); err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("adding public key", "error", err.Error()) - return - } - - w.WriteHeader(http.StatusNoContent) - return - } -} - -func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "RepoForkAheadBehind") - - data := struct { - Did string `json:"did"` - Source string `json:"source"` - Name string `json:"name,omitempty"` - HiddenRef string `json:"hiddenref"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - did := data.Did - source := data.Source - - if did == "" || source == "" { - l.Error("invalid request body, empty did or name") - w.WriteHeader(http.StatusBadRequest) - return - } - - var name string - if data.Name != "" { - name = data.Name - } else { - name = filepath.Base(source) - } - - branch := chi.URLParam(r, "branch") - branch, _ = url.PathUnescape(branch) - - relativeRepoPath := filepath.Join(did, name) - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) - - gr, err := git.PlainOpen(repoPath) - if err != nil { - log.Println(err) - notFound(w) - return - } - - forkCommit, err := gr.ResolveRevision(branch) - if err != nil { - l.Error("error resolving ref revision", "msg", err.Error()) - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) - return - } - - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) - if err != nil { - l.Error("error resolving hidden ref revision", "msg", err.Error()) - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) - return - } - - status := types.UpToDate - if forkCommit.Hash.String() != sourceCommit.Hash.String() { - isAncestor, err := forkCommit.IsAncestor(sourceCommit) - if err != nil { - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) - return - } - - if isAncestor { - status = types.FastForwardable - } else { - status = types.Conflict - } - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) -} - -func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - ref := chi.URLParam(r, "ref") - ref, _ = url.PathUnescape(ref) - - l := h.l.With("handler", "RepoLanguages") - - gr, err := git.Open(repoPath, ref) - if err != nil { - l.Error("opening repo", "error", err.Error()) - notFound(w) - return - } - - ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) - defer cancel() - - sizes, err := gr.AnalyzeLanguages(ctx) - if err != nil { - l.Error("failed to analyze languages", "error", err.Error()) - writeError(w, err.Error(), http.StatusNoContent) - return - } - - resp := types.RepoLanguageResponse{Languages: sizes} - - writeJSON(w, resp) -} - -func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "RepoForkSync") - - data := struct { - Did string `json:"did"` - Source string `json:"source"` - Name string `json:"name,omitempty"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - did := data.Did - source := data.Source - - if did == "" || source == "" { - l.Error("invalid request body, empty did or name") - w.WriteHeader(http.StatusBadRequest) - return - } - - var name string - if data.Name != "" { - name = data.Name - } else { - name = filepath.Base(source) - } - - branch := chi.URLParam(r, "*") - branch, _ = url.PathUnescape(branch) - - relativeRepoPath := filepath.Join(did, name) - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) - - gr, err := git.Open(repoPath, branch) - if err != nil { - log.Println(err) - notFound(w) - return - } - - err = gr.Sync() - if err != nil { - l.Error("error syncing repo fork", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "RepoFork") - - data := struct { - Did string `json:"did"` - Source string `json:"source"` - Name string `json:"name,omitempty"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - did := data.Did - source := data.Source - - if did == "" || source == "" { - l.Error("invalid request body, empty did or name") - w.WriteHeader(http.StatusBadRequest) - return - } - - var name string - if data.Name != "" { - name = data.Name - } else { - name = filepath.Base(source) - } - - relativeRepoPath := filepath.Join(did, name) - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) - - err := git.Fork(repoPath, source) - if err != nil { - l.Error("forking repo", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - // add perms for this user to access the repo - err = h.e.AddRepo(did, rbac.ThisServer, relativeRepoPath) - if err != nil { - l.Error("adding repo permissions", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - hook.SetupRepo( - hook.Config( - hook.WithScanPath(h.c.Repo.ScanPath), - hook.WithInternalApi(h.c.Server.InternalListenAddr), - ), - repoPath, - ) - - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "RemoveRepo") - - data := struct { - Did string `json:"did"` - Name string `json:"name"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - did := data.Did - name := data.Name - - if did == "" || name == "" { - l.Error("invalid request body, empty did or name") - w.WriteHeader(http.StatusBadRequest) - return - } - - relativeRepoPath := filepath.Join(did, name) - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) - err := os.RemoveAll(repoPath) - if err != nil { - l.Error("removing repo", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) - -} -func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - - data := types.MergeRequest{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) - return - } - - mo := &git.MergeOptions{ - AuthorName: data.AuthorName, - AuthorEmail: data.AuthorEmail, - CommitBody: data.CommitBody, - CommitMessage: data.CommitMessage, - } - - patch := data.Patch - branch := data.Branch - gr, err := git.Open(path, branch) - if err != nil { - notFound(w) - return - } - - mo.FormatPatch = patchutil.IsFormatPatch(patch) - - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { - var mergeErr *git.ErrMerge - if errors.As(err, &mergeErr) { - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) - for i, conflict := range mergeErr.Conflicts { - conflicts[i] = types.ConflictInfo{ - Filename: conflict.Filename, - Reason: conflict.Reason, - } - } - response := types.MergeCheckResponse{ - IsConflicted: true, - Conflicts: conflicts, - Message: mergeErr.Message, - } - writeConflict(w, response) - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) - } else { - writeError(w, err.Error(), http.StatusBadRequest) - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) - } - return - } - - w.WriteHeader(http.StatusOK) -} - -func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - - var data struct { - Patch string `json:"patch"` - Branch string `json:"branch"` - } - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) - return - } - - patch := data.Patch - branch := data.Branch - gr, err := git.Open(path, branch) - if err != nil { - notFound(w) - return - } - - err = gr.MergeCheck([]byte(patch), branch) - if err == nil { - response := types.MergeCheckResponse{ - IsConflicted: false, - } - writeJSON(w, response) - return - } - - var mergeErr *git.ErrMerge - if errors.As(err, &mergeErr) { - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) - for i, conflict := range mergeErr.Conflicts { - conflicts[i] = types.ConflictInfo{ - Filename: conflict.Filename, - Reason: conflict.Reason, - } - } - response := types.MergeCheckResponse{ - IsConflicted: true, - Conflicts: conflicts, - Message: mergeErr.Message, - } - writeConflict(w, response) - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) - return - } - writeError(w, err.Error(), http.StatusInternalServerError) - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) -} - -func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { - rev1 := chi.URLParam(r, "rev1") - rev1, _ = url.PathUnescape(rev1) - - rev2 := chi.URLParam(r, "rev2") - rev2, _ = url.PathUnescape(rev2) - - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.PlainOpen(path) - if err != nil { - notFound(w) - return - } - - commit1, err := gr.ResolveRevision(rev1) - if err != nil { - l.Error("error resolving revision 1", "msg", err.Error()) - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) - return - } - - commit2, err := gr.ResolveRevision(rev2) - if err != nil { - l.Error("error resolving revision 2", "msg", err.Error()) - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) - return - } - - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) - if err != nil { - l.Error("error comparing revisions", "msg", err.Error()) - writeError(w, "error comparing revisions", http.StatusBadRequest) - return - } - - writeJSON(w, types.RepoFormatPatchResponse{ - Rev1: commit1.Hash.String(), - Rev2: commit2.Hash.String(), - FormatPatch: formatPatch, - Patch: rawPatch, - }) - return -} - -func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "NewHiddenRef") - - forkRef := chi.URLParam(r, "forkRef") - forkRef, _ = url.PathUnescape(forkRef) - - remoteRef := chi.URLParam(r, "remoteRef") - remoteRef, _ = url.PathUnescape(remoteRef) - - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - gr, err := git.PlainOpen(path) - if err != nil { - notFound(w) - return - } - - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) - if err != nil { - l.Error("error tracking hidden remote ref", "msg", err.Error()) - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) - return - } - - w.WriteHeader(http.StatusNoContent) - return -} - -func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "AddMember") - - data := struct { - Did string `json:"did"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - did := data.Did - - if err := h.db.AddDid(did); err != nil { - l.Error("adding did", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - h.jc.AddDid(did) - - if err := h.e.AddKnotMember(rbac.ThisServer, did); err != nil { - l.Error("adding member", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { - l.Error("fetching and adding keys", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "AddRepoCollaborator") - - data := struct { - Did string `json:"did"` - }{} - - ownerDid := chi.URLParam(r, "did") - repo := chi.URLParam(r, "name") - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, "invalid request body", http.StatusBadRequest) - return - } - - if err := h.db.AddDid(data.Did); err != nil { - l.Error("adding did", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - h.jc.AddDid(data.Did) - - repoName, _ := securejoin.SecureJoin(ownerDid, repo) - if err := h.e.AddCollaborator(data.Did, rbac.ThisServer, repoName); err != nil { - l.Error("adding repo collaborator", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { - l.Error("fetching and adding keys", "error", err.Error()) - writeError(w, err.Error(), http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "DefaultBranch") - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - - gr, err := git.Open(path, "") - if err != nil { - notFound(w) - return - } - - branch, err := gr.FindMainBranch() - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("getting default branch", "error", err.Error()) - return - } - - writeJSON(w, types.RepoDefaultBranchResponse{ - Branch: branch, - }) -} - -func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { - l := h.l.With("handler", "SetDefaultBranch") - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) - - data := struct { - Branch string `json:"branch"` - }{} - - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - writeError(w, err.Error(), http.StatusBadRequest) - return - } - - gr, err := git.PlainOpen(path) - if err != nil { - notFound(w) - return - } - - err = gr.SetDefaultBranch(data.Branch) - if err != nil { - writeError(w, err.Error(), http.StatusInternalServerError) - l.Error("setting default branch", "error", err.Error()) - return - } - - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("ok")) -} - -func validateRepoName(name string) error { - // check for path traversal attempts - if name == "." || name == ".." || - strings.Contains(name, "/") || strings.Contains(name, "\\") { - return fmt.Errorf("Repository name contains invalid path characters") - } - - // check for sequences that could be used for traversal when normalized - if strings.Contains(name, "./") || strings.Contains(name, "../") || - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { - return fmt.Errorf("Repository name contains invalid path sequence") - } - - // then continue with character validation - for _, char := range name { - if !((char >= 'a' && char <= 'z') || - (char >= 'A' && char <= 'Z') || - (char >= '0' && char <= '9') || - char == '-' || char == '_' || char == '.') { - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") - } - } - - // additional check to prevent multiple sequential dots - if strings.Contains(name, "..") { - return fmt.Errorf("Repository name cannot contain sequential dots") - } - - // if all checks pass - return nil + return h.e.AddKnotOwner(rbacDomain, cfgOwner) } diff --git a/rbac/rbac.go b/rbac/rbac.go --- a/rbac/rbac.go +++ b/rbac/rbac.go @@ -277,6 +277,14 @@ return e.isInviteAllowed(user, intoSpindle(domain)) } +func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { + return e.E.Enforce(user, domain, domain, "repo:create") +} + +func (e *Enforcer) IsRepoDeleteAllowed(user, domain string) (bool, error) { + return e.E.Enforce(user, domain, domain, "repo:delete") +} + func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) { return e.E.Enforce(user, domain, repo, "repo:push") }