proxy.go

v1.0.1
Doc Versions Source
1
package server
2
3
import (
4
	"context"
5
	"errors"
6
	"fmt"
7
	"io"
8
	"log"
9
	"net/http"
10
	"strings"
11
12
	"golang.org/x/mod/module"
13
14
	"example.com/curator/internal/build"
15
	"example.com/curator/internal/git"
16
	"example.com/curator/internal/metrics"
17
	"example.com/curator/internal/storage"
18
)
19
20
func (s *Server) serveProxy(w http.ResponseWriter, r *http.Request, escapedModPath, file string) {
21
	// Unescape the module path (e.g., "!azure" → "Azure").
22
	modPath, err := module.UnescapePath(escapedModPath)
23
	if err != nil {
24
		log.Printf("proxy: invalid module path %q: %v", escapedModPath, err)
25
		http.NotFound(w, r)
26
		return
27
	}
28
29
	localName := modPath
30
	if strings.Contains(modPath, "/") {
31
		if after, ok := strings.CutPrefix(modPath, s.Cfg.Host+"/"); ok {
32
			localName = after
33
		}
34
	}
35
36
	if _, ok := s.Resolver.ResolveModule(localName); ok {
37
		if !s.CanAccessModule(localName, r) {
38
			http.NotFound(w, r)
39
			return
40
		}
41
42
		s.serveLocalModule(w, r, localName, file)
43
		return
44
	}
45
46
	s.serveFallback(w, r, escapedModPath, file)
47
}
48
49
func (s *Server) serveLocalModule(w http.ResponseWriter, r *http.Request, modName, file string) {
50
	mod, _ := s.Resolver.ResolveModule(modName)
51
	modulePath := s.Cfg.Host + "/" + modName
52
53
	// For version-specific artifacts, try S3 cache first.
54
	if s.Store != nil && file != "list" {
55
		key := StorageKey(modName, file)
56
		data, ct, err := s.Store.Get(r.Context(), key)
57
		if err == nil {
58
			metrics.ProxyCacheHitsTotal.Inc()
59
			w.Header().Set("Content-Type", ct)
60
			w.Write(data)
61
			return
62
		}
63
64
		metrics.ProxyCacheMissesTotal.Inc()
65
		if !errors.Is(err, storage.ErrNotFound) {
66
			log.Printf("s3 get %s: %v", key, err)
67
		}
68
	}
69
70
	repoPath, err := s.Git.CloneOrFetch(modName, mod.Repo)
71
	if err != nil {
72
		log.Printf("git error for %s: %v", modName, err)
73
		http.Error(w, "internal error", http.StatusInternalServerError)
74
		return
75
	}
76
77
	switch {
78
	case file == "list":
79
		s.serveList(w, repoPath, modulePath)
80
	case strings.HasSuffix(file, ".info"):
81
		escapedVer := strings.TrimSuffix(file, ".info")
82
		query, err := module.UnescapeVersion(escapedVer)
83
		if err != nil {
84
			log.Printf("proxy: invalid version %q: %v", escapedVer, err)
85
			http.NotFound(w, r)
86
			return
87
		}
88
		rv, err := git.ResolveVersion(repoPath, query)
89
		if err != nil {
90
			log.Printf("resolve %s: %v", query, err)
91
			http.NotFound(w, r)
92
			return
93
		}
94
		cacheFile := rv.Version + ".info"
95
		s.serveVersioned(w, r, modName, cacheFile, func() ([]byte, string, error) {
96
			return build.Info(repoPath, rv, modulePath)
97
		})
98
	case strings.HasSuffix(file, ".mod"):
99
		escapedVer := strings.TrimSuffix(file, ".mod")
100
		query, err := module.UnescapeVersion(escapedVer)
101
		if err != nil {
102
			log.Printf("proxy: invalid version %q: %v", escapedVer, err)
103
			http.NotFound(w, r)
104
			return
105
		}
106
		rv, err := git.ResolveVersion(repoPath, query)
107
		if err != nil {
108
			log.Printf("resolve %s: %v", query, err)
109
			http.NotFound(w, r)
110
			return
111
		}
112
		cacheFile := rv.Version + ".mod"
113
		s.serveVersioned(w, r, modName, cacheFile, func() ([]byte, string, error) {
114
			return build.Mod(repoPath, rv, modulePath)
115
		})
116
	case strings.HasSuffix(file, ".zip"):
117
		escapedVer := strings.TrimSuffix(file, ".zip")
118
		query, err := module.UnescapeVersion(escapedVer)
119
		if err != nil {
120
			log.Printf("proxy: invalid version %q: %v", escapedVer, err)
121
			http.NotFound(w, r)
122
			return
123
		}
124
		rv, err := git.ResolveVersion(repoPath, query)
125
		if err != nil {
126
			log.Printf("resolve %s: %v", query, err)
127
			http.NotFound(w, r)
128
			return
129
		}
130
		cacheFile := rv.Version + ".zip"
131
		s.serveVersioned(w, r, modName, cacheFile, func() ([]byte, string, error) {
132
			return build.Zip(repoPath, rv, modulePath)
133
		})
134
	default:
135
		http.NotFound(w, r)
136
	}
137
}
138
139
// serveLatest handles GET $base/$module/@latest requests.
140
func (s *Server) serveLatest(w http.ResponseWriter, r *http.Request, escapedModPath string) {
141
	modPath, err := module.UnescapePath(escapedModPath)
142
	if err != nil {
143
		log.Printf("proxy: invalid module path %q: %v", escapedModPath, err)
144
		http.NotFound(w, r)
145
		return
146
	}
147
148
	localName := modPath
149
	if strings.Contains(modPath, "/") {
150
		if after, ok := strings.CutPrefix(modPath, s.Cfg.Host+"/"); ok {
151
			localName = after
152
		}
153
	}
154
155
	mod, ok := s.Resolver.ResolveModule(localName)
156
	if !ok {
157
		// Try fallback for @latest.
158
		s.serveFallbackLatest(w, r, escapedModPath)
159
		return
160
	}
161
162
	if !s.CanAccessModule(localName, r) {
163
		http.NotFound(w, r)
164
		return
165
	}
166
167
	modulePath := s.Cfg.Host + "/" + localName
168
169
	repoPath, err := s.Git.CloneOrFetch(localName, mod.Repo)
170
	if err != nil {
171
		log.Printf("git error for %s: %v", localName, err)
172
		http.Error(w, "internal error", http.StatusInternalServerError)
173
		return
174
	}
175
176
	allTags, err := git.ListTags(repoPath)
177
	if err != nil || len(allTags) == 0 {
178
		http.NotFound(w, r)
179
		return
180
	}
181
182
	tags := git.FilterTagsByMajor(allTags, modulePath)
183
	if len(tags) == 0 {
184
		http.NotFound(w, r)
185
		return
186
	}
187
188
	latest := git.LatestRelease(tags)
189
	rv, err := git.ResolveVersion(repoPath, latest)
190
	if err != nil {
191
		log.Printf("resolve latest %s: %v", latest, err)
192
		http.NotFound(w, r)
193
		return
194
	}
195
196
	data, ct, err := build.Info(repoPath, rv, modulePath)
197
	if err != nil {
198
		log.Printf("build info for %s@latest: %v", localName, err)
199
		http.Error(w, "internal error", http.StatusInternalServerError)
200
		return
201
	}
202
203
	w.Header().Set("Content-Type", ct)
204
	w.Write(data)
205
}
206
207
func (s *Server) serveFallback(w http.ResponseWriter, r *http.Request, modName, file string) {
208
	mode := s.Cfg.FallbackMode()
209
	switch mode {
210
	case "redirect":
211
		http.Redirect(w, r, s.upstreamURL(modName, file), http.StatusFound)
212
	case "sync":
213
		s.fetchAndCache(w, r, modName, file, true)
214
	case "async":
215
		s.fetchAndCache(w, r, modName, file, false)
216
	default:
217
		http.NotFound(w, r)
218
	}
219
}
220
221
func (s *Server) serveFallbackLatest(w http.ResponseWriter, r *http.Request, modName string) {
222
	mode := s.Cfg.FallbackMode()
223
	upURL := strings.TrimRight(s.Cfg.Fallback.Upstream, "/") + "/" + modName + "/@latest"
224
225
	switch mode {
226
	case "redirect":
227
		http.Redirect(w, r, upURL, http.StatusFound)
228
	case "sync", "async":
229
		req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upURL, nil)
230
		if err != nil {
231
			http.Error(w, "internal error", http.StatusInternalServerError)
232
			return
233
		}
234
		resp, err := s.HTTPClient.Do(req)
235
		if err != nil {
236
			http.Error(w, "upstream error", http.StatusBadGateway)
237
			return
238
		}
239
		defer resp.Body.Close()
240
		w.WriteHeader(resp.StatusCode)
241
		io.Copy(w, resp.Body)
242
	default:
243
		http.NotFound(w, r)
244
	}
