server.lisp 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  1. (in-package :cl-user)
  2. (defpackage chad-music.server
  3. (:use :cl #:alexandria #:chad-music.db #:chad-music.utils #:jonathan)
  4. (:export #:main))
  5. (in-package :chad-music.server)
  6. (defvar *path-url-mappings* nil "alist map database paths to urls")
  7. ;; Server database
  8. (defstruct server
  9. (token-user (make-hash-table :test #'equal) :type hash-table))
  10. (defvar *server* (make-server) "Server database")
  11. (defvar *server-path* "db-server.sexp")
  12. (defun save-server (&optional (file-name *server-path*))
  13. (declare #.*standard-optimize-settings*)
  14. (with-output-to-file (s file-name :if-exists :supersede)
  15. (labels ((print-table (table)
  16. (print (hash-table-plist table) s)))
  17. (print-table (server-token-user *server*)))))
  18. (defun load-server (&optional (file-name *server-path*))
  19. (declare #.*standard-optimize-settings*)
  20. (with-input-from-file (s file-name)
  21. (make-server
  22. :token-user (plist-hash-table (read s nil) :test #'equal))))
  23. (defun get-url (path)
  24. (declare #.*standard-optimize-settings*)
  25. (when (and path (pathnamep path))
  26. (let ((path (uiop:unix-namestring path)))
  27. (loop for (path-prefix . url-prefix) in *path-url-mappings*
  28. do (multiple-value-bind (foundp suffix)
  29. (starts-with-subseq path-prefix path :return-suffix t)
  30. (when foundp
  31. (return-from get-url
  32. (format nil "~A~{~A~^/~}" url-prefix
  33. (mapcar #'quri:url-encode
  34. (split-sequence:split-sequence #\/ suffix))))))))))
  35. (defmacro maybe-key-value (key value)
  36. `(when ,value
  37. (write-key-value ,key ,value)))
  38. (defmethod %to-json ((album album))
  39. (with-object
  40. (maybe-key-value "id" (album-id album))
  41. (maybe-key-value "artist" (album-artist album))
  42. (maybe-key-value "year" (album-year album))
  43. (maybe-key-value "album" (album-album album))
  44. (maybe-key-value "original_date" (album-original-date album))
  45. (maybe-key-value "publisher" (album-publisher album))
  46. (maybe-key-value "country" (album-country album))
  47. (maybe-key-value "genre" (album-genre album))
  48. (maybe-key-value "type" (or (album-type album) "release"))
  49. (maybe-key-value "status" (album-status album))
  50. (maybe-key-value "mb_id" (album-mb-id album))
  51. (maybe-key-value "track_count" (album-track-count album))
  52. (maybe-key-value "total_duration" (album-total-duration album))
  53. (maybe-key-value "cover" (get-url (album-cover album)))))
  54. (defmethod %to-json ((track track))
  55. (let ((album-artist (album-artist (track-album track)))
  56. (track-artist (track-artist track)))
  57. (with-object
  58. (maybe-key-value "id" (track-id track))
  59. (maybe-key-value "artist" track-artist)
  60. (maybe-key-value "album_artist" (unless (equal track-artist album-artist) album-artist))
  61. (maybe-key-value "album" (album-album (track-album track)))
  62. (maybe-key-value "year" (album-year (track-album track)))
  63. (maybe-key-value "no" (clear-track-no (track-no track)))
  64. (maybe-key-value "title" (track-title track))
  65. (maybe-key-value "bit_rate" (track-bit-rate track))
  66. (maybe-key-value "duration" (track-duration track))
  67. (maybe-key-value "url" (get-url (track-path track)))
  68. (maybe-key-value "cover" (get-url (album-cover (track-album track)))))))
  69. (defparameter +400+ '(400 nil #.(trivial-utf-8:string-to-utf-8-bytes "Bad Request")))
  70. (defparameter +401+ '(401 nil #.(trivial-utf-8:string-to-utf-8-bytes "Unauthorized")))
  71. (defparameter +404+ '(404 nil #.(trivial-utf-8:string-to-utf-8-bytes "Not found")))
  72. (defparameter +413+
  73. '(413 (:content-type "application/json")
  74. #.(trivial-utf-8:string-to-utf-8-bytes
  75. "{\"status\":\"error\",\"message\":\"File too large (max 200 MB)\"}")))
  76. (defparameter +503+
  77. '(503 (:content-type "application/json")
  78. #.(trivial-utf-8:string-to-utf-8-bytes
  79. "{\"status\":\"error\",\"message\":\"Upload not configured on server\"}")))
  80. (defparameter +200-empty+ '(200 (:content-type "application/json")
  81. #.(trivial-utf-8:string-to-utf-8-bytes "{}")))
  82. (defun 200-json (data &optional (dumper #'to-json))
  83. (declare #.*standard-optimize-settings*
  84. (type function dumper))
  85. `(200 (:content-type "application/json")
  86. ,(trivial-utf-8:string-to-utf-8-bytes (funcall dumper data))))
  87. (let ((db-package (find-package :chad-music.db)))
  88. (defun getsym (place indicator)
  89. (declare #.*standard-optimize-settings*
  90. (type list place)
  91. (type symbol indicator))
  92. (intern (string-upcase (getf place indicator)) db-package)))
  93. (defun aget (place indicator &key (test #'equal))
  94. (declare #.*standard-optimize-settings*
  95. (type list place)
  96. (type (or string symbol) indicator)
  97. (type function test))
  98. (cdr (assoc indicator place :test test)))
  99. (defun may-integer (string)
  100. (declare #.*standard-optimize-settings*
  101. (type (or null string) string))
  102. (when string
  103. (parse-integer string :junk-allowed t)))
  104. (defun get-restrictions (query-params)
  105. (declare #.*standard-optimize-settings*
  106. (type list query-params))
  107. (loop for key in '(artist year album publisher country genre type status)
  108. for value = (aget query-params (string-downcase (symbol-name key)))
  109. when value collect (cons key (case key
  110. (year (parse-integer value :junk-allowed t))
  111. (otherwise value)))))
  112. (defparameter +max-limit+ 10000)
  113. (defmacro with-category ((params category filter restrictions offset limit latest) &body body)
  114. (with-gensyms (query-string query-params)
  115. `(let ((,category (getsym ,params :category)))
  116. (case ,category
  117. ((artist year album publisher country genre type status)
  118. (let* ((,query-string (getf myway:*env* :query-string))
  119. (,query-params (and ,query-string (quri:url-decode-params ,query-string)))
  120. (,filter (aget ,query-params "filter"))
  121. (,restrictions (get-restrictions ,query-params))
  122. (,offset (or (may-integer (aget ,query-params "offset")) 0))
  123. (,limit (min (the fixnum +max-limit+)
  124. (the fixnum (or (may-integer (aget ,query-params "limit")) +max-limit+))))
  125. (,latest (aget ,query-params "latest")))
  126. ,@body))
  127. (otherwise +404+)))))
  128. (defmacro with-user ((info) &body body)
  129. (with-gensyms (auth bearer headers)
  130. `(let* ((,headers (getf myway:*env* :headers))
  131. (,auth (ignore-errors (gethash "authorization" ,headers)))
  132. (,bearer (when (and ,auth
  133. (> (length (the string ,auth)) 7)
  134. (equal "Bearer " (subseq (the string ,auth) 0 7)))
  135. (subseq (the string ,auth) 7)))
  136. (,info (when ,bearer (gethash ,bearer (server-token-user *server*)))))
  137. (when (and (null ,info) (null (gethash "x-real-ip" ,headers)))
  138. (setf ,info `(:|username| "admin" :|id| 0 :|first_name| "cli" :|last_name| "admin")))
  139. (if ,info (handler-case (progn ,@body) (error (e) (log:error e) (print e)))
  140. +401+))))
  141. (defun get-category-list (params)
  142. (declare #.*standard-optimize-settings* (ignore params))
  143. (with-user (info)
  144. (200-json '("artist" "year" "album" "publisher" "country" "genre" "type" "status"))))
  145. (defun get-category-size (params)
  146. (declare #.*standard-optimize-settings*)
  147. (with-user (info)
  148. (with-category (params category filter restrictions offset limit latest)
  149. (declare (ignore offset limit latest))
  150. (200-json (query-category category
  151. :filter filter :restrictions restrictions
  152. :count-only t)))))
  153. (defun dumps-category-result (results)
  154. (with-output-to-string*
  155. (with-array
  156. (loop for (cat . count) in results
  157. do (write-item
  158. (with-object
  159. (write-key-value "item" cat)
  160. (write-key-value "count" count)))))))
  161. (defun get-category (params)
  162. (declare #.*standard-optimize-settings*)
  163. (with-user (info)
  164. (with-category (params category filter restrictions offset limit latest)
  165. (200-json (query-category category
  166. :filter filter :restrictions restrictions
  167. :limit limit :offset offset :latest latest)
  168. (case category
  169. (album #'to-json)
  170. (t #'dumps-category-result))))))
  171. (defun get-album-tracks (params)
  172. (declare #.*standard-optimize-settings*)
  173. (with-user (info)
  174. (200-json (album-tracks (getf params :id)))))
  175. (defun file-server (root)
  176. (lambda (params)
  177. (declare #.*standard-optimize-settings*)
  178. (let ((file (probe-file (cl-fad:merge-pathnames-as-file
  179. root (uiop:parse-unix-namestring (car (getf params :splat)))))))
  180. (if file (list 200 nil file) +404+))))
  181. ;; Admin tools
  182. (defvar *rescans* nil)
  183. (defun update-db ()
  184. (let (added updated removed)
  185. (sb-impl::call-with-timing #'(lambda (&rest args)
  186. (push (append args
  187. (list :timestamp (get-universal-time)
  188. :added added
  189. :updated updated
  190. :removed removed))
  191. *rescans*))
  192. #'(lambda ()
  193. (multiple-value-bind (a u r)
  194. (rescan (mapcar 'car *path-url-mappings*))
  195. (save-db)
  196. (setf added a updated u removed r))))))
  197. (defvar *rescan-lock* (bt:make-lock "Rescan lock"))
  198. (defvar *rescan-cond* (bt:make-condition-variable :name "Rescan requested"))
  199. (defvar *rescan-thread* nil)
  200. (defvar *rescan-active* nil)
  201. (defun rescanner ()
  202. (loop
  203. (bt:with-lock-held (*rescan-lock*)
  204. (bt:condition-wait *rescan-cond* *rescan-lock*)
  205. (setf *rescan-active* t)
  206. (handler-case (update-db)
  207. (error (e) (format t "Error updating db: ~a" e)))
  208. (setf *rescan-active* nil))))
  209. (defun request-rescan (params)
  210. (declare #.*standard-optimize-settings* (ignorable params))
  211. (with-user (info)
  212. (bt:condition-notify *rescan-cond*)
  213. +200-empty+))
  214. (defun stats (params)
  215. (declare #.*standard-optimize-settings* (ignorable params))
  216. (with-user (info)
  217. (let ((stats (db-stats)))
  218. (setf (getf stats :|duration|)
  219. (format-interval (getf stats :|duration|)))
  220. (200-json (append stats
  221. (list :|rescans| (subseq (the list *rescans*)
  222. 0 (min (length (the list *rescans*)) 10))))))))
  223. (defvar *bot-token* nil "Login bot token")
  224. (defun validate-user (info)
  225. (let ((hash (prog1 (getf info :|hash|)
  226. (remf info :|hash|)))
  227. (check-string (format nil "~{~a=~a~^~%~}"
  228. (alist-plist
  229. (sort (plist-alist info) #'string-lessp
  230. :key #'(lambda (p) (symbol-name (car p)))))))
  231. (hmac (crypto:make-hmac
  232. (crypto:digest-sequence :sha256 (crypto:ascii-string-to-byte-array *bot-token*)) :sha256)))
  233. (crypto:update-hmac hmac (trivial-utf-8:string-to-utf-8-bytes check-string))
  234. (string-equal hash (crypto:byte-array-to-hex-string (crypto:hmac-digest hmac)))))
  235. (defparameter +telegram-api-format+ "https://api.telegram.org/bot~A/~A")
  236. (defun telegram-request (method params)
  237. (json-request (format nil +telegram-api-format+ *bot-token* method)
  238. :method :post
  239. :content (trivial-utf-8:string-to-utf-8-bytes (to-json params))))
  240. (defvar *bot-auth-chat-id* nil "Authentication chat id")
  241. (defun tg-get-chat-member (user-id &optional (chat-id *bot-auth-chat-id*))
  242. (getf (telegram-request "getChatMember"
  243. `(:|chat_id| ,chat-id :|user_id| ,user-id))
  244. :|result|))
  245. (defparameter +authorized-statuses+ '("creator" "administrator" "member"))
  246. (defun authorize-user (info)
  247. (ignore-errors
  248. (let* ((chat-member (tg-get-chat-member (getf info :|id|)))
  249. (status (getf chat-member :|status|)))
  250. (member status +authorized-statuses+ :test #'equal))))
  251. (defparameter +token-length+ 16)
  252. (defun user-token (info)
  253. (let ((token (crypto:byte-array-to-hex-string
  254. (crypto:random-data +token-length+))))
  255. (setf (gethash token (server-token-user *server*)) info)
  256. (save-server)
  257. token))
  258. (defun login (params)
  259. (declare #.*standard-optimize-settings* (ignorable params))
  260. (handler-case
  261. (let* ((body (trivial-utf-8:read-utf-8-string
  262. (getf myway:*env* :raw-body)
  263. :stop-at-eof t))
  264. (info (parse (coerce (the string body) 'simple-string))))
  265. (unless (validate-user info)
  266. (error "Bad user info"))
  267. (if (authorize-user info)
  268. (200-json `(:|token| ,(user-token info)))
  269. +401+))
  270. (error (e) (log:error e) +400+)))
  271. (defun self-info (params)
  272. (declare #.*standard-optimize-settings* (ignorable params))
  273. (with-user (info)
  274. (200-json info)))
  275. (defun find-user-info (username)
  276. (first
  277. (sort
  278. (loop for info being the hash-value of (server-token-user *server*)
  279. when (equal username (getf info :|username|))
  280. collect info)
  281. #'< :key (lambda (i) (getf i :|auth_date|)))))
  282. (defun user-info (params)
  283. (declare #.*standard-optimize-settings* (ignorable params))
  284. (with-user (info)
  285. (let ((info (find-user-info (getf params :user))))
  286. (if info
  287. (progn
  288. (dolist (ind '(:|hash| :|id| :|auth_date|)) (remf info ind))
  289. (200-json info))
  290. +404+))))
  291. (defvar *mapper* (myway:make-mapper))
  292. ;; ---- Upload configuration ----
  293. (defvar *upload-dir* nil
  294. "Upload directory path. Set in config.lisp.")
  295. (defparameter *max-upload-size* (* 200 1024 1024)
  296. "Maximum upload file size in bytes (200 MB).")
  297. (defparameter +allowed-upload-types+
  298. '("audio/mpeg" "audio/flac" "audio/mp4" "audio/x-m4a"
  299. "audio/wav" "audio/aiff" "audio/ogg"))
  300. (defun sanitize-filename (name)
  301. "Keep only safe characters (alphanumeric, dot, dash, underscore, space).
  302. Truncate to 200 chars. Reject empty or all-dot results."
  303. (let* ((clean (remove-if-not
  304. (lambda (c)
  305. (or (alphanumericp c) (find c ".-_ ")))
  306. name))
  307. (trimmed (string-left-trim ".-" clean))
  308. (truncated (if (> (length trimmed) 200)
  309. (subseq trimmed 0 200)
  310. trimmed)))
  311. (if (or (zerop (length truncated))
  312. (every (lambda (c) (char= c #\.)) truncated))
  313. "unnamed-upload"
  314. truncated)))
  315. (defun parse-media-type (content-type)
  316. "Extract base media type from Content-Type, stripping parameters.
  317. E.g. 'audio/mpeg; charset=binary' -> 'audio/mpeg'."
  318. (when content-type
  319. (string-trim " " (first (split-sequence:split-sequence
  320. #\; content-type :count 1)))))
  321. (defun upload-file (params)
  322. "PUT /api/upload — receive a raw audio file and add to library."
  323. (declare #.*standard-optimize-settings* (ignorable params))
  324. (with-user (info)
  325. (unless *upload-dir*
  326. (return-from upload-file +503+))
  327. (let* ((headers (getf myway:*env* :headers))
  328. (content-type (parse-media-type (gethash "content-type" headers)))
  329. (content-length
  330. (ignore-errors
  331. (parse-integer
  332. (or (gethash "content-length" headers) "")
  333. :junk-allowed nil)))
  334. (raw-filename (or (gethash "x-filename" headers) "upload"))
  335. (filename (sanitize-filename raw-filename))
  336. (body (getf myway:*env* :raw-body))
  337. (upload-dir (uiop:ensure-directory-pathname *upload-dir*)))
  338. ;; Validate content type
  339. (unless (member content-type +allowed-upload-types+
  340. :test #'string-equal)
  341. (return-from upload-file +400+))
  342. ;; Early reject if Content-Length exceeds limit
  343. (when (and content-length (> content-length *max-upload-size*))
  344. (return-from upload-file +413+))
  345. ;; Ensure upload directory exists
  346. (ensure-directories-exist upload-dir)
  347. ;; Generate unique filename: timestamp-random-sanitized
  348. (let* ((dest-name (format nil "~D-~4,'0D-~A"
  349. (get-universal-time) (random 10000) filename))
  350. (dest (merge-pathnames dest-name upload-dir))
  351. (temp (merge-pathnames (concatenate 'string dest-name ".tmp")
  352. upload-dir)))
  353. ;; Stream to temp file with byte counting
  354. (handler-case
  355. (progn
  356. (with-open-file (out temp
  357. :direction :output
  358. :element-type '(unsigned-byte 8)
  359. :if-exists :supersede)
  360. (let ((buf (make-array 65536 :element-type '(unsigned-byte 8)))
  361. (total 0))
  362. (loop for n = (read-sequence buf body)
  363. while (plusp n)
  364. do (incf total n)
  365. (when (> total *max-upload-size*)
  366. (error "Upload exceeds size limit"))
  367. (write-sequence buf out :end n))))
  368. ;; Success — atomic rename
  369. (rename-file temp dest))
  370. (error (e)
  371. ;; Cleanup temp file on any error
  372. (ignore-errors (delete-file temp))
  373. (if (search "size limit" (princ-to-string e))
  374. (return-from upload-file +413+)
  375. (progn (log:error e)
  376. (return-from upload-file +400+)))))
  377. ;; Synchronous rescan — lock to avoid racing with rescanner thread
  378. (bt:with-lock-held (*rescan-lock*)
  379. (multiple-value-bind (added updated removed)
  380. (rescan (list (namestring upload-dir)))
  381. (declare (ignore removed))
  382. (save-db)
  383. (200-json
  384. (list :|status| "imported"
  385. :|tracks_added| added
  386. :|albums_updated| updated))))))))
  387. (myway:connect *mapper* "/api/cat/:category/size" 'get-category-size)
  388. (myway:connect *mapper* "/api/cat/:category" 'get-category)
  389. (myway:connect *mapper* "/api/cat" 'get-category-list)
  390. (myway:connect *mapper* "/api/album/:id/tracks" 'get-album-tracks)
  391. (myway:connect *mapper* "/api/stats" 'stats)
  392. (myway:connect *mapper* "/api/rescan" 'request-rescan :method :POST)
  393. (myway:connect *mapper* "/api/login" 'login :method :POST)
  394. (myway:connect *mapper* "/api/user" 'self-info)
  395. (myway:connect *mapper* "/api/user/:user" 'user-info)
  396. (myway:connect *mapper* "/api/upload" 'upload-file :method :PUT)
  397. (defun main (&rest args &key (port 5000) (debug nil) (use-thread t) (serve-files nil) &allow-other-keys)
  398. ;; Load config file
  399. (when-let (file (probe-file
  400. (merge-pathnames "config.lisp"
  401. (asdf:component-pathname
  402. (asdf:find-system '#:chad-music)))))
  403. (load file))
  404. ;; Load server
  405. (when-let (file (probe-file *server-path*))
  406. (setf *server* (load-server file)))
  407. ;; Load database
  408. (when-let (file (probe-file *db-path*))
  409. (let ((*package* (find-package :chad-music.db)))
  410. (setf *db* (load-db file))))
  411. ;; Set up debug file server
  412. (when serve-files
  413. (loop for (path-prefix . url-prefix) in *path-url-mappings*
  414. do (myway:connect *mapper* (concatenate 'string url-prefix "*")
  415. (file-server path-prefix))))
  416. ;; Start rescan processor
  417. (setf *rescan-thread* (bt:make-thread 'rescanner :name "DB rescanner"))
  418. ;; Start application
  419. (apply #'clack:clackup
  420. (myway:to-app *mapper*)
  421. :server :woo
  422. :port port
  423. :debug debug
  424. :use-default-middlewares nil
  425. :use-thread use-thread
  426. (alexandria:remove-from-plist args :data :port :debug :use-thread)))