frieren
a self-hosted git server in one binary — everyone reads, only the owner writes
fix: serve real content types for media on the raw endpoint
5a6be8ea963f9cf191e51dc163dd91f742637e55
justin06lee · Aug 18, 2026, 11:24 PM (4h ago)
e2e_test.go | 11 +++++++++++ web.go | 24 +++++++++++++++++++++--- 2 files changed, 32 insertions(+), 3 deletions(-)
| @@ -149,6 +149,7 @@ func TestWebPages(t *testing.T) { | |||
| 149 | 149 | os.MkdirAll(filepath.Join(dir, "docs"), 0o755) | |
| 150 | 150 | os.WriteFile(filepath.Join(dir, "README.md"), []byte("# site\nreadme body here\n"), 0o644) | |
| 151 | 151 | os.WriteFile(filepath.Join(dir, "docs", "guide.txt"), []byte("guide line one\n"), 0o644) | |
| 152 | + | os.WriteFile(filepath.Join(dir, "logo.svg"), []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"/>\n"), 0o644) | |
| 152 | 153 | mustGit(t, dir, "add", ".") | |
| 153 | 154 | mustGit(t, dir, "commit", "-m", "add docs") | |
| 154 | 155 | mustGit(t, dir, "push", withToken(t, ts.URL+"/site.git"), "master") | |
| @@ -182,6 +183,16 @@ func TestWebPages(t *testing.T) { | |||
| 182 | 183 | t.Errorf("commit page: code %d, diff shown: %v", code, strings.Contains(body, "guide line one")) | |
| 183 | 184 | } | |
| 184 | 185 | ||
| 186 | + | // Media files get their real content type so README embeds render. | |
| 187 | + | resp, err := http.Get(ts.URL + "/site/raw/master/logo.svg") | |
| 188 | + | if err != nil { | |
| 189 | + | t.Fatal(err) | |
| 190 | + | } | |
| 191 | + | resp.Body.Close() | |
| 192 | + | if ct := resp.Header.Get("Content-Type"); ct != "image/svg+xml" { | |
| 193 | + | t.Errorf("svg raw content-type = %q", ct) | |
| 194 | + | } | |
| 195 | + | ||
| 185 | 196 | // Traversal and junk stay 404. | |
| 186 | 197 | for _, path := range []string{"/nope", "/site/blob/master/../../etc/passwd", "/site/raw/master/%2e%2e/x"} { | |
| 187 | 198 | if code, _ := get(t, ts.URL+path); code != http.StatusNotFound { |
| @@ -244,6 +244,20 @@ func (srv *Server) blobPage(w http.ResponseWriter, r *http.Request) { | |||
| 244 | 244 | srv.render(w, "blob", data) | |
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | + | var rawTypes = map[string]string{ | |
| 248 | + | ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", | |
| 249 | + | ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", | |
| 250 | + | ".ico": "image/x-icon", ".avif": "image/avif", ".pdf": "application/pdf", | |
| 251 | + | ".mp4": "video/mp4", ".webm": "video/webm", ".mp3": "audio/mpeg", | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func extOf(path string) string { | |
| 255 | + | if i := strings.LastIndexByte(path, '.'); i >= 0 && !strings.ContainsRune(path[i:], '/') { | |
| 256 | + | return strings.ToLower(path[i:]) | |
| 257 | + | } | |
| 258 | + | return "" | |
| 259 | + | } | |
| 260 | + | ||
| 247 | 261 | func (srv *Server) rawFile(w http.ResponseWriter, r *http.Request) { | |
| 248 | 262 | repo := srv.openRepo(w, r) | |
| 249 | 263 | if repo == nil { | |
| @@ -259,11 +273,15 @@ func (srv *Server) rawFile(w http.ResponseWriter, r *http.Request) { | |||
| 259 | 273 | http.NotFound(w, r) | |
| 260 | 274 | return | |
| 261 | 275 | } | |
| 262 | − | // Never let the browser interpret repository content as HTML. | |
| 276 | + | // Never let the browser interpret repository content as HTML. Known | |
| 277 | + | // media types get their real content type so <img>/<video> embeds work. | |
| 263 | 278 | w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 264 | − | if isBinary(blob) { | |
| 279 | + | switch { | |
| 280 | + | case rawTypes[extOf(path)] != "": | |
| 281 | + | w.Header().Set("Content-Type", rawTypes[extOf(path)]) | |
| 282 | + | case isBinary(blob): | |
| 265 | 283 | w.Header().Set("Content-Type", "application/octet-stream") | |
| 266 | − | } else { | |
| 284 | + | default: | |
| 267 | 285 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 268 | 286 | } | |
| 269 | 287 | w.Write(blob) | |
| 270 | 288 |