245
}
246
247
func (s *Server) upstreamURL(modName, file string) string {
248
	return strings.TrimRight(s.Cfg.Fallback.Upstream, "/") + "/" + modName + "/@v/" + file
249
}
250
251
// fetchAndCache fetches from upstream and optionally caches.
252
// When syncCache is true (sync mode), the cache write completes before responding.
253
// When false (async mode), the cache write runs in a background goroutine.
254
func (s *Server) fetchAndCache(w http.ResponseWriter, r *http.Request, modName, file string, syncCache bool) {
255
	// Try cache first for both sync and async modes.
256
	if s.Store != nil {
257
		key := StorageKey(modName, file)
258
		data, ct, err := s.Store.Get(r.Context(), key)
259
		if err == nil {
260
			metrics.ProxyCacheHitsTotal.Inc()
261
			w.Header().Set("Content-Type", ct)
262
			w.Write(data)
263
			return
264
		}
265
266
		if !errors.Is(err, storage.ErrNotFound) {
267
			log.Printf("storage get %s: %v", key, err)
268
		}
269
	}
270
271
	upURL := s.upstreamURL(modName, file)
272
273
	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upURL, nil)
274
	if err != nil {
275
		log.Printf("upstream request: %v", err)
276
		http.Error(w, "internal error", http.StatusInternalServerError)
277
		return
278
	}
