Files
forge/modules/httplib/serve.go
zeekayandHanzo Dev aa87442fdd Record upstream authorship centrally in NOTICE instead of in every file header
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the
Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in
NOTICE, alongside LICENSE. The header lint accepts any copyright line above the
SPDX line, so it no longer demands an upstream one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-10 17:50:35 -07:00

241 lines
7.7 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package httplib
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"path"
"strconv"
"strings"
"time"
charsetModule "github.com/hanzoai/git/modules/charset"
"github.com/hanzoai/git/modules/container"
"github.com/hanzoai/git/modules/httpcache"
"github.com/hanzoai/git/modules/setting"
"github.com/hanzoai/git/modules/typesniffer"
"github.com/hanzoai/git/modules/util"
"github.com/klauspost/compress/gzhttp"
)
type ServeHeaderOptions struct {
ContentType string // defaults to "application/octet-stream"
ContentLength *int64
Filename string
ContentDisposition ContentDispositionType
CacheIsPublic bool
CacheDuration time.Duration // defaults to 5 minutes
LastModified time.Time
}
const (
// Disable JS execution on the same origin, since we serve the file from the same origin as the forge server.
// This rule can be relaxed in the future as long as it is properly sandboxed.
// "style-src" is for SVG inline styles (from Display SVG files as images instead of text #14101)
serveHeaderCspDefault = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
// No sandbox attribute for PDF as it breaks rendering in at least Safari.
// This should generally be safe as scripts inside PDF can not escape the PDF document.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion.
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
serveHeaderCspPdf = "default-src 'none'; style-src 'unsafe-inline'"
// For audios and videos, actually it doesn't really need CSP (just like the forge <= 1.25)
serveHeaderCspAudioVideo = ""
)
func serveSetHeaderContentRelated(w http.ResponseWriter, contentType string) {
header := w.Header()
contentType = util.IfZero(contentType, typesniffer.MimeTypeApplicationOctetStream)
header.Set("Content-Type", contentType)
header.Set("X-Content-Type-Options", "nosniff")
csp := serveHeaderCspDefault
if strings.HasPrefix(contentType, "application/pdf") {
csp = serveHeaderCspPdf
}
if strings.HasPrefix(contentType, "video/") || strings.HasPrefix(contentType, "audio/") {
csp = serveHeaderCspAudioVideo
}
if csp != "" {
header.Set("Content-Security-Policy", csp)
} else {
header.Del("Content-Security-Policy")
}
}
// ServeSetHeaders sets necessary content serve headers
func ServeSetHeaders(w http.ResponseWriter, opts ServeHeaderOptions) {
header := w.Header()
skipCompressionExts := container.SetOf(".gz", ".bz2", ".zip", ".xz", ".zst", ".deb", ".apk", ".jar", ".png", ".jpg", ".webp")
if skipCompressionExts.Contains(strings.ToLower(path.Ext(opts.Filename))) {
w.Header().Add(gzhttp.HeaderNoCompression, "1")
}
serveSetHeaderContentRelated(w, opts.ContentType)
if opts.ContentLength != nil {
header.Set("Content-Length", strconv.FormatInt(*opts.ContentLength, 10))
}
if opts.Filename != "" {
contentDisposition := util.IfZero(opts.ContentDisposition, ContentDispositionAttachment)
header.Set("Content-Disposition", encodeContentDisposition(contentDisposition, path.Base(opts.Filename)))
header.Set("Access-Control-Expose-Headers", "Content-Disposition")
}
httpcache.SetCacheControlInHeader(header, &httpcache.CacheControlOptions{
IsPublic: opts.CacheIsPublic,
MaxAge: opts.CacheDuration,
})
if !opts.LastModified.IsZero() {
// http.TimeFormat required a UTC time, refer to https://pkg.go.dev/net/http#TimeFormat
header.Set("Last-Modified", opts.LastModified.UTC().Format(http.TimeFormat))
}
}
func serveSetHeadersByUserContent(w http.ResponseWriter, contentPrefetchBuf []byte, opts ServeHeaderOptions) {
var detectCharset bool
if setting.MimeTypeMap.Enabled {
fileExtension := strings.ToLower(path.Ext(opts.Filename))
opts.ContentType = setting.MimeTypeMap.Map[fileExtension]
detectCharset = strings.HasPrefix(opts.ContentType, "text/") && !strings.Contains(opts.ContentType, "charset=")
}
if opts.ContentType == "" {
sniffedType := typesniffer.DetectContentType(contentPrefetchBuf)
if sniffedType.IsBrowsableBinaryType() {
opts.ContentType = sniffedType.GetMimeType()
} else if sniffedType.IsText() {
// intentionally do not render user's HTML content as a page, for safety, and avoid content spamming & abusing
opts.ContentType = "text/plain"
detectCharset = true
} else {
opts.ContentType = typesniffer.MimeTypeApplicationOctetStream
}
}
if detectCharset {
if charset, _ := charsetModule.DetectEncoding(contentPrefetchBuf); charset != "" {
opts.ContentType += "; charset=" + strings.ToLower(charset)
}
}
if opts.ContentDisposition == "" {
sniffedType := typesniffer.FromContentType(opts.ContentType)
opts.ContentDisposition = ContentDispositionInline
if sniffedType.IsSvgImage() && !setting.UI.SVG.Enabled {
opts.ContentDisposition = ContentDispositionAttachment
}
}
ServeSetHeaders(w, opts)
}
const mimeDetectionBufferLen = 1024
func ServeUserContentByReader(r *http.Request, w http.ResponseWriter, size int64, reader io.Reader, opts ServeHeaderOptions) {
if opts.ContentLength != nil {
panic("do not set ContentLength, use size argument instead")
}
buf := make([]byte, mimeDetectionBufferLen)
n, err := util.ReadAtMost(reader, buf)
if err != nil {
http.Error(w, "serve content: unable to pre-read", http.StatusRequestedRangeNotSatisfiable)
return
}
if n >= 0 {
buf = buf[:n]
}
serveSetHeadersByUserContent(w, buf, opts)
// reset the reader to the beginning
reader = io.MultiReader(bytes.NewReader(buf), reader)
rangeHeader := r.Header.Get("Range")
// if no size or no supported range, serve as 200 (complete response)
if size <= 0 || !strings.HasPrefix(rangeHeader, "bytes=") {
if size >= 0 {
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
}
_, _ = io.Copy(w, reader) // just like http.ServeContent, not necessary to handle the error
return
}
// do our best to support the minimal "Range" request (no support for multiple range: "Range: bytes=0-50, 100-150")
//
// GET /...
// Range: bytes=0-1023
//
// HTTP/1.1 206 Partial Content
// Content-Range: bytes 0-1023/146515
// Content-Length: 1024
_, rangeParts, _ := strings.Cut(rangeHeader, "=")
rangeBytesStart, rangeBytesEnd, found := strings.Cut(rangeParts, "-")
start, err := strconv.ParseInt(rangeBytesStart, 10, 64)
if start < 0 || start >= size {
err = errors.New("invalid start range")
}
if err != nil {
http.Error(w, err.Error(), http.StatusRequestedRangeNotSatisfiable)
return
}
end, err := strconv.ParseInt(rangeBytesEnd, 10, 64)
if rangeBytesEnd == "" && found {
err = nil
end = size - 1
}
if end >= size {
end = size - 1
}
if end < start {
err = errors.New("invalid end range")
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
partialLength := end - start + 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
w.Header().Set("Content-Length", strconv.FormatInt(partialLength, 10))
if seeker, ok := reader.(io.Seeker); ok {
if _, err = seeker.Seek(start, io.SeekStart); err != nil {
http.Error(w, "serve content: unable to seek", http.StatusInternalServerError)
return
}
} else {
if _, err = io.CopyN(io.Discard, reader, start); err != nil {
http.Error(w, "serve content: unable to skip", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusPartialContent)
_, _ = io.CopyN(w, reader, partialLength) // just like http.ServeContent, not necessary to handle the error
}
func ServeUserContentByFile(r *http.Request, w http.ResponseWriter, file fs.File, opts ServeHeaderOptions) {
info, err := file.Stat()
if err != nil {
http.Error(w, "unable to serve file, stat error", http.StatusInternalServerError)
return
}
opts.LastModified = info.ModTime()
ServeUserContentByReader(r, w, info.Size(), file, opts)
}