cheogram-www
created pr with
cheogram-www/tauri-media-handling.1
commented
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
1: d1d3a23 ! 1: 5c9c8ae Tauri file system media store
added cheogram-www/tauri-media-handling.3
1: 5c9c8ae < -: ------- Tauri file system media store
-: ------- > 1: 615caaf Review comments
added cheogram-www/tauri-media-handling.4
1: 615caaf < -: ------- Review comments
-: ------- > 1: 58e4016 Tauri file system media store
added cheogram-www/tauri-media-handling.5
1: 58e4016 = 1: b0c5136 Tauri file system media store
added cheogram-www/tauri-media-handling.6
1: b0c5136 ! 1: f6bda1a Tauri file system media store
added cheogram-www/tauri-media-handling.7
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 -3checkout specific patchset revision:
ssh pr.pico.sh pull cheogram-www:tauri-media-handling [rev] | git am -3add changes to patch request:
git format-patch main --stdout | ssh pr.pico.sh cheogram-www:tauri-media-handlingadd comment to patch request:
ssh pr.pico.sh comment cheogram-www:tauri-media-handling "lgtm!"
Patchset
cheogram-www/tauri-media-handling.1
Tauri file system media store
Eric Roberts
2026-10-16T18:10:16ZSemantic 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.rs
-
function_itemnewadded -
function_itemfinishadded -
function_itembegin_mediaadded -
function_itemremove_mediaadded -
function_itemopen_navigationadded -
function_itemprotocoladded -
struct_itemFixtureadded -
function_itembeginadded -
struct_itemMediaUploadadded -
function_itemwriteadded -
function_itemparse_pathadded -
function_itemhashadded -
function_itemclose_and_resource_drop_remove_partial_uploadsadded -
function_itemvalidates_paths_and_publicationadded -
function_itempending_request_loads_after_publicationadded -
function_itemhas_mediaadded -
function_itemwait_responseadded -
function_itemhash_urladded -
function_itempublishadded -
function_itemfinish_keeps_resource_available_for_closeadded -
function_itemwrite_mediaadded -
function_itemfinish_mediaadded -
function_itemerror_responseadded -
function_itemempty_and_interleaved_uploads_have_independent_hashesadded
src-tauri/src/media/store.rs
-
function_itemopening_generic_binary_detects_content_or_uses_binadded -
function_itemserves_aliases_and_mime_after_restartadded -
function_itemwrite_aliasadded -
function_itemurladded -
function_itemreadadded -
function_itemhashadded -
function_itembeginadded -
function_itemvalidates_external_algorithms_and_hashesadded -
function_itemvalidates_disk_aliasesadded -
function_itemfrom_bytesadded -
function_itemmedia_pathadded -
function_itemremove_fileadded -
function_itemhasadded -
function_itemresponseadded -
function_itemupload_stores_plain_text_mime_and_returns_a_typed_hashadded -
struct_itemResolvedMediaadded -
function_itemopening_copies_aliases_with_mime_extensionsadded -
function_itemmime_pathadded -
function_itemexistsadded -
struct_itemUploadadded -
struct_itemFixtureadded -
function_itemopening_uses_the_original_filename_or_falls_back_to_mimeadded -
function_itemopening_jpeg_uses_jpg_instead_of_jfifadded -
function_itemduplicates_and_deletion_work_for_both_algorithmsadded -
function_itemhexadded -
struct_itemMediaCacheadded -
function_itemmimeadded -
function_itemprepare_openadded -
function_itemmissing_or_evicted_files_and_old_files_without_mime_are_missesadded -
enum_itemMediaHashadded -
function_itemresolveadded -
function_itemwrite_mimeadded -
function_itemfrom_hexadded -
function_itemalias_pathadded -
function_itemnewadded -
function_itemwriteadded -
function_itempublishadded -
function_itemwrite_and_publication_failures_clean_up_temporary_filesadded -
function_itemremoveadded -
function_itemfinishadded -
function_itemhash_urladded -
function_iteminvalid_mime_sidecars_return_io_errorsadded
+1
-0
package.json
#
| ... | ... | @@ -20,6 +20,7 @@ | |
| 20 | 20 | "@privacyresearch/libsignal-protocol-typescript": "^0.0.16", | |
| 21 | 21 | "@sentry/browser": "^10.52.0", | |
| 22 | 22 | "@sqlite.org/sqlite-wasm": "^3.54.1-build1", | |
| 23 | + | "@tauri-apps/api": "^2.12.4", | |
| 23 | 24 | "@types/react": "<= 18.2.0", | |
| 24 | 25 | "@types/react-dom": "<= 18.2.0", | |
| 25 | 26 | "borogove": "^0.0.1", |
+147
-84
src-tauri/Cargo.lock
#
| ... | ... | @@ -68,12 +68,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 68 | 68 | name = "cheogram-www" | |
| 69 | 69 | version = "0.0.1" | |
| 70 | 70 | dependencies = [ | |
| 71 | + | "base64 0.22.1", | |
| 72 | + | "hex", | |
| 73 | + | "infer", | |
| 71 | 74 | "log", | |
| 75 | + | "mime_guess", | |
| 72 | 76 | "serde", | |
| 73 | 77 | "serde_json", | |
| 78 | + | "sha1", | |
| 79 | + | "sha2", | |
| 74 | 80 | "tauri", | |
| 75 | 81 | "tauri-build", | |
| 76 | 82 | "tauri-plugin-log", | |
| 83 | + | "tempfile", | |
| 84 | + | "tokio", | |
| 85 | + | "uuid", | |
| 77 | 86 | ] | |
| 78 | 87 | ||
| 79 | 88 | [[package]] |
| ... | ... | @@ -179,9 +188,9 @@ dependencies = [ | |
| 179 | 188 | ||
| 180 | 189 | [[package]] | |
| 181 | 190 | name = "brotli" | |
| 182 | - | version = "9.0.0" | |
| 191 | + | version = "9.1.1" | |
| 183 | 192 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 184 | - | checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" | |
| 193 | + | checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" | |
| 185 | 194 | dependencies = [ | |
| 186 | 195 | "alloc-no-stdlib", | |
| 187 | 196 | "alloc-stdlib", |
| ... | ... | @@ -190,9 +199,9 @@ dependencies = [ | |
| 190 | 199 | ||
| 191 | 200 | [[package]] | |
| 192 | 201 | name = "brotli-decompressor" | |
| 193 | - | version = "6.0.1" | |
| 202 | + | version = "6.2.0" | |
| 194 | 203 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 195 | - | checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" | |
| 204 | + | checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" | |
| 196 | 205 | dependencies = [ | |
| 197 | 206 | "alloc-no-stdlib", | |
| 198 | 207 | "alloc-stdlib", |
| ... | ... | @@ -304,9 +313,9 @@ dependencies = [ | |
| 304 | 313 | ||
| 305 | 314 | [[package]] | |
| 306 | 315 | name = "cc" | |
| 307 | - | version = "1.8.0" | |
| 316 | + | version = "1.10.0" | |
| 308 | 317 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 309 | - | checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" | |
| 318 | + | checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" | |
| 310 | 319 | dependencies = [ | |
| 311 | 320 | "find-msvc-tools", | |
| 312 | 321 | "shlex", |
| ... | ... | @@ -428,9 +437,9 @@ dependencies = [ | |
| 428 | 437 | ||
| 429 | 438 | [[package]] | |
| 430 | 439 | name = "crc32fast" | |
| 431 | - | version = "1.5.2" | |
| 440 | + | version = "1.5.3" | |
| 432 | 441 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 433 | - | checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" | |
| 442 | + | checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" | |
| 434 | 443 | dependencies = [ | |
| 435 | 444 | "cfg-if", | |
| 436 | 445 | ] |
| ... | ... | @@ -761,15 +770,25 @@ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" | |
| 761 | 770 | ||
| 762 | 771 | [[package]] | |
| 763 | 772 | name = "erased-serde" | |
| 764 | - | version = "0.4.10" | |
| 773 | + | version = "0.4.11" | |
| 765 | 774 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 766 | - | checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" | |
| 775 | + | checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" | |
| 767 | 776 | dependencies = [ | |
| 768 | 777 | "serde", | |
| 769 | 778 | "serde_core", | |
| 770 | 779 | "typeid", | |
| 771 | 780 | ] | |
| 772 | 781 | ||
| 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 | + | ||
| 773 | 792 | [[package]] | |
| 774 | 793 | name = "fastrand" | |
| 775 | 794 | version = "2.5.0" |
| ... | ... | @@ -806,9 +825,9 @@ dependencies = [ | |
| 806 | 825 | ||
| 807 | 826 | [[package]] | |
| 808 | 827 | name = "find-msvc-tools" | |
| 809 | - | version = "0.1.14" | |
| 828 | + | version = "0.1.15" | |
| 810 | 829 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 811 | - | checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" | |
| 830 | + | checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" | |
| 812 | 831 | ||
| 813 | 832 | [[package]] | |
| 814 | 833 | name = "flate2" |
| ... | ... | @@ -1368,19 +1387,19 @@ dependencies = [ | |
| 1368 | 1387 | ||
| 1369 | 1388 | [[package]] | |
| 1370 | 1389 | name = "ico" | |
| 1371 | - | version = "0.5.0" | |
| 1390 | + | version = "0.6.0" | |
| 1372 | 1391 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1373 | - | checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" | |
| 1392 | + | checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167" | |
| 1374 | 1393 | dependencies = [ | |
| 1375 | 1394 | "byteorder", | |
| 1376 | - | "png 0.17.16", | |
| 1395 | + | "png", | |
| 1377 | 1396 | ] | |
| 1378 | 1397 | ||
| 1379 | 1398 | [[package]] | |
| 1380 | 1399 | name = "icu_collections" | |
| 1381 | - | version = "2.3.0" | |
| 1400 | + | version = "2.4.0" | |
| 1382 | 1401 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1383 | - | checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" | |
| 1402 | + | checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" | |
| 1384 | 1403 | dependencies = [ | |
| 1385 | 1404 | "displaydoc", | |
| 1386 | 1405 | "potential_utf", |
| ... | ... | @@ -1392,9 +1411,9 @@ dependencies = [ | |
| 1392 | 1411 | ||
| 1393 | 1412 | [[package]] | |
| 1394 | 1413 | name = "icu_locale_core" | |
| 1395 | - | version = "2.3.0" | |
| 1414 | + | version = "2.4.0" | |
| 1396 | 1415 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1397 | - | checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" | |
| 1416 | + | checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" | |
| 1398 | 1417 | dependencies = [ | |
| 1399 | 1418 | "displaydoc", | |
| 1400 | 1419 | "litemap", |
| ... | ... | @@ -1405,9 +1424,9 @@ dependencies = [ | |
| 1405 | 1424 | ||
| 1406 | 1425 | [[package]] | |
| 1407 | 1426 | name = "icu_normalizer" | |
| 1408 | - | version = "2.3.0" | |
| 1427 | + | version = "2.4.0" | |
| 1409 | 1428 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1410 | - | checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" | |
| 1429 | + | checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" | |
| 1411 | 1430 | dependencies = [ | |
| 1412 | 1431 | "icu_collections", | |
| 1413 | 1432 | "icu_normalizer_data", |
| ... | ... | @@ -1419,15 +1438,15 @@ dependencies = [ | |
| 1419 | 1438 | ||
| 1420 | 1439 | [[package]] | |
| 1421 | 1440 | name = "icu_normalizer_data" | |
| 1422 | - | version = "2.3.0" | |
| 1441 | + | version = "2.4.0" | |
| 1423 | 1442 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1424 | - | checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" | |
| 1443 | + | checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" | |
| 1425 | 1444 | ||
| 1426 | 1445 | [[package]] | |
| 1427 | 1446 | name = "icu_properties" | |
| 1428 | - | version = "2.3.0" | |
| 1447 | + | version = "2.4.0" | |
| 1429 | 1448 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1430 | - | checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" | |
| 1449 | + | checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" | |
| 1431 | 1450 | dependencies = [ | |
| 1432 | 1451 | "displaydoc", | |
| 1433 | 1452 | "icu_collections", |
| ... | ... | @@ -1440,15 +1459,15 @@ dependencies = [ | |
| 1440 | 1459 | ||
| 1441 | 1460 | [[package]] | |
| 1442 | 1461 | name = "icu_properties_data" | |
| 1443 | - | version = "2.3.0" | |
| 1462 | + | version = "2.4.0" | |
| 1444 | 1463 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1445 | - | checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" | |
| 1464 | + | checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" | |
| 1446 | 1465 | ||
| 1447 | 1466 | [[package]] | |
| 1448 | 1467 | name = "icu_provider" | |
| 1449 | - | version = "2.3.1" | |
| 1468 | + | version = "2.4.0" | |
| 1450 | 1469 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1451 | - | checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" | |
| 1470 | + | checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" | |
| 1452 | 1471 | dependencies = [ | |
| 1453 | 1472 | "displaydoc", | |
| 1454 | 1473 | "icu_locale_core", |
| ... | ... | @@ -1720,9 +1739,9 @@ dependencies = [ | |
| 1720 | 1739 | ||
| 1721 | 1740 | [[package]] | |
| 1722 | 1741 | name = "libc" | |
| 1723 | - | version = "0.2.190" | |
| 1742 | + | version = "0.2.191" | |
| 1724 | 1743 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1725 | - | checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" | |
| 1744 | + | checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" | |
| 1726 | 1745 | ||
| 1727 | 1746 | [[package]] | |
| 1728 | 1747 | name = "libdbus-sys" |
| ... | ... | @@ -1752,6 +1771,12 @@ dependencies = [ | |
| 1752 | 1771 | "libc", | |
| 1753 | 1772 | ] | |
| 1754 | 1773 | ||
| 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 | + | ||
| 1755 | 1780 | [[package]] | |
| 1756 | 1781 | name = "litemap" | |
| 1757 | 1782 | version = "0.8.3" |
| ... | ... | @@ -1805,6 +1830,16 @@ version = "0.3.17" | |
| 1805 | 1830 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1806 | 1831 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" | |
| 1807 | 1832 | ||
| 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 | + | ||
| 1808 | 1843 | [[package]] | |
| 1809 | 1844 | name = "miniz_oxide" | |
| 1810 | 1845 | version = "0.8.9" |
| ... | ... | @@ -1932,9 +1967,9 @@ dependencies = [ | |
| 1932 | 1967 | ||
| 1933 | 1968 | [[package]] | |
| 1934 | 1969 | name = "num_threads" | |
| 1935 | - | version = "0.1.7" | |
| 1970 | + | version = "0.1.8" | |
| 1936 | 1971 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1937 | - | checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" | |
| 1972 | + | checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" | |
| 1938 | 1973 | dependencies = [ | |
| 1939 | 1974 | "libc", | |
| 1940 | 1975 | ] |
| ... | ... | @@ -2268,9 +2303,9 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" | |
| 2268 | 2303 | ||
| 2269 | 2304 | [[package]] | |
| 2270 | 2305 | name = "plist" | |
| 2271 | - | version = "1.10.1" | |
| 2306 | + | version = "1.10.3" | |
| 2272 | 2307 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2273 | - | checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" | |
| 2308 | + | checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" | |
| 2274 | 2309 | dependencies = [ | |
| 2275 | 2310 | "base64 0.23.1", | |
| 2276 | 2311 | "indexmap 2.14.2", |
| ... | ... | @@ -2279,19 +2314,6 @@ dependencies = [ | |
| 2279 | 2314 | "time", | |
| 2280 | 2315 | ] | |
| 2281 | 2316 | ||
| 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 | - | ||
| 2295 | 2317 | [[package]] | |
| 2296 | 2318 | name = "png" | |
| 2297 | 2319 | version = "0.18.1" |
| ... | ... | @@ -2556,6 +2578,19 @@ dependencies = [ | |
| 2556 | 2578 | "semver", | |
| 2557 | 2579 | ] | |
| 2558 | 2580 | ||
| 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 | + | ||
| 2559 | 2594 | [[package]] | |
| 2560 | 2595 | name = "rustversion" | |
| 2561 | 2596 | version = "1.0.23" |
| ... | ... | @@ -2816,6 +2851,17 @@ dependencies = [ | |
| 2816 | 2851 | "stable_deref_trait", | |
| 2817 | 2852 | ] | |
| 2818 | 2853 | ||
| 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 | + | ||
| 2819 | 2865 | [[package]] | |
| 2820 | 2866 | name = "sha2" | |
| 2821 | 2867 | version = "0.10.9" |
| ... | ... | @@ -2853,9 +2899,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" | |
| 2853 | 2899 | ||
| 2854 | 2900 | [[package]] | |
| 2855 | 2901 | name = "smallvec" | |
| 2856 | - | version = "1.16.3" | |
| 2902 | + | version = "1.16.4" | |
| 2857 | 2903 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2858 | - | checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" | |
| 2904 | + | checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" | |
| 2859 | 2905 | ||
| 2860 | 2906 | [[package]] | |
| 2861 | 2907 | name = "socket2" |
| ... | ... | @@ -3035,9 +3081,9 @@ dependencies = [ | |
| 3035 | 3081 | ||
| 3036 | 3082 | [[package]] | |
| 3037 | 3083 | name = "tao" | |
| 3038 | - | version = "0.37.1" | |
| 3084 | + | version = "0.37.2" | |
| 3039 | 3085 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3040 | - | checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" | |
| 3086 | + | checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" | |
| 3041 | 3087 | dependencies = [ | |
| 3042 | 3088 | "bitflags 2.13.2", | |
| 3043 | 3089 | "block2", |
| ... | ... | @@ -3092,9 +3138,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" | |
| 3092 | 3138 | ||
| 3093 | 3139 | [[package]] | |
| 3094 | 3140 | name = "tauri" | |
| 3095 | - | version = "2.12.4" | |
| 3141 | + | version = "2.12.5" | |
| 3096 | 3142 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3097 | - | checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" | |
| 3143 | + | checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072" | |
| 3098 | 3144 | dependencies = [ | |
| 3099 | 3145 | "anyhow", | |
| 3100 | 3146 | "bytes", |
| ... | ... | @@ -3143,9 +3189,9 @@ dependencies = [ | |
| 3143 | 3189 | ||
| 3144 | 3190 | [[package]] | |
| 3145 | 3191 | name = "tauri-build" | |
| 3146 | - | version = "2.7.3" | |
| 3192 | + | version = "2.7.4" | |
| 3147 | 3193 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3148 | - | checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" | |
| 3194 | + | checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614" | |
| 3149 | 3195 | dependencies = [ | |
| 3150 | 3196 | "anyhow", | |
| 3151 | 3197 | "cargo_toml", |
| ... | ... | @@ -3166,16 +3212,16 @@ dependencies = [ | |
| 3166 | 3212 | ||
| 3167 | 3213 | [[package]] | |
| 3168 | 3214 | name = "tauri-codegen" | |
| 3169 | - | version = "2.7.3" | |
| 3215 | + | version = "2.7.4" | |
| 3170 | 3216 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3171 | - | checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" | |
| 3217 | + | checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316" | |
| 3172 | 3218 | dependencies = [ | |
| 3173 | 3219 | "base64 0.23.1", | |
| 3174 | 3220 | "brotli", | |
| 3175 | 3221 | "ico", | |
| 3176 | 3222 | "json-patch", | |
| 3177 | 3223 | "plist", | |
| 3178 | - | "png 0.18.1", | |
| 3224 | + | "png", | |
| 3179 | 3225 | "proc-macro2", | |
| 3180 | 3226 | "quote", | |
| 3181 | 3227 | "semver", |
| ... | ... | @@ -3193,9 +3239,9 @@ dependencies = [ | |
| 3193 | 3239 | ||
| 3194 | 3240 | [[package]] | |
| 3195 | 3241 | name = "tauri-macros" | |
| 3196 | - | version = "2.7.3" | |
| 3242 | + | version = "2.7.4" | |
| 3197 | 3243 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3198 | - | checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" | |
| 3244 | + | checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1" | |
| 3199 | 3245 | dependencies = [ | |
| 3200 | 3246 | "heck 0.5.0", | |
| 3201 | 3247 | "proc-macro2", |
| ... | ... | @@ -3269,9 +3315,9 @@ dependencies = [ | |
| 3269 | 3315 | ||
| 3270 | 3316 | [[package]] | |
| 3271 | 3317 | name = "tauri-runtime-wry" | |
| 3272 | - | version = "2.12.2" | |
| 3318 | + | version = "2.12.3" | |
| 3273 | 3319 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3274 | - | checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" | |
| 3320 | + | checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" | |
| 3275 | 3321 | dependencies = [ | |
| 3276 | 3322 | "gtk", | |
| 3277 | 3323 | "http", |
| ... | ... | @@ -3333,15 +3378,28 @@ dependencies = [ | |
| 3333 | 3378 | ||
| 3334 | 3379 | [[package]] | |
| 3335 | 3380 | name = "tauri-winres" | |
| 3336 | - | version = "0.3.6" | |
| 3381 | + | version = "0.3.7" | |
| 3337 | 3382 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3338 | - | checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" | |
| 3383 | + | checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796" | |
| 3339 | 3384 | dependencies = [ | |
| 3340 | 3385 | "dunce", | |
| 3341 | 3386 | "embed-resource", | |
| 3342 | 3387 | "toml 1.1.8+spec-1.1.0", | |
| 3343 | 3388 | ] | |
| 3344 | 3389 | ||
| 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 | + | ||
| 3345 | 3403 | [[package]] | |
| 3346 | 3404 | name = "tendril" | |
| 3347 | 3405 | version = "0.5.1" |
| ... | ... | @@ -3425,9 +3483,9 @@ dependencies = [ | |
| 3425 | 3483 | ||
| 3426 | 3484 | [[package]] | |
| 3427 | 3485 | name = "tinystr" | |
| 3428 | - | version = "0.8.4" | |
| 3486 | + | version = "0.8.5" | |
| 3429 | 3487 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3430 | - | checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" | |
| 3488 | + | checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" | |
| 3431 | 3489 | dependencies = [ | |
| 3432 | 3490 | "displaydoc", | |
| 3433 | 3491 | "zerovec", |
| ... | ... | @@ -3441,9 +3499,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" | |
| 3441 | 3499 | ||
| 3442 | 3500 | [[package]] | |
| 3443 | 3501 | name = "tokio" | |
| 3444 | - | version = "1.53.3" | |
| 3502 | + | version = "1.54.1" | |
| 3445 | 3503 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3446 | - | checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" | |
| 3504 | + | checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" | |
| 3447 | 3505 | dependencies = [ | |
| 3448 | 3506 | "bytes", | |
| 3449 | 3507 | "libc", |
| ... | ... | @@ -3666,6 +3724,12 @@ version = "1.20.1" | |
| 3666 | 3724 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3667 | 3725 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" | |
| 3668 | 3726 | ||
| 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 | + | ||
| 3669 | 3733 | [[package]] | |
| 3670 | 3734 | name = "unicode-ident" | |
| 3671 | 3735 | version = "1.0.27" |
| ... | ... | @@ -3711,9 +3775,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" | |
| 3711 | 3775 | ||
| 3712 | 3776 | [[package]] | |
| 3713 | 3777 | name = "uuid" | |
| 3714 | - | version = "1.28.0" | |
| 3778 | + | version = "1.29.0" | |
| 3715 | 3779 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3716 | - | checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" | |
| 3780 | + | checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" | |
| 3717 | 3781 | dependencies = [ | |
| 3718 | 3782 | "getrandom 0.4.3", | |
| 3719 | 3783 | "js-sys", |
| ... | ... | @@ -4249,11 +4313,11 @@ checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" | |
| 4249 | 4313 | ||
| 4250 | 4314 | [[package]] | |
| 4251 | 4315 | name = "wry" | |
| 4252 | - | version = "0.57.0" | |
| 4316 | + | version = "0.57.1" | |
| 4253 | 4317 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4254 | - | checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" | |
| 4318 | + | checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" | |
| 4255 | 4319 | dependencies = [ | |
| 4256 | - | "base64 0.22.1", | |
| 4320 | + | "base64 0.23.1", | |
| 4257 | 4321 | "block2", | |
| 4258 | 4322 | "cookie", | |
| 4259 | 4323 | "crossbeam-channel", |
| ... | ... | @@ -4314,9 +4377,9 @@ dependencies = [ | |
| 4314 | 4377 | ||
| 4315 | 4378 | [[package]] | |
| 4316 | 4379 | name = "yoke" | |
| 4317 | - | version = "0.8.3" | |
| 4380 | + | version = "0.8.4" | |
| 4318 | 4381 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4319 | - | checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" | |
| 4382 | + | checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" | |
| 4320 | 4383 | dependencies = [ | |
| 4321 | 4384 | "stable_deref_trait", | |
| 4322 | 4385 | "yoke-derive", |
| ... | ... | @@ -4369,9 +4432,9 @@ dependencies = [ | |
| 4369 | 4432 | ||
| 4370 | 4433 | [[package]] | |
| 4371 | 4434 | name = "zerovec" | |
| 4372 | - | version = "0.11.8" | |
| 4435 | + | version = "0.11.9" | |
| 4373 | 4436 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4374 | - | checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" | |
| 4437 | + | checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" | |
| 4375 | 4438 | dependencies = [ | |
| 4376 | 4439 | "yoke", | |
| 4377 | 4440 | "zerofrom", |
| ... | ... | @@ -4380,9 +4443,9 @@ dependencies = [ | |
| 4380 | 4443 | ||
| 4381 | 4444 | [[package]] | |
| 4382 | 4445 | name = "zerovec-derive" | |
| 4383 | - | version = "0.11.6" | |
| 4446 | + | version = "0.11.7" | |
| 4384 | 4447 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4385 | - | checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" | |
| 4448 | + | checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" | |
| 4386 | 4449 | dependencies = [ | |
| 4387 | 4450 | "proc-macro2", | |
| 4388 | 4451 | "quote", |
+9
-0
src-tauri/Cargo.toml
#
| ... | ... | @@ -22,3 +22,12 @@ serde = { version = "1.0", features = ["derive"] } | |
| 22 | 22 | log = "0.4" | |
| 23 | 23 | tauri = { version = "2.12.4", features = [] } | |
| 24 | 24 | 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 | + | ||
| 1 | 5 | #[cfg_attr(mobile, tauri::mobile_entry_point)] | |
| 2 | 6 | 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"); | |
| 16 | 40 | } |
+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
#
+1
-1
src/App.tsx
#
| ... | ... | @@ -17,6 +17,7 @@ import { ios, webkit, href, useEffectEvent, showNotification } from "./util"; | |
| 17 | 17 | import { SettingsContext, SettingsData } from "./components/Settings"; | |
| 18 | 18 | import { Call } from "./components/Call"; | |
| 19 | 19 | import { LoginRequest } from "./components/LoginRequest"; | |
| 20 | + | import { mediaStore } from "./media-store"; | |
| 20 | 21 | ||
| 21 | 22 | Sentry.init({ | |
| 22 | 23 | dsn: "https://ec9ae5aef68e4035937d4c5943c4156c@app.glitchtip.com/7125", |
| ... | ... | @@ -33,7 +34,6 @@ Sentry.init({ | |
| 33 | 34 | replaysOnErrorSampleRate: 1.0, | |
| 34 | 35 | }); | |
| 35 | 36 | ||
| 36 | - | const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); | |
| 37 | 37 | const idbp = await borogove.persistence.IDB("snikket", mediaStore, null, stem); | |
| 38 | 38 | let persistence = null; | |
| 39 | 39 | if (typeof navigator.storage?.getDirectory === "function" && (webkit || ios)) { |
+25
-0
src/components/MessageAttachments.test.tsx
#
| ... | ... | @@ -70,6 +70,31 @@ function setup( | |
| 70 | 70 | afterEach(cleanup); | |
| 71 | 71 | ||
| 72 | 72 | 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 | + | ||
| 73 | 98 | it("fetches a whole batch, hides controls during fetching, and reveals content together", async () => { | |
| 74 | 99 | const first = attachment("first", null); | |
| 75 | 100 | const second = attachment("second", null); |
+7
-3
src/components/MessageAttachments.tsx
#
| ... | ... | @@ -120,11 +120,15 @@ const AttachmentContent = ({ | |
| 120 | 120 | fetchedAttachment: FetchedAttachment; | |
| 121 | 121 | }) => { | |
| 122 | 122 | 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>; | |
| 124 | 128 | ||
| 125 | 129 | if (attachment.mime?.indexOf("image/") === 0) { | |
| 126 | 130 | return ( | |
| 127 | - | <a href={uri}> | |
| 131 | + | <a href={href}> | |
| 128 | 132 | <img | |
| 129 | 133 | alt={attachment.name || "attachment"} | |
| 130 | 134 | crossOrigin="anonymous" |
+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 | + | } |