Monorepo for Tangled
tangled.org
1package knotserver
2
3import (
4 "compress/gzip"
5 "fmt"
6 "io"
7 "net/http"
8 "os"
9 "path/filepath"
10 "strings"
11
12 securejoin "github.com/cyphar/filepath-securejoin"
13 "github.com/go-chi/chi/v5"
14 "tangled.org/core/knotserver/git/service"
15)
16
17func (h *Knot) resolveRepoPath(r *http.Request) (string, string, error) {
18 did := chi.URLParam(r, "did")
19 name := chi.URLParam(r, "name")
20
21 if name == "" && strings.HasPrefix(did, "did:") {
22 repoPath, _, repoName, err := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, did)
23 if err != nil {
24 return "", "", fmt.Errorf("unknown repo DID: %w", err)
25 }
26 return repoPath, repoName, nil
27 }
28
29 alias, err := h.db.ResolveAlias(did, name)
30 if err == nil && alias != nil {
31 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, alias.RepoDid)
32 if resolveErr == nil {
33 return repoPath, name, nil
34 }
35 }
36
37 repoPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
38 if joinErr != nil {
39 return "", "", fmt.Errorf("repo not found: %w", joinErr)
40 }
41 if _, statErr := os.Stat(repoPath); statErr != nil {
42 return "", "", fmt.Errorf("repo not found: %w", statErr)
43 }
44 return repoPath, name, nil
45}
46
47func (h *Knot) repoNotFound(w http.ResponseWriter, r *http.Request) {
48 w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
49 w.WriteHeader(http.StatusNotFound)
50 fmt.Fprint(w, "repository not found\n")
51}
52
53func (h *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) {
54 repoPath, name, err := h.resolveRepoPath(r)
55 if err != nil {
56 h.repoNotFound(w, r)
57 h.l.Error("git: failed to resolve repo path", "handler", "InfoRefs", "error", err)
58 return
59 }
60
61 cmd := service.ServiceCommand{
62 GitProtocol: r.Header.Get("Git-Protocol"),
63 Dir: repoPath,
64 Stdout: w,
65 Sandbox: h.sandbox,
66 }
67
68 serviceName := r.URL.Query().Get("service")
69 switch serviceName {
70 case "git-upload-pack":
71 w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
72 w.Header().Set("Connection", "Keep-Alive")
73 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
74 w.WriteHeader(http.StatusOK)
75
76 if err := cmd.InfoRefs(); err != nil {
77 gitError(w, err.Error(), http.StatusInternalServerError)
78 h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
79 return
80 }
81 case "git-receive-pack":
82 h.RejectPush(w, r, name)
83 default:
84 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
85 }
86}
87
88func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) {
89 repo, _, err := h.resolveRepoPath(r)
90 if err != nil {
91 h.repoNotFound(w, r)
92 h.l.Error("git: failed to resolve repo path", "handler", "UploadArchive", "error", err)
93 return
94 }
95
96 const expectedContentType = "application/x-git-upload-archive-request"
97 contentType := r.Header.Get("Content-Type")
98 if contentType != expectedContentType {
99 gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
100 }
101
102 var bodyReader io.ReadCloser = r.Body
103 if r.Header.Get("Content-Encoding") == "gzip" {
104 gzipReader, err := gzip.NewReader(r.Body)
105 if err != nil {
106 gitError(w, err.Error(), http.StatusInternalServerError)
107 h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err)
108 return
109 }
110 defer gzipReader.Close()
111 bodyReader = gzipReader
112 }
113
114 w.Header().Set("Content-Type", "application/x-git-upload-archive-result")
115
116 h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo)
117
118 cmd := service.ServiceCommand{
119 GitProtocol: r.Header.Get("Git-Protocol"),
120 Dir: repo,
121 Stdout: w,
122 Stdin: bodyReader,
123 Sandbox: h.sandbox,
124 }
125
126 w.WriteHeader(http.StatusOK)
127
128 if err := cmd.UploadArchive(); err != nil {
129 h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
130 return
131 }
132}
133
134func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
135 repo, _, err := h.resolveRepoPath(r)
136 if err != nil {
137 h.repoNotFound(w, r)
138 h.l.Error("git: failed to resolve repo path", "handler", "UploadPack", "error", err)
139 return
140 }
141
142 const expectedContentType = "application/x-git-upload-pack-request"
143 contentType := r.Header.Get("Content-Type")
144 if contentType != expectedContentType {
145 gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
146 }
147
148 var bodyReader io.ReadCloser = r.Body
149 if r.Header.Get("Content-Encoding") == "gzip" {
150 gzipReader, err := gzip.NewReader(r.Body)
151 if err != nil {
152 gitError(w, err.Error(), http.StatusInternalServerError)
153 h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
154 return
155 }
156 defer gzipReader.Close()
157 bodyReader = gzipReader
158 }
159
160 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
161 w.Header().Set("Connection", "Keep-Alive")
162 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
163
164 h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
165
166 cmd := service.ServiceCommand{
167 GitProtocol: r.Header.Get("Git-Protocol"),
168 Dir: repo,
169 Stdout: w,
170 Stdin: bodyReader,
171 Sandbox: h.sandbox,
172 }
173
174 w.WriteHeader(http.StatusOK)
175
176 if err := cmd.UploadPack(); err != nil {
177 h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
178 return
179 }
180}
181
182func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
183 _, name, err := h.resolveRepoPath(r)
184 if err != nil {
185 h.repoNotFound(w, r)
186 h.l.Error("git: failed to resolve repo path", "handler", "ReceivePack", "error", err)
187 return
188 }
189
190 h.RejectPush(w, r, name)
191}
192
193func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
194 // A text/plain response will cause git to print each line of the body
195 // prefixed with "remote: ".
196 w.Header().Set("content-type", "text/plain; charset=UTF-8")
197 w.WriteHeader(http.StatusForbidden)
198
199 fmt.Fprintf(w, "Pushes are only supported over SSH.")
200
201 // If the appview gave us the repository owner's handle we can attempt to
202 // construct the correct ssh url.
203 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
204 ownerHandle = strings.TrimPrefix(ownerHandle, "@")
205 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
206 hostname := h.c.Server.Hostname
207 if strings.Contains(hostname, ":") {
208 hostname = strings.Split(hostname, ":")[0]
209 }
210
211 if hostname == "knot1.tangled.sh" {
212 hostname = "tangled.sh"
213 }
214
215 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
216 }
217 fmt.Fprintf(w, "\n\n")
218}
219
220func isDir(path string) (bool, error) {
221 info, err := os.Stat(path)
222 if err == nil && info.IsDir() {
223 return true, nil
224 }
225 if os.IsNotExist(err) {
226 return false, nil
227 }
228 return false, err
229}
230
231func gitError(w http.ResponseWriter, msg string, status int) {
232 w.Header().Set("content-type", "text/plain; charset=UTF-8")
233 w.WriteHeader(status)
234 fmt.Fprintf(w, "%s\n", msg)
235}