chore(emacs): added uploads to posts.el 43e2def9
Steve · 2026-10-12 15:28 1 file(s) · +114 −3
emacs/lisp/posts.el +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 `![](url)'."
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."