Many changes

This commit is contained in:
2026-05-31 20:17:49 -05:00
parent 9e398957a9
commit 99d46ecd2a
28 changed files with 3729 additions and 594 deletions
+131 -112
View File
@@ -1,64 +1,46 @@
package httpapi
import (
"bytes"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"log"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"unicode/utf8"
"scratchbox/internal/config"
"scratchbox/internal/storage"
webassets "scratchbox/web"
)
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
cfg config.Config
store *storage.FilesystemStore
logger *log.Logger
rawCache *rawContentCache
}
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,
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.index))
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.viewScratch))
mux.Handle("GET /raw/{id}", http.HandlerFunc(s.rawScratch))
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)
@@ -69,20 +51,69 @@ func (s *Server) Routes() http.Handler {
}
mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static")))))
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) 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) 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") {
@@ -102,7 +133,12 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
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"
@@ -123,10 +159,10 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
}
}
meta, err := s.store.Create(r.Context(), reader, contentType, s.cfg.Limits.DefaultTTLDuration)
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, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge)
http.Error(w, s.maxUploadSizeError(), http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "failed to save scratch", http.StatusInternalServerError)
@@ -139,8 +175,9 @@ func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
"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("/raw/%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)
@@ -166,7 +203,7 @@ func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) {
meta, err := s.store.Get(id)
if err != nil {
http.NotFound(w, r)
http.Error(w, "scratch gone", http.StatusGone)
return
}
if s.isExpired(meta) {
@@ -181,91 +218,73 @@ func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) {
"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("/raw/%s", meta.ID),
"raw_url": fmt.Sprintf("/api/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)
meta, err := s.store.Get(id)
if err != nil {
http.NotFound(w, r)
s.rawCache.Delete(id)
w.WriteHeader(http.StatusGone)
return
}
defer file.Close()
if s.isExpired(meta) {
_ = s.store.Delete(id)
http.Error(w, "scratch expired", http.StatusGone)
s.rawCache.Delete(id)
w.WriteHeader(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)
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, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge)
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())
}
@@ -274,21 +293,21 @@ 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 {
func (s *Server) isUploadAllowedForRequest(r *http.Request) bool {
if len(s.cfg.Security.AllowedPrefixes) == 0 {
return true
}
return utf8.Valid(content)
clientIP, ok := extractClientIP(r, s.cfg.Security.TrustProxyHeaders)
if !ok {
return false
}
for _, prefix := range s.cfg.Security.AllowedPrefixes {
if prefix.Contains(clientIP) {
return true
}
}
return false
}