Many changes
This commit is contained in:
+131
-112
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@ package httpapi
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -54,7 +55,7 @@ func TestCreateReadThenExpireScratch(t *testing.T) {
|
||||
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+id, nil)
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:9999"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
@@ -76,6 +77,80 @@ func TestCreateReadThenExpireScratch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadedScratchViewAndRawBeforeAndAfterExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 120 * time.Millisecond,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello from upload flow"))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = "127.0.0.1:5090"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode create payload error = %v", err)
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("create payload missing id")
|
||||
}
|
||||
|
||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
|
||||
viewReq.RemoteAddr = "127.0.0.1:5091"
|
||||
viewRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(viewRec, viewReq)
|
||||
if viewRec.Code != http.StatusOK {
|
||||
t.Fatalf("view-before-expiry status = %d, want %d", viewRec.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
|
||||
t.Fatalf("expected SPA shell body for /s/{id}, got %q", viewRec.Body.String())
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:5092"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusOK {
|
||||
t.Fatalf("raw-before-expiry status = %d, want %d", rawRec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rawRec.Body.String(); got != "hello from upload flow" {
|
||||
t.Fatalf("raw-before-expiry body = %q, want %q", got, "hello from upload flow")
|
||||
}
|
||||
|
||||
time.Sleep(180 * time.Millisecond)
|
||||
|
||||
viewExpiredReq := httptest.NewRequest(http.MethodGet, "/s/"+id, nil)
|
||||
viewExpiredReq.RemoteAddr = "127.0.0.1:5093"
|
||||
viewExpiredRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(viewExpiredRec, viewExpiredReq)
|
||||
if viewExpiredRec.Code != http.StatusGone {
|
||||
t.Fatalf("view-after-expiry status = %d, want %d", viewExpiredRec.Code, http.StatusGone)
|
||||
}
|
||||
if !strings.Contains(viewExpiredRec.Body.String(), `id="app"`) {
|
||||
t.Fatalf("expected SPA shell body for expired /s/{id}, got %q", viewExpiredRec.Body.String())
|
||||
}
|
||||
|
||||
rawExpiredReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawExpiredReq.RemoteAddr = "127.0.0.1:5094"
|
||||
rawExpiredRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawExpiredRec, rawExpiredReq)
|
||||
if rawExpiredRec.Code != http.StatusGone {
|
||||
t.Fatalf("raw-after-expiry status = %d, want %d", rawExpiredRec.Code, http.StatusGone)
|
||||
}
|
||||
if rawExpiredRec.Body.Len() != 0 {
|
||||
t.Fatalf("expected empty raw body after expiry, got %q", rawExpiredRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -97,6 +172,48 @@ func TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
||||
t.Fatalf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) {
|
||||
t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchRejectsMultipartFileOverLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 8,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", "too-big.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile() error = %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("this is too large")); err != nil {
|
||||
t.Fatalf("write file error = %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("multipart close error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.RemoteAddr = "127.0.0.1:5060"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("upload exceeds limits.max_upload_size")) {
|
||||
t.Fatalf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("(8 bytes)")) {
|
||||
t.Fatalf("expected response to include configured max size, got: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchRejectsMultipleMultipartFiles(t *testing.T) {
|
||||
@@ -171,6 +288,70 @@ func TestCreateScratchMultipartContentOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchMultipartPreservesFilenameForRawDownload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file, err := writer.CreateFormFile("file", "example upload.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile() error = %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("uploaded file body")); err != nil {
|
||||
t.Fatalf("write file error = %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("multipart close error = %v", err)
|
||||
}
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", &body)
|
||||
createReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
createReq.RemoteAddr = "127.0.0.1:5058"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode create payload error = %v", err)
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("create payload missing id")
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:5059"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusOK {
|
||||
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rawRec.Body.String(); got != "uploaded file body" {
|
||||
t.Fatalf("raw body = %q, want %q", got, "uploaded file body")
|
||||
}
|
||||
|
||||
disposition := rawRec.Header().Get("Content-Disposition")
|
||||
if disposition == "" {
|
||||
t.Fatalf("expected Content-Disposition header")
|
||||
}
|
||||
_, params, err := mime.ParseMediaType(disposition)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMediaType() error = %v", err)
|
||||
}
|
||||
if got := params["filename"]; got != "example upload.txt" {
|
||||
t.Fatalf("filename = %q, want %q", got, "example upload.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchMultipartRejectsFileAndContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -232,7 +413,7 @@ func TestCreateScratchMultipartRequiresFileOrContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScratchNotFound(t *testing.T) {
|
||||
func TestGetScratchNotFoundReturnsGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
@@ -245,8 +426,8 @@ func TestGetScratchNotFound(t *testing.T) {
|
||||
req.RemoteAddr = "127.0.0.1:5055"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
if rec.Code != http.StatusGone {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusGone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +445,7 @@ func TestRawScratchDefaultsContentType(t *testing.T) {
|
||||
t.Fatalf("store.Create() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
||||
req.RemoteAddr = "127.0.0.1:5056"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
@@ -276,10 +457,93 @@ func TestRawScratchDefaultsContentType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewScratchAndIndexRender(t *testing.T) {
|
||||
func TestRawScratchSupportsRangeRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, store := newTemplateHandlerAndStore(t, testServerOptions{
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = "127.0.0.1:5061"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode create payload error = %v", err)
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("create payload missing id")
|
||||
}
|
||||
|
||||
rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rangeReq.Header.Set("Range", "bytes=0-4")
|
||||
rangeReq.RemoteAddr = "127.0.0.1:5062"
|
||||
rangeRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rangeRec, rangeReq)
|
||||
if rangeRec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusPartialContent)
|
||||
}
|
||||
if got := rangeRec.Body.String(); got != "hello" {
|
||||
t.Fatalf("range body = %q, want %q", got, "hello")
|
||||
}
|
||||
if got := rangeRec.Header().Get("Content-Range"); got != "bytes 0-4/13" {
|
||||
t.Fatalf("Content-Range = %q, want %q", got, "bytes 0-4/13")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawScratchRejectsUnsatisfiableRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("hello scratch"))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = "127.0.0.1:5063"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode create payload error = %v", err)
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("create payload missing id")
|
||||
}
|
||||
|
||||
rangeReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rangeReq.Header.Set("Range", "bytes=999-1000")
|
||||
rangeReq.RemoteAddr = "127.0.0.1:5064"
|
||||
rangeRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rangeRec, rangeReq)
|
||||
if rangeRec.Code != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf("range status = %d, want %d", rangeRec.Code, http.StatusRequestedRangeNotSatisfiable)
|
||||
}
|
||||
if got := rangeRec.Header().Get("Content-Range"); got != "bytes */13" {
|
||||
t.Fatalf("Content-Range = %q, want %q", got, "bytes */13")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewUploadAndIndexRender(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
||||
maxUploadBytes: 10 * 1024 * 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
@@ -296,6 +560,22 @@ func TestViewScratchAndIndexRender(t *testing.T) {
|
||||
if indexRec.Code != http.StatusOK {
|
||||
t.Fatalf("index status = %d, want %d", indexRec.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(indexRec.Body.String(), `id="app"`) {
|
||||
t.Fatalf("expected index page to include SPA mount point")
|
||||
}
|
||||
if !strings.Contains(indexRec.Body.String(), `type="module"`) {
|
||||
t.Fatalf("expected index page to load frontend bundle")
|
||||
}
|
||||
|
||||
uploadReq := httptest.NewRequest(http.MethodGet, "/u", nil)
|
||||
uploadRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(uploadRec, uploadReq)
|
||||
if uploadRec.Code != http.StatusOK {
|
||||
t.Fatalf("upload status = %d, want %d", uploadRec.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(uploadRec.Body.String(), `id="app"`) {
|
||||
t.Fatalf("expected upload page to include SPA mount point")
|
||||
}
|
||||
|
||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil)
|
||||
viewRec := httptest.NewRecorder()
|
||||
@@ -303,15 +583,15 @@ func TestViewScratchAndIndexRender(t *testing.T) {
|
||||
if viewRec.Code != http.StatusOK {
|
||||
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusOK)
|
||||
}
|
||||
if !strings.Contains(viewRec.Body.String(), "hello view") {
|
||||
t.Fatalf("expected view body to include scratch content")
|
||||
if !strings.Contains(viewRec.Body.String(), `id="app"`) {
|
||||
t.Fatalf("expected scratch route to render SPA shell")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewAndRawScratchExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, store := newTemplateHandlerAndStore(t, testServerOptions{
|
||||
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
@@ -328,11 +608,99 @@ func TestViewAndRawScratchExpired(t *testing.T) {
|
||||
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil)
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusNotFound {
|
||||
t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusNotFound)
|
||||
if rawRec.Code != http.StatusGone {
|
||||
t.Fatalf("raw status after lazy delete = %d, want %d", rawRec.Code, http.StatusGone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawScratchExpiredReturnsStatusOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, store := newTestHandlerAndStore(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
meta, err := store.Create(t.Context(), strings.NewReader("expired body"), "text/plain", -time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create() error = %v", err)
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+meta.ID, nil)
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusGone {
|
||||
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone)
|
||||
}
|
||||
if rawRec.Body.Len() != 0 {
|
||||
t.Fatalf("expected empty response body, got %q", rawRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewAndRawUnknownIDsReturnGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/does-not-exist", nil)
|
||||
viewRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(viewRec, viewReq)
|
||||
if viewRec.Code != http.StatusGone {
|
||||
t.Fatalf("view status = %d, want %d", viewRec.Code, http.StatusGone)
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/does-not-exist", nil)
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusGone {
|
||||
t.Fatalf("raw status = %d, want %d", rawRec.Code, http.StatusGone)
|
||||
}
|
||||
if rawRec.Body.Len() != 0 {
|
||||
t.Fatalf("expected empty response body, got %q", rawRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeverUploadedScratchIDsAlsoReturnGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/never-uploaded-id", nil)
|
||||
viewReq.RemoteAddr = "127.0.0.1:5095"
|
||||
viewRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(viewRec, viewReq)
|
||||
if viewRec.Code != http.StatusGone {
|
||||
t.Fatalf("view-never-uploaded status = %d, want %d", viewRec.Code, http.StatusGone)
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/never-uploaded-id", nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:5096"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusGone {
|
||||
t.Fatalf("raw-never-uploaded status = %d, want %d", rawRec.Code, http.StatusGone)
|
||||
}
|
||||
if rawRec.Body.Len() != 0 {
|
||||
t.Fatalf("expected empty raw body for never-uploaded id, got %q", rawRec.Body.String())
|
||||
}
|
||||
|
||||
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/never-uploaded-id", nil)
|
||||
metaReq.RemoteAddr = "127.0.0.1:5097"
|
||||
metaRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(metaRec, metaReq)
|
||||
if metaRec.Code != http.StatusGone {
|
||||
t.Fatalf("metadata-never-uploaded status = %d, want %d", metaRec.Code, http.StatusGone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +723,263 @@ func TestCreateScratchRejectsMalformedMultipart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentUploadsRemainConsistentAndReadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024 * 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
const uploads = 80
|
||||
|
||||
type createdScratch struct {
|
||||
id string
|
||||
body string
|
||||
}
|
||||
|
||||
resultsCh := make(chan createdScratch, uploads)
|
||||
errCh := make(chan error, uploads)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < uploads; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
body := fmt.Sprintf("parallel-upload-%03d-%s", i, strings.Repeat("x", i%17))
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 8200+i)
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
errCh <- fmt.Errorf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
return
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
errCh <- fmt.Errorf("decode create payload error: %w", err)
|
||||
return
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
errCh <- fmt.Errorf("create payload missing id")
|
||||
return
|
||||
}
|
||||
|
||||
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
|
||||
metaRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(metaRec, metaReq)
|
||||
if metaRec.Code != http.StatusOK {
|
||||
errCh <- fmt.Errorf("metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/api/raw/"+id, nil)
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusOK {
|
||||
errCh <- fmt.Errorf("raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
||||
return
|
||||
}
|
||||
if got := rawRec.Body.String(); got != body {
|
||||
errCh <- fmt.Errorf("raw body mismatch = %q, want %q", got, body)
|
||||
return
|
||||
}
|
||||
|
||||
resultsCh <- createdScratch{id: id, body: body}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(resultsCh)
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, uploads)
|
||||
for result := range resultsCh {
|
||||
if _, exists := seen[result.id]; exists {
|
||||
t.Errorf("duplicate scratch id generated: %s", result.id)
|
||||
continue
|
||||
}
|
||||
seen[result.id] = struct{}{}
|
||||
}
|
||||
if len(seen) != uploads {
|
||||
t.Fatalf("unique IDs = %d, want %d", len(seen), uploads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelClientsCanReadSameScratchAcrossAllReadRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
const body = "parallel readers"
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString(body))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = "127.0.0.1:6010"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode create payload error = %v", err)
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("create payload missing id")
|
||||
}
|
||||
|
||||
paths := []string{
|
||||
"/api/raw/" + id,
|
||||
"/s/" + id,
|
||||
"/api/scratch/" + id,
|
||||
}
|
||||
const requests = 120
|
||||
|
||||
errCh := make(chan error, requests)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < requests; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
path := paths[i%len(paths)]
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 6100+i)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
errCh <- fmt.Errorf("path %s status = %d, want %d", path, rec.Code, http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/raw/"):
|
||||
if got := rec.Body.String(); got != body {
|
||||
errCh <- fmt.Errorf("raw body = %q, want %q", got, body)
|
||||
}
|
||||
case strings.HasPrefix(path, "/s/"):
|
||||
if !strings.Contains(rec.Body.String(), `id="app"`) {
|
||||
errCh <- fmt.Errorf("view body missing SPA marker")
|
||||
}
|
||||
case strings.HasPrefix(path, "/api/scratch/"):
|
||||
var gotPayload map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &gotPayload); err != nil {
|
||||
errCh <- fmt.Errorf("metadata decode error: %w", err)
|
||||
return
|
||||
}
|
||||
if gotID, _ := gotPayload["id"].(string); gotID != id {
|
||||
errCh <- fmt.Errorf("metadata id = %q, want %q", gotID, id)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelClientsExpiredScratchReadsReturnGone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: 80 * time.Millisecond,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
createReq := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("expires soon"))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = "127.0.0.1:7010"
|
||||
createRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d, want %d", createRec.Code, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(createRec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode create payload error = %v", err)
|
||||
}
|
||||
id, _ := payload["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatalf("create payload missing id")
|
||||
}
|
||||
|
||||
time.Sleep(130 * time.Millisecond)
|
||||
|
||||
paths := []string{
|
||||
"/api/raw/" + id,
|
||||
"/s/" + id,
|
||||
"/api/scratch/" + id,
|
||||
}
|
||||
const requests = 90
|
||||
|
||||
errCh := make(chan error, requests)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < requests; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
path := paths[i%len(paths)]
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.RemoteAddr = fmt.Sprintf("127.0.0.1:%d", 7100+i)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusGone {
|
||||
errCh <- fmt.Errorf("expired path %s status = %d, want %d", path, rec.Code, http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/s/") && !strings.Contains(rec.Body.String(), `id="app"`) {
|
||||
errCh <- fmt.Errorf("expired view body missing SPA marker")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errCh)
|
||||
for err := range errCh {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathReturnsNotFoundPage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/this-path-does-not-exist", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `id="app"`) {
|
||||
t.Fatalf("unexpected not found body: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type testServerOptions struct {
|
||||
maxUploadBytes int64
|
||||
defaultTTL time.Duration
|
||||
@@ -384,6 +1009,8 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
||||
DefaultTTLDuration: opts.defaultTTL,
|
||||
RawCacheMaxBytes: 64 * 1024 * 1024,
|
||||
RawCacheMaxEntries: 32,
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
DataDir: dataDir,
|
||||
@@ -398,57 +1025,10 @@ func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler,
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
templates: nil,
|
||||
}
|
||||
return srv.Routes(), store
|
||||
}
|
||||
|
||||
func newTemplateHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) {
|
||||
t.Helper()
|
||||
|
||||
handler, store := newTestHandlerAndStore(t, opts)
|
||||
_ = handler // keep shared setup path
|
||||
|
||||
_, filePath, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filePath), "..", ".."))
|
||||
tmpl, err := template.ParseFiles(
|
||||
filepath.Join(repoRoot, "web", "templates", "index.html"),
|
||||
filepath.Join(repoRoot, "web", "templates", "view.html"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFiles() error = %v", err)
|
||||
}
|
||||
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{
|
||||
ListenAddr: ":0",
|
||||
},
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
||||
DefaultTTLDuration: opts.defaultTTL,
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
DataDir: filepath.Join(t.TempDir(), "unused"),
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
RateLimit: config.RateLimitConfig{
|
||||
Enabled: opts.rateLimitEnable,
|
||||
RequestsPerMinute: 120,
|
||||
Burst: 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
srv := &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
templates: tmpl,
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
logger: log.New(io.Discard, "", 0),
|
||||
rawCache: newRawContentCache(cfg.Limits.RawCacheMaxEntries, cfg.Limits.RawCacheMaxBytes),
|
||||
}
|
||||
return srv.Routes(), store
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
@@ -48,27 +47,7 @@ func TestWriteCreateErrorBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLikelyText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !isLikelyText("text/plain; charset=utf-8", []byte{0x00}) {
|
||||
t.Fatalf("text/plain should be treated as text")
|
||||
}
|
||||
if !isLikelyText("application/json", []byte{0x00}) {
|
||||
t.Fatalf("application/json should be treated as text")
|
||||
}
|
||||
if isLikelyText("application/octet-stream", []byte{0xff, 0xfe, 0xfd}) {
|
||||
t.Fatalf("binary octet-stream should not be treated as text")
|
||||
}
|
||||
if !isLikelyText("", []byte("hello")) {
|
||||
t.Fatalf("utf8 payload should be treated as text")
|
||||
}
|
||||
if !isLikelyText("", nil) {
|
||||
t.Fatalf("empty payload should be treated as text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerLoadsTemplates(t *testing.T) {
|
||||
func TestNewServerInitializes(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
||||
if err != nil {
|
||||
@@ -81,32 +60,63 @@ func TestNewServerLoadsTemplates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
if err := os.Chdir(repoRoot); err != nil {
|
||||
t.Fatalf("Chdir() error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(wd)
|
||||
}()
|
||||
|
||||
srv, err := NewServer(cfg, store, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
if srv == nil || srv.templates == nil {
|
||||
t.Fatalf("expected initialized server with templates")
|
||||
if srv == nil {
|
||||
t.Fatalf("expected initialized server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerTemplateErrorAndIndexError(t *testing.T) {
|
||||
func TestGetUIConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{
|
||||
cfg: config.Config{
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: 2048,
|
||||
},
|
||||
},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
s.getUIConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Body.String(); got == "" || !containsAll(got, "max_upload_size_bytes", "2048", `"upload_allowed":true`) {
|
||||
t.Fatalf("unexpected body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUIConfigUploadDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{
|
||||
cfg: config.Config{
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: 2048,
|
||||
},
|
||||
Security: config.SecurityConfig{
|
||||
AllowedPrefixes: []netip.Prefix{
|
||||
netip.MustParsePrefix("10.0.0.0/8"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
||||
s.getUIConfig(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Body.String(); got == "" || !containsAll(got, `"upload_allowed":false`) {
|
||||
t.Fatalf("unexpected body: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCreateErrorBranchesWithConfig(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
||||
if err != nil {
|
||||
@@ -118,44 +128,16 @@ func TestNewServerTemplateErrorAndIndexError(t *testing.T) {
|
||||
RateLimit: config.RateLimitConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd() error = %v", err)
|
||||
}
|
||||
if err := os.Chdir(tmp); err != nil {
|
||||
t.Fatalf("Chdir() error = %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(wd)
|
||||
}()
|
||||
|
||||
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err == nil {
|
||||
t.Fatalf("expected NewServer() template parse error")
|
||||
}
|
||||
|
||||
s := &Server{}
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("expected panic when templates are nil")
|
||||
}
|
||||
}()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
s.index(rec, req)
|
||||
}()
|
||||
}
|
||||
|
||||
func TestIndexExecuteTemplateError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{
|
||||
templates: template.New("empty"),
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
s.index(rec, req)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("index status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
if _, err := NewServer(cfg, store, log.New(io.Discard, "", 0)); err != nil {
|
||||
t.Fatalf("NewServer() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(haystack string, needles ...string) bool {
|
||||
for _, needle := range needles {
|
||||
if !strings.Contains(haystack, needle) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRawCacheMaxEntries = 32
|
||||
defaultRawCacheMaxBytes = 64 * 1024 * 1024
|
||||
)
|
||||
|
||||
type rawContentCache struct {
|
||||
mu sync.Mutex
|
||||
maxEntries int
|
||||
maxBytes int64
|
||||
totalBytes int64
|
||||
items map[string]*list.Element
|
||||
lru *list.List
|
||||
inFlight map[string]*rawContentLoad
|
||||
}
|
||||
|
||||
type rawContentItem struct {
|
||||
id string
|
||||
data []byte
|
||||
size int64
|
||||
}
|
||||
|
||||
type rawContentLoad struct {
|
||||
wait chan struct{}
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func newRawContentCache(maxEntries int, maxBytes int64) *rawContentCache {
|
||||
if maxEntries <= 0 {
|
||||
maxEntries = defaultRawCacheMaxEntries
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultRawCacheMaxBytes
|
||||
}
|
||||
return &rawContentCache{
|
||||
maxEntries: maxEntries,
|
||||
maxBytes: maxBytes,
|
||||
items: make(map[string]*list.Element, maxEntries),
|
||||
lru: list.New(),
|
||||
inFlight: make(map[string]*rawContentLoad),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *rawContentCache) GetOrLoad(id string, loader func() ([]byte, error)) ([]byte, error, bool) {
|
||||
c.mu.Lock()
|
||||
if elem, ok := c.items[id]; ok {
|
||||
c.lru.MoveToFront(elem)
|
||||
item := elem.Value.(*rawContentItem)
|
||||
data := item.data
|
||||
c.mu.Unlock()
|
||||
return data, nil, true
|
||||
}
|
||||
if load, ok := c.inFlight[id]; ok {
|
||||
c.mu.Unlock()
|
||||
<-load.wait
|
||||
return load.data, load.err, false
|
||||
}
|
||||
|
||||
load := &rawContentLoad{wait: make(chan struct{})}
|
||||
c.inFlight[id] = load
|
||||
c.mu.Unlock()
|
||||
|
||||
data, err := loader()
|
||||
load.data = data
|
||||
load.err = err
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.inFlight, id)
|
||||
if err == nil {
|
||||
c.setLocked(id, data)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
close(load.wait)
|
||||
return data, err, false
|
||||
}
|
||||
|
||||
func (c *rawContentCache) Delete(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.deleteLocked(id)
|
||||
}
|
||||
|
||||
func (c *rawContentCache) setLocked(id string, data []byte) {
|
||||
size := int64(len(data))
|
||||
if size <= 0 || size > c.maxBytes {
|
||||
c.deleteLocked(id)
|
||||
return
|
||||
}
|
||||
|
||||
if elem, ok := c.items[id]; ok {
|
||||
item := elem.Value.(*rawContentItem)
|
||||
c.totalBytes -= item.size
|
||||
item.data = data
|
||||
item.size = size
|
||||
c.totalBytes += size
|
||||
c.lru.MoveToFront(elem)
|
||||
c.evictLocked()
|
||||
return
|
||||
}
|
||||
|
||||
item := &rawContentItem{
|
||||
id: id,
|
||||
data: data,
|
||||
size: size,
|
||||
}
|
||||
elem := c.lru.PushFront(item)
|
||||
c.items[id] = elem
|
||||
c.totalBytes += size
|
||||
c.evictLocked()
|
||||
}
|
||||
|
||||
func (c *rawContentCache) deleteLocked(id string) {
|
||||
elem, ok := c.items[id]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item := elem.Value.(*rawContentItem)
|
||||
c.totalBytes -= item.size
|
||||
delete(c.items, id)
|
||||
c.lru.Remove(elem)
|
||||
}
|
||||
|
||||
func (c *rawContentCache) evictLocked() {
|
||||
for c.totalBytes > c.maxBytes || len(c.items) > c.maxEntries {
|
||||
last := c.lru.Back()
|
||||
if last == nil {
|
||||
return
|
||||
}
|
||||
item := last.Value.(*rawContentItem)
|
||||
c.totalBytes -= item.size
|
||||
delete(c.items, item.id)
|
||||
c.lru.Remove(last)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRawContentCacheCoalescesConcurrentLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := newRawContentCache(8, 8*1024*1024)
|
||||
var loads int32
|
||||
want := []byte("coalesced")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
got, err, _ := cache.GetOrLoad("shared-id", func() ([]byte, error) {
|
||||
atomic.AddInt32(&loads, 1)
|
||||
time.Sleep(15 * time.Millisecond)
|
||||
return want, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("GetOrLoad() error = %v", err)
|
||||
return
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("GetOrLoad() content mismatch = %q, want %q", got, want)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&loads); got != 1 {
|
||||
t.Fatalf("loader call count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawContentCacheEvictionCausesReload(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := newRawContentCache(1, 3)
|
||||
var loadsA int32
|
||||
var loadsB int32
|
||||
|
||||
if _, err, _ := cache.GetOrLoad("a", func() ([]byte, error) {
|
||||
atomic.AddInt32(&loadsA, 1)
|
||||
return []byte("aaa"), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("GetOrLoad(a) error = %v", err)
|
||||
}
|
||||
if _, err, _ := cache.GetOrLoad("b", func() ([]byte, error) {
|
||||
atomic.AddInt32(&loadsB, 1)
|
||||
return []byte("bbb"), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("GetOrLoad(b) error = %v", err)
|
||||
}
|
||||
if _, err, _ := cache.GetOrLoad("a", func() ([]byte, error) {
|
||||
atomic.AddInt32(&loadsA, 1)
|
||||
return []byte("aaa"), nil
|
||||
}); err != nil {
|
||||
t.Fatalf("GetOrLoad(a second) error = %v", err)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&loadsB); got != 1 {
|
||||
t.Fatalf("loadsB = %d, want 1", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&loadsA); got != 2 {
|
||||
t.Fatalf("loadsA = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user