diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..702bbdf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target/ +tapfer_crypt/target/ +tapfer_crypt/pkg/ +data/ +.git/ \ No newline at end of file diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..b08d294 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,8 @@ +# For local development purposes +{ + http_port 4080 +} + +localhost:4000 { + reverse_proxy localhost:3000 +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f13947b..c252ba0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1336,6 +1336,7 @@ dependencies = [ "time", "time-tz", "tokio", + "tokio-stream", "tokio-util", "toml", "tower", @@ -1472,6 +1473,17 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.29.0" diff --git a/Cargo.toml b/Cargo.toml index f989b16..4989b49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ tokio = { version = "1.0", features = ["full"] } tokio-util = { version = "0.7.14", features = ["io"] } futures-core = "0.3.31" futures-util = "0.3.31" +tokio-stream = "0.1.18" # Parsing and formats diff --git a/Dockerfile b/Dockerfile index 639eb7d..685f81e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,21 @@ FROM docker.io/rust:1.92 as builder +RUN curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + WORKDIR /usr/src/app COPY Cargo.toml Cargo.lock ./ COPY ./src ./src COPY templates ./templates +COPY tapfer_crypt ./tapfer_crypt RUN cargo build --release +RUN cd tapfer_crypt && wasm-pack build --target web --release FROM docker.io/archlinux WORKDIR /usr/src/app COPY --from=builder /usr/src/app/target/release/tapfer . COPY ./static ./static +COPY --from=builder /usr/src/app/tapfer_crypt/pkg ./tapfer_crypt/pkg CMD ["./tapfer"] \ No newline at end of file diff --git a/README.md b/README.md index 301554b..80ffb97 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,10 @@ 2. edit `docker-compose.yml` HOST variable to match the domain that uploads will be available on (for QR code generation) 3. Configure your reverse proxy if applicable according to `rever_proxy`. Default configs are provided, replace {{TLD}} with your real TLD 4. (optional) Configure a ZFS storage quota (or similar) on the `data` folder or keep the data a volume without a local mountpoint -5. `docker-compose up -d --build` To build and deploy the container \ No newline at end of file +5. `docker-compose up -d --build` To build and deploy the container + +# Local dev +run caddy for local HTTP/2 support +```sh +caddy reverse-proxy --from localhost:4000 --to localhost:3000 +``` \ No newline at end of file diff --git a/dev.md b/dev.md new file mode 100644 index 0000000..7e44bea --- /dev/null +++ b/dev.md @@ -0,0 +1,5 @@ +# Caddy proxy +The frontend requires some features that are only enabled in secure HTTPS windows +```shell +caddy run +``` \ No newline at end of file diff --git a/src/api_doc.rs b/src/api_doc.rs index 229c401..37ce2ed 100644 --- a/src/api_doc.rs +++ b/src/api_doc.rs @@ -2,18 +2,11 @@ use crate::handlers::delete::__path_request_delete_asset; use crate::handlers::download::__path_download_file; use crate::handlers::qrcode::__path_get_qrcode_from_id; use crate::upload::__path_accept_form; -use crate::upload::__path_progress_token_to_id; use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( - paths( - accept_form, - download_file, - progress_token_to_id, - request_delete_asset, - get_qrcode_from_id - ), + paths(accept_form, download_file, request_delete_asset, get_qrcode_from_id), info(title = "Tapfer API", version = "1.0") )] pub struct ApiDoc; diff --git a/src/handlers/deposit.rs b/src/handlers/deposit.rs index c8844be..1fa8530 100644 --- a/src/handlers/deposit.rs +++ b/src/handlers/deposit.rs @@ -1,6 +1,5 @@ use crate::configuration::{EMBED_DESCRIPTION, FAVICON, QR_CODE_ECC, QR_CODE_SIZE}; use crate::structs::error::TapferResult; -use crate::websocket::wss_method; use askama::Template; use axum::extract::{Query, WebSocketUpgrade}; use axum::response::{Html, IntoResponse, Response}; @@ -17,26 +16,22 @@ pub struct Deposit { qr_size: usize, qr_b64: String, ws_url: String, + upload_url: String, } pub async fn show_form(Host(host): Host) -> TapferResult { let deposit_id = Uuid::new_v4().as_u64_pair().0; // Hacky? Sure. But this avoids another RNG library that we use once + let url = format!("https://{host}?deposit={deposit_id}"); - let qr_code = qrcode_generator::to_png_to_vec_from_str( - format!("https://{host}?deposit={deposit_id}",), - QR_CODE_ECC, - QR_CODE_SIZE, - )?; + let qr_code = qrcode_generator::to_png_to_vec_from_str(&url, QR_CODE_ECC, QR_CODE_SIZE)?; let template = Deposit { embed_image_url: FAVICON, embed_description: EMBED_DESCRIPTION, qr_size: QR_CODE_SIZE, qr_b64: BASE64_STANDARD.encode(&qr_code), - ws_url: format!( - "{}://{host}/deposit/ws?deposit={deposit_id}", - wss_method(&host) - ), + upload_url: url, + ws_url: format!("wss://{host}/deposit/ws?deposit={deposit_id}",), }; Ok(Html(template.render()?)) diff --git a/src/handlers/download.rs b/src/handlers/download.rs index ac0c295..7a9ce01 100644 --- a/src/handlers/download.rs +++ b/src/handlers/download.rs @@ -1,7 +1,6 @@ use crate::configuration::{DOWNLOAD_CHUNKSIZE, EMBED_DESCRIPTION, QR_CODE_SIZE}; use crate::handlers; use crate::handlers::checksum::get_sha512_for_asset; -use crate::handlers::is_localhost; use crate::handlers::qrcode::base64_qr_from_id; use crate::retention_control::delete_asset; use crate::structs::error::{TapferError, TapferResult}; @@ -9,7 +8,6 @@ use crate::structs::file_meta::{FileMeta, RemovalPolicy}; use crate::structs::tapfer_id::TapferId; use crate::updown::upload_handle::UploadHandle; use crate::updown::upload_pool::UploadFsm; -use crate::websocket::wss_method; use askama::Template; use axum::body::Body; use axum::extract::Path; @@ -38,6 +36,7 @@ struct DownloadTemplate<'a> { download_url: &'a str, mimetype: &'a str, filesize: &'a str, + file_size_bits: u64, embed_image_url: &'a str, qr_size: usize, embed_description: &'a str, @@ -62,16 +61,16 @@ pub async fn download_html( RemovalPolicy::Expiry { .. } => meta.expires_on_utc().unwrap().format(&DES)?.clone(), }; - let localhost = is_localhost(&host); + let prefix = if host.contains("localhost") { + "" + } else { + "cdn." + }; let sha512 = get_sha512_for_asset(id)?; let template = DownloadTemplate { filename: meta.name(), expiry: &expiry, - download_url: if !localhost { - &format!("https://cdn.{host}/uploads/{id}/download") - } else { - &format!("http://localhost:3000/uploads/{id}/download") - }, + download_url: &format!("https://{prefix}{host}/uploads/{id}/download"), mimetype: meta.content_type(), filesize: if meta.known_size().is_some() { &human_bytes(meta.size() as f64) @@ -80,6 +79,7 @@ pub async fn download_html( } else { &human_bytes(meta.size() as f64) }, + file_size_bits: meta.known_size().unwrap_or(0), embed_image_url: &format!("/qrcg/{id}"), qr_size: QR_CODE_SIZE, embed_description: EMBED_DESCRIPTION, @@ -88,7 +88,7 @@ pub async fn download_html( unix_expiry: meta .expires_on_utc() .map_or(0, time::UtcDateTime::unix_timestamp), - ws_url: &format!("{}://{host}/uploads/{id}/ws", wss_method(&host)), + ws_url: &format!("wss://{host}/uploads/{id}/ws"), sha512: sha512.as_deref().unwrap_or("computing..."), sha512url: format!("/uploads/{id}/checksum.sha512"), }; diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index dbea3c4..a603862 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,12 +1,9 @@ use crate::UPLOAD_POOL; use crate::handlers::download::UpDownFsm; -use crate::handlers::not_found::NotFound; +use crate::handlers::not_found::Reason404; use crate::structs::error::{TapferError, TapferResult}; use crate::structs::file_meta::FileMeta; use crate::structs::tapfer_id::TapferId; -use askama::Template; -use axum::http::StatusCode; -use axum::response::Html; use std::str::FromStr; use tokio::fs; @@ -15,7 +12,7 @@ pub mod delete; pub mod deposit; pub mod download; pub mod homepage; -mod not_found; +pub mod not_found; pub mod qrcode; pub mod upload; @@ -33,10 +30,7 @@ async fn get_any_meta(path: &String) -> TapferResult<((TapferId, FileMeta), UpDo match UPLOAD_POOL.uploads.get(&id) { // The upload is not in progress either, so it does not exist None => { - return Err(TapferError::Custom { - status_code: StatusCode::NOT_FOUND, - body: Html(NotFound::default().render()?), - }); + return Err(TapferError::NotFound(Reason404::Deleted)); } // The upload is in-progress Some(handle) => { @@ -58,7 +52,3 @@ async fn get_any_meta(path: &String) -> TapferResult<((TapferId, FileMeta), UpDo }; Ok(res) } - -pub fn is_localhost(host: &str) -> bool { - host.starts_with("localhost") || host.starts_with("127.0.0.1") -} diff --git a/src/handlers/not_found.rs b/src/handlers/not_found.rs index ae58aac..4820d3b 100644 --- a/src/handlers/not_found.rs +++ b/src/handlers/not_found.rs @@ -1,18 +1,62 @@ use crate::configuration::{EMBED_DESCRIPTION, FAVICON}; use askama::Template; +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect}; +use std::fmt::{Display, Formatter}; #[derive(Template)] #[template(path = "404.html")] pub struct NotFound { embed_image_url: &'static str, embed_description: &'static str, + reason: &'static str, } -impl Default for NotFound { - fn default() -> Self { +impl NotFound { + pub fn with_reason(hint: Option) -> Self { Self { embed_image_url: FAVICON, embed_description: EMBED_DESCRIPTION, + reason: match hint { + None => "The asset you're looking for doesn’t exist or has been deleted", + Some(Reason404::Deleted) => "The asset has been deleted", + }, + } + } +} + +pub async fn not_found_handler(Query(params): Query) -> impl IntoResponse { + match NotFound::with_reason(params.hint).render() { + Ok(html) => (StatusCode::NOT_FOUND, Html(html)), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Html("500 Internal Server Error".to_string()), + ), + } +} + +pub fn redirect_not_found(reason404: Reason404) -> impl IntoResponse { + Redirect::to(&format!("/404?hint={reason404}")) +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct Params { + hint: Option, +} + +#[derive(Debug, Copy, Clone, serde::Deserialize)] +pub enum Reason404 { + #[serde(rename = "deleted")] + Deleted, +} + +impl Display for Reason404 { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Reason404::Deleted => { + write!(f, "deleted") + } } } } diff --git a/src/handlers/upload.rs b/src/handlers/upload.rs index ac09442..a51ec61 100644 --- a/src/handlers/upload.rs +++ b/src/handlers/upload.rs @@ -7,33 +7,41 @@ use crate::structs::tapfer_id::TapferId; use crate::updown::upload_handle::UploadHandle; use crate::updown::upload_pool::UploadFsm; use crate::websocket::WsEvent; -use crate::{PROGRESS_TOKEN_LUT, UPLOAD_POOL, websocket}; +use crate::{UPLOAD_POOL, websocket}; use axum::extract::multipart::Field; -use axum::extract::{Multipart, Path, Query}; +use axum::extract::{FromRequest, Multipart, Path, Query, Request}; use axum::http::StatusCode; use axum::response::Html; use axum::response::IntoResponse; use axum_extra::extract::Host; +use dashmap::DashMap; +use futures_util::StreamExt; use futures_util::TryStreamExt; -use scopeguard::defer; use std::io::Error; use std::pin::{Pin, pin}; use std::str::FromStr; +use std::sync::LazyLock; use std::task::{Context, Poll}; use time::Duration as TimeDuration; use tokio::fs::File; use tokio::io::{AsyncWrite, BufReader, copy_buf}; +use tokio::sync::mpsc; use tokio::{fs, task}; +use tokio_stream::wrappers::ReceiverStream; use tokio_util::io::StreamReader; -use tracing::{error, info, warn}; +use tracing::{error, info}; + +static CHUNKED_UPLOADS: LazyLock< + DashMap>>, +> = LazyLock::new(DashMap::new); #[derive(Debug, Clone, serde::Deserialize)] pub struct UploadParameters { file_size: Option, - progress_token: Option, expiration: Option, timezone: Option, deposit: Option, + filename: Option, } #[utoipa::path( @@ -55,13 +63,13 @@ pub struct UploadParameters { pub async fn accept_form( Host(mut host): Host, Query(params): Query, - multipart: Multipart, + req: Request, ) -> TapferResult { let id = TapferId::new_random(); fs::create_dir(&format!("data/{id}")).await?; info!("Beginning upload of {id}"); - let res = do_upload(multipart, id, ¶ms).await; + let res = do_upload(req, id, ¶ms).await; if res.is_err() { delete_asset(id).await?; } @@ -79,19 +87,10 @@ pub async fn accept_form( Ok((StatusCode::OK, format!("{method}{host}/uploads/{id}\n"))) } -async fn do_upload( - mut multipart: Multipart, - id: TapferId, - params: &UploadParameters, -) -> TapferResult<()> { +async fn do_upload(req: Request, id: TapferId, params: &UploadParameters) -> TapferResult<()> { let mut meta = FileMetaBuilder::default(); let size: Option = params.file_size; - let in_progress_token: Option = params - .progress_token - .as_ref() - .map(|h| h.parse()) - .transpose()?; if let Some(tz) = params.timezone.as_ref() { meta.timezone = Some(tz.to_owned()); @@ -99,43 +98,72 @@ async fn do_upload( error!("Missing tapfer-timezone parameter"); } - if size.is_some() != in_progress_token.is_some() { - warn!( - "Size is {size:?} and progress token is {in_progress_token:?}. The frontend might not be sending both?" - ); - } - expiration_field(params.expiration.as_deref(), &mut meta)?; - if let Some(tok) = in_progress_token { - info!("Adding progress token {tok}"); - PROGRESS_TOKEN_LUT.insert(tok, id); - } - defer! { - if let Some(t) = in_progress_token { - info!("deleting progress token {t}"); - PROGRESS_TOKEN_LUT.remove(&t); - } - } - // Notify waiting deposit that they can now view the entry if let Some(deposit) = params.deposit { websocket::broadcast_event(deposit, WsEvent::DepositReady { id })?; } - while let Some(field) = multipart.next_field().await? { - let name = field - .name() - .ok_or(TapferError::MultipartFieldNameMissing)? - .to_string(); - if name.as_str() == "file" { - payload_field(field, id, meta.clone(), size).await?; - } else { - error!("Got unexpected form field {name}"); - Err(TapferError::UnknownMultipartField { - field_name: name.clone(), - })?; + let is_multipart = req + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.starts_with("multipart/form-data")) + .unwrap_or(false); + + if is_multipart { + let mut multipart = Multipart::from_request(req, &()) + .await + .map_err(|_| TapferError::MultipartFieldNameMissing)?; + + while let Some(field) = multipart.next_field().await? { + let name = field + .name() + .ok_or(TapferError::MultipartFieldNameMissing)? + .to_string(); + if name.as_str() == "file" { + payload_field(field, id, meta.clone(), size).await?; + } else { + error!("Got unexpected form field {name}"); + Err(TapferError::UnknownMultipartField { + field_name: name.clone(), + })?; + } } + } else { + let file_name = params.filename.clone().unwrap_or_else(|| id.to_string()); + let content_type = req + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref()) + .to_string(); + + let metadata = meta.build(file_name.clone(), content_type.clone(), size); + let handle = UPLOAD_POOL.handle(id, metadata.clone()); + let f = File::create(format!("data/{id}/{file_name}")).await?; + let mut f = UpdownWriter::new(f, handle.clone(), metadata, size.is_none()); + + let mut s = BufReader::with_capacity( + UPLOAD_BUFSIZE, + StreamReader::new( + req.into_body() + .into_data_stream() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())), + ), + ); + copy_buf(&mut s, &mut f).await?; + + let metadata = f.metadata(); + fs::write( + format!("data/{id}/meta.toml"), + toml::to_string_pretty(&metadata)?.as_bytes(), + ) + .await?; + + handle.write_fsm().await.mark_complete(); + websocket::broadcast_event(id, WsEvent::UploadComplete)?; } Ok(()) } @@ -194,21 +222,111 @@ fn expiration_field(field: Option<&str>, meta: &mut FileMetaBuilder) -> TapferRe Ok(()) } -#[utoipa::path( - get, - path = "/uploads/query_id/{token}", - responses( - (status = 200, description = "UUID of asset"), - (status = 404, description = "Token matches no (running) asset"), - ), +#[axum::debug_handler] +pub async fn init_chunked_upload( + Query(params): Query, +) -> TapferResult { + let id = TapferId::new_random(); + fs::create_dir(&format!("data/{id}")).await?; -)] -pub async fn progress_token_to_id(Path(path): Path) -> TapferResult { - let token = u32::from_str(&path)?; - Ok(PROGRESS_TOKEN_LUT - .get(&token) - .ok_or(TapferError::TokenDoesNotExist(token))? - .to_string()) + let mut meta = FileMetaBuilder::default(); + let size = params.file_size; + + if let Some(tz) = params.timezone.as_ref() { + meta.timezone = Some(tz.to_owned()); + } else { + error!("Missing tapfer-timezone parameter"); + } + expiration_field(params.expiration.as_deref(), &mut meta)?; + + if let Some(deposit) = params.deposit { + websocket::broadcast_event(deposit, WsEvent::DepositReady { id })?; + } + + let file_name = params.filename.clone().unwrap_or_else(|| id.to_string()); + let content_type = mime::APPLICATION_OCTET_STREAM.as_ref().to_string(); + + let metadata = meta.build(file_name.clone(), content_type.clone(), size); + let handle = UPLOAD_POOL.handle(id, metadata.clone()); + let f = File::create(format!("data/{id}/{file_name}")).await?; + let mut f = UpdownWriter::new(f, handle.clone(), metadata, size.is_none()); + + // Create a channel that will feed bytes to the UpdownWriter task + let (tx, rx) = mpsc::channel(16); + CHUNKED_UPLOADS.insert(id, tx); + + tokio::spawn(async move { + let mut s = + BufReader::with_capacity(UPLOAD_BUFSIZE, StreamReader::new(ReceiverStream::new(rx))); + if let Err(e) = copy_buf(&mut s, &mut f).await { + error!("Chunked upload error for {id}: {e}"); + return; + } + let metadata = f.metadata(); + if let Err(e) = fs::write( + format!("data/{id}/meta.toml"), + toml::to_string_pretty(&metadata).unwrap().as_bytes(), + ) + .await + { + error!("Failed to write meta for {id}: {e}"); + } + handle.write_fsm().await.mark_complete(); + let _ = websocket::broadcast_event(id, WsEvent::UploadComplete); + checksum::spawn_sha512_checksum(id); + }); + + Ok((StatusCode::OK, id.to_string())) +} + +#[axum::debug_handler] +pub async fn upload_chunk( + Path(id_str): Path, + req: Request, +) -> TapferResult { + let id = TapferId::from_str(&id_str)?; + // Using TokenDoesNotExist error for convenience as it yields a 404 + let tx = CHUNKED_UPLOADS + .get(&id) + .ok_or(TapferError::TokenDoesNotExist(0))? + .clone(); + + let mut stream = req.into_body().into_data_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; + if tx.send(Ok(chunk)).await.is_err() { + return Err(TapferError::Custom { + status_code: StatusCode::BAD_REQUEST, + body: Html("Upload aborted".to_string()), + }); + } + } + Ok(StatusCode::OK) +} + +#[axum::debug_handler] +pub async fn finalize_chunked_upload( + Path(id_str): Path, + Host(mut host): Host, +) -> TapferResult { + let id = TapferId::from_str(&id_str)?; + + if CHUNKED_UPLOADS.remove(&id).is_none() { + return Err(TapferError::Custom { + status_code: StatusCode::INTERNAL_SERVER_ERROR, + body: Html("Finalizing unknown upload?!".to_owned()), + }); + } + + let method = if host.contains("localhost") { + host = String::new(); + "" + } else { + host = host.replace("cdn.", ""); + "https://" + }; + Ok((StatusCode::OK, format!("{method}{host}/uploads/{id}\n")).into_response()) } pub struct UpdownWriter { diff --git a/src/main.rs b/src/main.rs index d67407e..a2ea180 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,9 +16,8 @@ use crate::retention_control::{GlobalRetentionPolicy, check_all_assets}; use crate::structs::error::TapferErrorExt; use crate::updown::upload_pool::UploadPool; use crate::websocket::{WsDestination, WsEvent}; -use axum::routing::{any, get_service}; +use axum::routing::any; use axum::{Router, extract::DefaultBodyLimit, middleware, routing::get}; -use dashmap::DashMap; use handlers::homepage; use http::HeaderValue; use std::process; @@ -27,7 +26,6 @@ use std::thread; use std::time::Duration; use std::{env, fs}; use structs::error::TapferResult; -use structs::tapfer_id::TapferId; use tokio::time::sleep; use tower::ServiceBuilder; use tower_http::cors::CorsLayer; @@ -38,8 +36,6 @@ use tracing::{info, warn}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use utoipa::OpenApi; use utoipa_scalar::{Scalar, Servable}; - -pub static PROGRESS_TOKEN_LUT: LazyLock> = LazyLock::new(DashMap::new); pub static GLOBAL_RETENTION_POLICY: LazyLock = LazyLock::new(GlobalRetentionPolicy::default); pub static UPLOAD_POOL: LazyLock = LazyLock::new(UploadPool::new); @@ -65,7 +61,8 @@ async fn main() -> TapferResult<()> { init_datadir(); - let static_dir_service = get_service(ServeDir::new("static")); + let static_dir_service = ServeDir::new("static"); + let wasm_service = ServeDir::new("tapfer_crypt/pkg"); let cors = CorsLayer::new() .allow_methods(Any) @@ -79,6 +76,7 @@ async fn main() -> TapferResult<()> { "/uploads/{id}", get(handlers::download::download_html).delete(handlers::delete::request_delete_asset), ) + .fallback(handlers::not_found::not_found_handler) .layer(cors.clone()); let fallback_service = ServiceBuilder::new() @@ -92,8 +90,16 @@ async fn main() -> TapferResult<()> { .route("/deposit", get(deposit::show_form)) .route("/deposit/ws", any(deposit::start_ws)) .route( - "/uploads/query_id/{token}", - get(handlers::upload::progress_token_to_id), + "/upload/init", + axum::routing::post(handlers::upload::init_chunked_upload), + ) + .route( + "/uploads/{id}/chunk", + axum::routing::patch(handlers::upload::upload_chunk), + ) + .route( + "/uploads/{id}/finalize", + axum::routing::post(handlers::upload::finalize_chunked_upload), ) .route( "/uploads/{id}/download", @@ -109,6 +115,7 @@ async fn main() -> TapferResult<()> { .layer(RequestBodyLimitLayer::new(MAX_UPLOAD_SIZE)) .layer(tower_http::trace::TraceLayer::new_for_http()) .nest_service("/static", static_dir_service) + .nest_service("/wasm", wasm_service) .merge(Scalar::with_url("/docs", ::openapi())) .fallback_service(fallback_service) .layer(cors); diff --git a/src/retention_control.rs b/src/retention_control.rs index 8aba129..aa8468a 100644 --- a/src/retention_control.rs +++ b/src/retention_control.rs @@ -9,7 +9,7 @@ use std::str::FromStr; use time::{Duration, UtcDateTime}; use tokio::fs; use tokio::fs::remove_dir_all; -use tracing::{info}; +use tracing::info; pub struct GlobalRetentionPolicy { pub maximum_age: Duration, @@ -46,7 +46,7 @@ pub async fn delete_asset(asset: TapferId) -> TapferResult<()> { pub async fn check_all_assets() -> TapferResult<()> { let now = UtcDateTime::now(); let mut dir = fs::read_dir("data").await?; - while let Some(entry) = dir.next_entry().await? { + while let Some(entry) = dir.next_entry().await? { let file_meta = match entry.metadata().await { Ok(m) => m, e => { @@ -61,8 +61,11 @@ pub async fn check_all_assets() -> TapferResult<()> { let mut path = entry.path().to_path_buf(); path.push("meta.toml"); let id = match TapferId::from_str(&entry.file_name().to_string_lossy()) { - Ok(t) => {t} - e => {e.log_error(&format!("Failed get ID from {}", path.display())); continue} + Ok(t) => t, + e => { + e.log_error(&format!("Failed get ID from {}", path.display())); + continue; + } }; if let Ok(meta) = FileMeta::read_from_id(id).await { diff --git a/src/structs/error.rs b/src/structs/error.rs index 28d5cdf..45e51e0 100644 --- a/src/structs/error.rs +++ b/src/structs/error.rs @@ -1,3 +1,4 @@ +use crate::handlers::not_found::{Reason404, redirect_not_found}; use crate::updown::upload_pool::UploadFsm; use axum::extract::multipart::MultipartError; use axum::response::{Html, IntoResponse, Response}; @@ -80,6 +81,9 @@ pub enum TapferError { #[error(transparent)] Http(#[from] http::Error), + + #[error("asset not found")] + NotFound(Reason404), } impl IntoResponse for TapferError { @@ -119,6 +123,7 @@ impl IntoResponse for TapferError { UploadHandleSize(_) => generic("upload handle size"), TryFromSlice(_) => generic("try from slice"), Http(_) => generic("http"), + NotFound(reason) => redirect_not_found(reason).into_response(), } } } diff --git a/src/websocket.rs b/src/websocket.rs index 10315c1..071e905 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -1,15 +1,15 @@ -use crate::handlers::is_localhost; use crate::structs::error::TapferResult; use crate::structs::tapfer_id::TapferId; use axum::extract::ws::{Message, WebSocket}; use axum::extract::{Path, WebSocketUpgrade}; use axum::response::Response; use dashmap::DashMap; +use std::fmt::Display; use std::sync::LazyLock; use std::time::{Duration, Instant}; use tokio::sync::broadcast::WeakSender; use tokio::sync::broadcast::channel; -use tracing::warn; +use tracing::{error, warn}; use uuid::Uuid; static WS_MAP: LazyLock>> = LazyLock::new(DashMap::new); @@ -58,11 +58,6 @@ pub fn broadcast_event(dst: impl Into + Copy, event: WsEvent) -> rx.send(event).unwrap(); Ok(()) } - -pub fn wss_method(host: &str) -> &str { - if is_localhost(host) { "ws" } else { "wss" } -} - // Impl #[axum::debug_handler] @@ -71,7 +66,10 @@ pub async fn start_ws(Path(id): Path, ws: WebSocketUpgrade) -> Response { ws.on_upgrade(move |socket| handle_socket(socket, id)) } -pub(crate) async fn handle_socket(mut socket: WebSocket, dst: impl Into + Copy) { +pub(crate) async fn handle_socket( + mut socket: WebSocket, + dst: impl Into + Copy + Display, +) { let mut tx_seq = 0; let (_tx, mut rx) = if let Some(tx) = WS_MAP.get(&dst.into()).and_then(|rx| rx.upgrade()) { (tx.clone(), tx.subscribe()) @@ -95,10 +93,12 @@ pub(crate) async fn handle_socket(mut socket: WebSocket, dst: impl Into>, + key: [u8; 32], + base_nonce: [u8; 8], +} + +#[wasm_bindgen] +impl FileEncrypter { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + let key = ChaCha20Poly1305::generate_key(&mut OsRng); + let cipher = ChaCha20Poly1305::new(&key); + + // EncryptorLE31 requires an 8-byte base nonce + let mut base_nonce = [0u8; 8]; + getrandom::getrandom(&mut base_nonce).expect("Failed to get random bytes"); + + let encryptor = EncryptorLE31::from_aead(cipher, &base_nonce.into()); + + Self { + encryptor: Some(encryptor), + key: key.into(), + base_nonce, + } + } + + pub fn export_key(&self) -> Vec { + self.key.to_vec() + } + + pub fn export_nonce(&self) -> Vec { + self.base_nonce.to_vec() + } + + pub fn encrypt_chunk(&mut self, data: &[u8], is_last: bool) -> Result, JsValue> { + if is_last { + let encryptor = self.encryptor.take().ok_or_else(|| JsValue::from_str("Stream already finished"))?; + encryptor.encrypt_last(data).map_err(|_| JsValue::from_str("Encryption failed")) + } else { + let encryptor = self.encryptor.as_mut().ok_or_else(|| JsValue::from_str("Stream already finished"))?; + encryptor.encrypt_next(data).map_err(|_| JsValue::from_str("Encryption failed")) + } + } +} + +#[wasm_bindgen] +pub struct FileDecrypter { + decryptor: Option>, +} + +#[wasm_bindgen] +impl FileDecrypter { + #[wasm_bindgen(constructor)] + pub fn new(key_bytes: &[u8], nonce_bytes: &[u8]) -> Result { + if key_bytes.len() != 32 || nonce_bytes.len() != 8 { + return Err(JsValue::from_str("Invalid length. Key must be 32 bytes and nonce 8 bytes.")); + } + + let cipher = ChaCha20Poly1305::new(key_bytes.into()); + let mut nonce = [0u8; 8]; + nonce.copy_from_slice(nonce_bytes); + let decryptor = DecryptorLE31::from_aead(cipher, &nonce.into()); + + Ok(Self { decryptor: Some(decryptor) }) + } + + pub fn decrypt_chunk(&mut self, data: &[u8], is_last: bool) -> Result, JsValue> { + if is_last { + // Consumes the decryptor so it can't be used again + let decryptor = self.decryptor.take().ok_or_else(|| JsValue::from_str("Stream already finished"))?; + decryptor.decrypt_last(data).map_err(|_| JsValue::from_str("Decryption failed. The data may be corrupted or this is the wrong key.")) + } else { + // Uses a mutable reference for continuous chunks + let decryptor = self.decryptor.as_mut().ok_or_else(|| JsValue::from_str("Stream already finished"))?; + decryptor.decrypt_next(data).map_err(|_| JsValue::from_str("Decryption failed. The data may be corrupted or this is the wrong key.")) + } + } +} \ No newline at end of file diff --git a/tapfer_crypt/src/qrcode.rs b/tapfer_crypt/src/qrcode.rs new file mode 100644 index 0000000..4017812 --- /dev/null +++ b/tapfer_crypt/src/qrcode.rs @@ -0,0 +1,18 @@ +use base64::Engine; +use base64::prelude::BASE64_STANDARD; +use qrcode_generator::QrCodeEcc; +use wasm_bindgen::prelude::wasm_bindgen; + +// TODO: This was taken from tapfer-src/handlers/qrcode.rs. Maybe dedup this code in the future +pub const QR_CODE_SIZE: usize = 200; // pixels +pub const QR_CODE_ECC: QrCodeEcc = QrCodeEcc::Medium; + +#[wasm_bindgen] +pub fn qr_base64_from_url(origin: &str, uri: &str) -> Option { + let data = qrcode_generator::to_png_to_vec_from_str( + format!("{origin}{uri}"), + QR_CODE_ECC, + QR_CODE_SIZE, + ).ok()?; + Some(format!("data:image/png;base64, {}", BASE64_STANDARD.encode(&data))) +} \ No newline at end of file diff --git a/templates/404.html b/templates/404.html index 585f2f5..51ab91c 100644 --- a/templates/404.html +++ b/templates/404.html @@ -11,16 +11,21 @@ text-align: center; display: flex; flex-direction: column; - gap: 1rem; + gap: 0.5rem; } + .number { + font-size: 3rem; + margin: 0.5rem; + padding: 0.5rem; + }
-

