Compare commits

..

4 Commits

6 changed files with 178 additions and 269 deletions

View File

@@ -70,12 +70,13 @@ async fn main() -> Result<()> {
}); });
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.unwrap(); let pool = PgPool::connect(&database_url).await.unwrap();
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 routes_file = routes_file(file_repository.clone())
.route_layer(middleware::from_fn(mw_require_auth)) .route_layer(middleware::from_fn(mw_require_auth))
.layer(DefaultBodyLimit::disable()); .layer(DefaultBodyLimit::max(BODY_LIMIT));
let user_repository = UserRepository::new(pool)?; let user_repository = UserRepository::new(pool)?;
let routes_auth = let routes_auth =

View File

@@ -120,6 +120,25 @@ impl FileRepository {
Ok(stream) Ok(stream)
} }
pub async fn stream_part(
&self,
file_id: i64,
user_id: i64,
from: u64,
to: u64,
) -> Result<impl Stream<Item = std::io::Result<Bytes>> + use<>, LoftError> {
info!(
"Fetching metadata of file \"{}\" from file_records",
file_id
);
let record = self.get_file(file_id, user_id).await?;
info!("Streaming chunk of file \"{}\"", file_id);
let reader = self.op.reader(&record.storage_key).await.unwrap();
let stream = reader.into_bytes_stream(from..to).await.unwrap();
Ok(stream)
}
pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> { pub async fn get_file(&self, file_id: i64, user_id: i64) -> Result<FileRecord, LoftError> {
info!( info!(
"Fetching metadata of file \"{}\" from file_records", "Fetching metadata of file \"{}\" from file_records",
@@ -412,12 +431,14 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let _downloaded = file_repository let chunks: Vec<_> = file_repository
.download_file(file_record.id, user.id) .download_file(file_record.id, user.id)
.await .await
.unwrap(); .unwrap()
.collect()
//TODO: add assert .await;
let downloaded: Vec<u8> = chunks.into_iter().flat_map(|c| c.unwrap()).collect();
assert_eq!(downloaded, vec![0u8; 10]);
truncate_file_records(&file_repository.pool).await; truncate_file_records(&file_repository.pool).await;
truncate_users(&user_repository.pool).await; truncate_users(&user_repository.pool).await;
@@ -435,6 +456,45 @@ mod tests {
)); ));
} }
#[tokio::test]
#[serial_test::serial]
async fn test_stream_part() {
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 file_name = "a.png";
let file_storage_key = format!("{}-{}", file_name, uuid::Uuid::new_v4());
let file_size = file_repository
.upload_file(
stream::iter(vec![Ok(Bytes::from((0u8..10).collect::<Vec<u8>>()))]),
&file_storage_key,
)
.await
.unwrap();
let file_record = file_repository
.create_file_record(user.id, "a.jpg", file_size, &file_storage_key)
.await
.unwrap();
let chunks: Vec<_> = file_repository
.stream_part(file_record.id, user.id, 2, 6)
.await
.unwrap()
.collect()
.await;
let partial: Vec<u8> = chunks.into_iter().flat_map(|c| c.unwrap()).collect();
assert_eq!(partial, vec![2u8, 3, 4, 5]);
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_delete() { async fn test_delete() {

View File

@@ -2,6 +2,7 @@ use axum::{
Json, Router, Json, Router,
body::Body, body::Body,
extract::{Multipart, Path, State}, extract::{Multipart, Path, State},
http::{HeaderMap, Response, StatusCode, header},
response::IntoResponse, response::IntoResponse,
routing::get, routing::get,
}; };
@@ -19,6 +20,7 @@ pub fn routes_file(file_repository: FileRepository) -> Router {
.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}/stream_part", get(stream_part))
.with_state(file_repository) .with_state(file_repository)
} }
@@ -54,7 +56,6 @@ async fn upload_file(
Err(LoftError::UndefinedErrorType) Err(LoftError::UndefinedErrorType)
} }
#[axum::debug_handler]
async fn get_file( async fn get_file(
State(file_repository): State<FileRepository>, State(file_repository): State<FileRepository>,
ctx: Ctx, ctx: Ctx,
@@ -66,7 +67,6 @@ async fn get_file(
Ok(Json(record)) Ok(Json(record))
} }
#[axum::debug_handler]
async fn download_file( async fn download_file(
State(file_repository): State<FileRepository>, State(file_repository): State<FileRepository>,
ctx: Ctx, ctx: Ctx,
@@ -78,6 +78,49 @@ async fn download_file(
Ok(Body::from_stream(stream)) Ok(Body::from_stream(stream))
} }
async fn stream_part(
State(file_repository): State<FileRepository>,
ctx: Ctx,
headers: HeaderMap,
Path(file_id): Path<u64>,
) -> Result<impl IntoResponse, LoftError> {
info!("stream_part");
let file_record = file_repository
.get_file(file_id as i64, ctx.user_id())
.await?;
let file_size = file_record.size as u64;
let (start, end): (u64, u64) = parse_range(&headers, file_size);
let stream = file_repository
.stream_part(file_id as i64, ctx.user_id(), start, end + 1)
.await?;
let respones = Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, file_record.file_type)
.header(header::CONTENT_LENGTH, end - start + 1)
.header(
header::CONTENT_RANGE,
format!("bytes {start}-{end}/{file_size}"),
)
.header(header::ACCEPT_RANGES, "bytes")
.body(Body::from_stream(stream))
.unwrap();
Ok(respones)
}
fn parse_range(headers: &HeaderMap, file_size: u64) -> (u64, u64) {
let range = headers.get(header::RANGE);
let str = range.unwrap().to_str().unwrap();
let strip = str.strip_prefix("bytes=").unwrap();
let tuple = strip.split_once("-").unwrap();
let start = tuple.0.parse::<u64>().unwrap();
let end = tuple.1.parse::<u64>().unwrap_or(file_size - 1);
(start, end)
}
async fn delete_file( async fn delete_file(
State(file_repository): State<FileRepository>, State(file_repository): State<FileRepository>,
ctx: Ctx, ctx: Ctx,
@@ -103,7 +146,7 @@ async fn list_files(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use axum::{Router, middleware}; use axum::{Router, http::header, middleware};
use axum_test::{ use axum_test::{
TestServer, TestServer,
multipart::{MultipartForm, Part}, multipart::{MultipartForm, Part},
@@ -291,6 +334,71 @@ mod tests {
truncate(&file_repository.pool).await; truncate(&file_repository.pool).await;
} }
#[tokio::test]
#[serial_test::serial]
async fn test_stream_part_range() {
let user_repository = user_repository().await;
let token = create_test_session(&user_repository).await;
let file_repository = file_repository().await;
truncate(&file_repository.pool).await;
let server = test_server().await;
let post_res = server
.post("/api/files")
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.multipart(MultipartForm::new().add_part(
"file",
Part::bytes(b"0123456789".to_vec()).file_name("a.txt"),
))
.await;
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
let res = server
.get(&format!("/api/files/{id}/stream_part"))
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.add_header(axum::http::header::RANGE, "bytes=2-5")
.await;
res.assert_status(axum::http::StatusCode::PARTIAL_CONTENT);
assert_eq!(res.as_bytes(), b"2345".as_ref());
assert_eq!(res.header(header::CONTENT_RANGE), "bytes 2-5/10");
assert_eq!(res.header(header::CONTENT_LENGTH), "4");
assert_eq!(res.header(header::ACCEPT_RANGES), "bytes");
truncate(&file_repository.pool).await;
}
#[tokio::test]
#[serial_test::serial]
async fn test_stream_part_open_ended() {
let user_repository = user_repository().await;
let token = create_test_session(&user_repository).await;
let file_repository = file_repository().await;
truncate(&file_repository.pool).await;
let server = test_server().await;
let post_res = server
.post("/api/files")
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.multipart(MultipartForm::new().add_part(
"file",
Part::bytes(b"0123456789".to_vec()).file_name("a.txt"),
))
.await;
let id = post_res.json::<serde_json::Value>()["id"].as_i64().unwrap();
let res = server
.get(&format!("/api/files/{id}/stream_part"))
.add_header(axum::http::header::COOKIE, format!("auth-token={token}"))
.add_header(axum::http::header::RANGE, "bytes=0-")
.await;
res.assert_status(axum::http::StatusCode::PARTIAL_CONTENT);
assert_eq!(res.as_bytes(), b"0123456789".as_ref());
assert_eq!(res.header(header::CONTENT_RANGE), "bytes 0-9/10");
assert_eq!(res.header(header::CONTENT_LENGTH), "10");
truncate(&file_repository.pool).await;
}
#[tokio::test] #[tokio::test]
#[serial_test::serial] #[serial_test::serial]
async fn test_download_file_not_found() { async fn test_download_file_not_found() {

View File

@@ -25,7 +25,7 @@
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
{#if isVideo} {#if isVideo}
<VideoPreview src={`/api/files/${file.id}/download`} /> <VideoPreview src={`/api/files/${file.id}/stream_part`} />
<div <div
class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-linear-to-b from-black/70 to-transparent px-4 py-3" class="pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-linear-to-b from-black/70 to-transparent px-4 py-3"
> >

View File

@@ -1,7 +0,0 @@
{
"devDependencies": {
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0"
}
}

253
pnpm-lock.yaml generated
View File

@@ -1,253 +0,0 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
prettier:
specifier: ^3.8.3
version: 3.8.3
prettier-plugin-svelte:
specifier: ^4.1.0
version: 4.1.0(prettier@3.8.3)(svelte@5.56.0)
prettier-plugin-tailwindcss:
specifier: ^0.8.0
version: 0.8.0(prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.56.0))(prettier@3.8.3)
packages:
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@sveltejs/acorn-typescript@1.0.10':
resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==}
peerDependencies:
acorn: ^8.9.0
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
hasBin: true
aria-query@5.3.1:
resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==}
engines: {node: '>= 0.4'}
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
devalue@5.8.1:
resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==}
esm-env@1.2.2:
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
esrap@2.2.9:
resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==}
peerDependencies:
'@typescript-eslint/types': ^8.2.0
peerDependenciesMeta:
'@typescript-eslint/types':
optional: true
is-reference@3.0.3:
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
prettier-plugin-svelte@4.1.0:
resolution: {integrity: sha512-YZkhA2Q9oOerFFG9tq+2f98WYT7Z2JgrybJrAyrB78jpsH9i/DdgplXemehuFPgsldetFNCcR/yCcYlDjPy94Q==}
engines: {node: '>=20'}
peerDependencies:
prettier: ^3.0.0
svelte: ^5.0.0
prettier-plugin-tailwindcss@0.8.0:
resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==}
engines: {node: '>=20.19'}
peerDependencies:
'@ianvs/prettier-plugin-sort-imports': '*'
'@prettier/plugin-hermes': '*'
'@prettier/plugin-oxc': '*'
'@prettier/plugin-pug': '*'
'@shopify/prettier-plugin-liquid': '*'
'@trivago/prettier-plugin-sort-imports': '*'
'@zackad/prettier-plugin-twig': '*'
prettier: ^3.0
prettier-plugin-astro: '*'
prettier-plugin-css-order: '*'
prettier-plugin-jsdoc: '*'
prettier-plugin-marko: '*'
prettier-plugin-multiline-arrays: '*'
prettier-plugin-organize-attributes: '*'
prettier-plugin-organize-imports: '*'
prettier-plugin-sort-imports: '*'
prettier-plugin-svelte: '*'
peerDependenciesMeta:
'@ianvs/prettier-plugin-sort-imports':
optional: true
'@prettier/plugin-hermes':
optional: true
'@prettier/plugin-oxc':
optional: true
'@prettier/plugin-pug':
optional: true
'@shopify/prettier-plugin-liquid':
optional: true
'@trivago/prettier-plugin-sort-imports':
optional: true
'@zackad/prettier-plugin-twig':
optional: true
prettier-plugin-astro:
optional: true
prettier-plugin-css-order:
optional: true
prettier-plugin-jsdoc:
optional: true
prettier-plugin-marko:
optional: true
prettier-plugin-multiline-arrays:
optional: true
prettier-plugin-organize-attributes:
optional: true
prettier-plugin-organize-imports:
optional: true
prettier-plugin-sort-imports:
optional: true
prettier-plugin-svelte:
optional: true
prettier@3.8.3:
resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==}
engines: {node: '>=14'}
hasBin: true
svelte@5.56.0:
resolution: {integrity: sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==}
engines: {node: '>=18'}
zimmerframe@1.1.4:
resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==}
snapshots:
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)':
dependencies:
acorn: 8.16.0
'@types/estree@1.0.9': {}
'@types/trusted-types@2.0.7': {}
acorn@8.16.0: {}
aria-query@5.3.1: {}
axobject-query@4.1.0: {}
clsx@2.1.1: {}
devalue@5.8.1: {}
esm-env@1.2.2: {}
esrap@2.2.9:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
is-reference@3.0.3:
dependencies:
'@types/estree': 1.0.9
locate-character@3.0.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.56.0):
dependencies:
prettier: 3.8.3
svelte: 5.56.0
prettier-plugin-tailwindcss@0.8.0(prettier-plugin-svelte@4.1.0(prettier@3.8.3)(svelte@5.56.0))(prettier@3.8.3):
dependencies:
prettier: 3.8.3
optionalDependencies:
prettier-plugin-svelte: 4.1.0(prettier@3.8.3)(svelte@5.56.0)
prettier@3.8.3: {}
svelte@5.56.0:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5
'@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0)
'@types/estree': 1.0.9
'@types/trusted-types': 2.0.7
acorn: 8.16.0
aria-query: 5.3.1
axobject-query: 4.1.0
clsx: 2.1.1
devalue: 5.8.1
esm-env: 1.2.2
esrap: 2.2.9
is-reference: 3.0.3
locate-character: 3.0.0
magic-string: 0.30.21
zimmerframe: 1.1.4
transitivePeerDependencies:
- '@typescript-eslint/types'
zimmerframe@1.1.4: {}