90f1cf8bdf
Format / gofmt (push) Successful in 5s
CI / Build (push) Successful in 12s
CI / Go Tests (push) Successful in 22s
Release Artifacts / Validate release tag (push) Successful in 1s
Release Artifacts / Build and release executables (push) Successful in 33s
Release Artifacts / Build and release Docker image (push) Successful in 3m10s
Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
15 KiB
Go
439 lines
15 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"scratchbox/internal/config"
|
|
"scratchbox/internal/storage"
|
|
webassets "scratchbox/web"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg config.Config
|
|
store *storage.FilesystemStore
|
|
logger *log.Logger
|
|
accessLogger *log.Logger
|
|
rawCache *rawContentCache
|
|
}
|
|
|
|
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger, accessLogger *log.Logger) (*Server, error) {
|
|
return &Server{
|
|
cfg: cfg,
|
|
store: store,
|
|
logger: logger,
|
|
accessLogger: accessLogger,
|
|
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
uiMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
|
|
apiReadMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
|
|
apiWriteMiddlewares := make([]func(http.Handler) http.Handler, 0, 1)
|
|
if s.cfg.Security.RateLimitUI.Enabled {
|
|
uiLimiter := NewRateLimiter(s.cfg.Security.RateLimitUI.RequestsPerMinute, s.cfg.Security.RateLimitUI.Burst)
|
|
uiMiddlewares = append(uiMiddlewares, RateLimitMiddleware(uiLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
|
|
}
|
|
if s.cfg.Security.RateLimitAPIRead.Enabled {
|
|
apiReadLimiter := NewRateLimiter(s.cfg.Security.RateLimitAPIRead.RequestsPerMinute, s.cfg.Security.RateLimitAPIRead.Burst)
|
|
apiReadMiddlewares = append(apiReadMiddlewares, RateLimitMiddleware(apiReadLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
|
|
}
|
|
if s.cfg.Security.RateLimitAPIWrite.Enabled {
|
|
apiWriteLimiter := NewRateLimiter(s.cfg.Security.RateLimitAPIWrite.RequestsPerMinute, s.cfg.Security.RateLimitAPIWrite.Burst)
|
|
apiWriteMiddlewares = append(apiWriteMiddlewares, RateLimitMiddleware(apiWriteLimiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes))
|
|
}
|
|
|
|
mux.Handle("GET /{$}", Chain(http.HandlerFunc(s.serveSPA), uiMiddlewares...))
|
|
mux.Handle("GET /u", Chain(http.HandlerFunc(s.serveSPA), uiMiddlewares...))
|
|
mux.Handle("GET /s/{id}", Chain(http.HandlerFunc(s.scratchPage), uiMiddlewares...))
|
|
|
|
mux.Handle("GET /api/config", Chain(http.HandlerFunc(s.getUIConfig), apiReadMiddlewares...))
|
|
mux.Handle("GET /api/scratch/{id}", Chain(http.HandlerFunc(s.getScratch), apiReadMiddlewares...))
|
|
mux.Handle("GET /api/raw/{id}", Chain(http.HandlerFunc(s.rawScratch), apiReadMiddlewares...))
|
|
|
|
writeHandler := http.HandlerFunc(s.createScratch)
|
|
writeMiddlewares := make([]func(http.Handler) http.Handler, 0, 2)
|
|
writeMiddlewares = append(writeMiddlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger))
|
|
writeMiddlewares = append(writeMiddlewares, apiWriteMiddlewares...)
|
|
mux.Handle("POST /api/scratch", Chain(writeHandler, writeMiddlewares...))
|
|
|
|
mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS()))
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS())))
|
|
|
|
// Special-case well-known paths to return small plain-text responses instead of full SPA 404 shell.
|
|
// This addresses low-priority audit item for robots.txt etc.
|
|
mux.Handle("GET /robots.txt", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("User-agent: *\nDisallow: /\n"))
|
|
}))
|
|
mux.Handle("GET /sitemap.xml", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
|
|
// Serve the real favicon (added to eliminate 404 noise). Placed in static root via public/ in Vite build.
|
|
mux.Handle("GET /favicon.svg", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFileFS(w, r, webassets.StaticFS(), "favicon.svg")
|
|
}))
|
|
|
|
mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage))
|
|
|
|
accessLogged := AccessLogMiddleware(
|
|
s.accessLogger,
|
|
s.cfg.Security.TrustProxyHeaders,
|
|
s.cfg.Security.TrustedProxyPrefixes,
|
|
)(mux)
|
|
return SecurityHeadersMiddleware(s.cfg.Security.HSTSEnabled, accessLogged)
|
|
}
|
|
|
|
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
|
s.serveSPAWithStatus(w, http.StatusOK, nil)
|
|
}
|
|
|
|
func (s *Server) getUIConfig(w http.ResponseWriter, r *http.Request) {
|
|
payload := map[string]any{
|
|
"max_upload_size_bytes": s.cfg.Limits.MaxUploadSizeBytes,
|
|
"upload_allowed": s.isUploadAllowedForRequest(r),
|
|
"default_ttl": s.cfg.Limits.DefaultTTL,
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
}
|
|
|
|
func (s *Server) notFoundPage(w http.ResponseWriter, _ *http.Request) {
|
|
s.serveSPAWithStatus(w, http.StatusNotFound, nil)
|
|
}
|
|
|
|
func (s *Server) expiredScratchPage(w http.ResponseWriter) {
|
|
s.serveSPAWithStatus(w, http.StatusGone, nil)
|
|
}
|
|
|
|
func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int, modifier func([]byte) []byte) {
|
|
b, err := fs.ReadFile(webassets.StaticFS(), "index.html")
|
|
if err != nil {
|
|
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if modifier != nil {
|
|
b = modifier(b)
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(b)
|
|
}
|
|
|
|
func (s *Server) scratchPage(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(r.PathValue("id"))
|
|
if id == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
meta, err := s.store.Get(id)
|
|
if err != nil || s.isExpired(meta) {
|
|
if err == nil {
|
|
_ = s.store.Delete(id)
|
|
}
|
|
// For /s/{id} 410/expired (including never-existed), inject id/filename based title+meta even on 410 shell.
|
|
// This provides good SEO/social for share links and bots (even on 410), per audit rec.
|
|
// We use the id (and name if we had meta before delete) for title.
|
|
goneTitle := id + " • Scratchbox"
|
|
if err == nil && meta.OriginalName != "" {
|
|
goneTitle = meta.OriginalName + " • Scratchbox"
|
|
}
|
|
s.serveSPAWithStatus(w, http.StatusGone, func(b []byte) []byte {
|
|
return injectMeta(b, goneTitle, goneTitle, "This scratch is no longer available.")
|
|
})
|
|
return
|
|
}
|
|
|
|
// Valid scratch: serve shell with per-scratch title and og meta injected server-side.
|
|
// (Client-side <svelte:head> also updates for SPA, but server inj benefits crawlers/JS-disabled.)
|
|
title := id + " • Scratchbox"
|
|
if meta.OriginalName != "" {
|
|
title = meta.OriginalName + " • Scratchbox"
|
|
}
|
|
desc := "Temporary scratch file shared on Scratchbox."
|
|
if meta.OriginalName != "" {
|
|
desc = meta.OriginalName + " — temporary file share on Scratchbox."
|
|
}
|
|
s.serveSPAWithStatus(w, http.StatusOK, func(b []byte) []byte {
|
|
return injectMeta(b, title, title, desc)
|
|
})
|
|
}
|
|
|
|
// multipartMaxMemory is how much of a multipart upload may live in RAM before
|
|
// the remainder spills to temporary files. It must NOT be tied to
|
|
// limits.max_upload_size: when that limit is multi-GiB, using it as maxMemory
|
|
// forces entire large files into process memory before storage compression.
|
|
const multipartMaxMemory = 32 << 20 // 32 MiB
|
|
|
|
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
|
|
|
|
var reader io.Reader
|
|
originalName := ""
|
|
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
|
|
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
|
if err := r.ParseMultipartForm(multipartMaxMemory); err != nil {
|
|
s.writeCreateError(w, err)
|
|
return
|
|
}
|
|
if fileCount := multipartFileCount(r); fileCount > 1 {
|
|
http.Error(w, "only one file is allowed per scratch upload", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err == nil {
|
|
defer file.Close()
|
|
if strings.TrimSpace(r.FormValue("content")) != "" {
|
|
http.Error(w, "multipart request must include either file or content, not both", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if header.Size > s.cfg.Limits.MaxUploadSizeBytes {
|
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
reader = file
|
|
originalName = header.Filename
|
|
contentType = header.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
} else {
|
|
body := strings.TrimSpace(r.FormValue("content"))
|
|
if body == "" {
|
|
http.Error(w, "multipart request requires file or content field", http.StatusBadRequest)
|
|
return
|
|
}
|
|
reader = strings.NewReader(body)
|
|
contentType = "text/plain; charset=utf-8"
|
|
}
|
|
} else {
|
|
reader = r.Body
|
|
if contentType == "" {
|
|
contentType = "text/plain; charset=utf-8"
|
|
}
|
|
}
|
|
|
|
meta, err := s.store.CreateWithOriginalName(reader, contentType, originalName, s.cfg.Limits.DefaultTTLDuration)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "request body too large") {
|
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "failed to save scratch", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"id": meta.ID,
|
|
"created_at": meta.CreatedAt,
|
|
"expires_at": meta.ExpiresAt,
|
|
"size": meta.Size,
|
|
"content_type": meta.ContentType,
|
|
"filename": meta.OriginalName,
|
|
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
|
"raw_url": fmt.Sprintf("/api/raw/%s", meta.ID),
|
|
"api_url": fmt.Sprintf("/api/scratch/%s", meta.ID),
|
|
}
|
|
writeJSON(w, http.StatusCreated, payload)
|
|
}
|
|
|
|
func multipartFileCount(r *http.Request) int {
|
|
if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 {
|
|
return 0
|
|
}
|
|
count := 0
|
|
for _, headers := range r.MultipartForm.File {
|
|
count += len(headers)
|
|
}
|
|
return count
|
|
}
|
|
|
|
func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(r.PathValue("id"))
|
|
if id == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
meta, err := s.store.Get(id)
|
|
if err != nil {
|
|
http.Error(w, "scratch gone", http.StatusGone)
|
|
return
|
|
}
|
|
if s.isExpired(meta) {
|
|
_ = s.store.Delete(id)
|
|
http.Error(w, "scratch expired", http.StatusGone)
|
|
return
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"id": meta.ID,
|
|
"created_at": meta.CreatedAt,
|
|
"expires_at": meta.ExpiresAt,
|
|
"size": meta.Size,
|
|
"content_type": meta.ContentType,
|
|
"filename": meta.OriginalName,
|
|
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
|
"raw_url": fmt.Sprintf("/api/raw/%s", meta.ID),
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
}
|
|
|
|
func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
|
id := strings.TrimSpace(r.PathValue("id"))
|
|
meta, err := s.store.Get(id)
|
|
if err != nil {
|
|
s.rawCache.Delete(id)
|
|
w.WriteHeader(http.StatusGone)
|
|
return
|
|
}
|
|
|
|
if s.isExpired(meta) {
|
|
_ = s.store.Delete(id)
|
|
s.rawCache.Delete(id)
|
|
w.WriteHeader(http.StatusGone)
|
|
return
|
|
}
|
|
|
|
if meta.ContentType == "" {
|
|
meta.ContentType = "application/octet-stream"
|
|
}
|
|
if meta.OriginalName != "" {
|
|
disposition := fmt.Sprintf(`attachment; filename=%q`, meta.OriginalName)
|
|
if formatted := mime.FormatMediaType("attachment", map[string]string{"filename": meta.OriginalName}); formatted != "" {
|
|
disposition = formatted
|
|
}
|
|
w.Header().Set("Content-Disposition", disposition)
|
|
}
|
|
w.Header().Set("Content-Type", meta.ContentType)
|
|
|
|
// Scratches larger than the raw cache cannot be retained in memory. Stream
|
|
// them from storage instead of io.ReadAll so multi-GiB downloads do not OOM.
|
|
// Range requests are only supported via the cache path (ServeContent needs a Seeker).
|
|
if meta.Size > s.cfg.Limits.RawCacheMaxBytes {
|
|
file, _, openErr := s.store.Open(meta.ID)
|
|
if openErr != nil {
|
|
if errors.Is(openErr, storage.ErrNotFound) {
|
|
s.rawCache.Delete(id)
|
|
w.WriteHeader(http.StatusGone)
|
|
return
|
|
}
|
|
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer file.Close()
|
|
w.Header().Set("Accept-Ranges", "none")
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.Copy(w, file)
|
|
return
|
|
}
|
|
|
|
content, err, _ := s.rawCache.GetOrLoad(meta.ID, func() ([]byte, error) {
|
|
file, _, openErr := s.store.Open(meta.ID)
|
|
if openErr != nil {
|
|
return nil, openErr
|
|
}
|
|
defer file.Close()
|
|
return io.ReadAll(file)
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, storage.ErrNotFound) {
|
|
s.rawCache.Delete(id)
|
|
w.WriteHeader(http.StatusGone)
|
|
return
|
|
}
|
|
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.ServeContent(w, r, meta.ID, meta.CreatedAt, bytes.NewReader(content))
|
|
}
|
|
|
|
func (s *Server) writeCreateError(w http.ResponseWriter, err error) {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
|
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
http.Error(w, "invalid upload payload", http.StatusBadRequest)
|
|
}
|
|
|
|
func (s *Server) maxUploadSizeError() string {
|
|
return fmt.Sprintf("upload exceeds limits.max_upload_size (%d bytes)", s.cfg.Limits.MaxUploadSizeBytes)
|
|
}
|
|
|
|
func (s *Server) isExpired(meta storage.Metadata) bool {
|
|
return !meta.ExpiresAt.After(nowUTC())
|
|
}
|
|
|
|
func nowUTC() time.Time {
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func (s *Server) isUploadAllowedForRequest(r *http.Request) bool {
|
|
if len(s.cfg.Security.AllowedPrefixes) == 0 {
|
|
return true
|
|
}
|
|
|
|
clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
for _, prefix := range s.cfg.Security.AllowedPrefixes {
|
|
if prefix.Contains(clientIP) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// injectMeta performs lightweight server-side injection of <title> and social meta tags
|
|
// into the static index.html shell bytes. Uses simple string replace (safe for this tiny known shell,
|
|
// no new deps). Generic pages keep the static metas from the HTML template; /s/{id} get dynamic.
|
|
func injectMeta(html []byte, pageTitle, ogTitle, ogDescription string) []byte {
|
|
if pageTitle == "" {
|
|
pageTitle = "Scratchbox"
|
|
}
|
|
if ogTitle == "" {
|
|
ogTitle = pageTitle
|
|
}
|
|
if ogDescription == "" {
|
|
ogDescription = "Minimal temporary file sharing. Upload once, share the link, auto-expires."
|
|
}
|
|
s := string(html)
|
|
// Override <title>
|
|
s = strings.Replace(s, "<title>Scratchbox</title>", "<title>"+htmlEscape(pageTitle)+"</title>", 1)
|
|
// Override the static og/twitter metas added to the shells (first occurrence only; safe).
|
|
s = strings.Replace(s, `<meta property="og:title" content="Scratchbox" />`, `<meta property="og:title" content="`+htmlEscape(ogTitle)+`" />`, 1)
|
|
s = strings.Replace(s, `<meta name="twitter:title" content="Scratchbox" />`, `<meta name="twitter:title" content="`+htmlEscape(ogTitle)+`" />`, 1)
|
|
s = strings.Replace(s, `<meta name="description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta name="description" content="`+htmlEscape(ogDescription)+`" />`, 1)
|
|
s = strings.Replace(s, `<meta property="og:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta property="og:description" content="`+htmlEscape(ogDescription)+`" />`, 1)
|
|
s = strings.Replace(s, `<meta name="twitter:description" content="Minimal temporary file sharing. Upload once, share the link, auto-expires." />`, `<meta name="twitter:description" content="`+htmlEscape(ogDescription)+`" />`, 1)
|
|
return []byte(s)
|
|
}
|
|
|
|
func htmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, `"`, """)
|
|
return s
|
|
}
|