initial commit
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
"scratchbox/internal/storage"
|
||||
)
|
||||
|
||||
func TestCreateReadThenExpireScratch(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 scratch"))
|
||||
createReq.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
createReq.RemoteAddr = "127.0.0.1:9999"
|
||||
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")
|
||||
}
|
||||
|
||||
metaReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
|
||||
metaReq.RemoteAddr = "127.0.0.1:9999"
|
||||
metaRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(metaRec, metaReq)
|
||||
if metaRec.Code != http.StatusOK {
|
||||
t.Fatalf("read metadata status = %d, want %d", metaRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
rawReq := httptest.NewRequest(http.MethodGet, "/raw/"+id, nil)
|
||||
rawReq.RemoteAddr = "127.0.0.1:9999"
|
||||
rawRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rawRec, rawReq)
|
||||
if rawRec.Code != http.StatusOK {
|
||||
t.Fatalf("read raw status = %d, want %d", rawRec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rawRec.Body.String(); got != "hello scratch" {
|
||||
t.Fatalf("raw body = %q, want %q", got, "hello scratch")
|
||||
}
|
||||
|
||||
time.Sleep(180 * time.Millisecond)
|
||||
|
||||
expiredReq := httptest.NewRequest(http.MethodGet, "/api/scratch/"+id, nil)
|
||||
expiredReq.RemoteAddr = "127.0.0.1:9999"
|
||||
expiredRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(expiredRec, expiredReq)
|
||||
if expiredRec.Code != http.StatusGone {
|
||||
t.Fatalf("expired status = %d, want %d", expiredRec.Code, http.StatusGone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchRejectsBodyOverLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 8,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", bytes.NewBufferString("this is too large"))
|
||||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||||
req.RemoteAddr = "127.0.0.1:5050"
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchRejectsMultipleMultipartFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
file1, err := writer.CreateFormFile("file", "first.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile first error = %v", err)
|
||||
}
|
||||
if _, err := file1.Write([]byte("first")); err != nil {
|
||||
t.Fatalf("write first file error = %v", err)
|
||||
}
|
||||
file2, err := writer.CreateFormFile("file", "second.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile second error = %v", err)
|
||||
}
|
||||
if _, err := file2.Write([]byte("second")); err != nil {
|
||||
t.Fatalf("write second 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:5051"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("only one file is allowed per scratch upload")) {
|
||||
t.Fatalf("unexpected body: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchMultipartContentOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if err := writer.WriteField("content", "hello from content field"); err != nil {
|
||||
t.Fatalf("WriteField() 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:5052"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchMultipartRejectsFileAndContent(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", "one.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile() error = %v", err)
|
||||
}
|
||||
if _, err := file.Write([]byte("file content")); err != nil {
|
||||
t.Fatalf("write file error = %v", err)
|
||||
}
|
||||
if err := writer.WriteField("content", "extra content"); err != nil {
|
||||
t.Fatalf("WriteField() 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:5053"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchMultipartRequiresFileOrContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
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:5054"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetScratchNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/scratch/does-not-exist", nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawScratchDefaultsContentType(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("no content type"), "", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/raw/"+meta.ID, nil)
|
||||
req.RemoteAddr = "127.0.0.1:5056"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if got := rec.Header().Get("Content-Type"); got != "application/octet-stream" {
|
||||
t.Fatalf("Content-Type = %q, want %q", got, "application/octet-stream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewScratchAndIndexRender(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, store := newTemplateHandlerAndStore(t, testServerOptions{
|
||||
maxUploadBytes: 10 * 1024 * 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
meta, err := store.Create(t.Context(), strings.NewReader("hello view"), "text/plain; charset=utf-8", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create() error = %v", err)
|
||||
}
|
||||
|
||||
indexReq := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
indexRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(indexRec, indexReq)
|
||||
if indexRec.Code != http.StatusOK {
|
||||
t.Fatalf("index status = %d, want %d", indexRec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, nil)
|
||||
viewRec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(viewRec, viewReq)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewAndRawScratchExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler, store := newTemplateHandlerAndStore(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)
|
||||
}
|
||||
|
||||
viewReq := httptest.NewRequest(http.MethodGet, "/s/"+meta.ID, 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, "/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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateScratchRejectsMalformedMultipart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := newTestHandler(t, testServerOptions{
|
||||
maxUploadBytes: 1024,
|
||||
defaultTTL: time.Hour,
|
||||
rateLimitEnable: false,
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", strings.NewReader("not-multipart"))
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=missing")
|
||||
req.RemoteAddr = "127.0.0.1:5057"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
type testServerOptions struct {
|
||||
maxUploadBytes int64
|
||||
defaultTTL time.Duration
|
||||
rateLimitEnable bool
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T, opts testServerOptions) http.Handler {
|
||||
t.Helper()
|
||||
|
||||
handler, _ := newTestHandlerAndStore(t, opts)
|
||||
return handler
|
||||
}
|
||||
|
||||
func newTestHandlerAndStore(t *testing.T, opts testServerOptions) (http.Handler, *storage.FilesystemStore) {
|
||||
t.Helper()
|
||||
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
store, err := storage.NewFilesystemStore(dataDir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
|
||||
cfg := config.Config{
|
||||
Server: config.ServerConfig{
|
||||
ListenAddr: ":0",
|
||||
},
|
||||
Limits: config.LimitsConfig{
|
||||
MaxUploadSizeBytes: opts.maxUploadBytes,
|
||||
DefaultTTLDuration: opts.defaultTTL,
|
||||
},
|
||||
Storage: config.StorageConfig{
|
||||
DataDir: dataDir,
|
||||
},
|
||||
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: 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,
|
||||
}
|
||||
return srv.Routes(), store
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
"scratchbox/internal/storage"
|
||||
)
|
||||
|
||||
func TestMultipartFileCount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
if got := multipartFileCount(req); got != 0 {
|
||||
t.Fatalf("multipartFileCount() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCreateErrorBranches(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{}
|
||||
rec1 := httptest.NewRecorder()
|
||||
s.writeCreateError(rec1, &http.MaxBytesError{Limit: 5})
|
||||
if rec1.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("max bytes error status = %d, want %d", rec1.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
s.writeCreateError(rec2, errors.New("request body too large"))
|
||||
if rec2.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("body too large string status = %d, want %d", rec2.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
|
||||
rec3 := httptest.NewRecorder()
|
||||
s.writeCreateError(rec3, errors.New("other parse failure"))
|
||||
if rec3.Code != http.StatusBadRequest {
|
||||
t.Fatalf("generic error status = %d, want %d", rec3.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
tmp := t.TempDir()
|
||||
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
cfg := config.Config{
|
||||
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
|
||||
Security: config.SecurityConfig{
|
||||
RateLimit: config.RateLimitConfig{Enabled: false},
|
||||
},
|
||||
}
|
||||
|
||||
_, 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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServerTemplateErrorAndIndexError(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
store, err := storage.NewFilesystemStore(filepath.Join(tmp, "data"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewFilesystemStore() error = %v", err)
|
||||
}
|
||||
cfg := config.Config{
|
||||
Limits: config.LimitsConfig{MaxUploadSizeBytes: 1024},
|
||||
Security: config.SecurityConfig{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
rate rate.Limit
|
||||
burst int
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func NewRateLimiter(requestsPerMinute int, burst int) *RateLimiter {
|
||||
return &RateLimiter{
|
||||
visitors: map[string]*visitor{},
|
||||
rate: rate.Limit(float64(requestsPerMinute) / 60.0),
|
||||
burst: burst,
|
||||
ttl: 10 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RateLimiter) Allow(ip string) bool {
|
||||
now := time.Now()
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for key, v := range r.visitors {
|
||||
if now.Sub(v.lastSeen) > r.ttl {
|
||||
delete(r.visitors, key)
|
||||
}
|
||||
}
|
||||
|
||||
v, ok := r.visitors[ip]
|
||||
if !ok {
|
||||
v = &visitor{
|
||||
limiter: rate.NewLimiter(r.rate, r.burst),
|
||||
}
|
||||
r.visitors[ip] = v
|
||||
}
|
||||
|
||||
v.lastSeen = now
|
||||
return v.limiter.Allow()
|
||||
}
|
||||
|
||||
func AllowlistMiddleware(allowed []netip.Prefix, trustProxyHeaders bool, logger *log.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if len(allowed) == 0 {
|
||||
return next
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP, ok := extractClientIP(r, trustProxyHeaders)
|
||||
if !ok {
|
||||
http.Error(w, "unable to determine client ip", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
for _, prefix := range allowed {
|
||||
if prefix.Contains(clientIP) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
logger.Printf("allowlist denied request method=%s path=%s ip=%s", r.Method, r.URL.Path, clientIP.String())
|
||||
http.Error(w, "ip not allowed", http.StatusForbidden)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RateLimitMiddleware(limiter *RateLimiter, trustProxyHeaders bool) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP, ok := extractClientIP(r, trustProxyHeaders)
|
||||
if !ok {
|
||||
http.Error(w, "unable to determine client ip", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
if limiter.Allow(clientIP.String()) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Retry-After", "60")
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "too many requests from this IP, retry later",
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
||||
wrapped := handler
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
wrapped = middlewares[i](wrapped)
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
func extractClientIP(r *http.Request, trustProxyHeaders bool) (netip.Addr, bool) {
|
||||
if trustProxyHeaders {
|
||||
if xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if len(parts) > 0 {
|
||||
if ip, err := netip.ParseAddr(strings.TrimSpace(parts[0])); err == nil {
|
||||
return ip, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if xrip := strings.TrimSpace(r.Header.Get("X-Real-IP")); xrip != "" {
|
||||
if ip, err := netip.ParseAddr(xrip); err == nil {
|
||||
return ip, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err != nil {
|
||||
if ip, parseErr := netip.ParseAddr(strings.TrimSpace(r.RemoteAddr)); parseErr == nil {
|
||||
return ip, true
|
||||
}
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return ip, true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAllowlistMiddlewareDeniesAndAllowsByIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
mw := AllowlistMiddleware(allowed, false, logger)
|
||||
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
handler := mw(next)
|
||||
|
||||
reqAllowed := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
reqAllowed.RemoteAddr = "10.1.2.3:1234"
|
||||
recAllowed := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recAllowed, reqAllowed)
|
||||
if recAllowed.Code != http.StatusNoContent {
|
||||
t.Fatalf("allowed request status = %d, want %d", recAllowed.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
reqDenied := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
reqDenied.RemoteAddr = "192.168.10.5:1234"
|
||||
recDenied := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recDenied, reqDenied)
|
||||
if recDenied.Code != http.StatusForbidden {
|
||||
t.Fatalf("denied request status = %d, want %d", recDenied.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistMiddlewareHonorsProxyHeaderWhenTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
allowed := []netip.Prefix{netip.MustParsePrefix("203.0.113.5/32")}
|
||||
mw := AllowlistMiddleware(allowed, true, logger)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req.RemoteAddr = "10.0.0.10:9999"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.5, 198.51.100.2")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddlewareKeysByClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := NewRateLimiter(60, 1)
|
||||
handler := RateLimitMiddleware(limiter, false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req1.RemoteAddr = "198.51.100.7:1111"
|
||||
rec1 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec1, req1)
|
||||
if rec1.Code != http.StatusNoContent {
|
||||
t.Fatalf("first request status = %d, want %d", rec1.Code, http.StatusNoContent)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req2.RemoteAddr = "198.51.100.7:2222"
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("second same-ip request status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
req3 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req3.RemoteAddr = "198.51.100.8:3333"
|
||||
rec3 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec3, req3)
|
||||
if rec3.Code != http.StatusNoContent {
|
||||
t.Fatalf("different-ip request status = %d, want %d", rec3.Code, http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistMiddlewareNoRestrictionsReturnsNext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := AllowlistMiddleware(nil, false, log.New(io.Discard, "", 0))(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req.RemoteAddr = "not-a-valid-remote-addr"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusAccepted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistMiddlewareRejectsUnknownClientIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
allowed := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}
|
||||
handler := AllowlistMiddleware(allowed, false, logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req.RemoteAddr = "garbage"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddlewareUnknownIP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := NewRateLimiter(60, 1)
|
||||
handler := RateLimitMiddleware(limiter, false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req.RemoteAddr = "bad-addr"
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimitMiddleware429PayloadAndRetryHeader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := NewRateLimiter(1, 1)
|
||||
handler := RateLimitMiddleware(limiter, false)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
req1 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req1.RemoteAddr = "198.18.0.1:1111"
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req1)
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/scratch", nil)
|
||||
req2.RemoteAddr = "198.18.0.1:1112"
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status = %d, want %d", rec2.Code, http.StatusTooManyRequests)
|
||||
}
|
||||
if got := rec2.Header().Get("Retry-After"); got != "60" {
|
||||
t.Fatalf("Retry-After = %q, want %q", got, "60")
|
||||
}
|
||||
if ct := rec2.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal(rec2.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json unmarshal error = %v", err)
|
||||
}
|
||||
if payload["error"] == "" {
|
||||
t.Fatalf("expected error payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateLimiterPrunesStaleVisitors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limiter := NewRateLimiter(60, 1)
|
||||
limiter.ttl = time.Nanosecond
|
||||
limiter.visitors["old"] = &visitor{lastSeen: time.Now().Add(-time.Hour)}
|
||||
if !limiter.Allow("new") {
|
||||
t.Fatalf("Allow(new) = false, want true")
|
||||
}
|
||||
if _, ok := limiter.visitors["old"]; ok {
|
||||
t.Fatalf("stale visitor should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractClientIPFallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "203.0.113.9"
|
||||
ip, ok := extractClientIP(req, false)
|
||||
if !ok || ip.String() != "203.0.113.9" {
|
||||
t.Fatalf("extract direct ip = (%v,%v), want (203.0.113.9,true)", ip, ok)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req2.RemoteAddr = "192.0.2.50:8080"
|
||||
req2.Header.Set("X-Forwarded-For", "bad,198.51.100.1")
|
||||
req2.Header.Set("X-Real-IP", "198.51.100.5")
|
||||
ip2, ok2 := extractClientIP(req2, true)
|
||||
if !ok2 || ip2.String() != "198.51.100.5" {
|
||||
t.Fatalf("extract x-real-ip = (%v,%v), want (198.51.100.5,true)", ip2, ok2)
|
||||
}
|
||||
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req3.RemoteAddr = "still-not-an-ip"
|
||||
if _, ok3 := extractClientIP(req3, false); ok3 {
|
||||
t.Fatalf("extractClientIP() ok = true, want false")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user