Compare commits

..

6 Commits

18 changed files with 970 additions and 75 deletions

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ node_modules/
.env.*.local .env.*.local
docs/ docs/
logs/

View File

@@ -2,16 +2,6 @@
A minimal, self-hosted web app (with a cloud storage option planned), built as a learning exercise to get more familiar with Axum and Svelte. A minimal, self-hosted web app (with a cloud storage option planned), built as a learning exercise to get more familiar with Axum and Svelte.
## What it does
- Register / log in (session-based auth)
- Upload, list, download, and delete files
- Browse files in a grid/tile view with image preview and prev/next navigation
- Stream file content via HTTP range requests — the groundwork for video streaming
## Requirements
- **To run:** Docker + Docker Compose — that's all (Rust/Node build inside the image).
- **To develop:** Rust (cargo + `sqlx-cli`), Node + pnpm, `just`, and Docker (for Postgres).
## Quick start ## Quick start
```bash ```bash
git clone https://git.stefiosif.dev/stefiosif/loft git clone https://git.stefiosif.dev/stefiosif/loft

View File

@@ -37,6 +37,11 @@
"ordinal": 6, "ordinal": 6,
"name": "user_id", "name": "user_id",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 7,
"name": "thumbnail_storage_path",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -51,7 +56,8 @@
false, false,
false, false,
false, false,
false false,
true
] ]
}, },
"hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c" "hash": "31e1333f84168ce0ba75c474f2a1d7a0d0c4d04857130da327b07821f20cf07c"

View File

@@ -37,6 +37,11 @@
"ordinal": 6, "ordinal": 6,
"name": "user_id", "name": "user_id",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 7,
"name": "thumbnail_storage_path",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -55,7 +60,8 @@
false, false,
false, false,
false, false,
false false,
true
] ]
}, },
"hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e" "hash": "638f29a940b4514c1ebd047975e1e1fb8d9b647dbe6ef025dbb13b6417b6e91e"

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE file_records\n SET thumbnail_storage_path = $1\n WHERE id = $2\n AND user_id = $3\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "ac8099bc9fa42a4152f2230903f9e9b51edfc01f03fd18cc2fe0ab3a956f3175"
}

View File

@@ -37,6 +37,11 @@
"ordinal": 6, "ordinal": 6,
"name": "user_id", "name": "user_id",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 7,
"name": "thumbnail_storage_path",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -52,7 +57,8 @@
false, false,
false, false,
false, false,
false false,
true
] ]
}, },
"hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0" "hash": "d059130365f7e4b3a6ae49bee073897a12e58a29b0b2e5c814ff8cb392e32fa0"

View File

@@ -37,6 +37,11 @@
"ordinal": 6, "ordinal": 6,
"name": "user_id", "name": "user_id",
"type_info": "Int8" "type_info": "Int8"
},
{
"ordinal": 7,
"name": "thumbnail_storage_path",
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -52,7 +57,8 @@
false, false,
false, false,
false, false,
false false,
true
] ]
}, },
"hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d" "hash": "f703be0699214db15f5d65ea7ec3186407f6a72cc1596f652fcdcd7c7f49562d"

610
backend/Cargo.lock generated
View File

