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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules
ui/dist
/.vs
/.vscode
.codex
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.

---
---
82 changes: 74 additions & 8 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<send-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**

Expand Down
157 changes: 125 additions & 32 deletions internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"github.com/gin-gonic/gin"
Expand All @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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"})
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 != ""})
}
}
}
Loading
Loading