279
280
	resp, err := s.HTTPClient.Do(req)
281
	if err != nil {
282
		log.Printf("upstream fetch %s: %v", upURL, err)
283
		http.Error(w, "upstream error", http.StatusBadGateway)
284
		return
285
	}
286
	defer resp.Body.Close()
287
288
	if resp.StatusCode != http.StatusOK {
289
		w.WriteHeader(resp.StatusCode)
290
		io.Copy(w, resp.Body)
291
		return
292
	}
293
294
	body, err := io.ReadAll(resp.Body)
295
	if err != nil {
296
		log.Printf("upstream read %s: %v", upURL, err)
297
		http.Error(w, "upstream error", http.StatusBadGateway)
298
		return
299
	}
300
301
	ct := resp.Header.Get("Content-Type")
302
303
	if s.Store != nil {
304
		key := StorageKey(modName, file)
305
		if syncCache {
306
			if err := s.Store.Put(context.Background(), key, body, ct); err != nil {
307
				log.Printf("storage put %s: %v", key, err)
308
			}
309
		} else {
310
			go func() {
311
				if err := s.Store.Put(context.Background(), key, body, ct); err != nil {
312
					log.Printf("storage put %s: %v", key, err)
313
				}
314
			}()
315
		}
316
	}
317
318
	if ct != "" {
319
		w.Header().Set("Content-Type", ct)
320
	}
321
322
	w.Write(body)
323
}
324
325
func (s *Server) serveVersioned(w http.ResponseWriter, r *http.Request, modName, file string, buildFn func() ([]byte, string, error)) {
326
	data, ct, err := buildFn()
327
	if err != nil {
328
		log.Printf("build %s/%s: %v", modName, file, err)
329
		http.NotFound(w, nil)
330
		return
331
	}
332
333
	if s.Store != nil {
334
		key := StorageKey(modName, file)
335
		go func() {
336
			if err := s.Store.Put(context.Background(), key, data, ct); err != nil {
337
				log.Printf("s3 put %s: %v", key, err)
338
			}
339
		}()
340
	}
341
342
	w.Header().Set("Content-Type", ct)
343
	w.Write(data)
344
}
345
346
func (s *Server) serveList(w http.ResponseWriter, repoPath, modulePath string) {
347
	tags, err := git.ListTags(repoPath)
348
	if err != nil {
349
		log.Printf("list tags: %v", err)
350
		http.Error(w, "internal error", http.StatusInternalServerError)
351
		return
352
	}
353
354
	tags = git.FilterTagsByMajor(tags, modulePath)
355
356
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
357
	for _, tag := range tags {
358
		_, t, err := git.CommitInfo(repoPath, tag)
359
		if err != nil {
360
			// Fall back to version only (no timestamp).
361
			fmt.Fprintln(w, tag)
362
			continue
363
		}
364
		fmt.Fprintf(w, "%s %s\n", tag, t.UTC().Format("2006-01-02T15:04:05Z"))
365
	}
366
}
367

Source Files