diff --git a/filed/docs/file_api.swagger.yml b/filed/docs/file_api.swagger.yml index 8f0818d..7539d45 100644 --- a/filed/docs/file_api.swagger.yml +++ b/filed/docs/file_api.swagger.yml @@ -74,7 +74,7 @@ paths: example: {} 401: description: |- - This error code is returned if one of the two conditions are met: + This error code is returned if one of these conditions are met: 1. The instance does not allow deleting files via API. 2. The file has been uploaded from another IP, which is not this one, and the API was not authorized via an API key. @@ -90,6 +90,9 @@ paths: fid: type: string example: ID or name of the file. It is the NAME in file.blek.codes/uploads/NAME + api_key: + type: string + example: '123' /api/files/upload: post: summary: Upload a file @@ -102,6 +105,8 @@ paths: security: - apikey: [ key ] requestBody: + description: |- + A multipart form content: multipart/form-data: schema: @@ -117,7 +122,18 @@ paths: description: Instance-specific password needed to upload files metadata: type: object - description: file info + description: |- + JSON object with file info: + + ``` + { + sha512: string, + name?: string, + pass?: string + } + ``` + + Note that the content type does not matter on this one. properties: sha512: type: string @@ -125,17 +141,48 @@ paths: name: type: string description: Optional name of the file so it would be accessible like file.blek.codes/uploads/{name} + pass: + type: string + description: Optional password protection for the file responses: 200: description: File uploaded successfully + content: + application/json: + schema: + type: object + properties: + status: + example: 'OK' 401: description: |- - This error code is returned if one of the two conditions are met: + This error code is returned if one of the 4 conditions are met: 1. The instance does not allow API file uploads. 2. The instance requires API key for all API manipulations. 3. The provided API key is invalid. 4. API authorization is not enabled, but a key is provided + content: + application/json: + schema: + type: object + properties: + error: + example: 'APIPasswordDenied' + details: + example: 'API password authorization has been denied.' + 403: + description: |- + This error code is returned if your request payload is malformed or the hash doesn't match the file + content: + application/json: + schema: + type: object + properties: + error: + example: 'APIError' + details: + example: 'Request payload invalid' components: diff --git a/filed/src/files/lookup.rs b/filed/src/files/lookup.rs index 3e67233..0af43d8 100644 --- a/filed/src/files/lookup.rs +++ b/filed/src/files/lookup.rs @@ -25,23 +25,18 @@ impl FileManager { async fn find_all(self: &Self, predicate: String) -> Result, Box> { let mut conn = self.conn.get_async_connection().await?; - let found: Vec = conn.keys(predicate).await?; - let serialized: Vec = - found.iter() - .map(|x| { - let result = serde_json::from_str(&x); - match result { - Ok(x) => Some(x), - Err(err) => { - log::error!("Error while serializing {x}: {:?}", err); - None - } - } - }) - .filter(|x| x.is_some()) - .map(|x| x.unwrap()) - .collect(); - Ok(serialized) + let keys: Vec = conn.keys(predicate).await?; + + let mut data: Vec = vec![]; + + for key in keys.iter() { + let raw: String = conn.get(key.clone()).await?; + + let mut parsed: File = serde_json::from_str(raw.as_str())?; + data.push(parsed); + } + + Ok(data) } /// Filter options diff --git a/filed/src/web/api.rs b/filed/src/web/api.rs index d07602b..71863c5 100644 --- a/filed/src/web/api.rs +++ b/filed/src/web/api.rs @@ -1,7 +1,7 @@ use warp::{reply::Reply, reject::Rejection, Filter}; use serde::{Serialize, Deserialize}; -use self::files::get_all::get_all_f; +use self::files::{get_all::get_all_f, delete::delete_f, upload::upload_f}; use super::state::SharedState; @@ -24,5 +24,7 @@ pub fn get_routes(state: SharedState) -> impl Filter Result<(), WithStatus> { + if ! state.config.api.enabled { + return Err( + warp::reply::with_status( + json(&ErrorMessage::new(Error::APIDisabled)), + StatusCode::SERVICE_UNAVAILABLE + ) + ) + } + Ok(()) +} + +fn is_api_pass(state: &SharedState) -> bool { + if let Some(keys) = state.config.api.apikeys.clone() { + keys.len() != 0 + } else { + false + } +} + +fn check_api_pass(state: &SharedState, key: String) -> Result<(), WithStatus> { + let mut valid = { + if let Some(keys) = state.config.api.apikeys.clone() { + keys.iter().find(|x| (**x) == key).is_some() + } else { + false + } + }; + + if key.len() == 0 { + valid = false + } + + if valid { + Ok(()) + } else { + Err( + warp::reply::with_status( + json(&ErrorMessage::new(Error::APIPasswordDenied)), + StatusCode::FORBIDDEN + ) + ) + } +} + +fn function_disabled_err(status: StatusCode) -> WithStatus { + warp::reply::with_status( + json(&ErrorMessage::new(Error::APIFunctionDisabled)), + status + ) +} + +pub mod get_all; +pub mod delete; +pub mod upload; \ No newline at end of file diff --git a/filed/src/web/api/files/delete.rs b/filed/src/web/api/files/delete.rs new file mode 100644 index 0000000..3ed354b --- /dev/null +++ b/filed/src/web/api/files/delete.rs @@ -0,0 +1,137 @@ +use std::net::IpAddr; + +use serde_json::json; +use warp::{reply::{Reply, json, with_status}, reject::Rejection, Filter, http::StatusCode}; +use serde::{Serialize, Deserialize}; +use warp_real_ip::real_ip; + +use crate::{web::{state::SharedState, rejection::HttpReject, api::types::{ErrorMessage, Error}}, files::File}; + +use super::{function_disabled_err, check_api_enabled}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct DeleteFunctionPayload { + pub fid: String, + pub api_key: Option +} + +pub async fn delete(state: SharedState, body: DeleteFunctionPayload, ip: Option) -> Result, Rejection> { + if let Err(res) = check_api_enabled(&state) { + return Ok(Box::new(res)); + } + + if (!state.config.api.delete) || (!state.config.api.enabled) { + return Ok(Box::new(function_disabled_err(StatusCode::UNAUTHORIZED))) + } + + let mut sudo_authorized = false; + let mut blocked = false; + + if let Some(keys) = state.config.api.apikeys.clone() { + if let Some(key) = body.api_key { + + if keys.contains(&key) { + sudo_authorized = true; + blocked = false; + } else { + sudo_authorized = false; + blocked = true; + } + + } else { + sudo_authorized = false; + blocked = true + } + } + + if ! sudo_authorized { + if ip.is_none() { // need the ip if sudo is not authorized + blocked = true // to check if the file is the own file + } + } + + let ip = ip.unwrap(); + + let id = body.fid; + let mut file = state.file_mgr.find_by_hash(id.clone()) + .map_err(|x| HttpReject::StringError(x.to_string()))?; + + if let None = file { + file = state.file_mgr.find_by_name(id) + .map_err(|x| HttpReject::StringError(x.to_string()))?; + } + + if let None = file.clone() { + return Ok( + Box::new( + warp::reply::with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some("No file with that ID was found.".into()) + } + ), + StatusCode::NOT_FOUND + ) + ) + ) + } + + let file: File = file.unwrap(); + + if let Some(uploader) = file.uploader_ip { + if uploader != ip && (!sudo_authorized) { + blocked = true; + } + } else { + blocked = true; + } + + if blocked { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIPasswordDenied, + details: Some( + "Request has been denied for one of the following reasons: password auth did not pass, file was uploaded by someone else, the instance does not allow deleting files via the API".into() + ) + } + ), + StatusCode::UNAUTHORIZED + ) + ) + ) + } + + let res = file.delete(state).await; + if let Err(err) = res { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some(format!("Couldn't delete file: {}", err)) + } + ), + StatusCode::INTERNAL_SERVER_ERROR + ) + ) + ) + } + + Ok(Box::new(json(&json!({})))) +} + +pub fn delete_f(state: SharedState) -> impl Filter + Clone { + + let proxy_ip = state.env.proxy_addr; + + warp::path!("api" / "files" / "delete") + .map(move || state.clone()) + .and(warp::body::json()) + .and(real_ip(vec![proxy_ip])) + .and_then(delete) +} \ No newline at end of file diff --git a/filed/src/web/api/files/get_all.rs b/filed/src/web/api/files/get_all.rs index 8af8395..fb77e72 100644 --- a/filed/src/web/api/files/get_all.rs +++ b/filed/src/web/api/files/get_all.rs @@ -1,35 +1,77 @@ -use warp::{reply::{Reply, json}, reject::Rejection, Filter, http::StatusCode}; +use std::net::IpAddr; -use crate::web::{state::SharedState, rejection::HttpReject}; +use warp::{reply::{Reply, json, with_status}, reject::Rejection, Filter, http::StatusCode}; +use warp_real_ip::real_ip; -use super::super::types::{ErrorMessage, Error}; +use crate::web::{state::SharedState, api::types::{ErrorMessage, Error}}; -pub async fn get_all(state: SharedState) -> Result, Rejection> { - if ! state.config.api.enabled { +use super::{check_api_enabled, function_disabled_err}; + +pub async fn get_all(state: SharedState, ip: Option) -> Result, Rejection> { + if let Err(res) = check_api_enabled(&state) { + return Ok(Box::new(res)) + } + + if (!state.config.api.get_all) || (!state.config.api.enabled) { + return Ok(Box::new(function_disabled_err(StatusCode::UNAUTHORIZED))) + } + + let found = + state.file_mgr.get_all(true, true) + .await + .map_err(|x| x.to_string()); + + if let Err(err) = found { return Ok( Box::new( - warp::reply::with_status( - json(&ErrorMessage::new(Error::APIDisabled)), - StatusCode::SERVICE_UNAVAILABLE + with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some( + format!("Error while getting all files: {err}") + ) + } + ), + StatusCode::INTERNAL_SERVER_ERROR + ) + ) + ); + } else { + + let mut found = found.unwrap(); + + if state.config.api.get_all_own_only { + found = found + .iter() + .filter( + |x| { + if let Some(owner) = x.uploader_ip { + if let Some(caller) = ip { + return owner == caller + } + } + false + } + ).map(|x| x.clone()).collect(); + } + + Ok( + Box::new( + json( + &found ) ) ) } - - Ok( - Box::new( - json( - &state.file_mgr.get_all(true, true) - .await - .map_err(|x| x.to_string()) - .map_err(|x| HttpReject::StringError(x))? - ) - ) - ) } pub fn get_all_f(state: SharedState) -> impl Filter + Clone { + + let proxy = state.env.proxy_addr; + warp::path!("api" / "files" / "get_all") .map(move || state.clone()) + .and(real_ip(vec![proxy])) .and_then(get_all) } \ No newline at end of file diff --git a/filed/src/web/api/files/upload.rs b/filed/src/web/api/files/upload.rs new file mode 100644 index 0000000..a2836e0 --- /dev/null +++ b/filed/src/web/api/files/upload.rs @@ -0,0 +1,266 @@ +use std::{collections::HashMap, net::IpAddr}; +use serde::{Serialize, Deserialize}; + +use serde_json::json; +use sha2::{Sha512, Digest, digest::FixedOutput}; +use warp::{reply::{Reply, with_status, json}, http::StatusCode, reject::Rejection, Filter, filters::multipart::FormData}; +use warp_real_ip::real_ip; + +use crate::{web::{state::SharedState, forms::FormElement, api::types::{ErrorMessage, Error}}, files::{File, lookup::LookupKind}}; + +use super::{is_api_pass, check_api_pass}; + +#[derive(Serialize, Deserialize)] +struct UploadAPIMetadata { + sha512: String, + name: Option, + pass: Option +} + +struct UploadAPIPayload { + file: Vec, + file_type: String, + instance_pass: Option, + metadata: UploadAPIMetadata +} + +impl Default for UploadAPIPayload { + fn default() -> Self { + Self { + file: vec![], + file_type: "application/octet-stream".into(), + instance_pass: None, + metadata: UploadAPIMetadata { + sha512: "".into(), + name: None, + pass: None + } + } + } +} + +impl UploadAPIPayload { + pub fn from_form(data: HashMap) -> Option { + + let mut out = Self::default(); + + let file = data.get("file"); + let instance_pass = data.get("instance_pass"); + let metadata = data.get("metadata"); + + let mut fields_set = false; + + // required fields + if let Some(file) = file { + if let Some(metadata) = metadata { + if let Some(metadata) = metadata.as_atr_or_none() { + out.file = file.data.clone(); + if let Ok(metadata) = serde_json::from_str(&metadata) { + out.metadata = metadata; + } + fields_set = true; + } + } + + out.file_type = file.mime.clone(); + } + + // optional ones + if let Some(pass) = instance_pass { + if let Some(pass) = pass.as_atr_or_none() { + out.instance_pass = Some(pass); + } + } + + if ! fields_set { + None + } else { + Some(out) + } + } +} + +pub async fn upload(state: SharedState, data: FormData, ip: Option) -> Result, Rejection> { + + if (!state.config.api.enabled) || (!state.config.api.upload) { + return Ok( + Box::new( + with_status( + json(&ErrorMessage::new(Error::APIDisabled)), + StatusCode::UNAUTHORIZED + ) + ) + ) + } + + if ! state.config.files.allow_uploads { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIDisabled, + details: Some( + match state.config.files.upload_disable_reason { + Some(reason) => format!("Uploads were disabled for the following reason: {reason}"), + None => format!("Uploads were disabled by the administrator") + } + ) + } + ), + StatusCode::UNAUTHORIZED + ) + ) + ) + } + + let data = FormElement::from_formdata(data) + .await; + + if let Err(err) = data { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some( + format!("Error while parsing payload: {err}") + ) + } + ), + StatusCode::BAD_REQUEST + ) + ) + ) + } + + let data = data.unwrap(); // this is guaranteed to be `Ok` at this point + + let payload = UploadAPIPayload::from_form(data); + if let Some(payload) = payload { + if is_api_pass(&state) { + if let Err(res) = check_api_pass( + &state, + match payload.instance_pass { + Some(x) => x, + None => "".into() + } + ) { + return Ok(Box::new(res)) + } + } + + // payload is all valid and accessible at this point + + let mut hash: Sha512 = Sha512::new(); + hash.update(&payload.file); + + let hash = hex::encode(hash.finalize_fixed()); + if hash != payload.metadata.sha512 { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some("Hash does not match file".into()) + } + ), + StatusCode::BAD_REQUEST + ) + ) + ) + } + + let file = File::create( + payload.file, + payload.file_type, + payload.metadata.name.clone(), + state.env, + crate::files::DeleteMode::Time, + payload.metadata.pass, + ip + ).await; + + if let Err(err) = file { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some( + format!("Error while saving the file: {err}") + ) + } + ), + StatusCode::INTERNAL_SERVER_ERROR + ) + ) + ) + } + + let file = file.unwrap(); + + let saved = state.file_mgr.save( + &file, + match payload.metadata.name { + Some(_) => LookupKind::ByName, + None => LookupKind::ByHash + } + ); + + if let Err(err) = saved { + return Ok( + Box::new( + with_status( + json( + &ErrorMessage { + error: Error::APIError, + details: Some( + format!("Error while saving the file: {err}") + ) + } + ), + StatusCode::INTERNAL_SERVER_ERROR + ) + ) + ) + } + + return Ok( + Box::new( + json( + &json!({ + "status": "OK" + }) + ) + ) + ) + } + + Ok( + Box::new( + with_status( + json(&ErrorMessage { + error: Error::APIError, + details: Some("Request payload invalid".into()) + }), + StatusCode::BAD_REQUEST + ) + ) + ) +} + +pub fn upload_f(state: SharedState) -> impl Filter + Clone { + + let proxy = state.env.proxy_addr.clone(); + + warp::path!("api" / "files" / "upload") + .and(warp::post()) + .map(move || state.clone()) + .and(warp::multipart::form()) + .and(real_ip(vec![proxy])) + .and_then(upload) +} diff --git a/filed/src/web/api/types.rs b/filed/src/web/api/types.rs index a1ecb62..5da38e0 100644 --- a/filed/src/web/api/types.rs +++ b/filed/src/web/api/types.rs @@ -3,6 +3,9 @@ use serde::{Serialize, Deserialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Error { APIDisabled, + APIFunctionDisabled, + APIError, + APIPasswordDenied } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -15,7 +18,10 @@ impl ErrorMessage { pub fn new(error: Error) -> ErrorMessage { ErrorMessage { details: match error { - Error::APIDisabled => Some("API is disabled by the administrator. Please contact them for further details".into()) + Error::APIDisabled => Some("API is disabled by the administrator. Please contact them for further details".into()), + Error::APIFunctionDisabled => Some("This API function is disabled by the administrator. Please contact them for further details.".into()), + Error::APIError => Some("An error has occured while executing the API request".into()), + Error::APIPasswordDenied => Some("API password authorization has been denied.".into()) }, error, } diff --git a/filed/src/web/forms.rs b/filed/src/web/forms.rs index cbfcecf..7f22aba 100644 --- a/filed/src/web/forms.rs +++ b/filed/src/web/forms.rs @@ -308,7 +308,8 @@ pub async fn upload(form: FormData, ip: Option, state: SharedState) -> R } pub fn get_routes(state: SharedState) -> impl Filter + Clone { - warp::post() + warp::path("upload") + .and(warp::post()) .and(warp::multipart::form()) .and(real_ip(vec![state.env.proxy_addr])) .and( diff --git a/filed/src/web/mod.rs b/filed/src/web/mod.rs index bbc786d..f8ac89f 100644 --- a/filed/src/web/mod.rs +++ b/filed/src/web/mod.rs @@ -20,10 +20,10 @@ use state::SharedState; pub fn routes(state: SharedState) -> impl Filter + Clone { static_dir!("static") .or(curlapi::get_routes(state.clone())) - .or(pages::get_routes(state.clone())) .or(forms::get_routes(state.clone())) .or(api::get_routes(state.clone())) - .or(uploaded::get_uploaded(state)) + .or(uploaded::get_uploaded(state.clone())) + .or(pages::get_routes(state)) } /*