404

-

The asset you're looking for doesn’t exist or has been deleted

+

404

+

{{reason}}

Return to Homepage
diff --git a/templates/components/style.html b/templates/components/style.html index 3ecfb09..02de4d9 100644 --- a/templates/components/style.html +++ b/templates/components/style.html @@ -2,7 +2,7 @@ body { margin: 0; padding: 0; - font-family: Courier New, Arial,sans-serif; + font-family: Courier New, Arial, sans-serif; display: flex; height: 100vh; align-items: center; @@ -32,18 +32,18 @@ cursor: pointer; transition: filter 0.3s ease; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); - /*Fixes for anchor element buttons*/ + /*Fixes for anchor element buttons*/ text-decoration: none; line-height: 1; } input[type="submit"]:hover, button:hover, .button:hover { - filter: brightness(1.2); + filter: brightness(1.2); } - code { - color: white; - background-color: #1b1b1b; + code { + color: white; + background-color: #1b1b1b; margin: 0.5rem 0; } @@ -62,14 +62,14 @@ border-radius: 10px; display: flex; flex-direction: column; - justify-content: center; - align-items: center; + justify-content: center; + align-items: center; gap: 1rem; width: 100%; min-width: 500px; - max-width: 90vw; + max-width: 90vw; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - background: white; + background: white; } @media (max-width: 768px) { diff --git a/templates/components/toast.html b/templates/components/toast.html index 65dba99..5a2d8af 100644 --- a/templates/components/toast.html +++ b/templates/components/toast.html @@ -26,43 +26,68 @@ /* Animations to fade the toast in and out */ @-webkit-keyframes fadein { - from {top: 0; opacity: 0;} - to {top: 30px; opacity: 1;} + from { + top: 0; + opacity: 0; + } + to { + top: 30px; + opacity: 1; + } } @keyframes fadein { - from {top: 0; opacity: 0;} - to {top: 30px; opacity: 1;} + from { + top: 0; + opacity: 0; + } + to { + top: 30px; + opacity: 1; + } } @-webkit-keyframes fadeout { - from {top: 30px; opacity: 1;} - to {top: 0; opacity: 0;} + from { + top: 30px; + opacity: 1; + } + to { + top: 0; + opacity: 0; + } } @keyframes fadeout { - from {bottom: 30px; opacity: 1;} - to {bottom: 0; opacity: 0;} + from { + bottom: 30px; + opacity: 1; + } + to { + bottom: 0; + opacity: 0; + } }
\ No newline at end of file diff --git a/templates/deposit.html b/templates/deposit.html index 7fc85d7..527bb2f 100644 --- a/templates/deposit.html +++ b/templates/deposit.html @@ -30,30 +30,32 @@

Upload to this device from the scanning device

- + + Debug URL
diff --git a/templates/download.html b/templates/download.html index eb102c1..de0e9b6 100644 --- a/templates/download.html +++ b/templates/download.html @@ -6,20 +6,32 @@ {% include "components/favicon.html" %} {% include "components/meta.html" %} @@ -32,38 +44,167 @@

MIME Type: {{mimetype}}

Expires: {{expiry}}

Size: {{filesize}}

-

Sha512: {{sha512}}

+

Sha512: {{sha512}}

-
- Download +
+
- - + +
+ + diff --git a/templates/homepage.html b/templates/homepage.html index 643b7fa..2d7fe2a 100644 --- a/templates/homepage.html +++ b/templates/homepage.html @@ -38,27 +38,32 @@ white-space: nowrap; } - #qrcode { - filter: blur(5px); - } - - #filelink { - border-bottom-style: dot-dot-dash; - transition: color 0.5s ease; - } - - #filelink:hover { - color: crimson; - } - - .show_on_upload { - visibility: hidden; - } - - /* Prevents purple link when clicked before */ - a[href="/deposit"]:visited { - color: #0000EE; - } + #qrcode { + filter: blur(5px); + } + + .filelink { + transition: color 0.5s ease; + border-radius: 5px; + padding: 0.5rem 1rem; + border: 2px dotted #2975D2; + color: #2975D2 !important; + text-decoration: none; + } + + .filelink:hover { + color: crimson; + text-decoration: underline; + } + + .show_on_upload { + visibility: hidden; + } + + /* Prevents purple link when clicked before */ + a[href="/deposit"]:visited { + color: #0000EE; + } @@ -69,22 +74,25 @@
-
-

Delete after:

- - - - +
+
+ + +
+
+ + +
{% include "components/footer.html" %} -