@@ -17,6 +17,24 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -47,6 +65,17 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -59,6 +88,21 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayvec"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@@ -80,6 +124,49 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror 2.0.18",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"log",
"nom",
"num-rational",
"v_frame",
]
[[package]]
name = "avif-serialize"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.17.0" version = "1.17.0"
@@ -206,6 +293,7 @@ dependencies = [
"chrono", "chrono",
"dotenvy", "dotenvy",
"futures-util", "futures-util",
"image",
"lazy-regex", "lazy-regex",
"mime_guess", "mime_guess",
"opendal", "opendal",
@@ -219,6 +307,7 @@ dependencies = [
"tower-http", "tower-http",
"tower_governor", "tower_governor",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"utoipa", "utoipa",
"utoipa-swagger-ui", "utoipa-swagger-ui",
@@ -247,6 +336,12 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.0" version = "2.11.0"
@@ -256,6 +351,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "bitstream-io"
version = "4.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
dependencies = [
"no_std_io2",
]
[[package]] [[package]]
name = "blake2" name = "blake2"
version = "0.10.6" version = "0.10.6"
@@ -274,18 +378,36 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "built"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -356,6 +478,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -450,6 +578,34 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-queue" name = "crossbeam-queue"
version = "0.3.12" version = "0.3.12"
@@ -465,6 +621,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -619,6 +781,26 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -697,12 +879,42 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.4.1" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -715,6 +927,7 @@ version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [ dependencies = [
"crc32fast",
"miniz_oxide", "miniz_oxide",
"zlib-rs", "zlib-rs",
] ]
@@ -923,6 +1136,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@@ -958,6 +1181,17 @@ dependencies = [
"web-time", "web-time",
] ]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1277,6 +1511,46 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.13.1" version = "2.13.1"
@@ -1289,6 +1563,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "interpolate_name"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "inventory" name = "inventory"
version = "0.3.24" version = "0.3.24"
@@ -1304,6 +1589,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1462,12 +1756,28 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@@ -1523,6 +1833,15 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -1544,6 +1863,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "maybe-rayon"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -1606,6 +1935,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@@ -1623,6 +1962,30 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "no_std_io2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nonempty" name = "nonempty"
version = "0.7.0" version = "0.7.0"
@@ -1635,6 +1998,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1699,6 +2068,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -1911,6 +2291,18 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -1985,6 +2377,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@@ -2053,6 +2458,40 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "profiling"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "quanta" name = "quanta"
version = "0.12.6" version = "0.12.6"
@@ -2068,6 +2507,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -2231,6 +2676,56 @@ version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.4",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror 2.0.18",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e",
"rayon",
"rgb",
]
[[package]] [[package]]
name = "raw-cpuid" name = "raw-cpuid"
version = "11.6.0" version = "11.6.0"
@@ -2240,6 +2735,26 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2356,6 +2871,12 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2788,6 +3309,15 @@ dependencies = [
"simdutf8", "simdutf8",
] ]
[[package]]
name = "simd_helpers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
[[package]] [[package]]
name = "simdutf8" name = "simdutf8"
version = "0.1.5" version = "0.1.5"
@@ -3068,6 +3598,12 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "symlink"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -3148,6 +3684,20 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -3366,6 +3916,19 @@ dependencies = [
"tracing-core", "tracing-core",
] ]
[[package]]
name = "tracing-appender"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
dependencies = [
"crossbeam-channel",
"symlink",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]] [[package]]
name = "tracing-attributes" name = "tracing-attributes"
version = "0.1.31" version = "0.1.31"
@@ -3575,6 +4138,17 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "v_frame"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -3791,6 +4365,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.6.1" version = "1.6.1"
@@ -4143,6 +4723,12 @@ dependencies = [
"rustix", "rustix",
] ]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yansi" name = "yansi"
version = "1.0.1" version = "1.0.1"
@@ -4289,3 +4875,27 @@ dependencies = [
"log", "log",
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]

View File

@@ -25,6 +25,8 @@ mime_guess = "2.0.5"
tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] } tower_governor = { version = "0.8.0", default-features = false, features = ["axum", "tracing"] }
utoipa = { version = "5.5.0", features = ["axum_extras", "chrono"] } utoipa = { version = "5.5.0", features = ["axum_extras", "chrono"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] } utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }
image = "0.25.10"
tracing-appender = "0.2.5"
[dev-dependencies] [dev-dependencies]
axum-test = "20.0.0" axum-test = "20.0.0"

View File

@@ -0,0 +1 @@
ALTER TABLE file_records ADD COLUMN thumbnail_storage_path TEXT;

