Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions internal/api/upload_deadline_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 13 additions & 0 deletions internal/api/upload_stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,30 @@ import (
"os"
"path/filepath"
"strings"
"time"

"github.com/antoniolg/postflow/internal/db"
)

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
Expand Down