server-patch.lisp 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. ;;; ==========================================================
  2. ;;; Cloud Upload v1 — server.lisp additions for chad-music
  3. ;;; ==========================================================
  4. ;;;
  5. ;;; Add these definitions to back/server.lisp
  6. ;;; Register the route after existing myway:connect calls
  7. ;;;
  8. ;;; Also requires config.lisp changes — see bottom of file.
  9. ;;;
  10. ;;; Woo body streaming: VERIFIED — (getf myway:*env* :raw-body)
  11. ;;; returns a stream. read-sequence in 64KB chunks streams to
  12. ;;; disk without buffering the entire file in memory.
  13. ;;;
  14. ;;; Review findings addressed (Codex + Gemini):
  15. ;;; - Byte-counting in read loop (don't trust Content-Length)
  16. ;;; - Atomic write: temp file + rename on success, delete on error
  17. ;;; - Content-Type parameter stripping (handles "audio/mpeg; charset=binary")
  18. ;;; - Stronger filename sanitization (allowlist + truncation)
  19. ;;; - 503 for unconfigured upload (not 200)
  20. ;;; - Random suffix for collision avoidance (get-universal-time is 1s resolution)
  21. ;; ---- Configuration ----
  22. (defvar *upload-dir* nil
  23. "Upload directory path. Set in config.lisp.
  24. Must also be mapped in *path-url-mappings* for streaming.")
  25. (defparameter *max-upload-size* (* 200 1024 1024)
  26. "Maximum upload file size in bytes (200 MB).")
  27. (defparameter +allowed-upload-types+
  28. '("audio/mpeg" "audio/flac" "audio/mp4" "audio/x-m4a"
  29. "audio/wav" "audio/aiff" "audio/ogg"))
  30. (defparameter +413+
  31. '(413 (:content-type "application/json")
  32. #.(trivial-utf-8:string-to-utf-8-bytes
  33. "{\"status\":\"error\",\"message\":\"File too large (max 200 MB)\"}")))
  34. (defparameter +503+
  35. '(503 (:content-type "application/json")
  36. #.(trivial-utf-8:string-to-utf-8-bytes
  37. "{\"status\":\"error\",\"message\":\"Upload not configured on server\"}")))
  38. ;; ---- Helpers ----
  39. (defun sanitize-filename (name)
  40. "Keep only safe characters (alphanumeric, dot, dash, underscore, space).
  41. Truncate to 200 chars. Reject empty or all-dot results."
  42. (let* ((clean (remove-if-not
  43. (lambda (c)
  44. (or (alphanumericp c) (find c ".-_ ")))
  45. name))
  46. ;; Strip leading dots and dashes (avoid hidden files / CLI confusion)
  47. (trimmed (string-left-trim ".-" clean))
  48. ;; Truncate to 200 chars (well under 255 FS limit)
  49. (truncated (if (> (length trimmed) 200)
  50. (subseq trimmed 0 200)
  51. trimmed)))
  52. (if (or (zerop (length truncated))
  53. (every (lambda (c) (char= c #\.)) truncated))
  54. "unnamed-upload"
  55. truncated)))
  56. (defun parse-media-type (content-type)
  57. "Extract base media type from Content-Type, stripping parameters.
  58. E.g. 'audio/mpeg; charset=binary' → 'audio/mpeg'."
  59. (when content-type
  60. (string-trim " " (first (split-sequence:split-sequence
  61. #\; content-type :count 1)))))
  62. ;; ---- Upload endpoint ----
  63. (defun upload-file (params)
  64. (declare #.*standard-optimize-settings* (ignorable params))
  65. (with-user (info)
  66. (unless *upload-dir*
  67. (return-from upload-file +503+))
  68. (let* ((headers (getf myway:*env* :headers))
  69. (content-type (parse-media-type (gethash "content-type" headers)))
  70. (content-length
  71. (ignore-errors
  72. (parse-integer
  73. (or (gethash "content-length" headers) "")
  74. :junk-allowed nil)))
  75. (raw-filename (or (gethash "x-filename" headers) "upload"))
  76. (filename (sanitize-filename raw-filename))
  77. (body (getf myway:*env* :raw-body))
  78. (upload-dir (uiop:ensure-directory-pathname *upload-dir*)))
  79. ;; Validate content type
  80. (unless (member content-type +allowed-upload-types+
  81. :test #'string-equal)
  82. (return-from upload-file +400+))
  83. ;; Early reject if Content-Length exceeds limit
  84. (when (and content-length (> content-length *max-upload-size*))
  85. (return-from upload-file +413+))
  86. ;; Ensure upload directory exists
  87. (ensure-directories-exist upload-dir)
  88. ;; Generate unique filename: timestamp-random-sanitized
  89. (let* ((dest-name (format nil "~D-~4,'0D-~A"
  90. (get-universal-time) (random 10000) filename))
  91. (dest (merge-pathnames dest-name upload-dir))
  92. ;; Write to .tmp suffix first, rename on success
  93. (temp (merge-pathnames (concatenate 'string dest-name ".tmp")
  94. upload-dir)))
  95. ;; Stream to temp file with byte counting
  96. (handler-case
  97. (progn
  98. (with-open-file (out temp
  99. :direction :output
  100. :element-type '(unsigned-byte 8)
  101. :if-exists :supersede)
  102. (let ((buf (make-array 65536 :element-type '(unsigned-byte 8)))
  103. (total 0))
  104. (loop for n = (read-sequence buf body)
  105. while (plusp n)
  106. do (incf total n)
  107. (when (> total *max-upload-size*)
  108. (error "Upload exceeds size limit"))
  109. (write-sequence buf out :end n))))
  110. ;; Success — atomic rename
  111. (rename-file temp dest))
  112. (error (e)
  113. ;; Cleanup temp file on any error
  114. (ignore-errors (delete-file temp))
  115. (if (search "size limit" (princ-to-string e))
  116. (return-from upload-file +413+)
  117. (progn (log:error e)
  118. (return-from upload-file +400+)))))
  119. ;; Synchronous rescan — lock to avoid racing with rescanner thread
  120. (bt:with-lock-held (*rescan-lock*)
  121. (multiple-value-bind (added updated removed)
  122. (rescan (list (namestring upload-dir)))
  123. (declare (ignore removed))
  124. (save-db)
  125. (200-json
  126. (list :|status| "imported"
  127. :|tracks_added| added
  128. :|albums_updated| updated))))))))
  129. ;; ---- Route registration (add after existing myway:connect calls) ----
  130. (myway:connect *mapper* "/api/upload" 'upload-file :method :PUT)
  131. ;;; ==========================================================
  132. ;;; config.lisp additions
  133. ;;; ==========================================================
  134. ;;;
  135. ;;; Add to your existing config.lisp:
  136. ;;;
  137. ;;; ;; Upload directory
  138. ;;; (setf chad-music.server:*upload-dir*
  139. ;;; #p"/data/upload/mixboard/")
  140. ;;;
  141. ;;; ;; Map upload dir to URL prefix so uploaded tracks are streamable
  142. ;;; (push (cons "/data/upload/mixboard/" "/upload/")
  143. ;;; chad-music.server:*path-url-mappings*)
  144. ;;;
  145. ;;; The /upload/ URL prefix lets file-server serve uploaded files
  146. ;;; for streaming before beets moves them to the organized library.