cheogram-www

created pr with cheogram-www/tauri-media-handling.1 on 2026-10-28T15:15:38Z · by 3c31bc8c
commented on 2026-10-28T15:19:01Z · by 3c31bc8c
The following changes since commit 62ea2af732f009407e7d2de6588cd10657f4e89b: npm run tauri init (2026-10-28 10:28:04 -0400) are available in the Git repository at: https://tangled.org/mrry.band/cheogram-www tauri-media-handling for you to fetch changes up to d1d3a238ce8271bab76def99868cfaf5402e1a54: Tauri file system media store (2026-10-28 11:11:55 -0400) ---------------------------------------------------------------- Eric Roberts (1): Tauri file system media store package.json | 1 + src-tauri/Cargo.lock | 231 +++++++----- src-tauri/Cargo.toml | 9 + src-tauri/src/lib.rs | 50 ++- src-tauri/src/media.rs | 460 ++++++++++++++++++++++ src-tauri/src/media/store.rs | 588 +++++++++++++++++++++++++++++ src-tauri/tauri.conf.json | 1 + src/App.tsx | 2 +- src/components/MessageAttachments.test.tsx | 25 ++ src/components/MessageAttachments.tsx | 10 +- src/media-store-tauri.test.ts | 89 +++++ src/media-store-tauri.ts | 49 +++ src/media-store.ts | 7 + 13 files changed, 1421 insertions(+), 101 deletions(-) create mode 100644 src-tauri/src/media.rs create mode 100644 src-tauri/src/media/store.rs create mode 100644 src/media-store-tauri.test.ts create mode 100644 src/media-store-tauri.ts create mode 100644 src/media-store.ts
added cheogram-www/tauri-media-handling.2 on 2026-10-28T15:44:56Z · by 3c31bc8c
1: d1d3a23 ! 1: 5c9c8ae Tauri file system media store
added cheogram-www/tauri-media-handling.3 on 2026-10-28T16:08:30Z · by 9e834e0d
1: 5c9c8ae < -: ------- Tauri file system media store
-: ------- > 1: 615caaf Review comments
added cheogram-www/tauri-media-handling.4 on 2026-10-28T21:31:45Z · by 3c31bc8c
1: 615caaf < -: ------- Review comments
-: ------- > 1: 58e4016 Tauri file system media store
added cheogram-www/tauri-media-handling.5 on 2026-10-28T21:33:19Z · by 3c31bc8c
1: 58e4016 = 1: b0c5136 Tauri file system media store
added cheogram-www/tauri-media-handling.6 on 2026-10-28T21:33:59Z · by 3c31bc8c
1: b0c5136 ! 1: f6bda1a Tauri file system media store
added cheogram-www/tauri-media-handling.7 on 2026-10-29T14:13:32Z · by 3c31bc8c
1: f6bda1a ! 1: efe5296 Tauri file system media store
cmds
checkout latest patchset:
ssh pr.pico.sh pull cheogram-www:tauri-media-handling | git am -3
checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tauri-media-handling [rev] | git am -3
add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tauri-media-handling
add comment to patch request:
ssh pr.pico.sh comment cheogram-www:tauri-media-handling "lgtm!"

Patchset cheogram-www/tauri-media-handling.1 on 2026-10-28T15:15:38Z · commit d1d3a23

