From 516ce9672cad8648a91ff47a73d57bf16468e5d4 Mon Sep 17 00:00:00 2001 From: J M <2364004+Blu-J@users.noreply.github.com> Date: Mon, 18 Oct 2021 11:45:42 -0600 Subject: [PATCH] Feat--auth-static (#684) * feat: static server * WIP: Idea (#685) * wip: New Idea Use tokens as proofs. Use proofs as arguments. Return proofs as indication of side effects? Forced constructor pattern? * chore: Use the has notation for reciepts * chore: Example to main db mod * feat: Content headers * polish Co-authored-by: Aiden McClelland --- appmgr/Cargo.lock | 1 + appmgr/Cargo.toml | 1 + appmgr/src/auth.rs | 60 ++++---- appmgr/src/bin/embassy-init.rs | 18 --- appmgr/src/bin/embassyd.rs | 13 +- appmgr/src/context/rpc.rs | 3 + appmgr/src/db/mod.rs | 87 +++++++----- appmgr/src/lib.rs | 1 + appmgr/src/middleware/auth.rs | 163 ++++++++++++++++------ appmgr/src/nginx/main-ui.conf.template | 8 ++ appmgr/src/static_server.rs | 181 +++++++++++++++++++++++++ 11 files changed, 408 insertions(+), 128 deletions(-) create mode 100644 appmgr/src/static_server.rs diff --git a/appmgr/Cargo.lock b/appmgr/Cargo.lock index 954ae55b0..eb59942b4 100644 --- a/appmgr/Cargo.lock +++ b/appmgr/Cargo.lock @@ -845,6 +845,7 @@ dependencies = [ "hex", "hmac", "http", + "hyper", "hyper-ws-listener", "indexmap", "isocountry", diff --git a/appmgr/Cargo.toml b/appmgr/Cargo.toml index b012cfe23..9ba4c7cd6 100644 --- a/appmgr/Cargo.toml +++ b/appmgr/Cargo.toml @@ -66,6 +66,7 @@ hex = "0.4.3" hmac = "0.11.0" http = "0.2.5" hyper-ws-listener = { git = "https://github.com/Start9Labs/hyper-ws-listener.git", branch = "main" } +hyper = "0.14.13" indexmap = { version = "1.7.0", features = ["serde"] } isocountry = "0.3.2" itertools = "0.10.1" diff --git a/appmgr/src/auth.rs b/appmgr/src/auth.rs index 36face018..e2ad0635a 100644 --- a/appmgr/src/auth.rs +++ b/appmgr/src/auth.rs @@ -1,11 +1,9 @@ use std::collections::BTreeMap; use std::marker::PhantomData; -use basic_cookies::Cookie; use chrono::{DateTime, Utc}; use clap::ArgMatches; use color_eyre::eyre::eyre; -use http::header::COOKIE; use http::HeaderValue; use rpc_toolkit::command; use rpc_toolkit::command_helpers::prelude::{RequestParts, ResponseParts}; @@ -15,7 +13,7 @@ use serde_json::Value; use tracing::instrument; use crate::context::{CliContext, RpcContext}; -use crate::middleware::auth::{get_id, hash_token}; +use crate::middleware::auth::{AsLogoutSessionId, HasLoggedOutSessions, HashSessionToken}; use crate::util::{display_none, display_serializable, IoFormat}; use crate::{ensure_code, Error, ResultExt}; @@ -98,17 +96,14 @@ pub async fn login( crate::ErrorKind::Authorization, "Password Incorrect" ); - let token = base32::encode( - base32::Alphabet::RFC4648 { padding: false }, - &rand::random::<[u8; 16]>(), - ) - .to_lowercase(); - let id = hash_token(&token); + + let hash_token = HashSessionToken::new(); let user_agent = req.headers.get("user-agent").and_then(|h| h.to_str().ok()); let metadata = serde_json::to_string(&metadata).with_kind(crate::ErrorKind::Database)?; + let hash_token_hashed = hash_token.hashed(); sqlx::query!( "INSERT INTO session (id, user_agent, metadata) VALUES (?, ?, ?)", - id, + hash_token_hashed, user_agent, metadata, ) @@ -116,11 +111,7 @@ pub async fn login( .await?; res.headers.insert( "set-cookie", - HeaderValue::from_str(&format!( - "session={}; Path=/; SameSite=Lax; Expires=Fri, 31 Dec 9999 23:59:59 GMT;", - token - )) - .with_kind(crate::ErrorKind::Unknown)?, // Should be impossible, but don't want to panic + hash_token.header_value()?, // Should be impossible, but don't want to panic ); Ok(()) @@ -131,21 +122,12 @@ pub async fn login( pub async fn logout( #[context] ctx: RpcContext, #[request] req: &RequestParts, -) -> Result<(), Error> { - if let Some(cookie_header) = req.headers.get(COOKIE) { - let cookies = Cookie::parse( - cookie_header - .to_str() - .with_kind(crate::ErrorKind::Authorization)?, - ) - .with_kind(crate::ErrorKind::Authorization)?; - if let Some(session) = cookies.iter().find(|c| c.get_name() == "session") { - let token = session.get_value(); - let id = hash_token(token); - kill(ctx, vec![id]).await?; - } - } - Ok(()) +) -> Result, Error> { + let auth = match HashSessionToken::from_request_parts(req) { + Err(_) => return Ok(None), + Ok(a) => a, + }; + Ok(Some(HasLoggedOutSessions::new(vec![auth], &ctx).await?)) } #[derive(Deserialize, Serialize)] @@ -212,7 +194,7 @@ pub async fn list( format: Option, ) -> Result { Ok(SessionList { - current: get_id(req)?, + current: HashSessionToken::from_request_parts(req)?.as_hash(), sessions: sqlx::query!( "SELECT * FROM session WHERE logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP" ) @@ -239,17 +221,21 @@ fn parse_comma_separated(arg: &str, _: &ArgMatches<'_>) -> Result, R Ok(arg.split(",").map(|s| s.trim().to_owned()).collect()) } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct KillSessionId(String); + +impl AsLogoutSessionId for KillSessionId { + fn as_logout_session_id(self) -> String { + self.0 + } +} + #[command(display(display_none))] #[instrument(skip(ctx))] pub async fn kill( #[context] ctx: RpcContext, #[arg(parse(parse_comma_separated))] ids: Vec, ) -> Result<(), Error> { - sqlx::query(&format!( - "UPDATE session SET logged_out = CURRENT_TIMESTAMP WHERE id IN ('{}')", - ids.join("','") - )) - .execute(&mut ctx.secret_store.acquire().await?) - .await?; + HasLoggedOutSessions::new(ids.into_iter().map(KillSessionId), &ctx).await?; Ok(()) } diff --git a/appmgr/src/bin/embassy-init.rs b/appmgr/src/bin/embassy-init.rs index 2d1e34dda..fe9e16b4f 100644 --- a/appmgr/src/bin/embassy-init.rs +++ b/appmgr/src/bin/embassy-init.rs @@ -132,24 +132,6 @@ async fn init(cfg_path: Option<&str>) -> Result<(), Error> { embassy::hostname::sync_hostname().await?; tracing::info!("Synced Hostname"); - - if tokio::fs::metadata("/var/www/html/main/public") - .await - .is_err() - { - tokio::fs::create_dir_all("/var/www/html/main/public").await? - } - if tokio::fs::symlink_metadata("/var/www/html/main/public/package-data") - .await - .is_err() - { - tokio::fs::symlink( - cfg.datadir().join("package-data").join("public"), - "/var/www/html/main/public/package-data", - ) - .await?; - } - tracing::info!("Enabled nginx public dir"); embassy::net::wifi::synchronize_wpa_supplicant_conf(&cfg.datadir().join("main")).await?; tracing::info!("Synchronized wpa_supplicant.conf"); diff --git a/appmgr/src/bin/embassyd.rs b/appmgr/src/bin/embassyd.rs index 5b497ae97..cea4c5e8b 100644 --- a/appmgr/src/bin/embassyd.rs +++ b/appmgr/src/bin/embassyd.rs @@ -13,7 +13,7 @@ use embassy::net::tor::tor_health_check; use embassy::shutdown::Shutdown; use embassy::status::{check_all, synchronize_all}; use embassy::util::{daemon, Invoke}; -use embassy::{Error, ErrorKind, ResultExt}; +use embassy::{static_server, Error, ErrorKind, ResultExt}; use futures::{FutureExt, TryFutureExt}; use reqwest::{Client, Proxy}; use rpc_toolkit::hyper::{Body, Response, Server, StatusCode}; @@ -164,6 +164,16 @@ async fn inner_main(cfg_path: Option<&str>) -> Result, Error> { } }); + let file_server_ctx = rpc_ctx.clone(); + let file_server = { + static_server::init(file_server_ctx, { + let mut shutdown = rpc_ctx.shutdown.subscribe(); + async move { + shutdown.recv().await.expect("context dropped"); + } + }) + }; + let status_ctx = rpc_ctx.clone(); let status_daemon = daemon( move || { @@ -227,6 +237,7 @@ async fn inner_main(cfg_path: Option<&str>) -> Result, Error> { ErrorKind::Unknown )), ws_server.map_err(|e| Error::new(e, ErrorKind::Network)), + file_server.map_err(|e| Error::new(e, ErrorKind::Network)), status_daemon.map_err(|e| Error::new( e.wrap_err("Status Sync daemon panicked!"), ErrorKind::Unknown diff --git a/appmgr/src/context/rpc.rs b/appmgr/src/context/rpc.rs index 1e4cd5108..957a9f16a 100644 --- a/appmgr/src/context/rpc.rs +++ b/appmgr/src/context/rpc.rs @@ -39,6 +39,7 @@ use crate::{Error, ResultExt}; pub struct RpcContextConfig { pub bind_rpc: Option, pub bind_ws: Option, + pub bind_static: Option, pub tor_control: Option, pub tor_socks: Option, pub revision_cache_size: Option, @@ -112,6 +113,7 @@ impl RpcContextConfig { pub struct RpcContextSeed { pub bind_rpc: SocketAddr, pub bind_ws: SocketAddr, + pub bind_static: SocketAddr, pub datadir: PathBuf, pub zfs_pool_name: Arc, pub db: PatchDb, @@ -162,6 +164,7 @@ impl RpcContext { let seed = Arc::new(RpcContextSeed { bind_rpc: base.bind_rpc.unwrap_or(([127, 0, 0, 1], 5959).into()), bind_ws: base.bind_ws.unwrap_or(([127, 0, 0, 1], 5960).into()), + bind_static: base.bind_static.unwrap_or(([127, 0, 0, 1], 5961).into()), datadir: base.datadir().to_path_buf(), zfs_pool_name: Arc::new(base.zfs_pool_name().to_owned()), db, diff --git a/appmgr/src/db/mod.rs b/appmgr/src/db/mod.rs index 88fdb3c73..7a8618c4e 100644 --- a/appmgr/src/db/mod.rs +++ b/appmgr/src/db/mod.rs @@ -24,7 +24,7 @@ use tracing::instrument; pub use self::model::DatabaseModel; use self::util::WithRevision; use crate::context::RpcContext; -use crate::middleware::auth::hash_token; +use crate::middleware::auth::{HasValidSession, HashSessionToken}; use crate::util::{display_serializable, GeneralGuard, IoFormat}; use crate::{Error, ResultExt}; @@ -34,7 +34,7 @@ async fn ws_handler< ctx: RpcContext, ws_fut: WSFut, ) -> Result<(), Error> { - let (dump, mut sub) = ctx.db.dump_and_sub().await; + let (dump, sub) = ctx.db.dump_and_sub().await; let mut stream = ws_fut .await .with_kind(crate::ErrorKind::Network)? @@ -54,7 +54,7 @@ async fn ws_handler< () }); - loop { + let has_valid_session = loop { if let Some(Message::Text(cookie)) = stream .next() .await @@ -63,6 +63,7 @@ async fn ws_handler< { let cookie_str = serde_json::from_str::>(&cookie) .with_kind(crate::ErrorKind::Deserialization)?; + let id = basic_cookies::Cookie::parse(&cookie_str) .with_kind(crate::ErrorKind::Authorization)? .into_iter() @@ -70,38 +71,40 @@ async fn ws_handler< .ok_or_else(|| { Error::new(eyre!("UNAUTHORIZED"), crate::ErrorKind::Authorization) })?; - if let Err(e) = - crate::middleware::auth::is_authed(&ctx, &hash_token(id.get_value())).await - { - stream - .send(Message::Text( - serde_json::to_string( - &RpcResponse::>::from_result( - Err::<_, RpcError>(e.into()), - ), - ) - .with_kind(crate::ErrorKind::Serialization)?, - )) - .await - .with_kind(crate::ErrorKind::Network)?; - return Ok(()); + let authenticated_session = HashSessionToken::from_cookie(&id); + match HasValidSession::from_session(&authenticated_session, &ctx).await { + Err(e) => { + stream + .send(Message::Text( + serde_json::to_string( + &RpcResponse::>::from_result(Err::< + _, + RpcError, + >( + e.into() + )), + ) + .with_kind(crate::ErrorKind::Serialization)?, + )) + .await + .with_kind(crate::ErrorKind::Network)?; + return Ok(()); + } + Ok(has_validation) => break has_validation, } - break; } - } - stream - .send(Message::Text( - serde_json::to_string(&RpcResponse::>::from_result(Ok::< - _, - RpcError, - >( - serde_json::to_value(&dump).with_kind(crate::ErrorKind::Serialization)?, - ))) - .with_kind(crate::ErrorKind::Serialization)?, - )) - .await - .with_kind(crate::ErrorKind::Network)?; + }; + send_dump(has_valid_session, &mut stream, dump).await?; + deal_with_messages(has_valid_session, sub, stream).await?; + Ok(()) +} + +async fn deal_with_messages( + _has_valid_authentication: HasValidSession, + mut sub: tokio::sync::broadcast::Receiver>, + mut stream: WebSocketStream, +) -> Result<(), Error> { loop { futures::select! { new_rev = sub.recv().fuse() => { @@ -149,6 +152,26 @@ async fn ws_handler< } } +async fn send_dump( + _has_valid_authentication: HasValidSession, + stream: &mut WebSocketStream, + dump: Dump, +) -> Result<(), Error> { + stream + .send(Message::Text( + serde_json::to_string(&RpcResponse::>::from_result(Ok::< + _, + RpcError, + >( + serde_json::to_value(&dump).with_kind(crate::ErrorKind::Serialization)?, + ))) + .with_kind(crate::ErrorKind::Serialization)?, + )) + .await + .with_kind(crate::ErrorKind::Network)?; + Ok(()) +} + pub async fn subscribe(ctx: RpcContext, req: Request) -> Result, Error> { let (parts, body) = req.into_parts(); let req = Request::from_parts(parts, body); diff --git a/appmgr/src/lib.rs b/appmgr/src/lib.rs index dfcecef7a..fdf34b427 100644 --- a/appmgr/src/lib.rs +++ b/appmgr/src/lib.rs @@ -34,6 +34,7 @@ pub mod setup; pub mod shutdown; pub mod sound; pub mod ssh; +pub mod static_server; pub mod status; pub mod system; pub mod update; diff --git a/appmgr/src/middleware/auth.rs b/appmgr/src/middleware/auth.rs index c5477c4a7..a9bc17e88 100644 --- a/appmgr/src/middleware/auth.rs +++ b/appmgr/src/middleware/auth.rs @@ -1,8 +1,11 @@ +use crate::context::RpcContext; +use crate::{Error, ResultExt}; + use basic_cookies::Cookie; use color_eyre::eyre::eyre; use digest::Digest; use futures::future::BoxFuture; -use futures::{FutureExt, TryFutureExt}; +use futures::FutureExt; use http::StatusCode; use rpc_toolkit::command_helpers::prelude::RequestParts; use rpc_toolkit::hyper::header::COOKIE; @@ -11,50 +14,133 @@ use rpc_toolkit::hyper::{Body, Request, Response}; use rpc_toolkit::rpc_server_helpers::{noop3, to_response, DynMiddleware, DynMiddlewareStage2}; use rpc_toolkit::yajrc::RpcMethod; use rpc_toolkit::Metadata; +use serde::{Deserialize, Serialize}; use sha2::Sha256; - -use crate::context::RpcContext; -use crate::{Error, ResultExt}; - -pub fn get_id(req: &RequestParts) -> Result { - if let Some(cookie_header) = req.headers.get(COOKIE) { - let cookies = Cookie::parse( - cookie_header - .to_str() - .with_kind(crate::ErrorKind::Authorization)?, - ) - .with_kind(crate::ErrorKind::Authorization)?; - if let Some(session) = cookies.iter().find(|c| c.get_name() == "session") { - return Ok(hash_token(session.get_value())); - } - } - Err(Error::new( - eyre!("UNAUTHORIZED"), - crate::ErrorKind::Authorization, - )) +pub trait AsLogoutSessionId { + fn as_logout_session_id(self) -> String; } -pub fn hash_token(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - base32::encode( - base32::Alphabet::RFC4648 { padding: false }, - hasher.finalize().as_slice(), - ) - .to_lowercase() -} +/// Will need to know when we have logged out from a route +#[derive(Serialize, Deserialize)] +pub struct HasLoggedOutSessions(()); -pub async fn is_authed(ctx: &RpcContext, id: &str) -> Result<(), Error> { - let session = sqlx::query!("UPDATE session SET last_active = CURRENT_TIMESTAMP WHERE id = ? AND logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP", id) +impl HasLoggedOutSessions { + pub async fn new( + logged_out_sessions: impl IntoIterator, + ctx: &RpcContext, + ) -> Result { + sqlx::query(&format!( + "UPDATE session SET logged_out = CURRENT_TIMESTAMP WHERE id IN ('{}')", + logged_out_sessions + .into_iter() + .by_ref() + .map(|x| x.as_logout_session_id()) + .collect::>() + .join("','") + )) .execute(&mut ctx.secret_store.acquire().await?) .await?; - if session.rows_affected() == 0 { - return Err(Error::new( + Ok(Self(())) + } +} + +/// Used when we need to know that we have logged in with a valid user +#[derive(Clone, Copy)] +pub struct HasValidSession(()); + +impl HasValidSession { + pub async fn from_request_parts( + request_parts: &RequestParts, + ctx: &RpcContext, + ) -> Result { + Self::from_session(&HashSessionToken::from_request_parts(request_parts)?, ctx).await + } + + pub async fn from_session(session: &HashSessionToken, ctx: &RpcContext) -> Result { + let session_hash = session.hashed(); + let session = sqlx::query!("UPDATE session SET last_active = CURRENT_TIMESTAMP WHERE id = ? AND logged_out IS NULL OR logged_out > CURRENT_TIMESTAMP", session_hash) + .execute(&mut ctx.secret_store.acquire().await?) + .await?; + if session.rows_affected() == 0 { + return Err(Error::new( + eyre!("UNAUTHORIZED"), + crate::ErrorKind::Authorization, + )); + } + Ok(Self(())) + } +} + +/// When we have a need to create a new session, +/// Or when we are using internal valid authenticated service. +#[derive(Debug, Clone)] +pub struct HashSessionToken { + hashed: String, + token: String, +} +impl HashSessionToken { + pub fn new() -> Self { + let token = base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + &rand::random::<[u8; 16]>(), + ) + .to_lowercase(); + let hashed = Self::hash(&token); + Self { hashed, token } + } + pub fn from_cookie(cookie: &Cookie) -> Self { + let token = cookie.get_value().to_owned(); + let hashed = Self::hash(&token); + Self { hashed, token } + } + + pub fn from_request_parts(request_parts: &RequestParts) -> Result { + if let Some(cookie_header) = request_parts.headers.get(COOKIE) { + let cookies = Cookie::parse( + cookie_header + .to_str() + .with_kind(crate::ErrorKind::Authorization)?, + ) + .with_kind(crate::ErrorKind::Authorization)?; + if let Some(session) = cookies.iter().find(|c| c.get_name() == "session") { + return Ok(Self::from_cookie(session)); + } + } + Err(Error::new( eyre!("UNAUTHORIZED"), crate::ErrorKind::Authorization, - )); + )) + } + + pub fn header_value(&self) -> Result { + http::HeaderValue::from_str(&format!( + "session={}; Path=/; SameSite=Lax; Expires=Fri, 31 Dec 9999 23:59:59 GMT;", + self.token + )) + .with_kind(crate::ErrorKind::Unknown) + } + + pub fn hashed(&self) -> &str { + self.hashed.as_str() + } + + pub fn as_hash(self) -> String { + self.hashed + } + fn hash(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + base32::encode( + base32::Alphabet::RFC4648 { padding: false }, + hasher.finalize().as_slice(), + ) + .to_lowercase() + } +} +impl AsLogoutSessionId for HashSessionToken { + fn as_logout_session_id(self) -> String { + self.hashed } - Ok(()) } pub fn auth(ctx: RpcContext) -> DynMiddleware { @@ -72,10 +158,7 @@ pub fn auth(ctx: RpcContext) -> DynMiddleware { .get(rpc_req.method.as_str(), "authenticated") .unwrap_or(true) { - if let Err(e) = async { get_id(req) } - .and_then(|id| async move { is_authed(&ctx, &id).await }) - .await - { + if let Err(e) = HasValidSession::from_request_parts(req, &ctx).await { let (res_parts, _) = Response::new(()).into_parts(); return Ok(Err(to_response( &req.headers, diff --git a/appmgr/src/nginx/main-ui.conf.template b/appmgr/src/nginx/main-ui.conf.template index 71c9e2ed1..3d8e7cf87 100644 --- a/appmgr/src/nginx/main-ui.conf.template +++ b/appmgr/src/nginx/main-ui.conf.template @@ -25,6 +25,10 @@ server {{ proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; }} + + location /public/ {{ + proxy_pass http://127.0.0.1:5961/; + }} location /marketplace/ {{ proxy_pass https://beta-registry-0-3.start9labs.com/; # TODO @@ -67,6 +71,10 @@ server {{ proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; }} + + location /public/ {{ + proxy_pass http://127.0.0.1:5961/; + }} location /marketplace/ {{ proxy_pass https://beta-registry-0-3.start9labs.com/; # TODO diff --git a/appmgr/src/static_server.rs b/appmgr/src/static_server.rs new file mode 100644 index 000000000..0117e1e49 --- /dev/null +++ b/appmgr/src/static_server.rs @@ -0,0 +1,181 @@ +use std::fs::Metadata; +use std::future::Future; +use std::path::PathBuf; +use std::time::UNIX_EPOCH; + +use digest::Digest; +use http::response::Builder; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Error as HyperError, Method, Request, Response, Server, StatusCode}; +use tokio::fs::File; +use tokio_util::codec::{BytesCodec, FramedRead}; + +use crate::context::RpcContext; +use crate::install::PKG_PUBLIC_DIR; +use crate::middleware::auth::HasValidSession; +use crate::{Error, ErrorKind, ResultExt}; + +static NOT_FOUND: &[u8] = b"Not Found"; +static NOT_AUTHORIZED: &[u8] = b"Not Authorized"; + +pub fn init( + ctx: RpcContext, + shutdown: impl Future + Send + 'static, +) -> impl Future> { + let addr = ctx.bind_static; + + let make_service = make_service_fn(move |_| { + let ctx = ctx.clone(); + async move { + Ok::<_, HyperError>(service_fn(move |req| { + let ctx = ctx.clone(); + async move { + match file_server_router(req, ctx).await { + Ok(x) => Ok::<_, HyperError>(x), + Err(err) => { + tracing::error!("{:?}", err); + Ok(server_error()) + } + } + } + })) + } + }); + + Server::bind(&addr) + .serve(make_service) + .with_graceful_shutdown(shutdown) +} + +async fn file_server_router(req: Request, ctx: RpcContext) -> Result, Error> { + let (request_parts, _body) = req.into_parts(); + let valid_session = HasValidSession::from_request_parts(&request_parts, &ctx).await; + match ( + valid_session, + request_parts.method, + request_parts + .uri + .path() + .strip_prefix("/") + .unwrap_or(request_parts.uri.path()) + .split_once("/"), + ) { + (Err(error), _, _) => { + tracing::warn!("unauthorized for {} @{:?}", error, request_parts.uri.path()); + tracing::debug!("{:?}", error); + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(NOT_AUTHORIZED.into()) + .unwrap()); + } + (Ok(valid_session), Method::GET, Some(("package-data", path))) => { + file_send(valid_session, &ctx, PathBuf::from(path)).await + } + _ => Ok(not_found()), + } +} + +/// HTTP status code 404 +fn not_found() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(NOT_FOUND.into()) + .unwrap() +} + +/// HTTP status code 500 +fn server_error() -> Response { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body("".into()) + .unwrap() +} +async fn file_send( + _valid_session: HasValidSession, + ctx: &RpcContext, + filename: PathBuf, +) -> Result, Error> { + // Serve a file by asynchronously reading it by chunks using tokio-util crate. + let path = ctx.datadir.join(PKG_PUBLIC_DIR).join(filename); + + if let Ok(file) = File::open(path.clone()).await { + let metadata = file.metadata().await.with_kind(ErrorKind::Filesystem)?; + let _is_non_empty = match IsNonEmptyFile::new(&metadata, &path) { + Some(a) => a, + None => return Ok(not_found()), + }; + + let mut builder = Response::builder().status(StatusCode::OK); + builder = with_e_tag(&path, &metadata, builder)?; + builder = with_content_type(&path, builder); + builder = with_content_length(&metadata, builder); + let stream = FramedRead::new(file, BytesCodec::new()); + let body = Body::wrap_stream(stream); + return Ok(builder.body(body).with_kind(ErrorKind::Network)?); + } + tracing::debug!("File not found: {:?}", path); + + Ok(not_found()) +} + +struct IsNonEmptyFile(()); +impl IsNonEmptyFile { + fn new(metadata: &Metadata, path: &PathBuf) -> Option { + let length = metadata.len(); + if !metadata.is_file() || length == 0 { + tracing::debug!("File is empty: {:?}", path); + return None; + } + Some(Self(())) + } +} + +fn with_e_tag(path: &PathBuf, metadata: &Metadata, builder: Builder) -> Result { + let modified = metadata.modified().with_kind(ErrorKind::Filesystem)?; + let mut hasher = sha2::Sha256::new(); + hasher.update(format!("{:?}", path).as_bytes()); + hasher.update( + format!( + "{}", + modified + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + ) + .as_bytes(), + ); + let res = hasher.finalize(); + Ok(builder.header( + "ETag", + base32::encode(base32::Alphabet::RFC4648 { padding: false }, res.as_slice()).to_lowercase(), + )) +} +///https://en.wikipedia.org/wiki/Media_type +fn with_content_type(path: &PathBuf, builder: Builder) -> Builder { + let content_type = match path.extension() { + Some(os_str) => match os_str.to_str() { + Some("apng") => "image/apng", + Some("avif") => "image/avif", + Some("flif") => "image/flif", + Some("gif") => "image/gif", + Some("jpg") | Some("jpeg") | Some("jfif") | Some("pjpeg") | Some("pjp") => "image/jpeg", + Some("jxl") => "image/jxl", + Some("png") => "image/png", + Some("svg") => "image/svg+xml", + Some("webp") => "image/webp", + Some("mng") | Some("x-mng") => "image/x-mng", + Some("css") => "text/css", + Some("csv") => "text/csv", + Some("html") => "text/html", + Some("php") => "text/php", + Some("plain") | Some("md") | Some("txt") => "text/plain", + Some("xml") => "text/xml", + None | Some(_) => "text/plain", + }, + None => "text/plain", + }; + builder.header("Content-Type", content_type) +} +fn with_content_length(metadata: &Metadata, builder: Builder) -> Builder { + builder.header("Content-Length", metadata.len()) +}