diff --git a/.gitignore b/.gitignore index 90d0ed1..3ec68f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules ui/dist /.vs /.vscode +.codex diff --git a/CHANGELOG.md b/CHANGELOG.md index 4166dfd..093a454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [10.0.10] + +### Added +- Added dedicated curl-friendly creation endpoints: + - `POST /send/text` for text pastes + - `POST /send/file` for file pastes +- Added support for raw-body paste creation: + - Text via raw request body to `/send/text` + - Files via raw binary body to `/send/file` with `filename` query param (or `X-Filename` header) +- Expanded API documentation in `Readme.md` with practical curl examples for text and file flows. + +### Changed +- Kept `POST /send` backward-compatible while improving create handling for CLI workflows. +- Hardened file storage handling to fall back to a safe temp directory when `STORAGE_PATH` is unset. + ## [1.0.9] ### Changed @@ -91,4 +106,4 @@ Other Enhancements: - Updated Docker images to use multi-stage builds for backend and frontend. - Improved project documentation and added sections for installation, configuration, and deployment. ---- \ No newline at end of file +--- diff --git a/Readme.md b/Readme.md index 5303707..a46fe1f 100644 --- a/Readme.md +++ b/Readme.md @@ -146,14 +146,80 @@ MAX_FILE_SIZE=10485760 ### **API Endpoints** -| Method | Endpoint | Description | -|--------|--------------------|------------------------------------------| -| `POST` | `/send` | Create a new send (text or file) | -| `GET` | `/send/:id` | Retrieve a send by its hash | -| `GET` | `/send/:id/check` | Check if a send requires a password | - - ---- +| Method | Endpoint | Description | +|--------|--------------------|------------------------------------------| +| `POST` | `/send` | Create a new send (text or file) | +| `POST` | `/send/text` | Create a text send (curl-friendly) | +| `POST` | `/send/file` | Create a file send (curl-friendly) | +| `GET` | `/send/:id` | Retrieve a send by its hash | +| `GET` | `/send/:id/check` | Check if a send requires a password | + +### **curl Examples** + +Base URL: + +```bash +BASE_URL="http://localhost:8080" +``` + +Create a text paste from a raw request body: + +```bash +curl -sS \ + -X POST "$BASE_URL/send/text?expires=24h&onetime=true&password=my-pass" \ + -H "Content-Type: text/plain" \ + --data-binary "my secret text" +``` + +Create a text paste using form data: + +```bash +curl -sS \ + -X POST "$BASE_URL/send/text" \ + -d "data=my secret text" \ + -d "expires=24h" \ + -d "onetime=true" +``` + +Create a file paste with multipart upload: + +```bash +curl -sS \ + -X POST "$BASE_URL/send/file?expires=24h&onetime=true&password=my-pass" \ + -F "file=@./secret.pdf" +``` + +Create a file paste from raw binary (no multipart): + +```bash +curl -sS \ + -X POST "$BASE_URL/send/file?filename=secret.pdf&expires=24h" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@./secret.pdf" +``` + +Backward-compatible create endpoint (`/send`) still works with `type=text` or `type=file`. + +Response shape for create endpoints: + +```json +{"hash":""} +``` + +Extract hash and print a ready-to-share URL: + +```bash +HASH=$(curl -sS -X POST "$BASE_URL/send/text" --data-binary "my secret" | jq -r '.hash') +echo "$BASE_URL/send/$HASH" +``` + +Create options accepted on all create endpoints: +- `password`: Optional decryption password. +- `onetime`: `true` or `false`. +- `expires`: Go duration format (examples: `30m`, `24h`, `7d` is not valid; use `168h`). + + +--- ## 🤝 **Contributing** diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index add005e..6262c00 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -8,6 +8,8 @@ import ( "net/http" "os" "path/filepath" + "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -21,20 +23,32 @@ import ( // CreateSend handles creation of a new send. // It accepts form data for type (text/file), optional password, one-time use, and expiration. func CreateSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { + return createSendWithType(cfg, db, "") +} + +// CreateTextSend handles text send creation without requiring a type field. +func CreateTextSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { + return createSendWithType(cfg, db, "text") +} + +// CreateFileSend handles file send creation without requiring a type field. +func CreateFileSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { + return createSendWithType(cfg, db, "file") +} + +func createSendWithType(cfg config.Config, db *gorm.DB, forcedType string) gin.HandlerFunc { return func(c *gin.Context) { - // Explicitly parse the multipart form before accessing form values. - // This is crucial for handling mixed file/text forms reliably in Gin. - if err := c.Request.ParseMultipartForm(cfg.MaxFileSize + 1024*1024); err != nil { // Add buffer to max size - log.Println("Error parsing multipart form:", err) - c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid form data or file too large"}) - return + stype := strings.TrimSpace(forcedType) + if stype == "" { + stype = strings.TrimSpace(c.PostForm("type")) + if stype == "" { + stype = strings.TrimSpace(c.Query("type")) + } } - // Use c.Request.FormValue now that the form is parsed. - stype := c.Request.FormValue("type") - pw := c.Request.FormValue("password") - ot := c.Request.FormValue("onetime") - exp := c.Request.FormValue("expires") + pw := firstNonEmpty(c.PostForm("password"), c.Query("password")) + ot := firstNonEmpty(c.PostForm("onetime"), c.Query("onetime")) + exp := firstNonEmpty(c.PostForm("expires"), c.Query("expires")) log.Println("CreateSend called with type:", stype) @@ -44,7 +58,14 @@ func CreateSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { return } - oneTime := (ot == "true") + oneTime, err := strconv.ParseBool(ot) + if ot == "" { + oneTime = false + } else if err != nil { + log.Println("Error parsing onetime flag:", err) + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid onetime value"}) + return + } log.Println("One-Time:", oneTime) var expiresAt time.Time @@ -72,7 +93,12 @@ func CreateSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { key := deriveKey(pw, cfg) if stype == "text" { - text := c.Request.FormValue("data") + text, err := readTextPayload(c, cfg.MaxFileSize) + if err != nil { + log.Println("Error reading text payload:", err) + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Text size exceeds the maximum allowed limit"}) + return + } if text == "" { log.Println("Error: 'data' field is empty for text type") c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Data field is required for text type"}) @@ -101,38 +127,38 @@ func CreateSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { } if stype == "file" { - // Use c.Request.FormFile now that the form is parsed. - file, header, err := c.Request.FormFile("file") + fileData, fileName, err := readFilePayload(c, cfg.MaxFileSize) if err != nil { - log.Println("Error retrieving file from form data:", err) + log.Println("Error retrieving file from request:", err) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Failed to retrieve file from form data"}) return } - defer file.Close() - - log.Println("Received file:", header.Filename, "Size:", header.Size) - - if header.Size > cfg.MaxFileSize { - log.Printf("Error: File size (%d bytes) exceeds maximum allowed size (%d bytes)\n", header.Size, cfg.MaxFileSize) + if int64(len(fileData)) > cfg.MaxFileSize { + log.Printf("Error: File size (%d bytes) exceeds maximum allowed size (%d bytes)\n", len(fileData), cfg.MaxFileSize) c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"error": "File size exceeds the maximum allowed limit"}) return } - data, err := io.ReadAll(file) - if err != nil { - log.Println("Error reading file data:", err) - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file data"}) - return - } + log.Println("Received file:", fileName, "Size:", len(fileData)) - enc, err := security.EncryptData(data, key) + enc, err := security.EncryptData(fileData, key) if err != nil { log.Println("Error encrypting file data:", err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to encrypt file data"}) return } - fp := filepath.Join(cfg.StoragePath, hash) + storagePath := cfg.StoragePath + if strings.TrimSpace(storagePath) == "" { + storagePath = os.TempDir() + } + if err := os.MkdirAll(storagePath, 0700); err != nil { + log.Println("Error preparing storage path:", err) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to prepare storage path"}) + return + } + + fp := filepath.Join(storagePath, hash) if err := os.WriteFile(fp, []byte(enc), 0600); err != nil { log.Println("Error writing encrypted file to storage:", err) c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Failed to write encrypted file to storage"}) @@ -145,7 +171,7 @@ func CreateSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { Hash: hash, Type: "file", FilePath: fp, - FileName: header.Filename, + FileName: sanitizeFilename(fileName), Password: pw, OneTime: oneTime, ExpiresAt: expiresAt, @@ -161,6 +187,73 @@ func CreateSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { } } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func readTextPayload(c *gin.Context, maxSize int64) (string, error) { + if text, ok := c.GetPostForm("data"); ok { + return text, nil + } + + limited := io.LimitReader(c.Request.Body, maxSize+1) + raw, err := io.ReadAll(limited) + if err != nil { + return "", err + } + if int64(len(raw)) > maxSize { + return "", fmt.Errorf("text payload exceeds max size") + } + + return string(raw), nil +} + +func readFilePayload(c *gin.Context, maxSize int64) ([]byte, string, error) { + if fileHeader, err := c.FormFile("file"); err == nil { + file, err := fileHeader.Open() + if err != nil { + return nil, "", err + } + defer file.Close() + + limited := io.LimitReader(file, maxSize+1) + raw, err := io.ReadAll(limited) + if err != nil { + return nil, "", err + } + return raw, fileHeader.Filename, nil + } + + limited := io.LimitReader(c.Request.Body, maxSize+1) + raw, err := io.ReadAll(limited) + if err != nil { + return nil, "", err + } + if len(raw) == 0 { + return nil, "", fmt.Errorf("empty file payload") + } + + fileName := firstNonEmpty(c.Query("filename"), c.GetHeader("X-Filename")) + if fileName == "" { + fileName = "upload.bin" + } + + return raw, fileName, nil +} + +func sanitizeFilename(name string) string { + base := filepath.Base(strings.TrimSpace(name)) + if base == "" || base == "." || base == string(filepath.Separator) { + return "upload.bin" + } + return base +} + // GetSend handles retrieving and decrypting a send by its hash. func GetSend(cfg config.Config, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { @@ -248,4 +341,4 @@ func CheckPasswordProtection(db *gorm.DB) gin.HandlerFunc { // Return whether the send requires a password c.JSON(http.StatusOK, gin.H{"requiresPassword": s.Password != ""}) } -} \ No newline at end of file +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index a3aff36..9c2a8a8 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -29,6 +29,8 @@ func setupTestDB() *gorm.DB { func setupTestRouter(cfg config.Config, db *gorm.DB) *gin.Engine { r := gin.Default() r.POST("/send", CreateSend(cfg, db)) + r.POST("/send/text", CreateTextSend(cfg, db)) + r.POST("/send/file", CreateFileSend(cfg, db)) r.GET("/send/:id", GetSend(cfg, db)) r.GET("/send/:id/check", CheckPasswordProtection(db)) return r @@ -139,6 +141,46 @@ func TestCreateSendText(t *testing.T) { } } +func TestCreateTextSendWithRawBody(t *testing.T) { + db := setupTestDB() + cfg := config.Config{ + SecretKey: "supersecretkeysupersecretkey32", + MaxFileSize: 1024 * 1024, // 1MB + } + r := setupTestRouter(cfg, db) + + body := bytes.NewBufferString("this came from curl raw body") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/send/text", body) + req.Header.Set("Content-Type", "text/plain") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 got %d", w.Code) + } +} + +func TestCreateFileSendWithRawBody(t *testing.T) { + db := setupTestDB() + cfg := config.Config{ + SecretKey: "supersecretkeysupersecretkey32", + MaxFileSize: 1024 * 1024, // 1MB + } + r := setupTestRouter(cfg, db) + + body := bytes.NewBufferString("file-bytes-from-curl") + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/send/file?filename=raw.txt", body) + req.Header.Set("Content-Type", "application/octet-stream") + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 got %d", w.Code) + } +} + func TestCreateSendFileTooLarge(t *testing.T) { db := setupTestDB() cfg := config.Config{ diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 587271e..0883959 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -8,8 +8,8 @@ import ( "github.com/jinzhu/gorm" "golang.org/x/time/rate" - "github.com/kek-Sec/gopherdrop/internal/handlers" "github.com/kek-Sec/gopherdrop/internal/config" + "github.com/kek-Sec/gopherdrop/internal/handlers" ) // Define a rate limiter with 1 request per second and a burst of 5. @@ -40,6 +40,8 @@ func SetupRouter(cfg config.Config, db *gorm.DB) *gin.Engine { // Apply rate limiting only to the POST /send endpoint r.POST("/send", rateLimiterMiddleware, handlers.CreateSend(cfg, db)) + r.POST("/send/text", rateLimiterMiddleware, handlers.CreateTextSend(cfg, db)) + r.POST("/send/file", rateLimiterMiddleware, handlers.CreateFileSend(cfg, db)) // Other routes without rate limiting r.GET("/send/:id", handlers.GetSend(cfg, db)) diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index 1f6fe29..1935aba 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -14,6 +14,7 @@ import ( "github.com/kek-Sec/gopherdrop/internal/config" "github.com/kek-Sec/gopherdrop/internal/models" "github.com/stretchr/testify/assert" + "golang.org/x/time/rate" ) func setupTestDB() *gorm.DB { @@ -41,10 +42,11 @@ func setupTestDB() *gorm.DB { return db } - func setupTestRouter() *gin.Engine { + limiter = rate.NewLimiter(1, 6) cfg := config.Config{ - SecretKey: "supersecretkey", + SecretKey: "supersecretkey", + MaxFileSize: 1024 * 1024, } db := setupTestDB() return SetupRouter(cfg, db) @@ -60,6 +62,8 @@ func TestRoutesExist(t *testing.T) { status int }{ {"POST", "/send", "type=text&data=test", http.StatusOK}, + {"POST", "/send/text", "data=test", http.StatusOK}, + {"POST", "/send/file", "test file payload", http.StatusBadRequest}, {"GET", "/send/testhash", "", http.StatusNotFound}, {"GET", "/send/testhash/check", "", http.StatusNotFound}, } @@ -69,7 +73,11 @@ func TestRoutesExist(t *testing.T) { var req *http.Request if tt.method == "POST" { req = httptest.NewRequest(tt.method, tt.endpoint, strings.NewReader(tt.payload)) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if tt.endpoint == "/send/file" { + req.Header.Set("Content-Type", "application/octet-stream") + } else { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } } else { req = httptest.NewRequest(tt.method, tt.endpoint, nil) } @@ -81,7 +89,6 @@ func TestRoutesExist(t *testing.T) { } } - func TestCORSHeaders(t *testing.T) { router := setupTestRouter() @@ -103,8 +110,8 @@ func TestRateLimiter(t *testing.T) { // Define a payload for the POST request payload := "type=text&data=test" - // Simulate 5 requests (the burst capacity) in quick succession with slight delays - for i := 0; i < 5; i++ { + // Simulate 6 requests (the burst capacity) in quick succession. + for i := 0; i < 6; i++ { req := httptest.NewRequest("POST", "/send", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -112,17 +119,14 @@ func TestRateLimiter(t *testing.T) { router.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code, "Request %d should succeed within the burst capacity", i+1) - - // Introduce a small delay (e.g., 10 milliseconds) between requests - time.Sleep(10 * time.Millisecond) } - // The 6th request should be rate limited and return a 429 status + // The 7th request should be rate limited and return a 429 status req := httptest.NewRequest("POST", "/send", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() router.ServeHTTP(w, req) - assert.Equal(t, http.StatusTooManyRequests, w.Code, "6th request should be rate limited") + assert.Equal(t, http.StatusTooManyRequests, w.Code, "7th request should be rate limited") } diff --git a/version.yaml b/version.yaml index 63a39f5..adc2db2 100644 --- a/version.yaml +++ b/version.yaml @@ -1,2 +1,2 @@ #Application version following https://semver.org/ -version: 1.0.9 \ No newline at end of file +version: 1.0.10 \ No newline at end of file