package httpapi import ( "errors" "fmt" "html/template" "io" "log" "mime" "net/http" "path/filepath" "strings" "time" "unicode/utf8" "scratchbox/internal/config" "scratchbox/internal/storage" ) const maxViewPreviewBytes int64 = 2 * 1024 * 1024 type Server struct { cfg config.Config store *storage.FilesystemStore logger *log.Logger templates *template.Template } type templateViewData struct { ID string Content string ContentType string RawURL string ExpiresAt string Truncated bool Binary bool } func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) { tmpl, err := template.ParseFiles( filepath.Join("web", "templates", "index.html"), filepath.Join("web", "templates", "view.html"), ) if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } return &Server{ cfg: cfg, store: store, logger: logger, templates: tmpl, }, nil } func (s *Server) Routes() http.Handler { mux := http.NewServeMux() mux.Handle("GET /", http.HandlerFunc(s.index)) mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch)) mux.Handle("GET /s/{id}", http.HandlerFunc(s.viewScratch)) mux.Handle("GET /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.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)) } mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...)) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static"))))) return mux } func (s *Server) index(w http.ResponseWriter, r *http.Request) { if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil { http.Error(w, "failed to render page", http.StatusInternalServerError) } } 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 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 } reader = file 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.Create(r.Context(), reader, contentType, s.cfg.Limits.DefaultTTLDuration) if err != nil { if strings.Contains(err.Error(), "request body too large") { http.Error(w, "upload exceeds limits.max_upload_size", 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, "view_url": fmt.Sprintf("/s/%s", meta.ID), "raw_url": fmt.Sprintf("/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.NotFound(w, r) 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, "view_url": fmt.Sprintf("/s/%s", meta.ID), "raw_url": fmt.Sprintf("/raw/%s", meta.ID), } writeJSON(w, http.StatusOK, payload) } func (s *Server) viewScratch(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("id")) file, meta, err := s.store.Open(id) if err != nil { http.NotFound(w, r) return } defer file.Close() if s.isExpired(meta) { _ = s.store.Delete(id) http.Error(w, "scratch expired", http.StatusGone) return } limited := io.LimitReader(file, maxViewPreviewBytes+1) b, err := io.ReadAll(limited) if err != nil { http.Error(w, "failed to read scratch", http.StatusInternalServerError) return } truncated := int64(len(b)) > maxViewPreviewBytes if truncated { b = b[:maxViewPreviewBytes] } isBinary := !isLikelyText(meta.ContentType, b) data := templateViewData{ ID: meta.ID, ContentType: meta.ContentType, RawURL: fmt.Sprintf("/raw/%s", meta.ID), ExpiresAt: meta.ExpiresAt.Format("2006-01-02 15:04:05 MST"), Truncated: truncated, Binary: isBinary, } if !isBinary { data.Content = string(b) } if err := s.templates.ExecuteTemplate(w, "view.html", data); err != nil { http.Error(w, "failed to render scratch view", http.StatusInternalServerError) } } func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.PathValue("id")) file, meta, err := s.store.Open(id) if err != nil { http.NotFound(w, r) return } defer file.Close() if s.isExpired(meta) { _ = s.store.Delete(id) http.Error(w, "scratch expired", http.StatusGone) return } if meta.ContentType == "" { meta.ContentType = "application/octet-stream" } w.Header().Set("Content-Type", meta.ContentType) w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size)) if _, err := io.Copy(w, file); err != nil { s.logger.Printf("failed to stream raw scratch id=%s err=%v", id, err) } } 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, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge) return } http.Error(w, "invalid upload payload", http.StatusBadRequest) } func (s *Server) isExpired(meta storage.Metadata) bool { return !meta.ExpiresAt.After(nowUTC()) } func nowUTC() time.Time { return time.Now().UTC() } func isLikelyText(contentType string, content []byte) bool { if ct := strings.TrimSpace(contentType); ct != "" { mediaType, _, err := mime.ParseMediaType(ct) if err == nil { if strings.HasPrefix(mediaType, "text/") { return true } switch mediaType { case "application/json", "application/xml", "application/javascript", "application/x-www-form-urlencoded": return true } } } if len(content) == 0 { return true } return utf8.Valid(content) }