nandi/frqpublic Fork 0
1bb3f7776d13837a3842ba7a0a62a4f9ee4b9194
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Send a picture from the phone 6665ec6 · on 1bb3f7776d13837a3842ba7a0a62a4f9ee4b9194 · nandi · 7d ago
core.cljc · 95 lines · 4.2 KBGDScript3 Blame HistoryRaw
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
(ns frq.upload.core
  "Sending a picture: what freeq's media endpoint is asked, and what its answer
  means.

  IRC carries text, so a picture is shared the way every other client shares
  one  it is uploaded, and the link goes in the line. `POST /api/v1/upload`
  takes a multipart form and answers with a URL under `/api/v1/media/`, served
  back to anyone the link reaches.

  The upload is authorised by the connection itself: the endpoint accepts a DID
  that has a live session on the server, which a signed-in client already has.
  A guest has no DID and cannot upload.

  Everything here is arithmetic over bytes and strings  the boundary, the
  parts, the cap, and reading a URL or a reason out of what came back. The
  sending is not: the desktop writes it down its own TLS and the phone hands it
  to `dart:io`, and neither is this namespace's business.

  Nothing is streamed. An image is at most a few megabytes and the body is
  built in memory, which is what keeps the request one write."
  (:require [clojure.string :as str]
            [frq.io :as io]))

;; The endpoint's own cap. Refusing here rather than at the server saves a
;; multi-megabyte upload that was always going to be rejected.
(def max-bytes (* 10 1024 1024))

(defn boundary
  "A delimiter of the form the RFC allows, and one no part of this body has in
  it: every byte of it is a hyphen, a letter or a digit, and the parts are a
  PNG and a DID.

  Written out of `size` rather than taken from a hash: `hash` is the host's and
  the two hosts do not agree on it, and what this needs is only that it does
  not collide with the bytes beside it."
  [size]
  (str "----frq" size "x" (mod (* 31 (+ size 7)) 100000)))

(defn multipart
  "The request body for `fields` (strings) plus the file part, as a vector of
  bytes."
  [bound fields filename content-type file]
  (let [dash (str "--" bound)
        text (fn [s] (io/utf8-bytes s))]
    (-> (reduce (fn [acc [k v]]
                  (into acc (text (str dash "\r\n"
                                       "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n"
                                       v "\r\n"))))
                []
                fields)
        (into (text (str dash "\r\n"
                         "Content-Disposition: form-data; name=\"file\";"
                         " filename=\"" filename "\"\r\n"
                         "Content-Type: " content-type "\r\n\r\n")))
        (into file)
        (into (text (str "\r\n" dash "--\r\n"))))))

(defn request
  "What to send for this upload: `{:path :content-type :body}`, body in bytes.

  Throws with a message meant to be shown when there is no point sending it 
  a guest has no account to file an upload under, and the endpoint's cap is
  worth refusing on this side of a few megabytes of wire."
  [did channel filename file]
  (when (str/blank? (str did))
    (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {})))
  (when (> (count file) max-bytes)
    (throw (ex-info "That picture is over the 10MB the server takes."
                    {:bytes (count file)})))
  (let [bound (boundary (count file))]
    {:path "/api/v1/upload"
     :content-type (str "multipart/form-data; boundary=" bound)
     :body (multipart bound
                      (cond-> [["did" did]]
                        (seq (str channel)) (conj ["channel" channel]))
                      filename "image/png" file)}))

(defn- field [body k]
  (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body ""))))

(defn error-message
  "What to show for a response that was not a 2xx. The endpoint answers JSON
  with a `message` or an `error` for the cases a user can do something about 
  not signed in, file too large  and plain text for the rest."
  [status body]
  (let [detail (or (field body "message") (field body "error") (str/trim (str body)))]
    (str "Upload failed (" status ")"
         (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail))))))))

(defn url-of
  "The URL freeq serves the picture back at, out of a 2xx body. Throws when it
  took the picture and named no URL for it, which leaves nothing to send."
  [body]
  (or (field body "url")
      (throw (ex-info "The server took the picture but named no URL for it." {}))))