Compare commits

..

16 Commits

10 changed files with 602 additions and 46 deletions

View File

@ -74,7 +74,7 @@ paths:
example: {} example: {}
401: 401:
description: |- 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. 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. 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: fid:
type: string type: string
example: ID or name of the file. It is the NAME in file.blek.codes/uploads/NAME 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: /api/files/upload:
post: post:
summary: Upload a file summary: Upload a file
@ -102,6 +105,8 @@ paths:
security: security:
- apikey: [ key ] - apikey: [ key ]
requestBody: requestBody:
description: |-
A multipart form
content: content:
multipart/form-data: multipart/form-data:
schema: schema:
@ -117,7 +122,18 @@ paths:
description: Instance-specific password needed to upload files description: Instance-specific password needed to upload files
metadata: metadata:
type: object 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: properties:
sha512: sha512:
type: string type: string
@ -125,17 +141,48 @@ paths:
name: name:
type: string type: string
description: Optional name of the file so it would be accessible like file.blek.codes/uploads/{name} 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: responses:
200: 200:
description: File uploaded successfully description: File uploaded successfully
content:
application/json:
schema:
type: object
properties:
status:
example: 'OK'
401: 401:
description: |- 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. 1. The instance does not allow API file uploads.
2. The instance requires API key for all API manipulations. 2. The instance requires API key for all API manipulations.
3. The provided API key is invalid. 3. The provided API key is invalid.
4. API authorization is not enabled, but a key is provided 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: components:

View File

@ -25,23 +25,18 @@ impl FileManager {
async fn find_all(self: &Self, predicate: String) -> Result<Vec<File>, Box<dyn Error>> { async fn find_all(self: &Self, predicate: String) -> Result<Vec<File>, Box<dyn Error>> {
let mut conn = self.conn.get_async_connection().await?; let mut conn = self.conn.get_async_connection().await?;
let found: Vec<String> = conn.keys(predicate).await?; let keys: Vec<String> = conn.keys(predicate).await?;
let serialized: Vec<File> =
found.iter() let mut data: Vec<File> = vec![];
.map(|x| {
let result = serde_json::from_str(&x); for key in keys.iter() {
match result { let raw: String = conn.get(key.clone()).await?;
Ok(x) => Some(x),
Err(err) => { let mut parsed: File = serde_json::from_str(raw.as_str())?;
log::error!("Error while serializing {x}: {:?}", err); data.push(parsed);
None
} }
}
}) Ok(data)
.filter(|x| x.is_some())
.map(|x| x.unwrap())
.collect();
Ok(serialized)
} }
/// Filter options /// Filter options

View File

@ -1,7 +1,7 @@
use warp::{reply::Reply, reject::Rejection, Filter}; use warp::{reply::Reply, reject::Rejection, Filter};
use serde::{Serialize, Deserialize}; 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; use super::state::SharedState;
@ -24,5 +24,7 @@ pub fn get_routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error
warp::path!("api") warp::path!("api")
.and(warp::path::end()) .and(warp::path::end())
.map(api_root) .map(api_root)
.or(get_all_f(state)) .or(get_all_f(state.clone()))
.or(delete_f(state.clone()))
.or(upload_f(state))
} }

View File

@ -1 +1,61 @@
use warp::{reply::{WithStatus, Json, json}, http::StatusCode};
use crate::web::state::SharedState;
use super::types::{ErrorMessage, Error};
fn check_api_enabled(state: &SharedState) -> Result<(), WithStatus<Json>> {
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<Json>> {
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<Json> {
warp::reply::with_status(
json(&ErrorMessage::new(Error::APIFunctionDisabled)),
status
)
}
pub mod get_all; pub mod get_all;
pub mod delete;
pub mod upload;

View File

@ -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<String>
}
pub async fn delete(state: SharedState, body: DeleteFunctionPayload, ip: Option<IpAddr>) -> Result<Box<dyn Reply>, 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<Extract = impl Reply, Error = Rejection> + 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)
}

View File

@ -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<Box<dyn Reply>, Rejection> { use super::{check_api_enabled, function_disabled_err};
if ! state.config.api.enabled {
pub async fn get_all(state: SharedState, ip: Option<IpAddr>) -> Result<Box<dyn Reply>, 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( return Ok(
Box::new( Box::new(
warp::reply::with_status( with_status(
json(&ErrorMessage::new(Error::APIDisabled)), json(
StatusCode::SERVICE_UNAVAILABLE &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( Ok(
Box::new( Box::new(
json( json(
&state.file_mgr.get_all(true, true) &found
.await
.map_err(|x| x.to_string())
.map_err(|x| HttpReject::StringError(x))?
) )
) )
) )
}
} }
pub fn get_all_f(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { pub fn get_all_f(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
let proxy = state.env.proxy_addr;
warp::path!("api" / "files" / "get_all") warp::path!("api" / "files" / "get_all")
.map(move || state.clone()) .map(move || state.clone())
.and(real_ip(vec![proxy]))
.and_then(get_all) .and_then(get_all)
} }

View File

@ -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<String>,
pass: Option<String>
}
struct UploadAPIPayload {
file: Vec<u8>,
file_type: String,
instance_pass: Option<String>,
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<String, FormElement>) -> Option<UploadAPIPayload> {
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<IpAddr>) -> Result<Box<dyn Reply>, 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<Extract = impl Reply, Error = Rejection> + 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)
}

View File

@ -3,6 +3,9 @@ use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Error { pub enum Error {
APIDisabled, APIDisabled,
APIFunctionDisabled,
APIError,
APIPasswordDenied
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -15,7 +18,10 @@ impl ErrorMessage {
pub fn new(error: Error) -> ErrorMessage { pub fn new(error: Error) -> ErrorMessage {
ErrorMessage { ErrorMessage {
details: match error { 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, error,
} }

View File

@ -307,7 +307,8 @@ pub async fn upload(form: FormData, ip: Option<IpAddr>, state: SharedState) -> R
} }
pub fn get_routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { pub fn get_routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::post() warp::path("upload")
.and(warp::post())
.and(warp::multipart::form()) .and(warp::multipart::form())
.and(real_ip(vec![state.env.proxy_addr])) .and(real_ip(vec![state.env.proxy_addr]))
.and( .and(

View File

@ -20,10 +20,10 @@ use state::SharedState;
pub fn routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone { pub fn routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
static_dir!("static") static_dir!("static")
.or(curlapi::get_routes(state.clone())) .or(curlapi::get_routes(state.clone()))
.or(pages::get_routes(state.clone()))
.or(forms::get_routes(state.clone())) .or(forms::get_routes(state.clone()))
.or(api::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))
} }
/* /*