Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/lfs.go

Line attribution on main · 173 line(s)

64ac5071
Keenan Nemetz
17d ago
1 package server
2
3 // LFS over the read path. Like everything else on HTTP, downloads are anonymous
4 // and gated only by the repository's default role: a private repository answers
5 // 404, not 403. Uploads are refused here on purpose — the write transport is
6 // SSH, where the key at the handshake is the identity that owns the bytes.
7
8 import (
9 "encoding/json"
10 "errors"
11 "io"
12 "net/http"
13 "strconv"
14 "strings"
15
d37d301f
Keenan Nemetz
15h ago
16 "rodent.software/vole/core/lfs"
17 "rodent.software/vole/forge/access"
18 "rodent.software/vole/forge/repo"
64ac5071
Keenan Nemetz
17d ago
19 )
20
21 const (
22 batchBodyLimit = 4 << 20 // a batch of 4 MiB of oids is already absurd
23 objectShard = "/info/lfs/"
24 )
25
26 // lfsRepo resolves the repository named in an LFS URL and applies the read
27 // rule. It writes the response itself when the repository is not readable.
28 func (s *Server) lfsRepo(w http.ResponseWriter, r *http.Request) (*repo.Meta, bool) {
29 name := strings.TrimSuffix(r.PathValue("name"), ".git")
30 m, err := s.Repos.Open(name)
31 if err != nil {
32 http.NotFound(w, r)
33 return nil, false
34 }
35 a, err := m.ACL()
36 if err != nil {
37 s.Log.Error("read acl", "repo", name, "err", err)
38 http.Error(w, "repository unavailable", http.StatusInternalServerError)
39 return nil, false
40 }
c5902451
Keenan Nemetz
16d ago
41 if !a.Can("", access.Read) {
64ac5071
Keenan Nemetz
17d ago
42 http.NotFound(w, r)
43 return nil, false
44 }
45 return m, true
46 }
47
ed03b3d1
Keenan Nemetz
2h ago
48 // LFSBatch implements POST /<name>.git/info/lfs/objects/batch.
64ac5071
Keenan Nemetz
17d ago
49 func (s *Server) LFSBatch(w http.ResponseWriter, r *http.Request) {
50 m, ok := s.lfsRepo(w, r)
51 if !ok {
52 return
53 }
54 if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, lfs.MediaType) {
55 lfs.WriteError(w, http.StatusUnsupportedMediaType,
56 "vole: expected Content-Type %s, got %q", lfs.MediaType, ct)
57 return
58 }
59 var req lfs.BatchRequest
60 dec := json.NewDecoder(io.LimitReader(r.Body, batchBodyLimit))
61 if err := dec.Decode(&req); err != nil {
62 lfs.WriteError(w, http.StatusBadRequest, "vole: malformed batch request: %v", err)
63 return
64 }
65 switch req.Operation {
66 case "download":
67 case "upload":
68 lfs.WriteError(w, http.StatusForbidden,
69 "vole: LFS uploads are not accepted over HTTP. Push over SSH — `git push` on an ssh remote sends the objects through the same key that pushes the pointer files.")
70 return
71 default:
72 lfs.WriteError(w, http.StatusBadRequest, "vole: unsupported operation %q", req.Operation)
73 return
74 }
75 if len(req.Objects) == 0 {
76 lfs.WriteError(w, http.StatusBadRequest, "vole: batch request lists no objects")
77 return
78 }
79 if req.HashAlgo != "" && req.HashAlgo != "sha256" {
80 lfs.WriteError(w, http.StatusBadRequest, "vole: only sha256 object ids are supported, not %q", req.HashAlgo)
81 return
82 }
83
84 base := s.lfsObjectBase(r, m.Name)
85 out := lfs.BatchResponse{Transfer: "basic", HashAlgo: "sha256"}
86 for _, o := range req.Objects {
87 item := lfs.ObjectResponse{OID: o.OID, Size: o.Size}
88 switch {
89 case !lfs.ValidOID(o.OID):
90 item.Error = &lfs.ObjectError{Code: 422, Message: "malformed object id"}
91 default:
65b88b3b
Keenan Nemetz
16d ago
92 if size, err := s.LFS.Stat(m.Name, o.OID); err == nil {
64ac5071
Keenan Nemetz
17d ago
93 item.Size = size
94 item.Actions = map[string]lfs.Action{
95 "download": {Href: base + "/" + o.OID},
96 }
97 } else if errors.Is(err, lfs.ErrNotFound) {
98 item.Error = &lfs.ObjectError{Code: 404, Message: "object not found on this server"}
99 } else {
100 s.Log.Error("lfs stat", "repo", m.Name, "oid", o.OID, "err", err)
101 item.Error = &lfs.ObjectError{Code: 500, Message: "store unavailable"}
102 }
103 }
104 out.Objects = append(out.Objects, item)
105 }
106
107 w.Header().Set("Content-Type", lfs.MediaType)
108 if err := json.NewEncoder(w).Encode(out); err != nil {
109 s.Log.Error("lfs batch response", "repo", m.Name, "err", err)
110 }
111 }
112
ed03b3d1
Keenan Nemetz
2h ago
113 // LFSObject implements GET /<name>.git/info/lfs/objects/<oid>. The object id
64ac5071
Keenan Nemetz
17d ago
114 // is its own ETag, so a client that already has the bytes never asks twice.
115 func (s *Server) LFSObject(w http.ResponseWriter, r *http.Request) {
116 m, ok := s.lfsRepo(w, r)
117 if !ok {
118 return
119 }
120 oid := r.PathValue("oid")
121 if !lfs.ValidOID(oid) {
122 http.Error(w, "vole: malformed object id", http.StatusBadRequest)
123 return
124 }
65b88b3b
Keenan Nemetz
16d ago
125 f, size, err := s.LFS.Open(m.Name, oid)
64ac5071
Keenan Nemetz
17d ago
126 if err != nil {
127 if errors.Is(err, lfs.ErrNotFound) {
128 http.Error(w, "vole: object not found", http.StatusNotFound)
129 return
130 }
131 s.Log.Error("lfs open", "repo", m.Name, "oid", oid, "err", err)
132 http.Error(w, "vole: store unavailable", http.StatusInternalServerError)
133 return
134 }
135 defer f.Close()
136
137 w.Header().Set("Content-Type", "application/octet-stream")
138 w.Header().Set("ETag", `"`+oid+`"`)
139 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
140 w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
141 if r.Method == http.MethodHead {
142 return
143 }
144 if _, err := io.Copy(w, f); err != nil {
145 // The client hung up; nothing to report to it.
146 s.Log.Debug("lfs download interrupted", "repo", m.Name, "oid", oid, "err", err)
147 }
148 }
149
701232cf
Keenan Nemetz
12d ago
150 // lfsObjectBase is where download hrefs point. The configured public_url wins when
151 // there is one: these hrefs are followed by git-lfs, so a Host header an attacker
152 // chose would send the client to a host the attacker chose. Without a public_url
153 // the origin the client used is the only thing there is, and the forwarding header
154 // is believed only when the instance declares a trusted proxy.
64ac5071
Keenan Nemetz
17d ago
155 func (s *Server) lfsObjectBase(r *http.Request, name string) string {
701232cf
Keenan Nemetz
12d ago
156 base := ""
157 if s.Cfg != nil {
158 base = s.Cfg.BaseURL()
159 }
160 if base == "" {
161 scheme := "http"
162 if r.TLS != nil {
163 scheme = "https"
164 }
165 if s.Cfg != nil && strings.TrimSpace(s.Cfg.GitHTTPClientIPHeader) != "" {
166 if p := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))); p == "https" || p == "http" {
167 scheme = p
168 }
169 }
170 base = scheme + "://" + r.Host
64ac5071
Keenan Nemetz
17d ago
171 }
ed03b3d1
Keenan Nemetz
2h ago
172 return base + "/" + name + ".git" + objectShard + "objects"
64ac5071
Keenan Nemetz
17d ago
173 }