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
73 changes: 73 additions & 0 deletions internal/api/handlers.download.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package api

import (
"encoding/json"
"fmt"
"net/http"
"time"

"github.com/go-chi/chi/v5"
)

// handleGetDropMetadata returns info about the file (name, size, salt)
// The CLI needs this *before* it starts downloading to set up decryption.
func (s *Server) handleGetDropMetadata() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dropID := chi.URLParam(r, "id")

var resp GetDropMetadataResponse
var expiresAt time.Time

// 1. Fetch metadata from Postgres
query := `
SELECT file_name, file_size, encryption_salt, expires_at
FROM drops WHERE id = $1`

err := s.DB.QueryRow(query, dropID).Scan(
&resp.FileName, &resp.FileSize, &resp.EncryptionSalt, &expiresAt,
)

if err != nil {
http.Error(w, "Drop not found", http.StatusNotFound)
return
}
Comment on lines +30 to +33

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QueryRow(...).Scan(...) errors are all mapped to 404. This will incorrectly return "Drop not found" on transient DB errors. Handle sql.ErrNoRows as 404, but return 500 (and ideally log) for other errors.

Copilot uses AI. Check for mistakes.

// 2. Check Expiry
if time.Now().After(expiresAt) {
http.Error(w, "Drop has expired", http.StatusGone)
return
}

// 3. Get chunk count (to know how many pieces to expect)
err = s.DB.QueryRow("SELECT COUNT(*) FROM chunks WHERE drop_id = $1", dropID).Scan(&resp.ChunkCount)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
Comment on lines +41 to +45

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

COUNT(*) in Postgres returns BIGINT; scanning into resp.ChunkCount (int) can cause conversion issues and is platform-dependent. Scan into an int64 variable and then convert (with bounds check) before assigning to the response.

Copilot uses AI. Check for mistakes.
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
}

// handleDownloadChunk retrieves a specific piece of binary data
func (s *Server) handleDownloadChunk() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dropID := chi.URLParam(r, "id")
chunkIndex := chi.URLParam(r, "chunkIndex") // We'll add this param to router

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "We'll add this param to router" but the router already defines {chunkIndex}. Update/remove this comment to avoid misleading future changes.

Suggested change
chunkIndex := chi.URLParam(r, "chunkIndex") // We'll add this param to router
chunkIndex := chi.URLParam(r, "chunkIndex") // chunkIndex is provided as a URL parameter

Copilot uses AI. Check for mistakes.

// 1. Construct S3 Key
key := fmt.Sprintf("drops/%s/%s", dropID, chunkIndex)
Comment on lines +56 to +60

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Download endpoint builds the S3 key directly from the URL chunkIndex without validating it or checking the drop/chunk state in Postgres (expiry, deletion, max/current downloads, chunk existence). Add validation (e.g. numeric index) and enforce drop expiry/download limits before serving data.

Copilot uses AI. Check for mistakes.

// 2. Fetch from S3
data, err := s.Store.DownloadChunk(key)
if err != nil {
http.Error(w, "Chunk not found or storage error", http.StatusNotFound)
return
Comment on lines +63 to +66

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This maps any DownloadChunk error to 404, which can hide genuine storage/permission/outage errors. Consider returning 404 only for missing objects and 500 for other errors (and/or log the underlying error).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

}

// 3. Stream binary back
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
}
}
File renamed without changes.
8 changes: 8 additions & 0 deletions internal/api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ type ChunkUploadResponse struct {
// 2. Server responds with CreateDropResponse (drop ID and expiration time)
// 3. CLI uploads file chunks to /api/v1/drops/{drop_id}/chunks (POST) with ChunkUploadResponse confirming each chunk

// GetDropMetadataResponse is what the server sends back when the CLI requests metadata about a drop
type GetDropMetadataResponse struct {
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
EncryptionSalt string `json:"encryption_salt"`
ChunkCount int `json:"chunk_count"`
}

4 changes: 4 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,9 @@ func (s *Server) routes() {
// Upload Endpoints
r.Post("/drop", s.handleCreateDrop())
r.Post("/drop/{id}/chunk", s.handleUploadChunk())

// Download Endpoints
r.Get("/drop/{id}", s.handleGetDropMetadata())
r.Get("/drop/{id}/chunk/{chunkIndex}", s.handleDownloadChunk())
})
}