A vibe coded tangled fork which supports pijul.
1package xrpc
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net/http"
8 "path/filepath"
9 "strings"
10
11 comatproto "github.com/bluesky-social/indigo/api/atproto"
12 "github.com/bluesky-social/indigo/atproto/syntax"
13 "github.com/bluesky-social/indigo/xrpc"
14 securejoin "github.com/cyphar/filepath-securejoin"
15 gogit "github.com/go-git/go-git/v5"
16 "tangled.org/core/api/tangled"
17 "tangled.org/core/hook"
18 "tangled.org/core/knotserver/git"
19 "tangled.org/core/knotserver/pijul"
20 "tangled.org/core/rbac"
21 xrpcerr "tangled.org/core/xrpc/errors"
22)
23
24func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) {
25 l := h.Logger.With("handler", "NewRepo")
26 fail := func(e xrpcerr.XrpcError) {
27 l.Error("failed", "kind", e.Tag, "error", e.Message)
28 writeError(w, e, http.StatusBadRequest)
29 }
30
31 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
32 if !ok {
33 fail(xrpcerr.MissingActorDidError)
34 return
35 }
36
37 isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer)
38 if err != nil {
39 fail(xrpcerr.GenericError(err))
40 return
41 }
42 if !isMember {
43 fail(xrpcerr.AccessControlError(actorDid.String()))
44 return
45 }
46
47 var data tangled.RepoCreate_Input
48 if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
49 fail(xrpcerr.GenericError(err))
50 return
51 }
52
53 vcs := "git"
54 if data.Vcs != nil && strings.TrimSpace(*data.Vcs) != "" {
55 vcs = strings.ToLower(strings.TrimSpace(*data.Vcs))
56 }
57 switch vcs {
58 case "git", "pijul":
59 default:
60 fail(xrpcerr.GenericError(fmt.Errorf("unsupported vcs: %s", vcs)))
61 return
62 }
63
64 rkey := data.Rkey
65
66 ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String())
67 if err != nil || ident.Handle.IsInvalidHandle() {
68 fail(xrpcerr.GenericError(err))
69 return
70 }
71
72 xrpcc := xrpc.Client{
73 Host: ident.PDSEndpoint(),
74 }
75
76 resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey)
77 if err != nil {
78 fail(xrpcerr.GenericError(err))
79 return
80 }
81
82 repo := resp.Value.Val.(*tangled.Repo)
83
84 defaultBranch := h.Config.Repo.MainBranch
85 if data.DefaultBranch != nil && *data.DefaultBranch != "" {
86 defaultBranch = *data.DefaultBranch
87 }
88
89 if err := validateRepoName(repo.Name); err != nil {
90 l.Error("creating repo", "error", err.Error())
91 fail(xrpcerr.GenericError(err))
92 return
93 }
94
95 relativeRepoPath := filepath.Join(actorDid.String(), repo.Name)
96 repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath)
97
98 if data.Source != nil && *data.Source != "" {
99 if vcs == "pijul" {
100 err = pijul.Clone(*data.Source, repoPath, "")
101 } else {
102 err = git.Fork(repoPath, *data.Source, h.Config)
103 }
104 if err != nil {
105 l.Error("forking repo", "error", err.Error())
106 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
107 return
108 }
109 } else {
110 if vcs == "pijul" {
111 err = pijul.InitBare(repoPath)
112 } else {
113 err = git.InitBare(repoPath, defaultBranch)
114 }
115 if err != nil {
116 l.Error("initializing bare repo", "error", err.Error())
117 if errors.Is(err, gogit.ErrRepositoryAlreadyExists) {
118 fail(xrpcerr.RepoExistsError("repository already exists"))
119 return
120 } else {
121 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
122 return
123 }
124 }
125 }
126
127 // add perms for this user to access the repo
128 err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath)
129 if err != nil {
130 l.Error("adding repo permissions", "error", err.Error())
131 writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError)
132 return
133 }
134 hook.SetupRepo(
135 hook.Config(
136 hook.WithScanPath(h.Config.Repo.ScanPath),
137 hook.WithInternalApi(h.Config.Server.InternalListenAddr),
138 ),
139 repoPath,
140 )
141
142 w.WriteHeader(http.StatusOK)
143}
144
145func validateRepoName(name string) error {
146 // check for path traversal attempts
147 if name == "." || name == ".." ||
148 strings.Contains(name, "/") || strings.Contains(name, "\\") {
149 return fmt.Errorf("Repository name contains invalid path characters")
150 }
151
152 // check for sequences that could be used for traversal when normalized
153 if strings.Contains(name, "./") || strings.Contains(name, "../") ||
154 strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") {
155 return fmt.Errorf("Repository name contains invalid path sequence")
156 }
157
158 // then continue with character validation
159 for _, char := range name {
160 if !((char >= 'a' && char <= 'z') ||
161 (char >= 'A' && char <= 'Z') ||
162 (char >= '0' && char <= '9') ||
163 char == '-' || char == '_' || char == '.') {
164 return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores")
165 }
166 }
167
168 // additional check to prevent multiple sequential dots
169 if strings.Contains(name, "..") {
170 return fmt.Errorf("Repository name cannot contain sequential dots")
171 }
172
173 // if all checks pass
174 return nil
175}