diff --git a/README.md b/README.md index 46e4db0..ca1131a 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,7 @@ Full production runbook: - verify `PUBLIC_BASE_URL` is public/reachable from the internet (not `localhost` in production) - for image posts, upload JPEG or PNG (`.jpg` / `.jpeg` / `.png`) - for video posts, use MP4 or MOV - - media uploads are capped at 512 MiB + - media uploads are capped at 512 MiB and must finish within 15 minutes - CLI auth errors: - verify `POSTFLOW_API_TOKEN` matches server `API_TOKEN` diff --git a/docs/specs/openapi.yaml b/docs/specs/openapi.yaml index c35ab3a..769140a 100644 --- a/docs/specs/openapi.yaml +++ b/docs/specs/openapi.yaml @@ -381,7 +381,7 @@ paths: /media: post: summary: Upload a media file - description: Uploads are capped at 512 MiB. + description: Uploads are capped at 512 MiB and must finish within 15 minutes. requestBody: required: true content: diff --git a/internal/api/api.go b/internal/api/api.go index 587f795..7cab410 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -108,6 +108,7 @@ func (s Server) appVersion() string { } func (s Server) handleUploadMedia(w http.ResponseWriter, r *http.Request) { + extendUploadDeadlines(w) upload, status, err := s.saveUploadToDisk(r) if err != nil { writeError(w, status, err) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index f2d0da0..f507a9e 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -411,6 +411,11 @@ func (r *responseRecorder) WriteHeader(statusCode int) { r.ResponseWriter.WriteHeader(statusCode) } +// Unwrap lets http.ResponseController reach the connection, for per-route deadlines. +func (r *responseRecorder) Unwrap() http.ResponseWriter { + return r.ResponseWriter +} + func generateRequestID() string { var b [12]byte if _, err := rand.Read(b[:]); err != nil { diff --git a/internal/api/upload_deadline_test.go b/internal/api/upload_deadline_test.go new file mode 100644 index 0000000..d25d789 --- /dev/null +++ b/internal/api/upload_deadline_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/antoniolg/postflow/internal/db" +) + +// A slow upload must outlive the server-wide timeouts, which stay short for every other route. +func TestUploadMediaOutlivesServerTimeouts(t *testing.T) { + tempDir := t.TempDir() + store, err := db.Open(filepath.Join(tempDir, "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + defer store.Close() + + srv := Server{Store: store, DataDir: tempDir, DefaultMaxRetries: 3} + ts := httptest.NewUnstartedServer(srv.Handler()) + // Same shape as cmd/postflow-server, scaled down so the test stays fast. + ts.Config.ReadTimeout = 100 * time.Millisecond + ts.Config.WriteTimeout = 200 * time.Millisecond + ts.Start() + defer ts.Close() + + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + go func() { + part, err := writer.CreateFormFile("file", "clip.mp4") + if err != nil { + pw.CloseWithError(err) + return + } + _, _ = part.Write([]byte("first half ")) + time.Sleep(400 * time.Millisecond) // longer than both server timeouts + _, _ = part.Write([]byte("second half")) + _ = writer.Close() + _ = pw.Close() + }() + + resp, err := http.Post(ts.URL+"/media", writer.FormDataContentType(), pr) + if err != nil { + t.Fatalf("upload was cut off by the server timeouts: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 201, got %d: %s", resp.StatusCode, body) + } + + entries, err := os.ReadDir(filepath.Join(tempDir, "media")) + if err != nil { + t.Fatalf("read media dir: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected the uploaded file on disk, found %d entries", len(entries)) + } + got, err := os.ReadFile(filepath.Join(tempDir, "media", entries[0].Name())) + if err != nil { + t.Fatalf("read uploaded file: %v", err) + } + if string(got) != "first half second half" { + t.Fatalf("uploaded file is incomplete: %q", got) + } +} diff --git a/internal/api/upload_stream.go b/internal/api/upload_stream.go index 3968200..a1bb8ad 100644 --- a/internal/api/upload_stream.go +++ b/internal/api/upload_stream.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/antoniolg/postflow/internal/db" ) @@ -17,10 +18,22 @@ import ( const ( maxUploadFieldBytes = 8 << 10 uploadCopyBufferSize = 256 << 10 + // mediaUploadWindow is how long POST /media may take. The server-wide timeouts are + // meant for small requests: at 10 MB/s, the 15 s read timeout stops at about 150 MB. + mediaUploadWindow = 15 * time.Minute ) var maxMediaUploadBytes int64 = 512 << 20 +// extendUploadDeadlines gives this request mediaUploadWindow to send the file and get its +// response. The call fails only for writers without deadlines, such as test recorders. +func extendUploadDeadlines(w http.ResponseWriter) { + rc := http.NewResponseController(w) + deadline := time.Now().Add(mediaUploadWindow) + _ = rc.SetReadDeadline(deadline) + _ = rc.SetWriteDeadline(deadline) +} + type mediaUpload struct { MediaID string Kind string