Tauri file system media store
Eric Roberts 2026-10-16T18:10:16Z
Semantic diff summary
75 added, 7 modified, 0 signature changed, 0 removed across 9 analyzed files (4 files skipped: unsupported file type)
src-tauri/src/media/store.rs
+1 -0 package.json #
......@@ -20,6 +20,7 @@
2020 "@privacyresearch/libsignal-protocol-typescript": "^0.0.16",
2121 "@sentry/browser": "^10.52.0",
2222 "@sqlite.org/sqlite-wasm": "^3.54.1-build1",
23+ "@tauri-apps/api": "^2.12.4",
2324 "@types/react": "<= 18.2.0",
2425 "@types/react-dom": "<= 18.2.0",
2526 "borogove": "^0.0.1",
+147 -84 src-tauri/Cargo.lock #
......@@ -68,12 +68,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
6868 name = "cheogram-www"
6969 version = "0.0.1"
7070 dependencies = [
71+ "base64 0.22.1",
72+ "hex",
73+ "infer",
7174 "log",
75+ "mime_guess",
7276 "serde",
7377 "serde_json",
78+ "sha1",
79+ "sha2",
7480 "tauri",
7581 "tauri-build",
7682 "tauri-plugin-log",
83+ "tempfile",
84+ "tokio",
85+ "uuid",
7786 ]
7887
7988 [[package]]
......@@ -179,9 +188,9 @@ dependencies = [
179188
180189 [[package]]
181190 name = "brotli"
182-version = "9.0.0"
191+version = "9.1.1"
183192 source = "registry+https://github.com/rust-lang/crates.io-index"
184-checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
193+checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81"
185194 dependencies = [
186195 "alloc-no-stdlib",
187196 "alloc-stdlib",
......@@ -190,9 +199,9 @@ dependencies = [
190199
191200 [[package]]
192201 name = "brotli-decompressor"
193-version = "6.0.1"
202+version = "6.2.0"
194203 source = "registry+https://github.com/rust-lang/crates.io-index"
195-checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
204+checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf"
196205 dependencies = [
197206 "alloc-no-stdlib",
198207 "alloc-stdlib",
......@@ -304,9 +313,9 @@ dependencies = [
304313
305314 [[package]]
306315 name = "cc"
307-version = "1.8.0"
316+version = "1.10.0"
308317 source = "registry+https://github.com/rust-lang/crates.io-index"
309-checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
318+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
310319 dependencies = [
311320 "find-msvc-tools",
312321 "shlex",
......@@ -428,9 +437,9 @@ dependencies = [
428437
429438 [[package]]
430439 name = "crc32fast"
431-version = "1.5.2"
440+version = "1.5.3"
432441 source = "registry+https://github.com/rust-lang/crates.io-index"
433-checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
442+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
434443 dependencies = [
435444 "cfg-if",
436445 ]
......@@ -761,15 +770,25 @@ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
761770
762771 [[package]]
763772 name = "erased-serde"
764-version = "0.4.10"
773+version = "0.4.11"
765774 source = "registry+https://github.com/rust-lang/crates.io-index"
766-checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
775+checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
767776 dependencies = [
768777 "serde",
769778 "serde_core",
770779 "typeid",
771780 ]
772781
782+[[package]]
783+name = "errno"
784+version = "0.3.14"
785+source = "registry+https://github.com/rust-lang/crates.io-index"
786+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
787+dependencies = [
788+ "libc",
789+ "windows-sys 0.61.2",
790+]
791+
773792 [[package]]
774793 name = "fastrand"
775794 version = "2.5.0"
......@@ -806,9 +825,9 @@ dependencies = [
806825
807826 [[package]]
808827 name = "find-msvc-tools"
809-version = "0.1.14"
828+version = "0.1.15"
810829 source = "registry+https://github.com/rust-lang/crates.io-index"
811-checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
830+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
812831
813832 [[package]]
814833 name = "flate2"
......@@ -1368,19 +1387,19 @@ dependencies = [
13681387
13691388 [[package]]
13701389 name = "ico"
1371-version = "0.5.0"
1390+version = "0.6.0"
13721391 source = "registry+https://github.com/rust-lang/crates.io-index"
1373-checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
1392+checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167"
13741393 dependencies = [
13751394 "byteorder",
1376- "png 0.17.16",
1395+ "png",
13771396 ]
13781397
13791398 [[package]]
13801399 name = "icu_collections"
1381-version = "2.3.0"
1400+version = "2.4.0"
13821401 source = "registry+https://github.com/rust-lang/crates.io-index"
1383-checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
1402+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
13841403 dependencies = [
13851404 "displaydoc",
13861405 "potential_utf",
......@@ -1392,9 +1411,9 @@ dependencies = [
13921411
13931412 [[package]]
13941413 name = "icu_locale_core"
1395-version = "2.3.0"
1414+version = "2.4.0"
13961415 source = "registry+https://github.com/rust-lang/crates.io-index"
1397-checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
1416+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
13981417 dependencies = [
13991418 "displaydoc",
14001419 "litemap",
......@@ -1405,9 +1424,9 @@ dependencies = [
14051424
14061425 [[package]]
14071426 name = "icu_normalizer"
1408-version = "2.3.0"
1427+version = "2.4.0"
14091428 source = "registry+https://github.com/rust-lang/crates.io-index"
1410-checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
1429+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
14111430 dependencies = [
14121431 "icu_collections",
14131432 "icu_normalizer_data",
......@@ -1419,15 +1438,15 @@ dependencies = [
14191438
14201439 [[package]]
14211440 name = "icu_normalizer_data"
1422-version = "2.3.0"
1441+version = "2.4.0"
14231442 source = "registry+https://github.com/rust-lang/crates.io-index"
1424-checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
1443+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
14251444
14261445 [[package]]
14271446 name = "icu_properties"
1428-version = "2.3.0"
1447+version = "2.4.0"
14291448 source = "registry+https://github.com/rust-lang/crates.io-index"
1430-checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
1449+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
14311450 dependencies = [
14321451 "displaydoc",
14331452 "icu_collections",
......@@ -1440,15 +1459,15 @@ dependencies = [
14401459
14411460 [[package]]
14421461 name = "icu_properties_data"
1443-version = "2.3.0"
1462+version = "2.4.0"
14441463 source = "registry+https://github.com/rust-lang/crates.io-index"
1445-checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
1464+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
14461465
14471466 [[package]]
14481467 name = "icu_provider"
1449-version = "2.3.1"
1468+version = "2.4.0"
14501469 source = "registry+https://github.com/rust-lang/crates.io-index"
1451-checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
1470+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
14521471 dependencies = [
14531472 "displaydoc",
14541473 "icu_locale_core",
......@@ -1720,9 +1739,9 @@ dependencies = [
17201739
17211740 [[package]]
17221741 name = "libc"
1723-version = "0.2.190"
1742+version = "0.2.191"
17241743 source = "registry+https://github.com/rust-lang/crates.io-index"
1725-checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
1744+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
17261745
17271746 [[package]]
17281747 name = "libdbus-sys"
......@@ -1752,6 +1771,12 @@ dependencies = [
17521771 "libc",
17531772 ]
17541773
1774+[[package]]
1775+name = "linux-raw-sys"
1776+version = "0.12.1"
1777+source = "registry+https://github.com/rust-lang/crates.io-index"
1778+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1779+
17551780 [[package]]
17561781 name = "litemap"
17571782 version = "0.8.3"
......@@ -1805,6 +1830,16 @@ version = "0.3.17"
18051830 source = "registry+https://github.com/rust-lang/crates.io-index"
18061831 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
18071832
1833+[[package]]
1834+name = "mime_guess"
1835+version = "2.0.5"
1836+source = "registry+https://github.com/rust-lang/crates.io-index"
1837+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
1838+dependencies = [
1839+ "mime",
1840+ "unicase",
1841+]
1842+
18081843 [[package]]
18091844 name = "miniz_oxide"
18101845 version = "0.8.9"
......@@ -1851,7 +1886,7 @@ dependencies = [
18511886 "objc2-core-foundation",
18521887 "objc2-foundation",
18531888 "once_cell",
1854- "png 0.18.1",
1889+ "png",
18551890 "serde",
18561891 "thiserror 2.0.21",
18571892 "windows-sys 0.61.2",
......@@ -1932,9 +1967,9 @@ dependencies = [
19321967
19331968 [[package]]
19341969 name = "num_threads"
1935-version = "0.1.7"
1970+version = "0.1.8"
19361971 source = "registry+https://github.com/rust-lang/crates.io-index"
1937-checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
1972+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
19381973 dependencies = [
19391974 "libc",
19401975 ]
......@@ -2268,9 +2303,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
22682303
22692304 [[package]]
22702305 name = "plist"
2271-version = "1.10.1"
2306+version = "1.10.3"
22722307 source = "registry+https://github.com/rust-lang/crates.io-index"
2273-checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
2308+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
22742309 dependencies = [
22752310 "base64 0.23.1",
22762311 "indexmap 2.14.2",
......@@ -2279,19 +2314,6 @@ dependencies = [
22792314 "time",
22802315 ]
22812316
2282-[[package]]
2283-name = "png"
2284-version = "0.17.16"
2285-source = "registry+https://github.com/rust-lang/crates.io-index"
2286-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
2287-dependencies = [
2288- "bitflags 1.3.2",
2289- "crc32fast",
2290- "fdeflate",
2291- "flate2",
2292- "miniz_oxide 0.8.9",
2293-]
2294-
22952317 [[package]]
22962318 name = "png"
22972319 version = "0.18.1"
......@@ -2556,6 +2578,19 @@ dependencies = [
25562578 "semver",
25572579 ]
25582580
2581+[[package]]
2582+name = "rustix"
2583+version = "1.1.6"
2584+source = "registry+https://github.com/rust-lang/crates.io-index"
2585+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
2586+dependencies = [
2587+ "bitflags 2.13.2",
2588+ "errno",
2589+ "libc",
2590+ "linux-raw-sys",
2591+ "windows-sys 0.61.2",
2592+]
2593+
25592594 [[package]]
25602595 name = "rustversion"
25612596 version = "1.0.23"
......@@ -2816,6 +2851,17 @@ dependencies = [
28162851 "stable_deref_trait",
28172852 ]
28182853
2854+[[package]]
2855+name = "sha1"
2856+version = "0.10.7"
2857+source = "registry+https://github.com/rust-lang/crates.io-index"
2858+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
2859+dependencies = [
2860+ "cfg-if",
2861+ "cpufeatures",
2862+ "digest",
2863+]
2864+
28192865 [[package]]
28202866 name = "sha2"
28212867 version = "0.10.9"
......@@ -2853,9 +2899,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
28532899
28542900 [[package]]
28552901 name = "smallvec"
2856-version = "1.16.3"
2902+version = "1.16.4"
28572903 source = "registry+https://github.com/rust-lang/crates.io-index"
2858-checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
2904+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
28592905
28602906 [[package]]
28612907 name = "socket2"
......@@ -3035,9 +3081,9 @@ dependencies = [
30353081
30363082 [[package]]
30373083 name = "tao"
3038-version = "0.37.1"
3084+version = "0.37.2"
30393085 source = "registry+https://github.com/rust-lang/crates.io-index"
3040-checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
3086+checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548"
30413087 dependencies = [
30423088 "bitflags 2.13.2",
30433089 "block2",
......@@ -3092,9 +3138,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
30923138
30933139 [[package]]
30943140 name = "tauri"
3095-version = "2.12.4"
3141+version = "2.12.5"
30963142 source = "registry+https://github.com/rust-lang/crates.io-index"
3097-checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212"
3143+checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072"
30983144 dependencies = [
30993145 "anyhow",
31003146 "bytes",
......@@ -3143,9 +3189,9 @@ dependencies = [
31433189
31443190 [[package]]
31453191 name = "tauri-build"
3146-version = "2.7.3"
3192+version = "2.7.4"
31473193 source = "registry+https://github.com/rust-lang/crates.io-index"
3148-checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc"
3194+checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614"
31493195 dependencies = [
31503196 "anyhow",
31513197 "cargo_toml",
......@@ -3166,16 +3212,16 @@ dependencies = [
31663212
31673213 [[package]]
31683214 name = "tauri-codegen"
3169-version = "2.7.3"
3215+version = "2.7.4"
31703216 source = "registry+https://github.com/rust-lang/crates.io-index"
3171-checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f"
3217+checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316"
31723218 dependencies = [
31733219 "base64 0.23.1",
31743220 "brotli",
31753221 "ico",
31763222 "json-patch",
31773223 "plist",
3178- "png 0.18.1",
3224+ "png",
31793225 "proc-macro2",
31803226 "quote",
31813227 "semver",
......@@ -3193,9 +3239,9 @@ dependencies = [
31933239
31943240 [[package]]
31953241 name = "tauri-macros"
3196-version = "2.7.3"
3242+version = "2.7.4"
31973243 source = "registry+https://github.com/rust-lang/crates.io-index"
3198-checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f"
3244+checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1"
31993245 dependencies = [
32003246 "heck 0.5.0",
32013247 "proc-macro2",
......@@ -3269,9 +3315,9 @@ dependencies = [
32693315
32703316 [[package]]
32713317 name = "tauri-runtime-wry"
3272-version = "2.12.2"
3318+version = "2.12.3"
32733319 source = "registry+https://github.com/rust-lang/crates.io-index"
3274-checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae"
3320+checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f"
32753321 dependencies = [
32763322 "gtk",
32773323 "http",
......@@ -3279,7 +3325,6 @@ dependencies = [
32793325 "log",
32803326 "objc2",
32813327 "objc2-app-kit",
3282- "once_cell",
32833328 "percent-encoding",
32843329 "raw-window-handle",
32853330 "softbuffer",
......@@ -3333,15 +3378,28 @@ dependencies = [
33333378
33343379 [[package]]
33353380 name = "tauri-winres"
3336-version = "0.3.6"
3381+version = "0.3.7"
33373382 source = "registry+https://github.com/rust-lang/crates.io-index"
3338-checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
3383+checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796"
33393384 dependencies = [
33403385 "dunce",
33413386 "embed-resource",
33423387 "toml 1.1.8+spec-1.1.0",
33433388 ]
33443389
3390+[[package]]
3391+name = "tempfile"
3392+version = "3.27.0"
3393+source = "registry+https://github.com/rust-lang/crates.io-index"
3394+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3395+dependencies = [
3396+ "fastrand",
3397+ "getrandom 0.4.3",
3398+ "once_cell",
3399+ "rustix",
3400+ "windows-sys 0.61.2",
3401+]
3402+
33453403 [[package]]
33463404 name = "tendril"
33473405 version = "0.5.1"
......@@ -3425,9 +3483,9 @@ dependencies = [
34253483
34263484 [[package]]
34273485 name = "tinystr"
3428-version = "0.8.4"
3486+version = "0.8.5"
34293487 source = "registry+https://github.com/rust-lang/crates.io-index"
3430-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
3488+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
34313489 dependencies = [
34323490 "displaydoc",
34333491 "zerovec",
......@@ -3441,9 +3499,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
34413499
34423500 [[package]]
34433501 name = "tokio"
3444-version = "1.53.3"
3502+version = "1.54.1"
34453503 source = "registry+https://github.com/rust-lang/crates.io-index"
3446-checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
3504+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
34473505 dependencies = [
34483506 "bytes",
34493507 "libc",
......@@ -3642,7 +3700,7 @@ dependencies = [
36423700 "objc2-core-graphics",
36433701 "objc2-foundation",
36443702 "once_cell",
3645- "png 0.18.1",
3703+ "png",
36463704 "serde",
36473705 "thiserror 2.0.21",
36483706 "windows-sys 0.61.2",
......@@ -3666,6 +3724,12 @@ version = "1.20.1"
36663724 source = "registry+https://github.com/rust-lang/crates.io-index"
36673725 checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
36683726
3727+[[package]]
3728+name = "unicase"
3729+version = "2.10.0"
3730+source = "registry+https://github.com/rust-lang/crates.io-index"
3731+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
3732+
36693733 [[package]]
36703734 name = "unicode-ident"
36713735 version = "1.0.27"
......@@ -3711,9 +3775,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
37113775
37123776 [[package]]
37133777 name = "uuid"
3714-version = "1.28.0"
3778+version = "1.29.0"
37153779 source = "registry+https://github.com/rust-lang/crates.io-index"
3716-checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
3780+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
37173781 dependencies = [
37183782 "getrandom 0.4.3",
37193783 "js-sys",
......@@ -4249,11 +4313,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
42494313
42504314 [[package]]
42514315 name = "wry"
4252-version = "0.57.0"
4316+version = "0.57.1"
42534317 source = "registry+https://github.com/rust-lang/crates.io-index"
4254-checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
4318+checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
42554319 dependencies = [
4256- "base64 0.22.1",
4320+ "base64 0.23.1",
42574321 "block2",
42584322 "cookie",
42594323 "crossbeam-channel",
......@@ -4274,7 +4338,6 @@ dependencies = [
42744338 "objc2-foundation",
42754339 "objc2-ui-kit",
42764340 "objc2-web-kit",
4277- "once_cell",
42784341 "percent-encoding",
42794342 "raw-window-handle",
42804343 "sha2",
......@@ -4314,9 +4377,9 @@ dependencies = [
43144377
43154378 [[package]]
43164379 name = "yoke"
4317-version = "0.8.3"
4380+version = "0.8.4"
43184381 source = "registry+https://github.com/rust-lang/crates.io-index"
4319-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
4382+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
43204383 dependencies = [
43214384 "stable_deref_trait",
43224385 "yoke-derive",
......@@ -4369,9 +4432,9 @@ dependencies = [
43694432
43704433 [[package]]
43714434 name = "zerovec"
4372-version = "0.11.8"
4435+version = "0.11.9"
43734436 source = "registry+https://github.com/rust-lang/crates.io-index"
4374-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
4437+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
43754438 dependencies = [
43764439 "yoke",
43774440 "zerofrom",
......@@ -4380,9 +4443,9 @@ dependencies = [
43804443
43814444 [[package]]
43824445 name = "zerovec-derive"
4383-version = "0.11.6"
4446+version = "0.11.7"
43844447 source = "registry+https://github.com/rust-lang/crates.io-index"
4385-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
4448+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
43864449 dependencies = [
43874450 "proc-macro2",
43884451 "quote",
+9 -0 src-tauri/Cargo.toml #
......@@ -22,3 +22,12 @@ serde = { version = "1.0", features = ["derive"] }
2222 log = "0.4"
2323 tauri = { version = "2.12.4", features = [] }
2424 tauri-plugin-log = "2"
25+base64 = "0.22"
26+hex = "0.4.3"
27+mime_guess = "2"
28+tokio = { version = "1", features = ["time"] }
29+sha1 = "0.10"
30+sha2 = "0.10"
31+uuid = { version = "1", features = ["v4"] }
32+infer = "0.22.0"
33+tempfile = "3.27.0"
+37 -13 src-tauri/src/lib.rs #
......@@ -1,16 +1,40 @@
1+mod media;
2+
3+use tauri::Manager;
4+
15 #[cfg_attr(mobile, tauri::mobile_entry_point)]
26 pub fn run() {
3- tauri::Builder::default()
4- .setup(|app| {
5- if cfg!(debug_assertions) {
6- app.handle().plugin(
7- tauri_plugin_log::Builder::default()
8- .level(log::LevelFilter::Info)
9- .build(),
10- )?;
11- }
12- Ok(())
13- })
14- .run(tauri::generate_context!())
15- .expect("error while building tauri application");
7+ tauri::Builder::default()
8+ .invoke_handler(tauri::generate_handler![
9+ media::begin_media,
10+ media::write_media,
11+ media::finish_media,
12+ media::has_media,
13+ media::remove_media
14+ ])
15+ .register_asynchronous_uri_scheme_protocol("tauri", media::protocol)
16+ .setup(|app| {
17+ app.manage(std::sync::Arc::new(media::MediaCache::new(
18+ app.path().app_cache_dir()?.join("media"),
19+ )?));
20+ if cfg!(debug_assertions) {
21+ app.handle().plugin(
22+ tauri_plugin_log::Builder::default()
23+ .level(log::LevelFilter::Info)
24+ .build(),
25+ )?;
26+ }
27+ // Build the configured window here so native navigation hooks are installed
28+ // before its first page loads.
29+ let navigation_app = app.handle().clone();
30+ let window =
31+ tauri::WebviewWindowBuilder::from_config(app, &app.config().app.windows[0])?
32+ .on_navigation(move |url| {
33+ !media::open_navigation(&navigation_app, url)
34+ });
35+ window.build()?;
36+ Ok(())
37+ })
38+ .run(tauri::generate_context!())
39+ .expect("error while building tauri application");
1640 }
+460 -0 src-tauri/src/media.rs #
......@@ -0,0 +1,460 @@
1+mod store;
2+
3+pub use store::MediaCache;
4+use store::{MediaHash, Upload};
5+
6+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
7+use std::{
8+ io,
9+ sync::{Arc, Mutex},
10+ time::Duration,
11+};
12+use tauri::{
13+ Manager,
14+ http::{Response, StatusCode, header::HeaderValue},
15+};
16+
17+// Kept in the webview resource table so abandoned uploads are cleaned up when it closes.
18+struct MediaUpload {
19+ cache: Arc<MediaCache>,
20+ pending: Mutex<Option<Upload>>,
21+}
22+
23+impl tauri::Resource for MediaUpload {}
24+
25+impl MediaUpload {
26+ fn new(cache: Arc<MediaCache>, mime: HeaderValue) -> io::Result<Self> {
27+ let upload = Upload::new(&cache, mime)?;
28+ Ok(Self {
29+ cache,
30+ pending: Mutex::new(Some(upload)),
31+ })
32+ }
33+
34+ fn write(&self, data: &[u8]) -> io::Result<()> {
35+ let mut pending = self.pending.lock().unwrap();
36+ let upload = pending
37+ .as_mut()
38+ .ok_or_else(|| io::Error::other("Media upload is closed"))?;
39+ if let Err(error) = upload.write(data) {
40+ pending.take();
41+ return Err(error);
42+ }
43+ Ok(())
44+ }
45+
46+ fn finish(&self) -> io::Result<String> {
47+ let upload = self
48+ .pending
49+ .lock()
50+ .unwrap()
51+ .take()
52+ .ok_or_else(|| io::Error::other("Media upload is closed"))?;
53+ Ok(upload.finish(&self.cache)?.url())
54+ }
55+}
56+
57+#[tauri::command]
58+pub async fn begin_media<R: tauri::Runtime>(
59+ webview: tauri::Webview<R>,
60+ cache: tauri::State<'_, Arc<MediaCache>>,
61+ mime: String,
62+) -> Result<tauri::ResourceId, String> {
63+ let mime = HeaderValue::from_str(&mime).map_err(|error| error.to_string())?;
64+ let cache = cache.inner().clone();
65+ let upload = tauri::async_runtime::spawn_blocking(move || MediaUpload::new(cache, mime))
66+ .await
67+ .map_err(|error| error.to_string())?
68+ .map_err(|error| error.to_string())?;
69+ Ok(webview.resources_table().add(upload))
70+}
71+
72+#[tauri::command]
73+pub async fn write_media<R: tauri::Runtime>(
74+ webview: tauri::Webview<R>,
75+ request: tauri::ipc::Request<'_>,
76+) -> Result<(), String> {
77+ let rid = request
78+ .headers()
79+ .get("X-Media-Id")
80+ .and_then(|value| value.to_str().ok())
81+ .and_then(|value| value.parse::<tauri::ResourceId>().ok())
82+ .ok_or("Missing media resource ID")?;
83+ let data: Vec<u8> = match request.body() {
84+ tauri::ipc::InvokeBody::Raw(data) => data.clone(),
85+ tauri::ipc::InvokeBody::Json(value) => {
86+ serde_json::from_value(value.clone()).map_err(|error| error.to_string())?
87+ }
88+ };
89+ let upload = webview
90+ .resources_table()
91+ .get::<MediaUpload>(rid)
92+ .map_err(|error| error.to_string())?;
93+ tauri::async_runtime::spawn_blocking(move || upload.write(&data))
94+ .await
95+ .map_err(|error| error.to_string())?
96+ .map_err(|error| error.to_string())
97+}
98+
99+#[tauri::command]
100+pub async fn finish_media<R: tauri::Runtime>(
101+ webview: tauri::Webview<R>,
102+ rid: tauri::ResourceId,
103+) -> Result<String, String> {
104+ let upload = webview
105+ .resources_table()
106+ .get::<MediaUpload>(rid)
107+ .map_err(|error| error.to_string())?;
108+ tauri::async_runtime::spawn_blocking(move || upload.finish())
109+ .await
110+ .map_err(|error| error.to_string())?
111+ .map_err(|error| error.to_string())
112+}
113+
114+#[tauri::command]
115+pub async fn has_media(
116+ cache: tauri::State<'_, Arc<MediaCache>>,
117+ algorithm: String,
118+ hex: String,
119+) -> Result<Option<String>, String> {
120+ if !matches!(algorithm.as_str(), "sha-1" | "sha-256") {
121+ return Ok(None);
122+ }
123+ let hash = MediaHash::from_hex(&algorithm, &hex).map_err(|error| error.to_string())?;
124+ let cache = cache.inner().clone();
125+ tauri::async_runtime::spawn_blocking(move || {
126+ cache.has(&hash).map(|found| found.then(|| hash.url()))
127+ })
128+ .await
129+ .map_err(|error| error.to_string())?
130+ .map_err(|error| error.to_string())
131+}
132+
133+#[tauri::command]
134+pub async fn remove_media(
135+ cache: tauri::State<'_, Arc<MediaCache>>,
136+ algorithm: String,
137+ hex: String,
138+) -> Result<(), String> {
139+ if !matches!(algorithm.as_str(), "sha-1" | "sha-256") {
140+ return Ok(());
141+ }
142+ let hash = MediaHash::from_hex(&algorithm, &hex).map_err(|error| error.to_string())?;
143+ let cache = cache.inner().clone();
144+ tauri::async_runtime::spawn_blocking(move || cache.remove(&hash))
145+ .await
146+ .map_err(|error| error.to_string())?
147+ .map_err(|error| error.to_string())
148+}
149+
150+// Only intercept navigations to this app's media URLs, never resource requests.
151+pub fn open_navigation<R: tauri::Runtime>(app: &tauri::AppHandle<R>, url: &tauri::Url) -> bool {
152+ // Windows/Android use http(s)://tauri.localhost; macOS/Linux use tauri://localhost.
153+ // https://docs.rs/tauri/latest/tauri/struct.Builder.html#method.register_uri_scheme_protocol
154+ let local = (url.scheme() == "tauri" && url.host_str() == Some("localhost"))
155+ || (matches!(url.scheme(), "http" | "https") && url.host_str() == Some("tauri.localhost"))
156+ || (cfg!(debug_assertions)
157+ && app
158+ .config()
159+ .build
160+ .dev_url
161+ .as_ref()
162+ .is_some_and(|dev| dev.origin() == url.origin()));
163+ if !local {
164+ return false;
165+ }
166+ let Some(hash) = parse_path(url.path()) else {
167+ return false;
168+ };
169+ let name = url
170+ .query_pairs()
171+ .find_map(|(key, value)| (key == "name").then(|| value.into_owned()));
172+ let app = app.clone();
173+ tauri::async_runtime::spawn(async move {
174+ use tauri_plugin_opener::OpenerExt;
175+ let cache = app.state::<Arc<MediaCache>>().inner().clone();
176+ let result = tauri::async_runtime::spawn_blocking(move || {
177+ cache.prepare_open(&hash, name.as_deref())
178+ })
179+ .await
180+ .map_err(|e| e.to_string())
181+ .and_then(|result| result.map_err(|error| error.to_string()))
182+ .and_then(|file| {
183+ app.opener()
184+ .open_path(file.to_string_lossy(), None::<&str>)
185+ .map_err(|e| e.to_string())
186+ });
187+ if let Err(error) = result {
188+ log::error!("Could not open cached attachment: {error}");
189+ }
190+ });
191+ true
192+}
193+
194+fn parse_path(path: &str) -> Option<MediaHash> {
195+ let (algorithm, encoded) = path.strip_prefix("/.well-known/ni/")?.split_once('/')?;
196+ let bytes = URL_SAFE_NO_PAD.decode(encoded).ok()?;
197+ MediaHash::from_bytes(algorithm, &bytes)
198+}
199+
200+fn error_response(status: StatusCode) -> Response<Vec<u8>> {
201+ Response::builder().status(status).body(vec![]).unwrap()
202+}
203+
204+pub fn protocol<R: tauri::Runtime>(
205+ context: tauri::UriSchemeContext<'_, R>,
206+ request: tauri::http::Request<Vec<u8>>,
207+ responder: tauri::UriSchemeResponder,
208+) {
209+ let app = context.app_handle().clone();
210+ let path = request.uri().path().to_owned();
211+ let head = request.method() == tauri::http::Method::HEAD;
212+ if path.starts_with("/.well-known/ni/") || path == "/.well-known/ni" {
213+ let Some(hash) = parse_path(&path) else {
214+ responder.respond(error_response(StatusCode::BAD_REQUEST));
215+ return;
216+ };
217+ if !head && request.method() != tauri::http::Method::GET {
218+ responder.respond(error_response(StatusCode::METHOD_NOT_ALLOWED));
219+ return;
220+ }
221+ let cache = app.state::<Arc<MediaCache>>().inner().clone();
222+ tauri::async_runtime::spawn(async move {
223+ match wait_response(cache, hash, head, Duration::from_secs(5)).await {
224+ Ok(response) => responder.respond(response),
225+ Err(error) => {
226+ log::error!("Failed to serve cached media: {error}");
227+ responder.respond(error_response(StatusCode::INTERNAL_SERVER_ERROR));
228+ }
229+ }
230+ });
231+ } else {
232+ tauri::async_runtime::spawn_blocking(move || {
233+ if let Some(asset) = app
234+ .asset_resolver()
235+ .get_for_scheme(path, request.uri().scheme_str() == Some("https"))
236+ {
237+ let mut response = Response::builder().header("Content-Type", asset.mime_type);
238+ if let Some(csp) = asset.csp_header {
239+ response = response.header("Content-Security-Policy", csp);
240+ }
241+ responder.respond(
242+ response
243+ .body(if head { vec![] } else { asset.bytes })
244+ .unwrap(),
245+ );
246+ } else {
247+ responder.respond(error_response(StatusCode::NOT_FOUND));
248+ }
249+ });
250+ }
251+}
252+
253+async fn wait_response(
254+ cache: Arc<MediaCache>,
255+ hash: MediaHash,
256+ head: bool,
257+ interval: Duration,
258+) -> Result<Response<Vec<u8>>, String> {
259+ loop {
260+ let cache = cache.clone();
261+ let hash = hash.clone();
262+ match tauri::async_runtime::spawn_blocking(move || cache.response(&hash, head))
263+ .await
264+ .map_err(|error| error.to_string())?
265+ {
266+ Ok(response) => return Ok(response),
267+ Err(error) if error.kind() == io::ErrorKind::NotFound => {}
268+ Err(error) => return Err(error.to_string()),
269+ }
270+ tokio::time::sleep(interval).await;
271+ }
272+}
273+
274+#[cfg(test)]
275+mod tests {
276+ use super::*;
277+ use std::fs;
278+
279+ const SHA256: &str = "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
280+ const SHA1: &str = "daa728737f5b69b8f46dd7df156ee770e738759c";
281+ const DATA: &[u8] = b"media bytes";
282+
283+ fn hash(algorithm: &str, hex: &str) -> MediaHash {
284+ MediaHash::from_hex(algorithm, hex).unwrap()
285+ }
286+
287+ fn hash_url(algorithm: &str, hex: &str) -> String {
288+ hash(algorithm, hex).url()
289+ }
290+
291+ struct Fixture {
292+ cache: Arc<MediaCache>,
293+ directory: tempfile::TempDir,
294+ }
295+ impl Fixture {
296+ fn new() -> Self {
297+ let directory = tempfile::tempdir().unwrap();
298+ Self {
299+ cache: Arc::new(MediaCache::new(directory.path().to_path_buf()).unwrap()),
300+ directory,
301+ }
302+ }
303+ fn begin(&self, mime: String) -> io::Result<MediaUpload> {
304+ MediaUpload::new(
305+ self.cache.clone(),
306+ HeaderValue::from_str(&mime)
307+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?,
308+ )
309+ }
310+
311+ fn publish(&self, mime: &str) {
312+ let upload = self.begin(mime.into()).unwrap();
313+ upload.write(&DATA[..3]).unwrap();
314+ upload.write(&DATA[3..]).unwrap();
315+ assert_eq!(upload.finish().unwrap(), hash_url("sha-256", SHA256));
316+ }
317+ }
318+
319+ #[test]
320+ fn close_and_resource_drop_remove_partial_uploads() {
321+ let fixture = Fixture::new();
322+ let mut resources = tauri::ResourceTable::default();
323+ let upload = fixture.begin("image/png".into()).unwrap();
324+ upload.write(DATA).unwrap();
325+ let rid = resources.add(upload);
326+ resources.close(rid).unwrap();
327+ assert!(!resources.has(rid));
328+ assert_eq!(
329+ fs::read_dir(fixture.directory.path().join("tmp"))
330+ .unwrap()
331+ .count(),
332+ 0
333+ );
334+
335+ let mut resources = tauri::ResourceTable::default();
336+ let upload = fixture.begin("image/png".into()).unwrap();
337+ upload.write(DATA).unwrap();
338+ resources.add(upload);
339+ drop(resources);
340+ assert_eq!(
341+ fs::read_dir(fixture.directory.path().join("tmp"))
342+ .unwrap()
343+ .count(),
344+ 0
345+ );
346+ assert!(!fixture.cache.has(&hash("sha-256", SHA256)).unwrap());
347+ }
348+
349+ #[test]
350+ fn finish_keeps_resource_available_for_close() {
351+ let fixture = Fixture::new();
352+ let mut resources = tauri::ResourceTable::default();
353+ let rid = resources.add(fixture.begin("image/png".into()).unwrap());
354+ resources
355+ .get::<MediaUpload>(rid)
356+ .unwrap()
357+ .write(DATA)
358+ .unwrap();
359+ let url = resources.get::<MediaUpload>(rid).unwrap().finish().unwrap();
360+ assert_eq!(url, hash_url("sha-256", SHA256));
361+ resources.close(rid).unwrap();
362+ assert!(!resources.has(rid));
363+ assert!(fixture.cache.has(&hash("sha-1", SHA1)).unwrap());
364+ assert_eq!(
365+ fs::read_dir(fixture.directory.path().join("tmp"))
366+ .unwrap()
367+ .count(),
368+ 0
369+ );
370+ }
371+
372+ #[test]
373+ fn empty_and_interleaved_uploads_have_independent_hashes() {
374+ let fixture = Fixture::new();
375+ let empty = fixture.begin("application/octet-stream".into()).unwrap();
376+ let first = fixture.begin("image/png".into()).unwrap();
377+ let second = fixture.begin("image/png".into()).unwrap();
378+ first.write(&DATA[..3]).unwrap();
379+ second.write(DATA).unwrap();
380+ first.write(&DATA[3..]).unwrap();
381+ assert_eq!(first.finish().unwrap(), second.finish().unwrap());
382+ assert!(first.finish().is_err());
383+ assert!(first.write(DATA).is_err());
384+ let empty_hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
385+ assert_eq!(empty.finish().unwrap(), hash_url("sha-256", empty_hex));
386+ let response = fixture
387+ .cache
388+ .response(
389+ &hash("sha-1", "da39a3ee5e6b4b0d3255bfef95601890afd80709"),
390+ false,
391+ )
392+ .unwrap();
393+ assert!(response.body().is_empty());
394+ assert!(fixture.cache.has(&hash("sha-1", SHA1)).unwrap());
395+ assert_eq!(
396+ fs::read_dir(fixture.directory.path().join("tmp"))
397+ .unwrap()
398+ .count(),
399+ 0
400+ );
401+ }
402+
403+ #[test]
404+ fn validates_paths_and_publication() {
405+ let fixture = Fixture::new();
406+ for path in [
407+ "/etc/passwd",
408+ "/.well-known/ni/sha-256/../../etc/passwd",
409+ "/.well-known/ni/sha-1/../file",
410+ "/.well-known/ni/sha-1/AA",
411+ "/.well-known/ni/sha-512/AA",
412+ "/.well-known/ni/sha-256/%2e%2e",
413+ "/.well-known/ni/sha-1/",
414+ ] {
415+ assert!(parse_path(path).is_none(), "{path}");
416+ }
417+ for (algorithm, hex, length) in [("sha-1", SHA1, 20), ("sha-256", SHA256, 32)] {
418+ let bytes: Vec<u8> = (0..length)
419+ .map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap())
420+ .collect();
421+ assert_eq!(
422+ parse_path(&format!(
423+ "/.well-known/ni/{algorithm}/{}",
424+ URL_SAFE_NO_PAD.encode(bytes)
425+ )),
426+ Some(hash(algorithm, hex))
427+ );
428+ }
429+ assert!(fixture.begin("image/png\r\nX-Test: bad".into()).is_err());
430+ assert_eq!(
431+ fs::read_dir(fixture.directory.path().join("tmp"))
432+ .unwrap()
433+ .count(),
434+ 0
435+ );
436+ }
437+
438+ #[test]
439+ fn pending_request_loads_after_publication() {
440+ let fixture = Fixture::new();
441+ tauri::async_runtime::block_on(async {
442+ let cache = fixture.cache.clone();
443+ let pending = tauri::async_runtime::spawn(wait_response(
444+ cache,
445+ hash("sha-1", SHA1),
446+ false,
447+ Duration::from_millis(10),
448+ ));
449+ tokio::time::sleep(Duration::from_millis(30)).await;
450+ assert!(!pending.inner().is_finished());
451+ fixture.publish("image/png");
452+ let response = tokio::time::timeout(Duration::from_secs(2), pending)
453+ .await
454+ .unwrap()
455+ .unwrap()
456+ .unwrap();
457+ assert_eq!(response.body(), DATA);
458+ });
459+ }
460+}
+588 -0 src-tauri/src/media/store.rs #
......@@ -0,0 +1,588 @@
1+use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2+use sha1::Sha1;
3+use sha2::{Digest, Sha256};
4+use std::{fs, io, io::Write, path::PathBuf, sync::Mutex};
5+use tauri::http::{Response, header::HeaderValue};
6+use tempfile::NamedTempFile;
7+
8+#[derive(Clone)]
9+#[cfg_attr(test, derive(Debug, PartialEq))]
10+pub(super) enum MediaHash {
11+ Sha1(String),
12+ Sha256(String),
13+}
14+
15+impl MediaHash {
16+ pub(super) fn from_bytes(algorithm: &str, bytes: &[u8]) -> Option<Self> {
17+ match (algorithm, bytes.len()) {
18+ ("sha-1", 20) => Some(Self::Sha1(hex::encode(bytes))),
19+ ("sha-256", 32) => Some(Self::Sha256(hex::encode(bytes))),
20+ _ => None,
21+ }
22+ }
23+
24+ pub(super) fn from_hex(algorithm: &str, hex: &str) -> io::Result<Self> {
25+ let bytes =
26+ hex::decode(hex).map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
27+ Self::from_bytes(algorithm, &bytes)
28+ .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid media hash"))
29+ }
30+
31+ fn hex(&self) -> &str {
32+ match self {
33+ Self::Sha1(hex) | Self::Sha256(hex) => hex,
34+ }
35+ }
36+
37+ pub(super) fn url(&self) -> String {
38+ let algorithm = match self {
39+ Self::Sha1(_) => "sha-1",
40+ Self::Sha256(_) => "sha-256",
41+ };
42+ // NI URLs require base64url
43+ let bytes = hex::decode(self.hex()).expect("MediaHash contains valid hex");
44+ format!(
45+ "/.well-known/ni/{algorithm}/{}",
46+ URL_SAFE_NO_PAD.encode(bytes)
47+ )
48+ }
49+}
50+
51+pub struct MediaCache {
52+ root: PathBuf,
53+ access: Mutex<()>,
54+}
55+
56+struct ResolvedMedia {
57+ root: PathBuf,
58+ hex: String,
59+}
60+
61+impl ResolvedMedia {
62+ fn media_path(&self) -> PathBuf {
63+ self.root.join("files").join(format!("f{}", self.hex))
64+ }
65+
66+ fn mime_path(&self) -> PathBuf {
67+ self.root.join("mime").join(format!("{}.mime", self.hex))
68+ }
69+
70+ fn alias_path(&self, hash: &MediaHash) -> PathBuf {
71+ self.root.join("aliases").join(hash.hex())
72+ }
73+
74+ fn read(&self) -> io::Result<Vec<u8>> {
75+ fs::read(self.media_path())
76+ }
77+
78+ fn mime(&self) -> io::Result<String> {
79+ fs::read_to_string(self.mime_path())
80+ }
81+
82+ fn write_mime(&self, mime: &HeaderValue) -> io::Result<()> {
83+ fs::write(self.mime_path(), mime.as_bytes())
84+ }
85+
86+ fn write_alias(&self, hash: &MediaHash) -> io::Result<()> {
87+ fs::write(self.alias_path(hash), &self.hex)
88+ }
89+
90+ fn exists(&self) -> io::Result<bool> {
91+ Ok(self.media_path().try_exists()? && self.mime_path().try_exists()?)
92+ }
93+
94+ fn remove(&self) -> io::Result<()> {
95+ self.remove_file(self.media_path())?;
96+ self.remove_file(self.mime_path())
97+ }
98+
99+ fn remove_file(&self, path: PathBuf) -> io::Result<()> {
100+ match fs::remove_file(path) {
101+ Ok(()) => Ok(()),
102+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
103+ Err(error) => Err(error),
104+ }
105+ }
106+}
107+
108+pub(super) struct Upload {
109+ file: NamedTempFile,
110+ mime: HeaderValue,
111+ sha256: Sha256,
112+ sha1: Sha1,
113+}
114+
115+impl Upload {
116+ pub(super) fn new(cache: &MediaCache, mime: HeaderValue) -> io::Result<Self> {
117+ Ok(Self {
118+ file: NamedTempFile::new_in(cache.root.join("tmp"))?,
119+ mime,
120+ sha256: Sha256::new(),
121+ sha1: Sha1::new(),
122+ })
123+ }
124+
125+ pub(super) fn write(&mut self, data: &[u8]) -> io::Result<()> {
126+ self.file.write_all(data)?;
127+ self.sha256.update(data);
128+ self.sha1.update(data);
129+ Ok(())
130+ }
131+
132+ pub(super) fn finish(self, cache: &MediaCache) -> io::Result<MediaHash> {
133+ let sha256 = MediaHash::Sha256(format!("{:x}", self.sha256.finalize()));
134+ let sha1 = MediaHash::Sha1(format!("{:x}", self.sha1.finalize()));
135+ cache.publish(self.file, &self.mime, &sha256, &sha1)?;
136+ Ok(sha256)
137+ }
138+}
139+
140+impl MediaCache {
141+ pub(super) fn prepare_open(&self, hash: &MediaHash, name: Option<&str>) -> io::Result<PathBuf> {
142+ let _guard = self.access.lock().unwrap();
143+ let media = self.resolve(hash)?;
144+ let mime = media.mime()?;
145+ let mime = mime.split(';').next().unwrap().trim();
146+ let source = media.media_path();
147+ let name = name
148+ .map(sanitize_filename::sanitize)
149+ .filter(|name| !name.is_empty());
150+ let name = if let Some(name) = name {
151+ name
152+ } else {
153+ // TODO: This sucks I think, need to rework this
154+ //
155+ // mime_guess lists JPEG's `jfif` alias first; prefer the extension
156+ // commonly registered with desktop image viewers.
157+ let extension = if mime.eq_ignore_ascii_case("image/jpeg") {
158+ Some("jpg")
159+ } else if mime.eq_ignore_ascii_case("application/octet-stream") {
160+ // Generic binary MIME has no meaningful extension. Inspect the bytes
161+ // rather than selecting an arbitrary alias such as `aaf`.
162+ use std::io::Read;
163+ let mut header = Vec::new();
164+ fs::File::open(&source)?
165+ .take(8192)
166+ .read_to_end(&mut header)?;
167+ Some(infer::get(&header).map_or("bin", |kind| kind.extension()))
168+ } else {
169+ mime_guess::get_mime_extensions_str(mime).and_then(|v| v.first().copied())
170+ };
171+ extension.map_or_else(
172+ || "attachment".to_owned(),
173+ |ext| format!("attachment.{ext}"),
174+ )
175+ };
176+ // A separate copy prevents external editors from changing the content-addressed cache.
177+ let directory = self
178+ .root
179+ .join("open")
180+ .join(uuid::Uuid::new_v4().to_string());
181+ fs::create_dir_all(&directory)?;
182+ let destination = directory.join(name);
183+ fs::copy(source, &destination)?;
184+ Ok(destination)
185+ }
186+
187+ pub fn new(root: PathBuf) -> io::Result<Self> {
188+ for directory in ["files", "mime", "aliases", "tmp"] {
189+ fs::create_dir_all(root.join(directory))?;
190+ }
191+ Ok(Self {
192+ root,
193+ access: Mutex::new(()),
194+ })
195+ }
196+
197+ fn resolve(&self, hash: &MediaHash) -> io::Result<ResolvedMedia> {
198+ let hex = match hash {
199+ MediaHash::Sha256(_) => hash.hex().to_owned(),
200+ MediaHash::Sha1(_) => {
201+ let alias = fs::read_to_string(self.root.join("aliases").join(hash.hex()))?;
202+ MediaHash::from_hex("sha-256", &alias)
203+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?
204+ .hex()
205+ .to_owned()
206+ }
207+ };
208+ Ok(ResolvedMedia {
209+ root: self.root.clone(),
210+ hex,
211+ })
212+ }
213+
214+ fn publish(
215+ &self,
216+ temporary: NamedTempFile,
217+ mime: &HeaderValue,
218+ sha256: &MediaHash,
219+ sha1: &MediaHash,
220+ ) -> io::Result<()> {
221+ let _guard = self.access.lock().unwrap();
222+ let media = ResolvedMedia {
223+ root: self.root.clone(),
224+ hex: sha256.hex().to_owned(),
225+ };
226+ temporary.persist(media.media_path())?;
227+ media.write_mime(mime)?;
228+ media.write_alias(sha1)?;
229+ Ok(())
230+ }
231+
232+ pub(super) fn has(&self, hash: &MediaHash) -> io::Result<bool> {
233+ let _guard = self.access.lock().unwrap();
234+ match self.resolve(hash) {
235+ Ok(media) => media.exists(),
236+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false),
237+ Err(error) => Err(error),
238+ }
239+ }
240+
241+ pub(super) fn remove(&self, hash: &MediaHash) -> io::Result<()> {
242+ let _guard = self.access.lock().unwrap();
243+ match self.resolve(hash) {
244+ Ok(media) => media.remove(),
245+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
246+ Err(error) => Err(error),
247+ }
248+ }
249+
250+ pub(super) fn response(&self, hash: &MediaHash, head: bool) -> io::Result<Response<Vec<u8>>> {
251+ let _guard = self.access.lock().unwrap();
252+ let media = self.resolve(hash)?;
253+ let mime = media.mime()?;
254+ let bytes = media.read()?;
255+ Response::builder()
256+ .header("Content-Type", mime)
257+ .header("Content-Length", bytes.len())
258+ .body(if head { vec![] } else { bytes })
259+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
260+ }
261+}
262+
263+#[cfg(test)]
264+mod tests {
265+ use super::*;
266+ use std::sync::Arc;
267+
268+ const SHA256: &str = "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
269+ const SHA1: &str = "daa728737f5b69b8f46dd7df156ee770e738759c";
270+ const DATA: &[u8] = b"media bytes";
271+
272+ fn hash(algorithm: &str, hex: &str) -> MediaHash {
273+ MediaHash::from_hex(algorithm, hex).unwrap()
274+ }
275+
276+ fn hash_url(algorithm: &str, hex: &str) -> String {
277+ hash(algorithm, hex).url()
278+ }
279+
280+ struct Fixture {
281+ cache: Arc<MediaCache>,
282+ _directory: tempfile::TempDir,
283+ }
284+ impl Fixture {
285+ fn new() -> Self {
286+ let directory = tempfile::tempdir().unwrap();
287+ Self {
288+ cache: Arc::new(MediaCache::new(directory.path().to_path_buf()).unwrap()),
289+ _directory: directory,
290+ }
291+ }
292+ fn begin(&self, mime: String) -> io::Result<Upload> {
293+ Upload::new(
294+ &self.cache,
295+ HeaderValue::from_str(&mime)
296+ .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?,
297+ )
298+ }
299+
300+ fn publish(&self, mime: &str) {
301+ let mut upload = self.begin(mime.into()).unwrap();
302+ upload.write(&DATA[..3]).unwrap();
303+ upload.write(&DATA[3..]).unwrap();
304+ assert_eq!(
305+ upload.finish(&self.cache).unwrap().url(),
306+ hash_url("sha-256", SHA256)
307+ );
308+ }
309+ }
310+
311+ #[test]
312+ fn upload_stores_plain_text_mime_and_returns_a_typed_hash() {
313+ let fixture = Fixture::new();
314+ let mime = "text/plain; charset=utf-8";
315+ let mut upload = Upload::new(&fixture.cache, HeaderValue::from_static(mime)).unwrap();
316+ upload.write(&DATA[..3]).unwrap();
317+ upload.write(&DATA[3..]).unwrap();
318+ let hash = upload.finish(&fixture.cache).unwrap();
319+ assert_eq!(hash, MediaHash::from_hex("sha-256", SHA256).unwrap());
320+ let media = fixture.cache.resolve(&hash).unwrap();
321+ assert_eq!(fs::read(media.mime_path()).unwrap(), mime.as_bytes());
322+ assert_eq!(media.read().unwrap(), DATA);
323+ assert_eq!(
324+ fs::read_to_string(fixture.cache.root.join("aliases").join(SHA1)).unwrap(),
325+ SHA256
326+ );
327+ }
328+
329+ #[test]
330+ fn invalid_mime_sidecars_return_io_errors() {
331+ let fixture = Fixture::new();
332+ fixture.publish("image/png");
333+ let media = fixture.cache.resolve(&hash("sha-256", SHA256)).unwrap();
334+ for bytes in [b"image/png\r\nX-Test: bad".as_slice(), &[0xff]] {
335+ fs::write(media.mime_path(), bytes).unwrap();
336+ let error = fixture
337+ .cache
338+ .response(&hash("sha-1", SHA1), false)
339+ .unwrap_err();
340+ assert_eq!(error.kind(), io::ErrorKind::InvalidData);
341+ }
342+ }
343+
344+ #[test]
345+ fn opening_copies_aliases_with_mime_extensions() {
346+ let fixture = Fixture::new();
347+ fixture.publish("image/png");
348+ let path = fixture
349+ .cache
350+ .prepare_open(&hash("sha-1", SHA1), None)
351+ .unwrap();
352+ assert_eq!(path.extension().unwrap(), "png");
353+ assert!(path.starts_with(fixture.cache.root.join("open")));
354+ assert_eq!(fs::read(&path).unwrap(), DATA);
355+ fs::write(&path, b"edited").unwrap();
356+ assert_eq!(
357+ fs::read(fixture.cache.root.join("files").join(format!("f{SHA256}"))).unwrap(),
358+ DATA
359+ );
360+ }
361+
362+ #[test]
363+ fn opening_uses_the_original_filename_or_falls_back_to_mime() {
364+ let fixture = Fixture::new();
365+ fixture.publish("image/png");
366+ let hash = hash("sha-256", SHA256);
367+ for name in ["photo.jpg", "résumé + notes & stuff #1.pdf", "notes"] {
368+ let path = fixture.cache.prepare_open(&hash, Some(name)).unwrap();
369+ assert_eq!(path.file_name().unwrap(), name);
370+ assert_eq!(
371+ path.parent().unwrap().parent().unwrap(),
372+ fixture.cache.root.join("open")
373+ );
374+ assert_eq!(fs::read(&path).unwrap(), DATA);
375+ }
376+ for (name, expected) in [
377+ ("", "attachment.png"),
378+ (".", "attachment.png"),
379+ ("..", "attachment.png"),
380+ ("/", "attachment.png"),
381+ ("../outside.png", "..outside.png"),
382+ ("/outside.png", "outside.png"),
383+ ("..\\outside.png", "..outside.png"),
384+ ("bad\0.png", "bad.png"),
385+ ("C:photo.png", "Cphoto.png"),
386+ ] {
387+ let path = fixture.cache.prepare_open(&hash, Some(name)).unwrap();
388+ assert_eq!(path.file_name().unwrap(), expected);
389+ assert_eq!(
390+ path.parent().unwrap().parent().unwrap(),
391+ fixture.cache.root.join("open")
392+ );
393+ assert_eq!(fs::read(&path).unwrap(), DATA);
394+ }
395+ }
396+
397+ #[test]
398+ fn opening_generic_binary_detects_content_or_uses_bin() {
399+ let fixture = Fixture::new();
400+ fixture.publish("application/octet-stream");
401+ let path = fixture
402+ .cache
403+ .prepare_open(&hash("sha-256", SHA256), None)
404+ .unwrap();
405+ assert_eq!(path.extension().unwrap(), "bin");
406+
407+ let mut upload = fixture.begin("application/octet-stream".into()).unwrap();
408+ upload.write(b"%PDF-1.7\n").unwrap();
409+ let hash = upload.finish(&fixture.cache).unwrap();
410+ let path = fixture.cache.prepare_open(&hash, None).unwrap();
411+ assert_eq!(path.extension().unwrap(), "pdf");
412+ }
413+
414+ #[test]
415+ fn opening_jpeg_uses_jpg_instead_of_jfif() {
416+ let fixture = Fixture::new();
417+ fixture.publish("image/jpeg; charset=binary");
418+ let path = fixture
419+ .cache
420+ .prepare_open(&hash("sha-256", SHA256), None)
421+ .unwrap();
422+ assert_eq!(path.file_name().unwrap(), "attachment.jpg");
423+ assert_eq!(fs::read(path).unwrap(), DATA);
424+ }
425+
426+ #[test]
427+ fn write_and_publication_failures_clean_up_temporary_files() {
428+ let fixture = Fixture::new();
429+ let upload = super::super::MediaUpload::new(
430+ fixture.cache.clone(),
431+ HeaderValue::from_static("image/png"),
432+ )
433+ .unwrap();
434+ // Replace the writable handle with a read-only one to force a disk write error.
435+ {
436+ let mut pending = upload.pending.lock().unwrap();
437+ let pending = pending.as_mut().unwrap();
438+ *pending.file.as_file_mut() = fs::File::open(pending.file.path()).unwrap();
439+ }
440+ assert!(upload.write(DATA).is_err());
441+ assert!(upload.finish().is_err());
442+ assert_eq!(
443+ fs::read_dir(fixture.cache.root.join("tmp"))
444+ .unwrap()
445+ .count(),
446+ 0
447+ );
448+
449+ for stage in ["files", "mime", "aliases"] {
450+ let fixture = Fixture::new();
451+ let upload = super::super::MediaUpload::new(
452+ fixture.cache.clone(),
453+ HeaderValue::from_static("image/png"),
454+ )
455+ .unwrap();
456+ upload.write(DATA).unwrap();
457+ match stage {
458+ // A missing parent prevents content persistence; directories at
459+ // the sidecar destinations prevent writing them.
460+ "files" => fs::remove_dir(fixture.cache.root.join("files")).unwrap(),
461+ "mime" => fs::create_dir(
462+ fixture
463+ .cache
464+ .root
465+ .join("mime")
466+ .join(format!("{SHA256}.mime")),
467+ )
468+ .unwrap(),
469+ _ => fs::create_dir(fixture.cache.root.join("aliases").join(SHA1)).unwrap(),
470+ }
471+ assert!(upload.finish().is_err(), "{stage}");
472+ assert!(upload.finish().is_err());
473+ assert!(upload.write(DATA).is_err());
474+ assert_eq!(
475+ fs::read_dir(fixture.cache.root.join("tmp"))
476+ .unwrap()
477+ .count(),
478+ 0
479+ );
480+ }
481+ }
482+
483+ #[test]
484+ fn serves_aliases_and_mime_after_restart() {
485+ let fixture = Fixture::new();
486+ fixture.publish("image/svg+xml");
487+ let reopened = MediaCache::new(fixture.cache.root.clone()).unwrap();
488+ for (algorithm, hex) in [("sha-1", SHA1), ("sha-256", SHA256)] {
489+ assert!(reopened.has(&hash(algorithm, hex)).unwrap());
490+ let response = reopened.response(&hash(algorithm, hex), false).unwrap();
491+ assert_eq!(response.body(), DATA);
492+ assert_eq!(response.headers()["content-type"], "image/svg+xml");
493+ assert_eq!(response.headers()["content-length"], DATA.len().to_string());
494+ }
495+ let head = reopened.response(&hash("sha-1", SHA1), true).unwrap();
496+ assert!(head.body().is_empty());
497+ assert_eq!(head.headers()["content-length"], DATA.len().to_string());
498+ }
499+
500+ #[test]
501+ fn duplicates_and_deletion_work_for_both_algorithms() {
502+ for algorithm in ["sha-1", "sha-256"] {
503+ let fixture = Fixture::new();
504+ fixture.publish("image/png");
505+ fixture.publish("application/octet-stream");
506+ let response = fixture.cache.response(&hash("sha-1", SHA1), false).unwrap();
507+ assert_eq!(response.body(), DATA);
508+ assert_eq!(
509+ response.headers()["content-type"],
510+ "application/octet-stream"
511+ );
512+ assert_eq!(
513+ fs::read_dir(fixture.cache.root.join("tmp"))
514+ .unwrap()
515+ .count(),
516+ 0
517+ );
518+ let hex = if algorithm == "sha-1" { SHA1 } else { SHA256 };
519+ fixture.cache.remove(&hash(algorithm, hex)).unwrap();
520+ fixture.cache.remove(&hash(algorithm, hex)).unwrap();
521+ assert!(!fixture.cache.has(&hash("sha-1", SHA1)).unwrap());
522+ assert!(!fixture.cache.has(&hash("sha-256", SHA256)).unwrap());
523+ assert!(
524+ !fixture
525+ .cache
526+ .root
527+ .join("mime")
528+ .join(format!("{SHA256}.mime"))
529+ .exists()
530+ );
531+ }
532+ }
533+
534+ #[test]
535+ fn missing_or_evicted_files_and_old_files_without_mime_are_misses() {
536+ let fixture = Fixture::new();
537+ assert!(
538+ fixture
539+ .cache
540+ .response(&hash("sha-1", SHA1), false)
541+ .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
542+ );
543+ let path = fixture.cache.root.join("files").join(format!("f{SHA256}"));
544+ fs::write(&path, DATA).unwrap();
545+ // Old JSON sidecars are ignored; the next download writes a MIME sidecar.
546+ fs::create_dir(fixture.cache.root.join("metadata")).unwrap();
547+ fs::write(
548+ fixture
549+ .cache
550+ .root
551+ .join("metadata")
552+ .join(format!("{SHA256}.json")),
553+ br#"{"contentType":"image/png"}"#,
554+ )
555+ .unwrap();
556+ assert!(!fixture.cache.has(&hash("sha-256", SHA256)).unwrap());
557+ fixture.publish("image/png");
558+ fs::remove_file(path).unwrap();
559+ assert!(!fixture.cache.has(&hash("sha-1", SHA1)).unwrap());
560+ assert!(
561+ fixture
562+ .cache
563+ .response(&hash("sha-256", SHA256), false)
564+ .is_err_and(|error| error.kind() == io::ErrorKind::NotFound)
565+ );
566+ }
567+
568+ #[test]
569+ fn validates_external_algorithms_and_hashes() {
570+ assert!(MediaHash::from_hex("sha-512", "00").is_err());
571+ for algorithm in ["sha-1", "sha-256"] {
572+ for hex in ["00".to_owned(), "../outside".to_owned(), "g".repeat(64)] {
573+ assert!(MediaHash::from_hex(algorithm, &hex).is_err());
574+ }
575+ }
576+ }
577+
578+ #[test]
579+ fn validates_disk_aliases() {
580+ let fixture = Fixture::new();
581+ fs::write(
582+ fixture.cache.root.join("aliases").join(SHA1),
583+ "../../outside",
584+ )
585+ .unwrap();
586+ assert!(fixture.cache.has(&hash("sha-1", SHA1)).is_err());
587+ }
588+}
+1 -0 src-tauri/tauri.conf.json #
......@@ -12,6 +12,7 @@
1212 "app": {
1313 "windows": [
1414 {
15+ "create": false,
1516 "title": "Cheogram",
1617 "titleBarStyle": "Transparent",
1718 "backgroundColor": "#7401cf",
+1 -1 src/App.tsx #
......@@ -17,6 +17,7 @@ import { ios, webkit, href, useEffectEvent, showNotification } from "./util";
1717 import { SettingsContext, SettingsData } from "./components/Settings";
1818 import { Call } from "./components/Call";
1919 import { LoginRequest } from "./components/LoginRequest";
20+import { mediaStore } from "./media-store";
2021
2122 Sentry.init({
2223 dsn: "https://ec9ae5aef68e4035937d4c5943c4156c@app.glitchtip.com/7125",
......@@ -33,7 +34,6 @@ Sentry.init({
3334 replaysOnErrorSampleRate: 1.0,
3435 });
3536
36-const mediaStore = await borogove.persistence.MediaStoreCache("snikket");
3737 const idbp = await borogove.persistence.IDB("snikket", mediaStore, null, stem);
3838 let persistence = null;
3939 if (typeof navigator.storage?.getDirectory === "function" && (webkit || ios)) {
+25 -0 src/components/MessageAttachments.test.tsx #
......@@ -70,6 +70,31 @@ function setup(
7070 afterEach(cleanup);
7171
7272 describe("MessageAttachments", () => {
73+ it.each(["image/png", "application/pdf"])(
74+ "passes the original filename on cached %s links",
75+ (mime) => {
76+ const uri = "/.well-known/ni/sha-256/abc";
77+ const name = "résumé + notes & stuff #1.pdf";
78+ const view = setup([
79+ { attachment: { ...attachment(name, uri), mime }, outcomes: [] },
80+ ]);
81+ const url = new URL(
82+ view.getByRole("link").getAttribute("href")!,
83+ location.href,
84+ );
85+ expect(url.pathname).toBe(uri);
86+ expect(url.searchParams.get("name")).toBe(name);
87+ if (mime === "image/png")
88+ expect(view.getByRole("img").getAttribute("src")).toBe(uri);
89+ },
90+ );
91+
92+ it("keeps unnamed cached links unchanged", () => {
93+ const uri = "/.well-known/ni/sha-256/abc";
94+ const view = setup([{ attachment: attachment("", uri), outcomes: [] }]);
95+ expect(view.getByRole("link").getAttribute("href")).toBe(uri);
96+ });
97+
7398 it("fetches a whole batch, hides controls during fetching, and reveals content together", async () => {
7499 const first = attachment("first", null);
75100 const second = attachment("second", null);
+7 -3 src/components/MessageAttachments.tsx #
......@@ -120,11 +120,15 @@ const AttachmentContent = ({
120120 fetchedAttachment: FetchedAttachment;
121121 }) => {
122122 const { uri, embeddable } = fetchedAttachment;
123- if (!embeddable) return <a href={uri}>{attachment.name || "attachment"}</a>;
123+ const href =
124+ attachment.name && uri.startsWith("/.well-known/ni/")
125+ ? `${uri}?name=${encodeURIComponent(attachment.name)}`
126+ : uri;
127+ if (!embeddable) return <a href={href}>{attachment.name || "attachment"}</a>;
124128
125129 if (attachment.mime?.indexOf("image/") === 0) {
126130 return (
127- <a href={uri}>
131+ <a href={href}>
128132 <img
129133 alt={attachment.name || "attachment"}
130134 crossOrigin="anonymous"
......@@ -145,7 +149,7 @@ const AttachmentContent = ({
145149 ></video>
146150 );
147151 } else {
148- return <a href={uri}>{attachment.name || "attachment"}</a>;
152+ return <a href={href}>{attachment.name || "attachment"}</a>;
149153 }
150154 };
151155
+89 -0 src/media-store-tauri.test.ts #
......@@ -0,0 +1,89 @@
1+import { beforeEach, expect, it, vi } from "vitest";
2+import { Hash } from "borogove";
3+import { MediaStoreTauri } from "./media-store-tauri";
4+
5+const native = vi.hoisted(() => ({ invoke: vi.fn() }));
6+vi.mock("@tauri-apps/api/core", () => ({
7+ ...native,
8+ Resource: class {
9+ constructor(readonly rid: number) {}
10+ close() {
11+ return native.invoke("plugin:resources|close", { rid: this.rid });
12+ }
13+ },
14+}));
15+
16+const data = new TextEncoder().encode("media bytes");
17+const sha1 = "daa728737f5b69b8f46dd7df156ee770e738759c";
18+const sha256 =
19+ "b6acae78c76442a73a94762eb91711a59c642ac3cfdaad636a061bdb7fad6975";
20+const url = "/.well-known/ni/sha-256/native-result";
21+const stream = (chunks: Uint8Array[] = [data]) =>
22+ new ReadableStream<Uint8Array>({
23+ start(controller) {
24+ for (const chunk of chunks) controller.enqueue(chunk);
25+ controller.close();
26+ },
27+ });
28+
29+beforeEach(() => {
30+ vi.resetAllMocks();
31+ native.invoke.mockImplementation(async (command) => {
32+ if (command === "begin_media") return 42;
33+ if (command === "finish_media") return url;
34+ if (command === "has_media") return null;
35+ });
36+});
37+
38+it("sends chunk views in order and returns the native URL", async () => {
39+ const store = await MediaStoreTauri();
40+ const padded = new Uint8Array(data.length + 4);
41+ padded.set(data, 2);
42+ const chunks = [padded.subarray(2, 5), padded.subarray(5, data.length + 2)];
43+ const source = stream(chunks);
44+ expect(await store.storeMedia("image/png", source)).toBe(url);
45+ expect(native.invoke.mock.calls).toEqual([
46+ ["begin_media", { mime: "image/png" }],
47+ ["write_media", chunks[0], { headers: { "X-Media-Id": "42" } }],
48+ ["write_media", chunks[1], { headers: { "X-Media-Id": "42" } }],
49+ ["finish_media", { rid: 42 }],
50+ ["plugin:resources|close", { rid: 42 }],
51+ ]);
52+ expect(source.locked).toBe(false);
53+});
54+
55+it("closes the native resource after a write fails", async () => {
56+ const store = await MediaStoreTauri();
57+ const error = new Error("disk full");
58+ const source = stream();
59+ native.invoke.mockResolvedValueOnce(42).mockRejectedValueOnce(error);
60+ await expect(store.storeMedia("image/png", source)).rejects.toBe(error);
61+ expect(source.locked).toBe(false);
62+ expect(native.invoke).toHaveBeenLastCalledWith("plugin:resources|close", {
63+ rid: 42,
64+ });
65+});
66+
67+it.each(["sha-1", "sha-256"])(
68+ "delegates %s lookup and removal to Rust",
69+ async (algorithm) => {
70+ const store = await MediaStoreTauri();
71+ const hex = algorithm === "sha-1" ? sha1 : sha256;
72+ const hash = Hash.fromHex(algorithm, hex)!;
73+ native.invoke.mockResolvedValueOnce(null).mockResolvedValueOnce(url);
74+ expect(await store.hasMedia(hash)).toBeNull();
75+ expect(await store.hasMedia(hash)).toBe(url);
76+ expect(native.invoke).toHaveBeenLastCalledWith("has_media", {
77+ algorithm,
78+ hex,
79+ });
80+ const bytes = Uint8Array.from(hex.match(/../g)!, (byte) =>
81+ parseInt(byte, 16),
82+ ).buffer;
83+ expect(await store.removeMedia(algorithm, bytes)).toBe(true);
84+ expect(native.invoke).toHaveBeenLastCalledWith("remove_media", {
85+ algorithm,
86+ hex,
87+ });
88+ },
89+);
+49 -0 src/media-store-tauri.ts #
......@@ -0,0 +1,49 @@
1+import { Hash, type persistence } from "borogove";
2+import { invoke, Resource } from "@tauri-apps/api/core";
3+
4+export async function MediaStoreTauri() {
5+ return {
6+ // Instead of using KV we store data on the filesystem because the rust
7+ // implementation doesn't have access to this KV store
8+ setKV(value: persistence.KeyValueStore) {
9+ void value;
10+ },
11+
12+ async storeMedia(mime: string, source: ReadableStream<Uint8Array>) {
13+ const upload = new Resource(
14+ await invoke<number>("begin_media", { mime }),
15+ );
16+ try {
17+ await source.pipeTo(
18+ new WritableStream<Uint8Array>({
19+ write(chunk) {
20+ return invoke("write_media", chunk, {
21+ headers: { "X-Media-Id": String(upload.rid) },
22+ });
23+ },
24+ }),
25+ );
26+ return await invoke<string>("finish_media", { rid: upload.rid });
27+ } finally {
28+ await upload.close();
29+ }
30+ },
31+
32+ hasMedia(hash: Hash) {
33+ return invoke<string | null>("has_media", {
34+ algorithm: hash.algorithm,
35+ hex: hash.toHex(),
36+ });
37+ },
38+
39+ async removeMedia(algorithm: string, bytes: ArrayBuffer) {
40+ await invoke("remove_media", {
41+ algorithm,
42+ hex: new Hash(algorithm, bytes).toHex(),
43+ });
44+ return true;
45+ },
46+ } satisfies persistence.MediaStore & {
47+ setKV(value: persistence.KeyValueStore): void;
48+ };
49+}
+7 -0 src/media-store.ts #
......@@ -0,0 +1,7 @@
1+import * as borogove from "borogove";
2+import { isTauri } from "@tauri-apps/api/core";
3+import { MediaStoreTauri } from "./media-store-tauri";
4+
5+export const mediaStore = isTauri()
6+ ? await MediaStoreTauri()
7+ : await borogove.persistence.MediaStoreCache("snikket");
Back to top