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 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 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", ""+htmlEscape(pageTitle)+"", 1) // Override the static og/twitter metas added to the shells (first occurrence only; safe). s = strings.Replace(s, ``, ``, 1) s = strings.Replace(s, ``, ``, 1) s = strings.Replace(s, ``, ``, 1) s = strings.Replace(s, ``, ``, 1) s = strings.Replace(s, ``, ``, 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 }