| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- ;;; ==========================================================
- ;;; Cloud Upload v1 — server.lisp additions for chad-music
- ;;; ==========================================================
- ;;;
- ;;; Add these definitions to back/server.lisp
- ;;; Register the route after existing myway:connect calls
- ;;;
- ;;; Also requires config.lisp changes — see bottom of file.
- ;;;
- ;;; Woo body streaming: VERIFIED — (getf myway:*env* :raw-body)
- ;;; returns a stream. read-sequence in 64KB chunks streams to
- ;;; disk without buffering the entire file in memory.
- ;;;
- ;;; Review findings addressed (Codex + Gemini):
- ;;; - Byte-counting in read loop (don't trust Content-Length)
- ;;; - Atomic write: temp file + rename on success, delete on error
- ;;; - Content-Type parameter stripping (handles "audio/mpeg; charset=binary")
- ;;; - Stronger filename sanitization (allowlist + truncation)
- ;;; - 503 for unconfigured upload (not 200)
- ;;; - Random suffix for collision avoidance (get-universal-time is 1s resolution)
- ;; ---- Configuration ----
- (defvar *upload-dir* nil
- "Upload directory path. Set in config.lisp.
- Must also be mapped in *path-url-mappings* for streaming.")
- (defparameter *max-upload-size* (* 200 1024 1024)
- "Maximum upload file size in bytes (200 MB).")
- (defparameter +allowed-upload-types+
- '("audio/mpeg" "audio/flac" "audio/mp4" "audio/x-m4a"
- "audio/wav" "audio/aiff" "audio/ogg"))
- (defparameter +413+
- '(413 (:content-type "application/json")
- #.(trivial-utf-8:string-to-utf-8-bytes
- "{\"status\":\"error\",\"message\":\"File too large (max 200 MB)\"}")))
- (defparameter +503+
- '(503 (:content-type "application/json")
- #.(trivial-utf-8:string-to-utf-8-bytes
- "{\"status\":\"error\",\"message\":\"Upload not configured on server\"}")))
- ;; ---- Helpers ----
- (defun sanitize-filename (name)
- "Keep only safe characters (alphanumeric, dot, dash, underscore, space).
- Truncate to 200 chars. Reject empty or all-dot results."
- (let* ((clean (remove-if-not
- (lambda (c)
- (or (alphanumericp c) (find c ".-_ ")))
- name))
- ;; Strip leading dots and dashes (avoid hidden files / CLI confusion)
- (trimmed (string-left-trim ".-" clean))
- ;; Truncate to 200 chars (well under 255 FS limit)
- (truncated (if (> (length trimmed) 200)
- (subseq trimmed 0 200)
- trimmed)))
- (if (or (zerop (length truncated))
- (every (lambda (c) (char= c #\.)) truncated))
- "unnamed-upload"
- truncated)))
- (defun parse-media-type (content-type)
- "Extract base media type from Content-Type, stripping parameters.
- E.g. 'audio/mpeg; charset=binary' → 'audio/mpeg'."
- (when content-type
- (string-trim " " (first (split-sequence:split-sequence
- #\; content-type :count 1)))))
- ;; ---- Upload endpoint ----
- (defun upload-file (params)
- (declare #.*standard-optimize-settings* (ignorable params))
- (with-user (info)
- (unless *upload-dir*
- (return-from upload-file +503+))
- (let* ((headers (getf myway:*env* :headers))
- (content-type (parse-media-type (gethash "content-type" headers)))
- (content-length
- (ignore-errors
- (parse-integer
- (or (gethash "content-length" headers) "")
- :junk-allowed nil)))
- (raw-filename (or (gethash "x-filename" headers) "upload"))
- (filename (sanitize-filename raw-filename))
- (body (getf myway:*env* :raw-body))
- (upload-dir (uiop:ensure-directory-pathname *upload-dir*)))
- ;; Validate content type
- (unless (member content-type +allowed-upload-types+
- :test #'string-equal)
- (return-from upload-file +400+))
- ;; Early reject if Content-Length exceeds limit
- (when (and content-length (> content-length *max-upload-size*))
- (return-from upload-file +413+))
- ;; Ensure upload directory exists
- (ensure-directories-exist upload-dir)
- ;; Generate unique filename: timestamp-random-sanitized
- (let* ((dest-name (format nil "~D-~4,'0D-~A"
- (get-universal-time) (random 10000) filename))
- (dest (merge-pathnames dest-name upload-dir))
- ;; Write to .tmp suffix first, rename on success
- (temp (merge-pathnames (concatenate 'string dest-name ".tmp")
- upload-dir)))
- ;; Stream to temp file with byte counting
- (handler-case
- (progn
- (with-open-file (out temp
- :direction :output
- :element-type '(unsigned-byte 8)
- :if-exists :supersede)
- (let ((buf (make-array 65536 :element-type '(unsigned-byte 8)))
- (total 0))
- (loop for n = (read-sequence buf body)
- while (plusp n)
- do (incf total n)
- (when (> total *max-upload-size*)
- (error "Upload exceeds size limit"))
- (write-sequence buf out :end n))))
- ;; Success — atomic rename
- (rename-file temp dest))
- (error (e)
- ;; Cleanup temp file on any error
- (ignore-errors (delete-file temp))
- (if (search "size limit" (princ-to-string e))
- (return-from upload-file +413+)
- (progn (log:error e)
- (return-from upload-file +400+)))))
- ;; Synchronous rescan — lock to avoid racing with rescanner thread
- (bt:with-lock-held (*rescan-lock*)
- (multiple-value-bind (added updated removed)
- (rescan (list (namestring upload-dir)))
- (declare (ignore removed))
- (save-db)
- (200-json
- (list :|status| "imported"
- :|tracks_added| added
- :|albums_updated| updated))))))))
- ;; ---- Route registration (add after existing myway:connect calls) ----
- (myway:connect *mapper* "/api/upload" 'upload-file :method :PUT)
- ;;; ==========================================================
- ;;; config.lisp additions
- ;;; ==========================================================
- ;;;
- ;;; Add to your existing config.lisp:
- ;;;
- ;;; ;; Upload directory
- ;;; (setf chad-music.server:*upload-dir*
- ;;; #p"/data/upload/mixboard/")
- ;;;
- ;;; ;; Map upload dir to URL prefix so uploaded tracks are streamable
- ;;; (push (cons "/data/upload/mixboard/" "/upload/")
- ;;; chad-music.server:*path-url-mappings*)
- ;;;
- ;;; The /upload/ URL prefix lets file-server serve uploaded files
- ;;; for streaming before beets moves them to the organized library.
|