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 rawCache *rawContentCache } func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) { return &Server{ cfg: cfg, store: store, logger: logger, rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes), }, nil } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.Handle("GET /{$}", http.HandlerFunc(s.serveSPA)) mux.Handle("GET /u", http.HandlerFunc(s.serveSPA)) mux.Handle("GET /api/config", http.HandlerFunc(s.getUIConfig)) mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch)) mux.Handle("GET /s/{id}", http.HandlerFunc(s.scratchPage)) mux.Handle("GET /api/raw/{id}", http.HandlerFunc(s.rawScratch)) writeHandler := http.HandlerFunc(s.createScratch) middlewares := make([]func(http.Handler) http.Handler, 0, 2) middlewares = append(middlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes, s.logger)) if s.cfg.Security.RateLimit.Enabled { limiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst) middlewares = append(middlewares, RateLimitMiddleware(limiter, s.cfg.Security.TrustProxyHeaders, s.cfg.Security.TrustedProxyPrefixes)) } mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...)) mux.Handle("GET /assets/", http.FileServerFS(webassets.StaticFS())) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(webassets.StaticFS()))) mux.Handle("GET /{path...}", http.HandlerFunc(s.notFoundPage)) return mux } func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { s.serveSPAWithStatus(w, http.StatusOK) } 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), } writeJSON(w, http.StatusOK, payload) } func (s *Server) notFoundPage(w http.ResponseWriter, _ *http.Request) { s.serveSPAWithStatus(w, http.StatusNotFound) } func (s *Server) expiredScratchPage(w http.ResponseWriter) { s.serveSPAWithStatus(w, http.StatusGone) } func (s *Server) serveSPAWithStatus(w http.ResponseWriter, status int) { b, err := fs.ReadFile(webassets.StaticFS(), "index.html") if err != nil { http.Error(w, "failed to render page", http.StatusInternalServerError) return } 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.expiredScratchPage(w) return } if s.isExpired(meta) { _ = s.store.Delete(id) s.expiredScratchPage(w) return } s.serveSPA(w, r) } 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(s.cfg.Limits.MaxUploadSizeBytes); 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(r.Context(), 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) 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 }