Monorepo for Tangled
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 repoDid, err := h.db.GetRepoDid(did, name)
30 if err == nil {
31 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, 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) InfoRefs(w http.ResponseWriter, r *http.Request) {
48 repoPath, name, err := h.resolveRepoPath(r)
49 if err != nil {
50 gitError(w, "repository not found", http.StatusNotFound)
51 h.l.Error("git: failed to resolve repo path", "handler", "InfoRefs", "error", err)
52 return
53 }
54
55 cmd := service.ServiceCommand{
56 GitProtocol: r.Header.Get("Git-Protocol"),
57 Dir: repoPath,
58 Stdout: w,
59 }
60
61 serviceName := r.URL.Query().Get("service")
62 switch serviceName {
63 case "git-upload-pack":
64 w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
65 w.Header().Set("Connection", "Keep-Alive")
66 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
67 w.WriteHeader(http.StatusOK)
68
69 if err := cmd.InfoRefs(); err != nil {
70 gitError(w, err.Error(), http.StatusInternalServerError)
71 h.l.Error("git: process failed", "handler", "InfoRefs", "service", serviceName, "error", err)
72 return
73 }
74 case "git-receive-pack":
75 h.RejectPush(w, r, name)
76 default:
77 gitError(w, fmt.Sprintf("service unsupported: '%s'", serviceName), http.StatusForbidden)
78 }
79}
80
81func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) {
82 repo, _, err := h.resolveRepoPath(r)
83 if err != nil {
84 gitError(w, "repository not found", http.StatusNotFound)
85 h.l.Error("git: failed to resolve repo path", "handler", "UploadArchive", "error", err)
86 return
87 }
88
89 const expectedContentType = "application/x-git-upload-archive-request"
90 contentType := r.Header.Get("Content-Type")
91 if contentType != expectedContentType {
92 gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
93 }
94
95 var bodyReader io.ReadCloser = r.Body
96 if r.Header.Get("Content-Encoding") == "gzip" {
97 gzipReader, err := gzip.NewReader(r.Body)
98 if err != nil {
99 gitError(w, err.Error(), http.StatusInternalServerError)
100 h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err)
101 return
102 }
103 defer gzipReader.Close()
104 bodyReader = gzipReader
105 }
106
107 w.Header().Set("Content-Type", "application/x-git-upload-archive-result")
108
109 h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo)
110
111 cmd := service.ServiceCommand{
112 GitProtocol: r.Header.Get("Git-Protocol"),
113 Dir: repo,
114 Stdout: w,
115 Stdin: bodyReader,
116 }
117
118 w.WriteHeader(http.StatusOK)
119
120 if err := cmd.UploadArchive(); err != nil {
121 h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
122 return
123 }
124}
125
126func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
127 repo, _, err := h.resolveRepoPath(r)
128 if err != nil {
129 gitError(w, "repository not found", http.StatusNotFound)
130 h.l.Error("git: failed to resolve repo path", "handler", "UploadPack", "error", err)
131 return
132 }
133
134 const expectedContentType = "application/x-git-upload-pack-request"
135 contentType := r.Header.Get("Content-Type")
136 if contentType != expectedContentType {
137 gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
138 }
139
140 var bodyReader io.ReadCloser = r.Body
141 if r.Header.Get("Content-Encoding") == "gzip" {
142 gzipReader, err := gzip.NewReader(r.Body)
143 if err != nil {
144 gitError(w, err.Error(), http.StatusInternalServerError)
145 h.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
146 return
147 }
148 defer gzipReader.Close()
149 bodyReader = gzipReader
150 }
151
152 w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
153 w.Header().Set("Connection", "Keep-Alive")
154 w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
155
156 h.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo)
157
158 cmd := service.ServiceCommand{
159 GitProtocol: r.Header.Get("Git-Protocol"),
160 Dir: repo,
161 Stdout: w,
162 Stdin: bodyReader,
163 }
164
165 w.WriteHeader(http.StatusOK)
166
167 if err := cmd.UploadPack(); err != nil {
168 h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
169 return
170 }
171}
172
173func (h *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) {
174 _, name, err := h.resolveRepoPath(r)
175 if err != nil {
176 gitError(w, "repository not found", http.StatusNotFound)
177 h.l.Error("git: failed to resolve repo path", "handler", "ReceivePack", "error", err)
178 return
179 }
180
181 h.RejectPush(w, r, name)
182}
183
184func (h *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) {
185 // A text/plain response will cause git to print each line of the body
186 // prefixed with "remote: ".
187 w.Header().Set("content-type", "text/plain; charset=UTF-8")
188 w.WriteHeader(http.StatusForbidden)
189
190 fmt.Fprintf(w, "Pushes are only supported over SSH.")
191
192 // If the appview gave us the repository owner's handle we can attempt to
193 // construct the correct ssh url.
194 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle")
195 ownerHandle = strings.TrimPrefix(ownerHandle, "@")
196 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") {
197 hostname := h.c.Server.Hostname
198 if strings.Contains(hostname, ":") {
199 hostname = strings.Split(hostname, ":")[0]
200 }
201
202 if hostname == "knot1.tangled.sh" {
203 hostname = "tangled.sh"
204 }
205
206 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
207 }
208 fmt.Fprintf(w, "\n\n")
209}
210
211func gitError(w http.ResponseWriter, msg string, status int) {
212 w.Header().Set("content-type", "text/plain; charset=UTF-8")
213 w.WriteHeader(status)
214 fmt.Fprintf(w, "%s\n", msg)
215}