chore(emacs): added uploads to posts.el
43e2def9
1 file(s) · +114 −3
| 19 | 19 | ||
| 20 | 20 | (require 'url) |
|
| 21 | 21 | (require 'json) |
|
| 22 | + | (require 'mailcap) |
|
| 22 | 23 | (require 'subr-x) |
|
| 23 | 24 | (require 'auth-source-pass) |
|
| 24 | 25 | ||
| 33 | 34 | ||
| 34 | 35 | (defun posts--api-key () |
|
| 35 | 36 | "Return the API key from password-store." |
|
| 36 | - | (or (auth-source-pass-get 'secret posts-pass-entry) |
|
| 37 | - | (user-error "No pass entry `%s'" posts-pass-entry))) |
|
| 37 | + | ;; Encode so the header is unibyte; otherwise url-http refuses to mix |
|
| 38 | + | ;; it with a binary request body. |
|
| 39 | + | (encode-coding-string |
|
| 40 | + | (or (auth-source-pass-get 'secret posts-pass-entry) |
|
| 41 | + | (user-error "No pass entry `%s'" posts-pass-entry)) |
|
| 42 | + | 'utf-8)) |
|
| 38 | 43 | ||
| 39 | 44 | (defcustom posts-endpoint "https://posts.stevedylan.dev/api/posts" |
|
| 40 | 45 | "URL that receives the JSON post." |
|
| 41 | 46 | :type 'string |
|
| 42 | 47 | :group 'posts) |
|
| 48 | + | ||
| 49 | + | (defcustom posts-files-endpoint "https://posts.stevedylan.dev/api/files" |
|
| 50 | + | "URL that receives multipart file uploads." |
|
| 51 | + | :type 'string |
|
| 52 | + | :group 'posts) |
|
| 53 | + | ||
| 54 | + | (defconst posts--max-file-size (* 10 1024 1024) |
|
| 55 | + | "Largest file the server accepts, in bytes.") |
|
| 43 | 56 | ||
| 44 | 57 | ;;;; Fields |
|
| 45 | 58 | ||
| 132 | 145 | (kill-buffer (current-buffer)))) |
|
| 133 | 146 | nil t))) |
|
| 134 | 147 | ||
| 148 | + | ;;;; Uploading files |
|
| 149 | + | ||
| 150 | + | (defun posts--multipart-body (boundary file name) |
|
| 151 | + | "Return a unibyte multipart/form-data body for FILE using BOUNDARY. |
|
| 152 | + | NAME, when non-nil, is sent as the `name' field." |
|
| 153 | + | (let ((type (or (mailcap-file-name-to-mime-type file) |
|
| 154 | + | "application/octet-stream")) |
|
| 155 | + | (filename (replace-regexp-in-string |
|
| 156 | + | "\"" "\\\\\"" |
|
| 157 | + | (encode-coding-string (file-name-nondirectory file) 'utf-8)))) |
|
| 158 | + | (with-temp-buffer |
|
| 159 | + | (set-buffer-multibyte nil) |
|
| 160 | + | (when name |
|
| 161 | + | (insert "--" boundary "\r\n" |
|
| 162 | + | "Content-Disposition: form-data; name=\"name\"\r\n\r\n" |
|
| 163 | + | (encode-coding-string name 'utf-8) "\r\n")) |
|
| 164 | + | (insert "--" boundary "\r\n" |
|
| 165 | + | "Content-Disposition: form-data; name=\"file\"; filename=\"" |
|
| 166 | + | filename "\"\r\n" |
|
| 167 | + | "Content-Type: " type "\r\n\r\n") |
|
| 168 | + | (insert-file-contents-literally file) |
|
| 169 | + | (goto-char (point-max)) |
|
| 170 | + | (insert "\r\n--" boundary "--\r\n") |
|
| 171 | + | (buffer-string)))) |
|
| 172 | + | ||
| 173 | + | (defun posts--response-json () |
|
| 174 | + | "Parse the JSON body of the current HTTP response buffer, or nil." |
|
| 175 | + | (goto-char (point-min)) |
|
| 176 | + | (when (re-search-forward "\r?\n\r?\n" nil t) |
|
| 177 | + | (ignore-errors (json-parse-buffer :object-type 'alist)))) |
|
| 178 | + | ||
| 179 | + | (defun posts-upload-file (file &optional name callback) |
|
| 180 | + | "Upload FILE to `posts-files-endpoint'. |
|
| 181 | + | NAME, if non-empty, sets the stored filename. On success the file's |
|
| 182 | + | URL is copied to the kill ring and CALLBACK, if non-nil, is called |
|
| 183 | + | with the parsed response alist." |
|
| 184 | + | (interactive |
|
| 185 | + | (list (read-file-name "Upload file: " nil nil t) |
|
| 186 | + | (read-string "Name (optional): "))) |
|
| 187 | + | (setq file (expand-file-name file)) |
|
| 188 | + | (unless (file-regular-p file) |
|
| 189 | + | (user-error "Not a regular file: %s" file)) |
|
| 190 | + | (when (> (file-attribute-size (file-attributes file)) posts--max-file-size) |
|
| 191 | + | (user-error "File exceeds 10 MB: %s" file)) |
|
| 192 | + | (when (and name (string-empty-p (string-trim name))) |
|
| 193 | + | (setq name nil)) |
|
| 194 | + | (let* ((boundary (format "posts-%s" (md5 (format "%s%s" (random) (float-time))))) |
|
| 195 | + | (url-request-method "POST") |
|
| 196 | + | (url-request-extra-headers |
|
| 197 | + | `(("Content-Type" . ,(encode-coding-string |
|
| 198 | + | (concat "multipart/form-data; boundary=" boundary) |
|
| 199 | + | 'utf-8)) |
|
| 200 | + | ("X-API-Key" . ,(posts--api-key)))) |
|
| 201 | + | (url-request-data (posts--multipart-body boundary file name))) |
|
| 202 | + | (message "Uploading %s..." (file-name-nondirectory file)) |
|
| 203 | + | (url-retrieve |
|
| 204 | + | posts-files-endpoint |
|
| 205 | + | (lambda (status) |
|
| 206 | + | (unwind-protect |
|
| 207 | + | (let ((err (plist-get status :error)) |
|
| 208 | + | (resp (posts--response-json))) |
|
| 209 | + | (cond |
|
| 210 | + | ((equal err '(error http 409)) |
|
| 211 | + | (message "Upload failed: name `%s' is already taken" name)) |
|
| 212 | + | (err |
|
| 213 | + | (message "Upload error: %S%s" err |
|
| 214 | + | (if resp (format " %S" resp) ""))) |
|
| 215 | + | ((null (alist-get 'url resp)) |
|
| 216 | + | (message "Uploaded (unexpected response)")) |
|
| 217 | + | (t |
|
| 218 | + | (kill-new (alist-get 'url resp)) |
|
| 219 | + | (message "Uploaded: %s (copied)" (alist-get 'url resp)) |
|
| 220 | + | (when callback (funcall callback resp))))) |
|
| 221 | + | (kill-buffer (current-buffer)))) |
|
| 222 | + | nil t))) |
|
| 223 | + | ||
| 135 | 224 | ;;;; Compose buffer |
|
| 136 | 225 | ||
| 137 | 226 | (defvar posts-compose-mode-map |
|
| 138 | 227 | (let ((m (make-sparse-keymap))) |
|
| 139 | 228 | (define-key m (kbd "C-c C-c") #'posts-compose-submit) |
|
| 140 | 229 | (define-key m (kbd "C-c C-k") #'posts-compose-cancel) |
|
| 230 | + | (define-key m (kbd "C-c C-a") #'posts-compose-attach) |
|
| 141 | 231 | m) |
|
| 142 | 232 | "Keymap for `posts-compose-mode'.") |
|
| 143 | 233 | ||
| 145 | 235 | "Compose a post. Leading `name: value' lines are fields, the rest is content. |
|
| 146 | 236 | \\{posts-compose-mode-map}" |
|
| 147 | 237 | (setq header-line-format |
|
| 148 | - | "Fields as `name: value' lines, then content. C-c C-c to send, C-c C-k to cancel")) |
|
| 238 | + | "Fields as `name: value' lines, then content. C-c C-c send, C-c C-a attach file, C-c C-k cancel")) |
|
| 149 | 239 | ||
| 150 | 240 | ;;;###autoload |
|
| 151 | 241 | (defun posts-compose () |
|
| 170 | 260 | (user-error "Content is empty")) |
|
| 171 | 261 | (posts fields content) |
|
| 172 | 262 | (quit-window t))) |
|
| 263 | + | ||
| 264 | + | (defun posts-compose-attach (file &optional name) |
|
| 265 | + | "Upload FILE (stored as NAME) and insert a Markdown link at point. |
|
| 266 | + | Images are inserted as `'." |
|
| 267 | + | (interactive |
|
| 268 | + | (list (read-file-name "Attach file: " nil nil t) |
|
| 269 | + | (read-string "Name (optional): "))) |
|
| 270 | + | (let ((marker (point-marker))) |
|
| 271 | + | (posts-upload-file |
|
| 272 | + | file name |
|
| 273 | + | (lambda (resp) |
|
| 274 | + | (when (buffer-live-p (marker-buffer marker)) |
|
| 275 | + | (with-current-buffer (marker-buffer marker) |
|
| 276 | + | (save-excursion |
|
| 277 | + | (goto-char marker) |
|
| 278 | + | (insert (format "%s[%s](%s)" |
|
| 279 | + | (if (string-prefix-p "image/" (or (alist-get 'content_type resp) "")) |
|
| 280 | + | "!" "") |
|
| 281 | + | (or (alist-get 'original_name resp) |
|
| 282 | + | (alist-get 'filename resp) "") |
|
| 283 | + | (alist-get 'url resp)))))))))) |
|
| 173 | 284 | ||
| 174 | 285 | (defun posts-compose-cancel () |
|
| 175 | 286 | "Discard the compose buffer." |
|