summaryrefslogtreecommitdiff
path: root/internal/api/drops.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/drops.go')
-rw-r--r--internal/api/drops.go165
1 files changed, 165 insertions, 0 deletions
diff --git a/internal/api/drops.go b/internal/api/drops.go
new file mode 100644
index 0000000..a5000f1
--- /dev/null
+++ b/internal/api/drops.go
@@ -0,0 +1,165 @@
+package api
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// handleListDrops returns a JSON array of files in the drops directory.
+func (s *Server) handleListDrops(w http.ResponseWriter, r *http.Request) {
+ if s.dropsDir == "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"})
+ return
+ }
+
+ entries, err := os.ReadDir(s.dropsDir)
+ if err != nil {
+ if os.IsNotExist(err) {
+ writeJSON(w, http.StatusOK, []map[string]interface{}{})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list drops"})
+ return
+ }
+
+ type fileEntry struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Modified time.Time `json:"modified"`
+ }
+ files := []fileEntry{}
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ files = append(files, fileEntry{
+ Name: e.Name(),
+ Size: info.Size(),
+ Modified: info.ModTime().UTC(),
+ })
+ }
+ writeJSON(w, http.StatusOK, files)
+}
+
+// handleGetDrop serves a file from the drops directory as an attachment.
+func (s *Server) handleGetDrop(w http.ResponseWriter, r *http.Request) {
+ if s.dropsDir == "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"})
+ return
+ }
+
+ filename := r.PathValue("filename")
+ if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+
+ path := filepath.Join(s.dropsDir, filepath.Clean(filename))
+ // Extra safety: ensure the resolved path is still inside dropsDir.
+ if !strings.HasPrefix(path, s.dropsDir) {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+
+ f, err := os.Open(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "file not found"})
+ return
+ }
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to open file"})
+ return
+ }
+ defer f.Close()
+
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
+ w.Header().Set("Content-Type", "application/octet-stream")
+ io.Copy(w, f) //nolint:errcheck
+}
+
+// handlePostDrop accepts a file upload (multipart/form-data or raw body with ?filename=).
+func (s *Server) handlePostDrop(w http.ResponseWriter, r *http.Request) {
+ if s.dropsDir == "" {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "drops directory not configured"})
+ return
+ }
+
+ if err := os.MkdirAll(s.dropsDir, 0700); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create drops directory"})
+ return
+ }
+
+ ct := r.Header.Get("Content-Type")
+ if strings.Contains(ct, "multipart/form-data") {
+ s.handleMultipartDrop(w, r)
+ return
+ }
+
+ // Raw body with ?filename= query param.
+ filename := r.URL.Query().Get("filename")
+ if filename == "" {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "filename query param required for raw upload"})
+ return
+ }
+ if strings.Contains(filename, "/") || strings.Contains(filename, "..") {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+ path := filepath.Join(s.dropsDir, filename)
+ data, err := io.ReadAll(r.Body)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read body"})
+ return
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file"})
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": len(data)})
+}
+
+func (s *Server) handleMultipartDrop(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(32 << 20); err != nil { // 32 MB limit
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to parse multipart form: " + err.Error()})
+ return
+ }
+
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'file' field: " + err.Error()})
+ return
+ }
+ defer file.Close()
+
+ filename := filepath.Base(header.Filename)
+ if filename == "" || filename == "." {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid filename"})
+ return
+ }
+
+ path := filepath.Join(s.dropsDir, filename)
+ dst, err := os.Create(path)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create file"})
+ return
+ }
+ defer dst.Close()
+
+ n, err := io.Copy(dst, file)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to write file"})
+ return
+ }
+
+ writeJSON(w, http.StatusCreated, map[string]interface{}{"name": filename, "size": n})
+}
+