View File

@@ -19,6 +19,7 @@ pub enum LoftError {
NoFileProvided, NoFileProvided,
MultipartError(String), MultipartError(String),
InvalidRange, InvalidRange,
ThumbnailGenerationError(String),
} }
impl fmt::Display for LoftError { impl fmt::Display for LoftError {
@@ -51,6 +52,12 @@ impl From<argon2::password_hash::Error> for LoftError {
} }
} }
impl From<image::ImageError> for LoftError {
fn from(value: image::ImageError) -> Self {
LoftError::ThumbnailGenerationError(value.to_string())
}
}
impl std::error::Error for LoftError {} impl std::error::Error for LoftError {}
impl IntoResponse for LoftError { impl IntoResponse for LoftError {
@@ -80,6 +87,10 @@ impl IntoResponse for LoftError {
error!("opendal storage error: {e}"); error!("opendal storage error: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response() StatusCode::INTERNAL_SERVER_ERROR.into_response()
} }
Self::ThumbnailGenerationError(e) => {
error!("thumbnail generation error: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Self::NoFileProvided => StatusCode::BAD_REQUEST.into_response(), Self::NoFileProvided => StatusCode::BAD_REQUEST.into_response(),
Self::MultipartError(e) => { Self::MultipartError(e) => {
info!("bad request: {e}"); info!("bad request: {e}");

View File

@@ -1,6 +1,7 @@
mod ctx; mod ctx;
mod error; mod error;
mod model; mod model;
mod tasks;
mod web; mod web;
use std::{net::SocketAddr, time::Duration}; use std::{net::SocketAddr, time::Duration};
@@ -13,6 +14,7 @@ use axum::{
middleware, middleware,
}; };
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::mpsc::{self};
use tower_cookies::CookieManagerLayer; use tower_cookies::CookieManagerLayer;
use tower_governor::{ use tower_governor::{
GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor, GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor,
@@ -31,60 +33,57 @@ use crate::{
model::{FileRepository, UserRepository}, model::{FileRepository, UserRepository},
web::{ web::{
mw_auth::{mw_ctx_resolver, mw_require_auth}, mw_auth::{mw_ctx_resolver, mw_require_auth},
routes_file::{FileApi, routes_file}, routes_file::{FileApi, FileState, routes_file},
routes_health::{HealthApi, routes_health}, routes_health::{HealthApi, routes_health},
routes_login::{AuthApi, routes_auth}, routes_login::{AuthApi, routes_auth},
}, },
}; };
const BUFFER_SIZE: usize = 100;
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let file_appender = tracing_appender::rolling::daily("logs", "loft.log");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::registry() tracing_subscriber::registry()
.with( .with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| { tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
format!("{}=info,tower_http=debug", env!("CARGO_CRATE_NAME")).into() format!("{}=info,tower_http=debug", env!("CARGO_CRATE_NAME")).into()
}), }),
) )
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(non_blocking),
)
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.init(); .init();
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
const BODY_LIMIT: usize = 1000 * 1000 * 1000 * 5;
let pool = PgPool::connect(&database_url).await?; let pool = PgPool::connect(&database_url).await?;
sqlx::migrate!().run(&pool).await?; sqlx::migrate!().run(&pool).await?;
let file_repository = FileRepository::new(pool.clone())?; let file_repository = FileRepository::new(pool.clone())?;
let routes_file = routes_file(file_repository.clone())
let (tx, rx) = mpsc::channel(BUFFER_SIZE);
tokio::spawn(tasks::run_thumbnail_worker(rx, file_repository.clone()));
let file_service = FileState::new(file_repository.clone(), tx);
let routes_file = routes_file(file_service)
.route_layer(middleware::from_fn(mw_require_auth)) .route_layer(middleware::from_fn(mw_require_auth))
.layer(DefaultBodyLimit::max(BODY_LIMIT)); .layer(DefaultBodyLimit::max(BODY_LIMIT));
let user_repository = UserRepository::new(pool); let user_repository = UserRepository::new(pool);
let mut routes_auth = routes_auth(user_repository.clone()); let routes_auth = routes_auth(user_repository.clone());
let routes_auth = if std::env::var("ENVIRONMENT").unwrap_or_default() == "test" {
if std::env::var("ENVIRONMENT").unwrap_or_default() != "test" { routes_auth
let governor_conf_auth = GovernorConfigBuilder::default() } else {
.per_second(4) apply_governor(routes_auth)
.burst_size(2) };
.key_extractor(SmartIpKeyExtractor)
.finish()
.expect("failed to initialize rate limiter configurations");
let governor_auth_limiter = governor_conf_auth.limiter().clone();
let interval = Duration::from_secs(60);
std::thread::spawn(move || {
loop {
std::thread::sleep(interval);
let len = governor_auth_limiter.len();
if len > 0 {
info!("rate limiting auth storage size: {len}");
}
governor_auth_limiter.retain_recent();
}
});
routes_auth = routes_auth.layer(GovernorLayer::new(governor_conf_auth));
}
let mut openapi = AuthApi::openapi(); let mut openapi = AuthApi::openapi();
openapi.merge(FileApi::openapi()); openapi.merge(FileApi::openapi());
@@ -123,3 +122,27 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn apply_governor(routes_auth: Router) -> Router {
let governor_conf_auth = GovernorConfigBuilder::default()
.per_second(4)
.burst_size(2)
.key_extractor(SmartIpKeyExtractor)
.finish()
.expect("failed to initialize rate limiter configurations");
let governor_auth_limiter = governor_conf_auth.limiter().clone();
let interval = Duration::from_secs(60);
std::thread::spawn(move || {
loop {
std::thread::sleep(interval);
let len = governor_auth_limiter.len();
if len > 0 {
info!("rate limiting auth storage size: {len}");
}
governor_auth_limiter.retain_recent();
}
});
routes_auth.layer(GovernorLayer::new(governor_conf_auth))
}

View File

@@ -1,6 +1,6 @@
use axum::{body::Bytes, extract::multipart::MultipartError}; use axum::{body::Bytes, extract::multipart::MultipartError};
use futures_util::{Stream, StreamExt}; use futures_util::{Stream, StreamExt};
use opendal::{Operator, layers::LoggingLayer, services}; use opendal::{FuturesBytesStream, Operator, layers::LoggingLayer, services};
use serde::Serialize; use serde::Serialize;
use sqlx::{PgPool, prelude::FromRow}; use sqlx::{PgPool, prelude::FromRow};
@@ -17,6 +17,7 @@ pub struct FileRecord {
#[serde(skip_serializing)] #[serde(skip_serializing)]
pub storage_key: String, pub storage_key: String,
pub uploaded_at: chrono::DateTime<chrono::Utc>, pub uploaded_at: chrono::DateTime<chrono::Utc>,
pub thumbnail_storage_path: Option<String>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -79,24 +80,28 @@ impl FileRepository {
Ok(record) Ok(record)
} }
pub async fn download_file( pub async fn download_file(&self, file_id: i64, user_id: i64) -> Result<FuturesBytesStream> {
&self,
file_id: i64,
user_id: i64,
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>> {
let record = self.get_file(file_id, user_id).await?; let record = self.get_file(file_id, user_id).await?;
let reader = self.op.reader(&record.storage_key).await?; let reader = self.op.reader(&record.storage_key).await?;
let stream = reader.into_bytes_stream(0..).await?; let stream = reader.into_bytes_stream(0..).await?;
Ok(stream) Ok(stream)
} }
pub async fn thumbnail(&self, file_id: i64, user_id: i64) -> Result<FuturesBytesStream> {
let record = self.get_file(file_id, user_id).await?;
let path = format!("thumbnail/{}", &record.storage_key);
let reader = self.op.reader(&path).await?;
let stream = reader.into_bytes_stream(0..).await?;
Ok(stream)
}
pub async fn stream_part( pub async fn stream_part(
&self, &self,
file_id: i64, file_id: i64,
user_id: i64, user_id: i64,
from: u64, from: u64,
to: u64, to: u64,
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>> { ) -> Result<FuturesBytesStream> {
let record = self.get_file(file_id, user_id).await?; let record = self.get_file(file_id, user_id).await?;
let reader = self.op.reader(&record.storage_key).await?; let reader = self.op.reader(&record.storage_key).await?;
let stream = reader.into_bytes_stream(from..to).await?; let stream = reader.into_bytes_stream(from..to).await?;
@@ -155,6 +160,29 @@ impl FileRepository {
Ok(records) Ok(records)
} }
pub async fn update_thumbnail_storage_path(
&self,
path: &str,
file_record_id: i64,
user_id: i64,
) -> Result<()> {
sqlx::query!(
r#"
UPDATE file_records
SET thumbnail_storage_path = $1
WHERE id = $2
AND user_id = $3
"#,
path,
file_record_id,
user_id
)
.execute(&self.pool)
.await?;
Ok(())
}
} }
#[derive(Clone, Debug, FromRow)] #[derive(Clone, Debug, FromRow)]
@@ -383,6 +411,37 @@ mod tests {
)); ));
} }
#[tokio::test]
#[serial_test::serial]
async fn test_update_thumbnail_storage_path() {
let user_repository = user_repository().await.unwrap();
let user = user_repository
.create_user("username", "password_hash")
.await
.unwrap();
let file_repository = file_repository().await.unwrap();
truncate_file_records(&file_repository.pool).await;
let record = file_repository
.create_file_record(user.id, "a.png", 2, "a.png-uuid")
.await
.unwrap();
assert_eq!(record.thumbnail_storage_path, None);
let path = "thumbnail/a.png-uuid";
file_repository
.update_thumbnail_storage_path(path, record.id, user.id)
.await
.unwrap();
let updated = file_repository.get_file(record.id, user.id).await.unwrap();
assert_eq!(updated.thumbnail_storage_path, Some(path.to_string()));
truncate_file_records(&file_repository.pool).await;
truncate_users(&user_repository.pool).await;
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn test_stream_part() { async fn test_stream_part() {

81
backend/src/tasks.rs Normal file
View File

@@ -0,0 +1,81 @@
use image::codecs::jpeg::JpegEncoder;
use tokio::sync::mpsc::Receiver;
use tracing::{info, warn};
use crate::{
error::Result,
model::{FileRecord, FileRepository},
};
// change thumbnail task to have the file id in the param
// with the file id we can call the file repository and get the file record
// which will grand us access to the opendal storage... and also the MIME type
// so we can have a match statement after a parser that will give us the type of
// process we should do in order to create the thumbnail..
// for example an image will be processed with image crate but a video will be processed with tokio::command->ffmpeg
#[derive(Clone, Debug)]
pub struct ThumbnailTask {
record_id: i64,
user_id: i64,
}
impl ThumbnailTask {
pub fn new(record_id: i64, user_id: i64) -> Self {
Self { record_id, user_id }
}
}
pub async fn run_thumbnail_worker(
mut rx: Receiver<ThumbnailTask>,
file_repository: FileRepository,
) {
while let Some(task) = rx.recv().await {
let record_id = task.record_id;
if let Err(e) = process_task(task, &file_repository).await {
warn!("thumbnail task {} failed: {}", record_id, e);
}
}
}
async fn process_task(task: ThumbnailTask, file_repository: &FileRepository) -> Result<()> {
let record_id = task.record_id;
info!("task for file record {} received", record_id);
let record = file_repository
.get_file(task.record_id, task.user_id)
.await?;
let file_type = &record.file_type;
// TODO: create enum that maps file_type (which is actually mime_type) into file category (image, video, ...)
if file_type.contains("image") {
generate_for_image(&record, file_repository).await?;
} else {
warn!(
"thumbnail generation for file type {} not yet supported",
file_type
);
}
Ok(())
}
async fn generate_for_image(record: &FileRecord, file_repository: &FileRepository) -> Result<()> {
let storage_key = &record.storage_key;
let bytes = file_repository.op.read(storage_key).await?.to_vec();
let image = image::load_from_memory(&bytes)?;
let thumbnail = image.thumbnail(256, 256);
let rgb = thumbnail.to_rgb8();
let mut buf = Vec::new();
let mut enc = JpegEncoder::new_with_quality(&mut buf, 50);
enc.encode_image(&rgb)?;
let path = format!("thumbnail/{}", storage_key);
file_repository.op.write(&path, buf).await?;
file_repository
.update_thumbnail_storage_path(&path, record.id, record.user_id)
.await?;
Ok(())
}

View File

@@ -7,12 +7,15 @@ use axum::{
routing::get, routing::get,
}; };
use sqlx::types::uuid; use sqlx::types::uuid;
use tokio::sync::mpsc::Sender;
use tracing::warn;
use utoipa::OpenApi; use utoipa::OpenApi;
use crate::{ use crate::{
ctx::Ctx, ctx::Ctx,
error::{LoftError, Result}, error::{LoftError, Result},
model::{FileRecord, FileRepository}, model::{FileRecord, FileRepository},
tasks::ThumbnailTask,
}; };
#[derive(OpenApi)] #[derive(OpenApi)]
@@ -23,19 +26,36 @@ use crate::{
get_file, get_file,
delete_file, delete_file,
download_file, download_file,
thumbnail,
stream_part stream_part
), ),
components(schemas(FileRecord)) components(schemas(FileRecord))
)] )]
pub struct FileApi; pub struct FileApi;
pub fn routes_file(file_repository: FileRepository) -> Router { #[derive(Clone)]
pub struct FileState {
pub file_repository: FileRepository,
pub tx: Sender<ThumbnailTask>,
}
impl FileState {
pub fn new(file_repository: FileRepository, tx: Sender<ThumbnailTask>) -> Self {
Self {
file_repository,
tx,
}
}
}
pub fn routes_file(file_service: FileState) -> Router {
Router::new() Router::new()
.route("/files", get(list_files).post(upload_file)) .route("/files", get(list_files).post(upload_file))
.route("/files/{id}", get(get_file).delete(delete_file)) .route("/files/{id}", get(get_file).delete(delete_file))
.route("/files/{id}/download", get(download_file)) .route("/files/{id}/download", get(download_file))
.route("/files/{id}/thumbnail", get(thumbnail))
.route("/files/{id}/stream_part", get(stream_part)) .route("/files/{id}/stream_part", get(stream_part))
.with_state(file_repository) .with_state(file_service)
} }
/// Upload a file /// Upload a file
@@ -53,7 +73,7 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
) )
)] )]
async fn upload_file( async fn upload_file(
State(file_repository): State<FileRepository>, State(service): State<FileState>,
ctx: Ctx, ctx: Ctx,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<(StatusCode, Json<FileRecord>)> { ) -> Result<(StatusCode, Json<FileRecord>)> {
@@ -63,18 +83,27 @@ async fn upload_file(
if field.name() == Some("file") { if field.name() == Some("file") {
let name = field.file_name().map(str::to_string).unwrap_or_default(); let name = field.file_name().map(str::to_string).unwrap_or_default();
let key = format!("{name}-{}", uuid::Uuid::new_v4()); let key = format!("{name}-{}", uuid::Uuid::new_v4());
let size = file_repository.upload_file(field, &key).await?; let size = service.file_repository.upload_file(field, &key).await?;
uploaded = Some((name, key, size)); uploaded = Some((name, key, size));
} }
} }
let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?; let (name, key, size) = uploaded.ok_or(LoftError::NoFileProvided)?;
let file_record = file_repository let record = service
.file_repository
.create_file_record(ctx.user_id(), &name, size, &key) .create_file_record(ctx.user_id(), &name, size, &key)
.await?; .await?;
Ok((StatusCode::CREATED, Json(file_record))) if let Err(e) = service
.tx
.send(ThumbnailTask::new(record.id, ctx.user_id()))
.await
{
warn!("failed to send thumbnail task for file {}: {e}", record.id)
}
Ok((StatusCode::CREATED, Json(record)))
} }
/// Get a file's metadata /// Get a file's metadata
@@ -92,11 +121,12 @@ async fn upload_file(
) )
)] )]
async fn get_file( async fn get_file(
State(file_repository): State<FileRepository>, State(service): State<FileState>,
ctx: Ctx, ctx: Ctx,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>> { ) -> Result<Json<FileRecord>> {
let record = file_repository let record = service
.file_repository
.get_file(file_id as i64, ctx.user_id()) .get_file(file_id as i64, ctx.user_id())
.await?; .await?;
Ok(Json(record)) Ok(Json(record))
@@ -117,16 +147,43 @@ async fn get_file(
) )
)] )]
async fn download_file( async fn download_file(
State(file_repository): State<FileRepository>, State(service): State<FileState>,
ctx: Ctx, ctx: Ctx,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let stream = file_repository let stream = service
.file_repository
.download_file(file_id as i64, ctx.user_id()) .download_file(file_id as i64, ctx.user_id())
.await?; .await?;
Ok(Body::from_stream(stream)) Ok(Body::from_stream(stream))
} }
/// Download a file thumbnail
#[utoipa::path(
get,
path = "/api/files/{id}/thumbnail",
tag = "files",
security(("cookie_auth" = [])),
params(("id" = u64, Path, description = "File id")),
responses(
(status = 200, description = "File bytes", content_type = "application/octet-stream"),
(status = 404, description = "File not found"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
)
)]
async fn thumbnail(
State(service): State<FileState>,
ctx: Ctx,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> {
let stream = service
.file_repository
.thumbnail(file_id as i64, ctx.user_id())
.await?;
Ok(Body::from_stream(stream))
}
/// Stream a byte range of a file (HTTP 206) /// Stream a byte range of a file (HTTP 206)
#[utoipa::path( #[utoipa::path(
get, get,
@@ -146,24 +203,26 @@ async fn download_file(
) )
)] )]
async fn stream_part( async fn stream_part(
State(file_repository): State<FileRepository>, State(service): State<FileState>,
ctx: Ctx, ctx: Ctx,
headers: HeaderMap, headers: HeaderMap,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<impl IntoResponse> { ) -> Result<impl IntoResponse> {
let file_record = file_repository let record = service
.file_repository
.get_file(file_id as i64, ctx.user_id()) .get_file(file_id as i64, ctx.user_id())
.await?; .await?;
let file_size = file_record.size as u64; let file_size = record.size as u64;
let (start, end): (u64, u64) = parse_range(&headers, file_size)?; let (start, end): (u64, u64) = parse_range(&headers, file_size)?;
let stream = file_repository let stream = service
.file_repository
.stream_part(file_id as i64, ctx.user_id(), start, end + 1) .stream_part(file_id as i64, ctx.user_id(), start, end + 1)
.await?; .await?;
let respones = Response::builder() let respones = Response::builder()
.status(StatusCode::PARTIAL_CONTENT) .status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, file_record.file_type) .header(header::CONTENT_TYPE, record.file_type)
.header(header::CONTENT_LENGTH, end - start + 1) .header(header::CONTENT_LENGTH, end - start + 1)
.header( .header(
header::CONTENT_RANGE, header::CONTENT_RANGE,
@@ -207,11 +266,12 @@ fn parse_range(headers: &HeaderMap, file_size: u64) -> Result<(u64, u64)> {
) )
)] )]
async fn delete_file( async fn delete_file(
State(file_repository): State<FileRepository>, State(service): State<FileState>,
ctx: Ctx, ctx: Ctx,
Path(file_id): Path<u64>, Path(file_id): Path<u64>,
) -> Result<Json<FileRecord>> { ) -> Result<Json<FileRecord>> {
let file = file_repository let file = service
.file_repository
.delete_file(file_id as i64, ctx.user_id()) .delete_file(file_id as i64, ctx.user_id())
.await?; .await?;
Ok(Json(file)) Ok(Json(file))
@@ -229,11 +289,8 @@ async fn delete_file(
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
) )
)] )]
async fn list_files( async fn list_files(State(service): State<FileState>, ctx: Ctx) -> Result<Json<Vec<FileRecord>>> {
State(file_repository): State<FileRepository>, let files = service.file_repository.list_files(ctx.user_id()).await?;
ctx: Ctx,
) -> Result<Json<Vec<FileRecord>>> {
let files = file_repository.list_files(ctx.user_id()).await?;
Ok(Json(files)) Ok(Json(files))
} }
@@ -251,13 +308,14 @@ mod tests {
use rand::RngExt; use rand::RngExt;
use serde_json::json; use serde_json::json;
use sqlx::PgPool; use sqlx::PgPool;
use tokio::sync::mpsc;
use tower_cookies::CookieManagerLayer; use tower_cookies::CookieManagerLayer;
use crate::{ use crate::{
model::{FileRepository, UserRepository}, model::{FileRepository, UserRepository},
web::{ web::{
mw_auth::{mw_ctx_resolver, mw_require_auth}, mw_auth::{mw_ctx_resolver, mw_require_auth},
routes_file::routes_file, routes_file::{FileState, routes_file},
}, },
}; };
@@ -282,8 +340,12 @@ mod tests {
async fn test_server() -> TestServer { async fn test_server() -> TestServer {
let user_repository = user_repository().await; let user_repository = user_repository().await;
let file_repository = file_repository().await; let file_repository = file_repository().await;
let (tx, _) = mpsc::channel(100);
let file_service = FileState::new(file_repository.clone(), tx);
let routes_file = let routes_file =
routes_file(file_repository.clone()).route_layer(middleware::from_fn(mw_require_auth)); routes_file(file_service).route_layer(middleware::from_fn(mw_require_auth));
let app = Router::new() let app = Router::new()
.nest("/api", routes_file) .nest("/api", routes_file)
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(

View File

@@ -40,9 +40,9 @@
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50 class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg border transition-all hover:border-sky-400/50
{selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}" {selected ? 'border-sky-400/50 ring-1 ring-sky-400/50' : 'border-sky-200/20'}"
> >
{#if isImage} {#if isImage && file.thumbnailStoragePath}
<img <img
src={`/api/files/${file.id}/download`} src={`/api/files/${file.id}/thumbnail`}
alt={file.name} alt={file.name}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />

View File

@@ -4,4 +4,5 @@ export type FileRecord = {
size: number; size: number;
fileType: string; fileType: string;
uploadedAt: string; uploadedAt: string;
thumbnailStoragePath: string | null;
}; };

View File

@@ -82,6 +82,19 @@
window.location.href = '/auth'; window.location.href = '/auth';
} }
$effect(() => {
const pending = fileRecords.some(
(f) => f.fileType.includes('image') && !f.thumbnailStoragePath
);
if (!pending) {
return;
}
const id = setInterval(refresh, 1000);
return () => clearInterval(id);
});
onMount(() => { onMount(() => {
const saved = localStorage.getItem('viewMode'); const saved = localStorage.getItem('viewMode');
if (saved === 'rows' || saved === 'tiles') viewMode = saved; if (saved === 'rows' || saved === 'tiles') viewMode = saved;