nandi/frqpublic Fork 0
6665ec6
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

`frq.upload.core` is what freeq's media endpoint is asked and what its
answer means: the boundary, the parts, the 10MB the server takes, and
reading a URL or a reason out of what came back. All of it is arithmetic
over bytes and strings. The sending is not — the desktop writes it down its
own TLS and the phone hands it to dart:io — and that is the whole of the
difference.

The chooser is the platform's, which is what `frq.state/open-image-picker!`
already reached for first and fell back from: what a chooser hands back is a
grant for the one picture chosen, so the app needs no permission over the
reader's pictures at all. Without such a permission browsing finds almost
nothing, so the browse screen's actions are installed as the honest empty
answer rather than as a screen with nothing on it. Android has no clipboard
of pictures either, and paste says so.

Two things came out of testing rather than reading. The multipart body is a
PersistentVector and `HttpClientRequest.add` wants a List<int>, which
ClojureDart does not bridge — the same generic edge `frq.net.dart` splits
its own lines to avoid. And the boundary was built out of `hash`, which is
the host's and the two hosts do not agree on it; it is arithmetic now, since
all it has to do is not collide with the bytes beside it.

Verified on a Pixel 6a: the system chooser opened, the picture uploaded
while the preview said so, and Send put the media link in #test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T07:24:24-07:00 Browse files
6665ec6 parent: 40f8817
added common/frq/upload/core.cljc +95 -0
new file mode 100644
@@ -0,0 +1,95 @@
1+(ns frq.upload.core
2+ "Sending a picture: what freeq's media endpoint is asked, and what its answer
3+ means.
4+
5+ IRC carries text, so a picture is shared the way every other client shares
6+ one it is uploaded, and the link goes in the line. `POST /api/v1/upload`
7+ takes a multipart form and answers with a URL under `/api/v1/media/`, served
8+ back to anyone the link reaches.
9+
10+ The upload is authorised by the connection itself: the endpoint accepts a DID
11+ that has a live session on the server, which a signed-in client already has.
12+ A guest has no DID and cannot upload.
13+
14+ Everything here is arithmetic over bytes and strings the boundary, the
15+ parts, the cap, and reading a URL or a reason out of what came back. The
16+ sending is not: the desktop writes it down its own TLS and the phone hands it
17+ to `dart:io`, and neither is this namespace's business.
18+
19+ Nothing is streamed. An image is at most a few megabytes and the body is
20+ built in memory, which is what keeps the request one write."
21+ (:require [clojure.string :as str]
22+ [frq.io :as io]))
23+
24+;; The endpoint's own cap. Refusing here rather than at the server saves a
25+;; multi-megabyte upload that was always going to be rejected.
26+(def max-bytes (* 10 1024 1024))
27+
28+(defn boundary
29+ "A delimiter of the form the RFC allows, and one no part of this body has in
30+ it: every byte of it is a hyphen, a letter or a digit, and the parts are a
31+ PNG and a DID.
32+
33+ Written out of `size` rather than taken from a hash: `hash` is the host's and
34+ the two hosts do not agree on it, and what this needs is only that it does
35+ not collide with the bytes beside it."
36+ [size]
37+ (str "----frq" size "x" (mod (* 31 (+ size 7)) 100000)))
38+
39+(defn multipart
40+ "The request body for `fields` (strings) plus the file part, as a vector of
41+ bytes."
42+ [bound fields filename content-type file]
43+ (let [dash (str "--" bound)
44+ text (fn [s] (io/utf8-bytes s))]
45+ (-> (reduce (fn [acc [k v]]
46+ (into acc (text (str dash "\r\n"
47+ "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n"
48+ v "\r\n"))))
49+ []
50+ fields)
51+ (into (text (str dash "\r\n"
52+ "Content-Disposition: form-data; name=\"file\";"
53+ " filename=\"" filename "\"\r\n"
54+ "Content-Type: " content-type "\r\n\r\n")))
55+ (into file)
56+ (into (text (str "\r\n" dash "--\r\n"))))))
57+
58+(defn request
59+ "What to send for this upload: `{:path :content-type :body}`, body in bytes.
60+
61+ Throws with a message meant to be shown when there is no point sending it
62+ a guest has no account to file an upload under, and the endpoint's cap is
63+ worth refusing on this side of a few megabytes of wire."
64+ [did channel filename file]
65+ (when (str/blank? (str did))
66+ (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {})))
67+ (when (> (count file) max-bytes)
68+ (throw (ex-info "That picture is over the 10MB the server takes."
69+ {:bytes (count file)})))
70+ (let [bound (boundary (count file))]
71+ {:path "/api/v1/upload"
72+ :content-type (str "multipart/form-data; boundary=" bound)
73+ :body (multipart bound
74+ (cond-> [["did" did]]
75+ (seq (str channel)) (conj ["channel" channel]))
76+ filename "image/png" file)}))
77+
78+(defn- field [body k]
79+ (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body ""))))
80+
81+(defn error-message
82+ "What to show for a response that was not a 2xx. The endpoint answers JSON
83+ with a `message` or an `error` for the cases a user can do something about
84+ not signed in, file too large and plain text for the rest."
85+ [status body]
86+ (let [detail (or (field body "message") (field body "error") (str/trim (str body)))]
87+ (str "Upload failed (" status ")"
88+ (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail))))))))
89+
90+(defn url-of
91+ "The URL freeq serves the picture back at, out of a 2xx body. Throws when it
92+ took the picture and named no URL for it, which leaves nothing to send."
93+ [body]
94+ (or (field body "url")
95+ (throw (ex-info "The server took the picture but named no URL for it." {}))))
new file mode 100644
@@ -0,0 +1,95 @@
1+(ns frq.upload.core
2+ "Sending a picture: what freeq's media endpoint is asked, and what its answer
3+ means.
4+
5+ IRC carries text, so a picture is shared the way every other client shares
6+ one it is uploaded, and the link goes in the line. `POST /api/v1/upload`
7+ takes a multipart form and answers with a URL under `/api/v1/media/`, served
8+ back to anyone the link reaches.
9+
10+ The upload is authorised by the connection itself: the endpoint accepts a DID
11+ that has a live session on the server, which a signed-in client already has.
12+ A guest has no DID and cannot upload.
13+
14+ Everything here is arithmetic over bytes and strings the boundary, the
15+ parts, the cap, and reading a URL or a reason out of what came back. The
16+ sending is not: the desktop writes it down its own TLS and the phone hands it
17+ to `dart:io`, and neither is this namespace's business.
18+
19+ Nothing is streamed. An image is at most a few megabytes and the body is
20+ built in memory, which is what keeps the request one write."
21+ (:require [clojure.string :as str]
22+ [frq.io :as io]))
23+
24+;; The endpoint's own cap. Refusing here rather than at the server saves a
25+;; multi-megabyte upload that was always going to be rejected.
26+(def max-bytes (* 10 1024 1024))
27+
28+(defn boundary
29+ "A delimiter of the form the RFC allows, and one no part of this body has in
30+ it: every byte of it is a hyphen, a letter or a digit, and the parts are a
31+ PNG and a DID.
32+
33+ Written out of `size` rather than taken from a hash: `hash` is the host's and
34+ the two hosts do not agree on it, and what this needs is only that it does
35+ not collide with the bytes beside it."
36+ [size]
37+ (str "----frq" size "x" (mod (* 31 (+ size 7)) 100000)))
38+
39+(defn multipart
40+ "The request body for `fields` (strings) plus the file part, as a vector of
41+ bytes."
42+ [bound fields filename content-type file]
43+ (let [dash (str "--" bound)
44+ text (fn [s] (io/utf8-bytes s))]
45+ (-> (reduce (fn [acc [k v]]
46+ (into acc (text (str dash "\r\n"
47+ "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n"
48+ v "\r\n"))))
49+ []
50+ fields)
51+ (into (text (str dash "\r\n"
52+ "Content-Disposition: form-data; name=\"file\";"
53+ " filename=\"" filename "\"\r\n"
54+ "Content-Type: " content-type "\r\n\r\n")))
55+ (into file)
56+ (into (text (str "\r\n" dash "--\r\n"))))))
57+
58+(defn request
59+ "What to send for this upload: `{:path :content-type :body}`, body in bytes.
60+
61+ Throws with a message meant to be shown when there is no point sending it
62+ a guest has no account to file an upload under, and the endpoint's cap is
63+ worth refusing on this side of a few megabytes of wire."
64+ [did channel filename file]
65+ (when (str/blank? (str did))
66+ (throw (ex-info "Sign in to send a picture — an upload is filed under your account." {})))
67+ (when (> (count file) max-bytes)
68+ (throw (ex-info "That picture is over the 10MB the server takes."
69+ {:bytes (count file)})))
70+ (let [bound (boundary (count file))]
71+ {:path "/api/v1/upload"
72+ :content-type (str "multipart/form-data; boundary=" bound)
73+ :body (multipart bound
74+ (cond-> [["did" did]]
75+ (seq (str channel)) (conj ["channel" channel]))
76+ filename "image/png" file)}))
77+
78+(defn- field [body k]
79+ (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body ""))))
80+
81+(defn error-message
82+ "What to show for a response that was not a 2xx. The endpoint answers JSON
83+ with a `message` or an `error` for the cases a user can do something about
84+ not signed in, file too large and plain text for the rest."
85+ [status body]
86+ (let [detail (or (field body "message") (field body "error") (str/trim (str body)))]
87+ (str "Upload failed (" status ")"
88+ (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail))))))))
89+
90+(defn url-of
91+ "The URL freeq serves the picture back at, out of a 2xx body. Throws when it
92+ took the picture and named no URL for it, which leaves nothing to send."
93+ [body]
94+ (or (field body "url")
95+ (throw (ex-info "The server took the picture but named no URL for it." {}))))
modified flutter/linux/flutter/generated_plugin_registrant.cc +4 -0
@@ -6,9 +6,13 @@
66
77 #include "generated_plugin_registrant.h"
88
9+#include <file_selector_linux/file_selector_plugin.h>
910 #include <url_launcher_linux/url_launcher_plugin.h>
1011
1112 void fl_register_plugins(FlPluginRegistry* registry) {
13+ g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
14+ fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
15+ file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
1216 g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
1317 fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
1418 url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
@@ -6,9 +6,13 @@
6 6
7 #include "generated_plugin_registrant.h"7 #include "generated_plugin_registrant.h"
8 8
9+#include <file_selector_linux/file_selector_plugin.h>
9 #include <url_launcher_linux/url_launcher_plugin.h>10 #include <url_launcher_linux/url_launcher_plugin.h>
10 11
11 void fl_register_plugins(FlPluginRegistry* registry) {12 void fl_register_plugins(FlPluginRegistry* registry) {
13+ g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
14+ fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
15+ file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
12 g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =16 g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
13 fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");17 fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
14 url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);18 url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
modified flutter/linux/flutter/generated_plugins.cmake +1 -0
@@ -3,6 +3,7 @@
33 #
44
55 list(APPEND FLUTTER_PLUGIN_LIST
6+ file_selector_linux
67 url_launcher_linux
78 )
89
@@ -3,6 +3,7 @@
3 #3 #
4 4
5 list(APPEND FLUTTER_PLUGIN_LIST5 list(APPEND FLUTTER_PLUGIN_LIST
6+ file_selector_linux
6 url_launcher_linux7 url_launcher_linux
7 )8 )
8 9
modified flutter/pubspec.lock +136 -0
@@ -73,6 +73,14 @@ packages:
7373 url: "https://pub.dev"
7474 source: hosted
7575 version: "3.1.2"
76+ cross_file:
77+ dependency: transitive
78+ description:
79+ name: cross_file
80+ sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
81+ url: "https://pub.dev"
82+ source: hosted
83+ version: "0.3.5+5"
7684 crypto:
7785 dependency: "direct main"
7886 description:
@@ -113,6 +121,38 @@ packages:
113121 url: "https://pub.dev"
114122 source: hosted
115123 version: "2.2.0"
124+ file_selector_linux:
125+ dependency: transitive
126+ description:
127+ name: file_selector_linux
128+ sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
129+ url: "https://pub.dev"
130+ source: hosted
131+ version: "0.9.4+1"
132+ file_selector_macos:
133+ dependency: transitive
134+ description:
135+ name: file_selector_macos
136+ sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
137+ url: "https://pub.dev"
138+ source: hosted
139+ version: "0.9.5+1"
140+ file_selector_platform_interface:
141+ dependency: transitive
142+ description:
143+ name: file_selector_platform_interface
144+ sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
145+ url: "https://pub.dev"
146+ source: hosted
147+ version: "2.7.0"
148+ file_selector_windows:
149+ dependency: transitive
150+ description:
151+ name: file_selector_windows
152+ sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
153+ url: "https://pub.dev"
154+ source: hosted
155+ version: "0.9.3+6"
116156 fixnum:
117157 dependency: transitive
118158 description:
@@ -134,6 +174,14 @@ packages:
134174 url: "https://pub.dev"
135175 source: hosted
136176 version: "6.0.0"
177+ flutter_plugin_android_lifecycle:
178+ dependency: transitive
179+ description:
180+ name: flutter_plugin_android_lifecycle
181+ sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
182+ url: "https://pub.dev"
183+ source: hosted
184+ version: "2.0.35"
137185 flutter_test:
138186 dependency: "direct dev"
139187 description: flutter
@@ -152,6 +200,86 @@ packages:
152200 url: "https://pub.dev"
153201 source: hosted
154202 version: "2.0.2"
203+ http:
204+ dependency: transitive
205+ description:
206+ name: http
207+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
208+ url: "https://pub.dev"
209+ source: hosted
210+ version: "1.6.0"
211+ http_parser:
212+ dependency: transitive
213+ description:
214+ name: http_parser
215+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
216+ url: "https://pub.dev"
217+ source: hosted
218+ version: "4.1.2"
219+ image_picker:
220+ dependency: "direct main"
221+ description:
222+ name: image_picker
223+ sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
224+ url: "https://pub.dev"
225+ source: hosted
226+ version: "1.2.3"
227+ image_picker_android:
228+ dependency: transitive
229+ description:
230+ name: image_picker_android
231+ sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219"
232+ url: "https://pub.dev"
233+ source: hosted
234+ version: "0.8.13+23"
235+ image_picker_for_web:
236+ dependency: transitive
237+ description:
238+ name: image_picker_for_web
239+ sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
240+ url: "https://pub.dev"
241+ source: hosted
242+ version: "3.1.1"
243+ image_picker_ios:
244+ dependency: transitive
245+ description:
246+ name: image_picker_ios
247+ sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
248+ url: "https://pub.dev"
249+ source: hosted
250+ version: "0.8.13+7"
251+ image_picker_linux:
252+ dependency: transitive
253+ description:
254+ name: image_picker_linux
255+ sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
256+ url: "https://pub.dev"
257+ source: hosted
258+ version: "0.2.2"
259+ image_picker_macos:
260+ dependency: transitive
261+ description:
262+ name: image_picker_macos
263+ sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
264+ url: "https://pub.dev"
265+ source: hosted
266+ version: "0.2.2+1"
267+ image_picker_platform_interface:
268+ dependency: transitive
269+ description:
270+ name: image_picker_platform_interface
271+ sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
272+ url: "https://pub.dev"
273+ source: hosted
274+ version: "2.11.1"
275+ image_picker_windows:
276+ dependency: transitive
277+ description:
278+ name: image_picker_windows
279+ sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
280+ url: "https://pub.dev"
281+ source: hosted
282+ version: "0.2.2"
155283 jni:
156284 dependency: transitive
157285 description:
@@ -240,6 +368,14 @@ packages:
240368 url: "https://pub.dev"
241369 source: hosted
242370 version: "1.18.3"
371+ mime:
372+ dependency: transitive
373+ description:
374+ name: mime
375+ sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
376+ url: "https://pub.dev"
377+ source: hosted
378+ version: "2.1.0"
243379 objective_c:
244380 dependency: transitive
245381 description:
@@ -73,6 +73,14 @@ packages:
73 url: "https://pub.dev"73 url: "https://pub.dev"
74 source: hosted74 source: hosted
75 version: "3.1.2"75 version: "3.1.2"
76+ cross_file:
77+ dependency: transitive
78+ description:
79+ name: cross_file
80+ sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
81+ url: "https://pub.dev"
82+ source: hosted
83+ version: "0.3.5+5"
76 crypto:84 crypto:
77 dependency: "direct main"85 dependency: "direct main"
78 description:86 description:
@@ -113,6 +121,38 @@ packages:
113 url: "https://pub.dev"121 url: "https://pub.dev"
114 source: hosted122 source: hosted
115 version: "2.2.0"123 version: "2.2.0"
124+ file_selector_linux:
125+ dependency: transitive
126+ description:
127+ name: file_selector_linux
128+ sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab
129+ url: "https://pub.dev"
130+ source: hosted
131+ version: "0.9.4+1"
132+ file_selector_macos:
133+ dependency: transitive
134+ description:
135+ name: file_selector_macos
136+ sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4
137+ url: "https://pub.dev"
138+ source: hosted
139+ version: "0.9.5+1"
140+ file_selector_platform_interface:
141+ dependency: transitive
142+ description:
143+ name: file_selector_platform_interface
144+ sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
145+ url: "https://pub.dev"
146+ source: hosted
147+ version: "2.7.0"
148+ file_selector_windows:
149+ dependency: transitive
150+ description:
151+ name: file_selector_windows
152+ sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec
153+ url: "https://pub.dev"
154+ source: hosted
155+ version: "0.9.3+6"
116 fixnum:156 fixnum:
117 dependency: transitive157 dependency: transitive
118 description:158 description:
@@ -134,6 +174,14 @@ packages:
134 url: "https://pub.dev"174 url: "https://pub.dev"
135 source: hosted175 source: hosted
136 version: "6.0.0"176 version: "6.0.0"
177+ flutter_plugin_android_lifecycle:
178+ dependency: transitive
179+ description:
180+ name: flutter_plugin_android_lifecycle
181+ sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
182+ url: "https://pub.dev"
183+ source: hosted
184+ version: "2.0.35"
137 flutter_test:185 flutter_test:
138 dependency: "direct dev"186 dependency: "direct dev"
139 description: flutter187 description: flutter
@@ -152,6 +200,86 @@ packages:
152 url: "https://pub.dev"200 url: "https://pub.dev"
153 source: hosted201 source: hosted
154 version: "2.0.2"202 version: "2.0.2"
203+ http:
204+ dependency: transitive
205+ description:
206+ name: http
207+ sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
208+ url: "https://pub.dev"
209+ source: hosted
210+ version: "1.6.0"
211+ http_parser:
212+ dependency: transitive
213+ description:
214+ name: http_parser
215+ sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
216+ url: "https://pub.dev"
217+ source: hosted
218+ version: "4.1.2"
219+ image_picker:
220+ dependency: "direct main"
221+ description:
222+ name: image_picker
223+ sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
224+ url: "https://pub.dev"
225+ source: hosted
226+ version: "1.2.3"
227+ image_picker_android:
228+ dependency: transitive
229+ description:
230+ name: image_picker_android
231+ sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219"
232+ url: "https://pub.dev"
233+ source: hosted
234+ version: "0.8.13+23"
235+ image_picker_for_web:
236+ dependency: transitive
237+ description:
238+ name: image_picker_for_web
239+ sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
240+ url: "https://pub.dev"
241+ source: hosted
242+ version: "3.1.1"
243+ image_picker_ios:
244+ dependency: transitive
245+ description:
246+ name: image_picker_ios
247+ sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae
248+ url: "https://pub.dev"
249+ source: hosted
250+ version: "0.8.13+7"
251+ image_picker_linux:
252+ dependency: transitive
253+ description:
254+ name: image_picker_linux
255+ sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
256+ url: "https://pub.dev"
257+ source: hosted
258+ version: "0.2.2"
259+ image_picker_macos:
260+ dependency: transitive
261+ description:
262+ name: image_picker_macos
263+ sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
264+ url: "https://pub.dev"
265+ source: hosted
266+ version: "0.2.2+1"
267+ image_picker_platform_interface:
268+ dependency: transitive
269+ description:
270+ name: image_picker_platform_interface
271+ sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
272+ url: "https://pub.dev"
273+ source: hosted
274+ version: "2.11.1"
275+ image_picker_windows:
276+ dependency: transitive
277+ description:
278+ name: image_picker_windows
279+ sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
280+ url: "https://pub.dev"
281+ source: hosted
282+ version: "0.2.2"
155 jni:283 jni:
156 dependency: transitive284 dependency: transitive
157 description:285 description:
@@ -240,6 +368,14 @@ packages:
240 url: "https://pub.dev"368 url: "https://pub.dev"
241 source: hosted369 source: hosted
242 version: "1.18.3"370 version: "1.18.3"
371+ mime:
372+ dependency: transitive
373+ description:
374+ name: mime
375+ sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
376+ url: "https://pub.dev"
377+ source: hosted
378+ version: "2.1.0"
243 objective_c:379 objective_c:
244 dependency: transitive380 dependency: transitive
245 description:381 description:
modified flutter/pubspec.yaml +4 -0
@@ -41,6 +41,10 @@ dependencies:
4141 # dart:io cannot do on its own — the loopback listener that catches the
4242 # answer is plain dart:io.
4343 url_launcher: ^6.3.1
44+ # The system picture chooser. What it hands back is a grant for the one
45+ # picture chosen, so the app needs no permission over the reader's pictures
46+ # at all — which is also why browsing for one finds almost nothing here.
47+ image_picker: ^1.1.2
4448 dev_dependencies:
4549 flutter_test:
4650 sdk: flutter
@@ -41,6 +41,10 @@ dependencies:
41 # dart:io cannot do on its own — the loopback listener that catches the41 # dart:io cannot do on its own — the loopback listener that catches the
42 # answer is plain dart:io.42 # answer is plain dart:io.
43 url_launcher: ^6.3.143 url_launcher: ^6.3.1
44+ # The system picture chooser. What it hands back is a grant for the one
45+ # picture chosen, so the app needs no permission over the reader's pictures
46+ # at all — which is also why browsing for one finds almost nothing here.
47+ image_picker: ^1.1.2
44 dev_dependencies:48 dev_dependencies:
45 flutter_test:49 flutter_test:
46 sdk: flutter50 sdk: flutter
modified flutter/src/frq/main.cljd +109 -3
@@ -17,6 +17,7 @@
1717 compiler: the clock and the saved session, read through exactly the
1818 namespaces the desktop reads them through."
1919 (:require ["dart:async" :as async]
20+ ["dart:io" :as dio]
2021 ["package:flutter/material.dart" :as m]
2122 ["package:path_provider/path_provider.dart" :as pp]
2223 [cljd.flutter :as f]
@@ -46,6 +47,8 @@
4647 [frq.edits :as edits]
4748 [frq.profile :as profile]
4849 [frq.avatars.dart :as avatars]
50+ ["package:image_picker/image_picker.dart" :as picker]
51+ [frq.upload.dart :as upload]
4952 [frq.irc.mutate :as mutate]
5053 [frq.oauth.core :as oauth]
5154 [frq.oauth.dart :as oauth-dart]
@@ -559,6 +562,75 @@
559562 [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]
560563 [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])
561564
565+(defn- outgoing-dir []
566+ (let [d (str (fio/config-dir) "/outgoing")]
567+ (fio/mkdirs! d)
568+ d))
569+
570+(defn- discard! [path]
571+ (try (fio/delete-file! path) (catch Object _ nil)))
572+
573+(defn- clear-attachment!
574+ "Drop the picture without sending it, and the copy of it with it."
575+ []
576+ (when-let [a @cells/attachment]
577+ (reset! cells/attachment nil)
578+ (discard! (:path a))))
579+
580+(defn ^:async attach!
581+ "Hold the picture already copied to `path` against the next line, and start
582+ its upload.
583+
584+ The upload starts at once rather than at send, so by the time a line is
585+ written the picture is usually already up. A failure lands in `error` like
586+ any other and takes the attachment with it there is nothing to send and
587+ nothing to show."
588+ [path filename]
589+ (let [did (:did @cells/session)
590+ host-name (str @cells/form-host)
591+ channel (str @cells/current)]
592+ (reset! cells/error nil)
593+ (clear-attachment!)
594+ (reset! cells/attachment {:path path :status :uploading})
595+ (try
596+ (let [url (await (upload/upload! host-name did channel path filename))]
597+ ;; Only if this is still the picture on screen: a reader who attached
598+ ;; another, or cleared it, has said what they want, and an upload
599+ ;; landing afterwards does not get to undo that.
600+ (swap! cells/attachment #(if (= (:path %) path)
601+ (assoc % :url url :status :ready)
602+ %))
603+ (when-not (= (:path @cells/attachment) path) (discard! path)))
604+ (catch Object e
605+ (swap! cells/attachment #(if (= (:path %) path) nil %))
606+ (discard! path)
607+ (reset! cells/error (str (or (ex-message e) e)))))))
608+
609+(defn ^:async open-image-picker!
610+ "Ask for a picture, the way a phone asks.
611+
612+ The system chooser, not this app's browsing screen: what the chooser hands
613+ back is a grant for the one picture chosen, so the app needs no permission
614+ over the reader's pictures at all — and without such a permission, browsing
615+ finds almost nothing to show. `frq.state/open-image-picker!` says the same
616+ thing from the other side and falls back to browsing where there is no
617+ chooser, which is every desktop.
618+
619+ Copied into our own storage rather than attached where it lies: the send
620+ drops the attachment's file when it is done with it, and what it drops has
621+ to be ours not the reader's own picture, sitting in their gallery."
622+ []
623+ (try
624+ (let [chosen (await (.pickImage (picker/ImagePicker)
625+ .source picker/ImageSource.gallery))]
626+ (when chosen
627+ (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now))
628+ ".png")]
629+ (await (.copy (dio/File. (.-path chosen)) copy))
630+ (await (attach! copy "picture.png")))))
631+ (catch Object e
632+ (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e))))))
633+
562634 (defn- send-draft! []
563635 (let [text (str @cells/draft)
564636 room (str @cells/current)
@@ -576,8 +648,21 @@
576648 (reset! cells/editing nil)
577649 (reset! cells/draft ""))
578650
579- (and (seq text) (seq room) @conn)
580- (do
651+ ;; A picture still on its way up holds the send rather than losing it:
652+ ;; the line stays in the box, said so, and the reader presses send again
653+ ;; a moment later. Sending the text without its picture would be the one
654+ ;; outcome nobody asked for.
655+ (= :uploading (:status @cells/attachment))
656+ (reset! cells/error "The picture is still uploading.")
657+
658+ (and (or (seq text) (:url @cells/attachment)) (seq room) @conn)
659+ (let [url (:url @cells/attachment)
660+ ;; The picture becomes its link, at the end of the line: what goes
661+ ;; on the wire is the text the reader wrote and a URL after it,
662+ ;; which is what every other client in the channel knows how to
663+ ;; show. A line that is only a picture is only the link.
664+ text (.trim (str text (when url (str " " url))))]
665+ (do
581666 (net/send-line! @conn (str "PRIVMSG " room " :" text))
582667 ;; Echoed locally only when the server will not echo it back. With
583668 ;; `echo-message` negotiated it does that is what the cap is for, and
@@ -586,7 +671,11 @@
586671 (when-not (handshake/acked? @caps "echo-message")
587672 (swap! cells/channels update room
588673 #(update % :messages conj {:from @cells/form-nick :text text})))
589- (reset! cells/draft "")))))
674+ ;; The attachment has done its job the moment the link is on the wire.
675+ (when-let [a @cells/attachment]
676+ (reset! cells/attachment nil)
677+ (discard! (:path a)))
678+ (reset! cells/draft ""))))))
590679
591680 (defn- message-by-id [room id]
592681 (when id
@@ -678,6 +767,23 @@
678767 (swap! cells/channels dissoc name))
679768 :send-draft! send-draft!
680769
770+ ;; Pictures. The chooser is the platform's, so the browsing screen the
771+ ;; desktop falls back to is never opened here and its actions would
772+ ;; have nothing to list anyway, since everything outside this app's own
773+ ;; storage is behind a permission it does not ask for.
774+ :open-image-picker! open-image-picker!
775+ :clear-attachment! clear-attachment!
776+ :close-image-picker! (fn [] (reset! cells/image-picker nil))
777+ :browse! (fn [_] nil)
778+ :picker-roots (fn [] [])
779+ :picker-entries (fn [_] {:dirs [] :files []})
780+ :parent-dir (fn [_] nil)
781+ :pick-image! (fn [_] nil)
782+ ;; Android has no clipboard of pictures to read, which is the other half
783+ ;; of why the chooser above exists.
784+ :paste-image! (fn []
785+ (reset! cells/error "No picture on the clipboard."))
786+
681787 ;; Answering. Both are a cell and nothing else what the composer does
682788 ;; with `replying-to` is the shared screen's business.
683789 :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))
@@ -17,6 +17,7 @@
17 compiler: the clock and the saved session, read through exactly the17 compiler: the clock and the saved session, read through exactly the
18 namespaces the desktop reads them through."18 namespaces the desktop reads them through."
19 (:require ["dart:async" :as async]19 (:require ["dart:async" :as async]
20+ ["dart:io" :as dio]
20 ["package:flutter/material.dart" :as m]21 ["package:flutter/material.dart" :as m]
21 ["package:path_provider/path_provider.dart" :as pp]22 ["package:path_provider/path_provider.dart" :as pp]
22 [cljd.flutter :as f]23 [cljd.flutter :as f]
@@ -46,6 +47,8 @@
46 [frq.edits :as edits]47 [frq.edits :as edits]
47 [frq.profile :as profile]48 [frq.profile :as profile]
48 [frq.avatars.dart :as avatars]49 [frq.avatars.dart :as avatars]
50+ ["package:image_picker/image_picker.dart" :as picker]
51+ [frq.upload.dart :as upload]
49 [frq.irc.mutate :as mutate]52 [frq.irc.mutate :as mutate]
50 [frq.oauth.core :as oauth]53 [frq.oauth.core :as oauth]
51 [frq.oauth.dart :as oauth-dart]54 [frq.oauth.dart :as oauth-dart]
@@ -559,6 +562,75 @@
559 [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]562 [:button {:label "Disconnect" :destructive true :on-click #(disconnect!)}]]
560 [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])563 [:dim-label {:label "The chats screen is next: it wants rooms, messages and avatars, none of which are ported yet."}]])
561 564
565+(defn- outgoing-dir []
566+ (let [d (str (fio/config-dir) "/outgoing")]
567+ (fio/mkdirs! d)
568+ d))
569+
570+(defn- discard! [path]
571+ (try (fio/delete-file! path) (catch Object _ nil)))
572+
573+(defn- clear-attachment!
574+ "Drop the picture without sending it, and the copy of it with it."
575+ []
576+ (when-let [a @cells/attachment]
577+ (reset! cells/attachment nil)
578+ (discard! (:path a))))
579+
580+(defn ^:async attach!
581+ "Hold the picture already copied to `path` against the next line, and start
582+ its upload.
583+
584+ The upload starts at once rather than at send, so by the time a line is
585+ written the picture is usually already up. A failure lands in `error` like
586+ any other and takes the attachment with it there is nothing to send and
587+ nothing to show."
588+ [path filename]
589+ (let [did (:did @cells/session)
590+ host-name (str @cells/form-host)
591+ channel (str @cells/current)]
592+ (reset! cells/error nil)
593+ (clear-attachment!)
594+ (reset! cells/attachment {:path path :status :uploading})
595+ (try
596+ (let [url (await (upload/upload! host-name did channel path filename))]
597+ ;; Only if this is still the picture on screen: a reader who attached
598+ ;; another, or cleared it, has said what they want, and an upload
599+ ;; landing afterwards does not get to undo that.
600+ (swap! cells/attachment #(if (= (:path %) path)
601+ (assoc % :url url :status :ready)
602+ %))
603+ (when-not (= (:path @cells/attachment) path) (discard! path)))
604+ (catch Object e
605+ (swap! cells/attachment #(if (= (:path %) path) nil %))
606+ (discard! path)
607+ (reset! cells/error (str (or (ex-message e) e)))))))
608+
609+(defn ^:async open-image-picker!
610+ "Ask for a picture, the way a phone asks.
611+
612+ The system chooser, not this app's browsing screen: what the chooser hands
613+ back is a grant for the one picture chosen, so the app needs no permission
614+ over the reader's pictures at all — and without such a permission, browsing
615+ finds almost nothing to show. `frq.state/open-image-picker!` says the same
616+ thing from the other side and falls back to browsing where there is no
617+ chooser, which is every desktop.
618+
619+ Copied into our own storage rather than attached where it lies: the send
620+ drops the attachment's file when it is done with it, and what it drops has
621+ to be ours not the reader's own picture, sitting in their gallery."
622+ []
623+ (try
624+ (let [chosen (await (.pickImage (picker/ImagePicker)
625+ .source picker/ImageSource.gallery))]
626+ (when chosen
627+ (let [copy (str (outgoing-dir) "/" (.-millisecondsSinceEpoch (DateTime/now))
628+ ".png")]
629+ (await (.copy (dio/File. (.-path chosen)) copy))
630+ (await (attach! copy "picture.png")))))
631+ (catch Object e
632+ (reset! cells/error (str "Could not read that picture: " (or (ex-message e) e))))))
633+
562 (defn- send-draft! []634 (defn- send-draft! []
563 (let [text (str @cells/draft)635 (let [text (str @cells/draft)
564 room (str @cells/current)636 room (str @cells/current)
@@ -576,8 +648,21 @@
576 (reset! cells/editing nil)648 (reset! cells/editing nil)
577 (reset! cells/draft ""))649 (reset! cells/draft ""))
578 650
579- (and (seq text) (seq room) @conn)651+ ;; A picture still on its way up holds the send rather than losing it:
580- (do652+ ;; the line stays in the box, said so, and the reader presses send again
653+ ;; a moment later. Sending the text without its picture would be the one
654+ ;; outcome nobody asked for.
655+ (= :uploading (:status @cells/attachment))
656+ (reset! cells/error "The picture is still uploading.")
657+
658+ (and (or (seq text) (:url @cells/attachment)) (seq room) @conn)
659+ (let [url (:url @cells/attachment)
660+ ;; The picture becomes its link, at the end of the line: what goes
661+ ;; on the wire is the text the reader wrote and a URL after it,
662+ ;; which is what every other client in the channel knows how to
663+ ;; show. A line that is only a picture is only the link.
664+ text (.trim (str text (when url (str " " url))))]
665+ (do
581 (net/send-line! @conn (str "PRIVMSG " room " :" text))666 (net/send-line! @conn (str "PRIVMSG " room " :" text))
582 ;; Echoed locally only when the server will not echo it back. With667 ;; Echoed locally only when the server will not echo it back. With
583 ;; `echo-message` negotiated it does that is what the cap is for, and668 ;; `echo-message` negotiated it does that is what the cap is for, and
@@ -586,7 +671,11 @@
586 (when-not (handshake/acked? @caps "echo-message")671 (when-not (handshake/acked? @caps "echo-message")
587 (swap! cells/channels update room672 (swap! cells/channels update room
588 #(update % :messages conj {:from @cells/form-nick :text text})))673 #(update % :messages conj {:from @cells/form-nick :text text})))
589- (reset! cells/draft "")))))674+ ;; The attachment has done its job the moment the link is on the wire.
675+ (when-let [a @cells/attachment]
676+ (reset! cells/attachment nil)
677+ (discard! (:path a)))
678+ (reset! cells/draft ""))))))
590 679
591 (defn- message-by-id [room id]680 (defn- message-by-id [room id]
592 (when id681 (when id
@@ -678,6 +767,23 @@
678 (swap! cells/channels dissoc name))767 (swap! cells/channels dissoc name))
679 :send-draft! send-draft!768 :send-draft! send-draft!
680 769
770+ ;; Pictures. The chooser is the platform's, so the browsing screen the
771+ ;; desktop falls back to is never opened here and its actions would
772+ ;; have nothing to list anyway, since everything outside this app's own
773+ ;; storage is behind a permission it does not ask for.
774+ :open-image-picker! open-image-picker!
775+ :clear-attachment! clear-attachment!
776+ :close-image-picker! (fn [] (reset! cells/image-picker nil))
777+ :browse! (fn [_] nil)
778+ :picker-roots (fn [] [])
779+ :picker-entries (fn [_] {:dirs [] :files []})
780+ :parent-dir (fn [_] nil)
781+ :pick-image! (fn [_] nil)
782+ ;; Android has no clipboard of pictures to read, which is the other half
783+ ;; of why the chooser above exists.
784+ :paste-image! (fn []
785+ (reset! cells/error "No picture on the clipboard."))
786+
681 ;; Answering. Both are a cell and nothing else what the composer does787 ;; Answering. Both are a cell and nothing else what the composer does
682 ;; with `replying-to` is the shared screen's business.788 ;; with `replying-to` is the shared screen's business.
683 :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))789 :reply-to! (fn [m] (reset! cells/replying-to (select-keys m [:id :from :text])))
added flutter/src/frq/upload/dart.cljd +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+(ns frq.upload.dart
2+ "Sending the picture, over dart:io.
3+
4+ `frq.upload.core` builds the multipart body and reads freeq's answer; this is
5+ the one write and the one read. The desktop's half of this is jolt's own TLS
6+ and a hand-built request line — here `HttpClient` is the runtime's, which is
7+ the same reason `frq.net.dart` has no OpenSSL in it."
8+ (:require ["dart:io" :as io]
9+ ["dart:convert" :as conv]
10+ [frq.upload.core :as core]))
11+
12+(defonce ^:private client (io/HttpClient))
13+
14+(defn ^:async upload!
15+ "Upload the file at `path` as `did`'s, answering the URL freeq serves it back
16+ at. Throws with a message meant to be shown."
17+ [host did channel path filename]
18+ (let [file (io/File. path)
19+ bytes (await (.readAsBytes file))
20+ {p :path ct :content-type body :body} (core/request did channel filename
21+ (vec bytes))
22+ uri (Uri.https host p)
23+ req (await (.postUrl client uri))]
24+ (.set (.-headers req) "user-agent" "frq")
25+ (.set (.-headers req) "accept" "application/json")
26+ (.set (.-headers req) "content-type" ct)
27+ ;; Byte by byte into a builder rather than handing the vector over: what
28+ ;; `frq.upload.core` answers with is a PersistentVector, `add` wants a
29+ ;; List<int>, and ClojureDart does not bridge the one to the other the
30+ ;; same generic edge `frq.net.dart` splits its own lines to avoid. The
31+ ;; first attempt put "PersistentVector<dynamic> is not a subtype of
32+ ;; List<int>" on screen, which at least the error banner showed.
33+ (let [out (io/BytesBuilder)]
34+ (doseq [b body] (.addByte out b))
35+ (.add req (.takeBytes out)))
36+ (let [resp (await (.close req))
37+ payload (await (.join (.transform resp (.-decoder conv/utf8))))
38+ status (.-statusCode resp)]
39+ (if (and (>= status 200) (< status 300))
40+ (core/url-of payload)
41+ (throw (ex-info (core/error-message (str status) payload) {}))))))
new file mode 100644
@@ -0,0 +1,41 @@
1+(ns frq.upload.dart
2+ "Sending the picture, over dart:io.
3+
4+ `frq.upload.core` builds the multipart body and reads freeq's answer; this is
5+ the one write and the one read. The desktop's half of this is jolt's own TLS
6+ and a hand-built request line — here `HttpClient` is the runtime's, which is
7+ the same reason `frq.net.dart` has no OpenSSL in it."
8+ (:require ["dart:io" :as io]
9+ ["dart:convert" :as conv]
10+ [frq.upload.core :as core]))
11+
12+(defonce ^:private client (io/HttpClient))
13+
14+(defn ^:async upload!
15+ "Upload the file at `path` as `did`'s, answering the URL freeq serves it back
16+ at. Throws with a message meant to be shown."
17+ [host did channel path filename]
18+ (let [file (io/File. path)
19+ bytes (await (.readAsBytes file))
20+ {p :path ct :content-type body :body} (core/request did channel filename
21+ (vec bytes))
22+ uri (Uri.https host p)
23+ req (await (.postUrl client uri))]
24+ (.set (.-headers req) "user-agent" "frq")
25+ (.set (.-headers req) "accept" "application/json")
26+ (.set (.-headers req) "content-type" ct)
27+ ;; Byte by byte into a builder rather than handing the vector over: what
28+ ;; `frq.upload.core` answers with is a PersistentVector, `add` wants a
29+ ;; List<int>, and ClojureDart does not bridge the one to the other the
30+ ;; same generic edge `frq.net.dart` splits its own lines to avoid. The
31+ ;; first attempt put "PersistentVector<dynamic> is not a subtype of
32+ ;; List<int>" on screen, which at least the error banner showed.
33+ (let [out (io/BytesBuilder)]
34+ (doseq [b body] (.addByte out b))
35+ (.add req (.takeBytes out)))
36+ (let [resp (await (.close req))
37+ payload (await (.join (.transform resp (.-decoder conv/utf8))))
38+ status (.-statusCode resp)]
39+ (if (and (>= status 200) (< status 300))
40+ (core/url-of payload)
41+ (throw (ex-info (core/error-message (str status) payload) {}))))))
modified src/frq/upload.clj +13 -53
@@ -15,11 +15,12 @@
1515 Nothing here is streamed: an image is at most a few megabytes and the body is
1616 built in memory, which is what keeps the request one write."
1717 (:require [clojure.string :as str]
18+ [frq.upload.core :as core]
1819 [jolt.mvn-http :as tls]))
1920
2021 ;; The endpoint's own cap. Refusing here rather than at the server saves a
2122 ;; multi-megabyte upload that was always going to be rejected.
22-(def max-bytes (* 10 1024 1024))
23+(def max-bytes core/max-bytes)
2324
2425 (defn- file-bytes [path]
2526 (let [in (java.io.FileInputStream. path)]
@@ -28,30 +29,6 @@
2829
2930 (defn- bytes-of [s] (.getBytes (str s)))
3031
31-(defn- boundary
32- "A delimiter of the form the RFC allows, and one no part of this body has in
33- it: every byte of it is a hyphen, a letter or a digit, and the parts are a
34- PNG and a DID."
35- [size]
36- (str "----frq" (Math/abs (hash (str size "-frq")))))
37-
38-(defn- multipart
39- "The request body for `fields` (strings) plus the file part, as bytes."
40- [bound fields filename content-type file]
41- (let [out (java.io.ByteArrayOutputStream.)
42- dash (str "--" bound)]
43- (doseq [[k v] fields]
44- (.write out (bytes-of (str dash "\r\n"
45- "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n"
46- v "\r\n"))))
47- (.write out (bytes-of (str dash "\r\n"
48- "Content-Disposition: form-data; name=\"file\";"
49- " filename=\"" filename "\"\r\n"
50- "Content-Type: " content-type "\r\n\r\n")))
51- (.write out file)
52- (.write out (bytes-of (str "\r\n" dash "--\r\n")))
53- (.toByteArray out)))
54-
5532 (defn- read-all!
5633 "Drain a TLS connection into a string. The response is a short JSON body, so
5734 it is read whole rather than by Content-Length."
@@ -65,41 +42,25 @@
6542 (defn- status-of [resp]
6643 (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second))
6744
68-(defn- error-message
69- "What to show for a response that was not a 2xx. The endpoint answers JSON
70- with a `message` or an `error` for the cases a user can do something about —
71- not signed in, file too large — and plain text for the rest."
72- [status body]
73- (let [field (fn [k] (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body ""))))
74- detail (or (field "message") (field "error") (str/trim (str body)))]
75- (str "Upload failed (" status ")"
76- (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail))))))))
77-
7845 (defn upload!
7946 "Upload `path` as `did`'s, returning the URL freeq serves it back at.
8047
48+ `frq.upload.core` builds the request and reads the answer; what is left here
49+ is this backend's way of sending one — jolt's own TLS, in a single write.
50+
8151 `channel` is passed along when there is one: the server files an upload under
8252 the conversation it was meant for. Nothing is shared to the PDS or posted to
83- Bluesky — those are opt-in fields this client does not send.
84-
85- Throws with a message meant to be shown when the upload is refused."
53+ Bluesky those are opt-in fields this client does not send."
8654 [host did channel path filename]
87- (when (str/blank? (str did))
88- (throw (ex-info "Sign in to send a picture an upload is filed under your account." {})))
89- (let [file (file-bytes path)]
90- (when (> (alength file) max-bytes)
91- (throw (ex-info "That picture is over the 10MB the server takes." {:bytes (alength file)})))
55+ (let [{:keys [path content-type body]}
56+ (core/request did channel filename (vec (file-bytes path)))
57+ body (byte-array body)]
9258 (tls/ensure-native!)
93- (let [bound (boundary (alength file))
94- body (multipart bound
95- (cond-> [["did" did]]
96- (seq (str channel)) (conj ["channel" channel]))
97- filename "image/png" file)
98- head (bytes-of (str "POST /api/v1/upload HTTP/1.1\r\n"
59+ (let [head (bytes-of (str "POST " path " HTTP/1.1\r\n"
9960 "Host: " host "\r\n"
10061 "User-Agent: frq\r\n"
10162 "Accept: application/json\r\n"
102- "Content-Type: multipart/form-data; boundary=" bound "\r\n"
63+ "Content-Type: " content-type "\r\n"
10364 "Content-Length: " (alength body) "\r\n"
10465 "Connection: close\r\n\r\n"))
10566 t (tls/tls-connect host 443)]
@@ -113,7 +74,6 @@
11374 status (status-of resp)
11475 [_ payload] (str/split resp #"\r\n\r\n" 2)]
11576 (if (and status (str/starts-with? status "2"))
116- (or (second (re-find #"\"url\"\s*:\s*\"([^\"]*)\"" (or payload "")))
117- (throw (ex-info "The server took the picture but named no URL for it." {})))
118- (throw (ex-info (error-message (or status "no response") payload) {}))))
77+ (core/url-of payload)
78+ (throw (ex-info (core/error-message (or status "no response") payload) {}))))
11979 (finally (try (tls/tls-close t) (catch Exception _ nil)))))))
@@ -15,11 +15,12 @@
15 Nothing here is streamed: an image is at most a few megabytes and the body is15 Nothing here is streamed: an image is at most a few megabytes and the body is
16 built in memory, which is what keeps the request one write."16 built in memory, which is what keeps the request one write."
17 (:require [clojure.string :as str]17 (:require [clojure.string :as str]
18+ [frq.upload.core :as core]
18 [jolt.mvn-http :as tls]))19 [jolt.mvn-http :as tls]))
19 20
20 ;; The endpoint's own cap. Refusing here rather than at the server saves a21 ;; The endpoint's own cap. Refusing here rather than at the server saves a
21 ;; multi-megabyte upload that was always going to be rejected.22 ;; multi-megabyte upload that was always going to be rejected.
22-(def max-bytes (* 10 1024 1024))23+(def max-bytes core/max-bytes)
23 24
24 (defn- file-bytes [path]25 (defn- file-bytes [path]
25 (let [in (java.io.FileInputStream. path)]26 (let [in (java.io.FileInputStream. path)]
@@ -28,30 +29,6 @@
28 29
29 (defn- bytes-of [s] (.getBytes (str s)))30 (defn- bytes-of [s] (.getBytes (str s)))
30 31
31-(defn- boundary
32- "A delimiter of the form the RFC allows, and one no part of this body has in
33- it: every byte of it is a hyphen, a letter or a digit, and the parts are a
34- PNG and a DID."
35- [size]
36- (str "----frq" (Math/abs (hash (str size "-frq")))))
37-
38-(defn- multipart
39- "The request body for `fields` (strings) plus the file part, as bytes."
40- [bound fields filename content-type file]
41- (let [out (java.io.ByteArrayOutputStream.)
42- dash (str "--" bound)]
43- (doseq [[k v] fields]
44- (.write out (bytes-of (str dash "\r\n"
45- "Content-Disposition: form-data; name=\"" k "\"\r\n\r\n"
46- v "\r\n"))))
47- (.write out (bytes-of (str dash "\r\n"
48- "Content-Disposition: form-data; name=\"file\";"
49- " filename=\"" filename "\"\r\n"
50- "Content-Type: " content-type "\r\n\r\n")))
51- (.write out file)
52- (.write out (bytes-of (str "\r\n" dash "--\r\n")))
53- (.toByteArray out)))
54-
55 (defn- read-all!32 (defn- read-all!
56 "Drain a TLS connection into a string. The response is a short JSON body, so33 "Drain a TLS connection into a string. The response is a short JSON body, so
57 it is read whole rather than by Content-Length."34 it is read whole rather than by Content-Length."
@@ -65,41 +42,25 @@
65 (defn- status-of [resp]42 (defn- status-of [resp]
66 (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second))43 (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second))
67 44
68-(defn- error-message
69- "What to show for a response that was not a 2xx. The endpoint answers JSON
70- with a `message` or an `error` for the cases a user can do something about —
71- not signed in, file too large — and plain text for the rest."
72- [status body]
73- (let [field (fn [k] (second (re-find (re-pattern (str "\"" k "\"\\s*:\\s*\"([^\"]*)\"")) (or body ""))))
74- detail (or (field "message") (field "error") (str/trim (str body)))]
75- (str "Upload failed (" status ")"
76- (when (seq detail) (str ": " (subs detail 0 (min 200 (count detail))))))))
77-
78 (defn upload!45 (defn upload!
79 "Upload `path` as `did`'s, returning the URL freeq serves it back at.46 "Upload `path` as `did`'s, returning the URL freeq serves it back at.
80 47
48+ `frq.upload.core` builds the request and reads the answer; what is left here
49+ is this backend's way of sending one — jolt's own TLS, in a single write.
50+
81 `channel` is passed along when there is one: the server files an upload under51 `channel` is passed along when there is one: the server files an upload under
82 the conversation it was meant for. Nothing is shared to the PDS or posted to52 the conversation it was meant for. Nothing is shared to the PDS or posted to
83- Bluesky — those are opt-in fields this client does not send.53+ Bluesky those are opt-in fields this client does not send."
84-
85- Throws with a message meant to be shown when the upload is refused."
86 [host did channel path filename]54 [host did channel path filename]
87- (when (str/blank? (str did))55+ (let [{:keys [path content-type body]}
88- (throw (ex-info "Sign in to send a picture an upload is filed under your account." {})))56+ (core/request did channel filename (vec (file-bytes path)))
89- (let [file (file-bytes path)]57+ body (byte-array body)]
90- (when (> (alength file) max-bytes)
91- (throw (ex-info "That picture is over the 10MB the server takes." {:bytes (alength file)})))
92 (tls/ensure-native!)58 (tls/ensure-native!)
93- (let [bound (boundary (alength file))59+ (let [head (bytes-of (str "POST " path " HTTP/1.1\r\n"
94- body (multipart bound
95- (cond-> [["did" did]]
96- (seq (str channel)) (conj ["channel" channel]))
97- filename "image/png" file)
98- head (bytes-of (str "POST /api/v1/upload HTTP/1.1\r\n"
99 "Host: " host "\r\n"60 "Host: " host "\r\n"
100 "User-Agent: frq\r\n"61 "User-Agent: frq\r\n"
101 "Accept: application/json\r\n"62 "Accept: application/json\r\n"
102- "Content-Type: multipart/form-data; boundary=" bound "\r\n"63+ "Content-Type: " content-type "\r\n"
103 "Content-Length: " (alength body) "\r\n"64 "Content-Length: " (alength body) "\r\n"
104 "Connection: close\r\n\r\n"))65 "Connection: close\r\n\r\n"))
105 t (tls/tls-connect host 443)]66 t (tls/tls-connect host 443)]
@@ -113,7 +74,6 @@
113 status (status-of resp)74 status (status-of resp)
114 [_ payload] (str/split resp #"\r\n\r\n" 2)]75 [_ payload] (str/split resp #"\r\n\r\n" 2)]
115 (if (and status (str/starts-with? status "2"))76 (if (and status (str/starts-with? status "2"))
116- (or (second (re-find #"\"url\"\s*:\s*\"([^\"]*)\"" (or payload "")))77+ (core/url-of payload)
117- (throw (ex-info "The server took the picture but named no URL for it." {})))78+ (throw (ex-info (core/error-message (or status "no response") payload) {}))))
118- (throw (ex-info (error-message (or status "no response") payload) {}))))
119 (finally (try (tls/tls-close t) (catch Exception _ nil)))))))79 (finally (try (tls/tls-close t) (catch Exception _ nil)))))))