Implement cURL API #15

Merged
blek merged 7 commits from curlapi into 0.2-dev 2023-11-02 15:17:00 +01:00
11 changed files with 413 additions and 17 deletions

View File

@ -84,3 +84,7 @@ sudo_delete=false
# Whether /api/upload is enabled
# It is not recommended to enable it if API key auth is not enabled
upload=false
# Whether curlapi is enabled
# curl {url}/curlapi/help for more info
curlapi=true

View File

@ -112,6 +112,10 @@ pub struct APISettings {
/// Whether /api/upload is enabled
#[serde(default)]
pub upload: bool,
/// Whether curlapi is enabled
#[serde(default)]
pub curlapi: bool
}
impl Default for APISettings {
@ -123,7 +127,8 @@ impl Default for APISettings {
get_all_own_only: true,
delete: false,
sudo_delete: false,
upload: false
upload: false,
curlapi: true
}
}
}

11
filed/src/web/curlapi.rs Normal file
View File

@ -0,0 +1,11 @@
use warp::{Filter, reply::Reply, reject::Rejection};
use super::state::SharedState;
mod upload;
mod help;
pub fn get_routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
upload::get_routes(state.clone())
.or(help::get_routes(state))
}

View File

@ -0,0 +1,85 @@
use askama::Template;
use warp::{Filter, reply::{Reply, html}, reject::Rejection};
use crate::web::{state::SharedState, pages::CurlHelpPage, rejection::HttpReject};
pub async fn help(state: SharedState, ua: String) -> Result<Box<dyn Reply>, Rejection> {
if ! ua.starts_with("curl/") {
let page = CurlHelpPage { conf: state.config.clone(), env: state.env.clone() };
return Ok(
Box::new(
html(page.render().map_err(|x| HttpReject::AskamaError(x))?)
)
)
}
let brand = format!(
"{} \x1b[1m{}\x1b[0m {}",
state.config.brand.instance_emoji,
state.config.brand.instance_name,
{
if state.config.brand.instance_name != "blek! File" {
"\n\x1b[90mPowered by blek! File\x1b[0m"
} else { "" }
}
);
let mut warns: String = String::new();
if ! state.config.api.curlapi {
warns += "\x1b[1;31mWarning: curl API is disabled on this instance.\nYou can use the web UI to upload files.\x1b[0m\n\n"
}
if ! state.config.files.allow_uploads {
warns += {
format!(
"\x1b[1;31mWarning: all uploads are disabled on this instance{}\x1b[0m",
{
if let Some(reason) = state.config.files.upload_disable_reason {
format!(" for this reason:\n\"{}\"", reason)
} else { ".".to_string() }
}
).as_str()
}
}
let instance = state.env.instanceurl;
let help =
format!(
"To upload a new file, you can POST it like this:
\x1b[32mcurl\x1b[0m \x1b[33m-X POST\x1b[0m \x1b[34m{instance}/curlapi/upload\x1b[0m \x1b[33m-F'file=@file.txt'\x1b[0m \x1b[33m-F'tos_consent=on'\x1b[0m
You can also add a password:
\x1b[32mcurl\x1b[0m \x1b[33m-X POST\x1b[0m \x1b[34m{instance}/curlapi/upload\x1b[0m \x1b[33m-F'file=@file.txt'\x1b[0m \x1b[33m-F'filename=uwu'\x1b[0m \x1b[33m-F'tos_consent=on'\x1b[0m \x1b[33m-F'named=on'\x1b[0m
The `named=on` switch is neede because this API is basically
the HTML used at the regular web UI form wrapped into this URL
\x1b[1;32mIMPORTANT:\x1b[0m Read the terms of service \x1b[1mbefore\x1b[0m uploading the file!
The ToS can be found here: \x1b[34m{instance}/tos\x1b[0m .
{warns}"
);
Ok(
Box::new(
format!("
\x1b[31m \x1b[0m \x1b[35m \x1b[0m
\x1b[32m\x1b[0m \x1b[95m \x1b[0m
\x1b[34m\x1b[0m \x1b[35m \x1b[0m
{brand}
{help}
").to_string())
)
}
pub fn get_routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::any()
.and(warp::path!("curlapi" / "help"))
.and(
warp::any()
.map(move || state.clone())
)
.and(
warp::header::<String>("user-agent")
)
.and_then(help)
}

View File

@ -0,0 +1,139 @@
use std::net::IpAddr;
use warp::{filters::multipart::FormData, reply::{Reply, with_status}, reject::Rejection, Filter};
use warp_real_ip::real_ip;
use crate::{web::{state::SharedState, forms::{FormElement, UploadFormData}, rejection::HttpReject}, files::File};
pub async fn upload(form: FormData, ip: Option<IpAddr>, state: SharedState) -> Result<Box<dyn Reply>, Rejection> {
if ! state.config.files.allow_uploads {
return Ok(
Box::new(
with_status(
match state.config.files.upload_disable_reason {
Some(reason) => format!("Uploads are disabled for the following reason:\n{reason}"),
None => "Uploads are disabled.".into()
},
warp::http::StatusCode::SERVICE_UNAVAILABLE
)
)
)
}
let params = FormElement::from_formdata(form)
.await
.map_err(|x| HttpReject::WarpError(x))?;
if let Some(consent) = params.get("tos_consent") {
if consent.data != "on".bytes().collect::<Vec<u8>>() {
return Ok(
Box::new(
format!("You need to agree to the ToS to upload a file.\nSee {}/curlapi/help for details\n\nTo agree to the ToS, add a -F'tos_consent=on'\n", state.env.instanceurl)
)
)
}
} else {
return Ok(
Box::new(
format!("You need to agree to the ToS to upload a file.\nSee {}/curlapi/help for details\n\nTo agree to the ToS, add a -F'tos_consent=on'\n", state.env.instanceurl)
)
)
}
let formdata = UploadFormData::from_formdata(params, true);
if let Some(formdata) = formdata {
let mut breaks_conf = false;
if (!state.config.files.allow_custom_names) && formdata.filename.is_some() {
breaks_conf = true;
}
if (!state.config.files.allow_pass_protection) && formdata.password.is_some() {
breaks_conf = true;
}
if breaks_conf {
return Ok(
Box::new(
with_status(
"Attempt to set name or password when they are disabled".to_string(),
warp::http::StatusCode::BAD_REQUEST
)
)
);
}
if let Some(pass) = state.config.files.upload_pass {
let pass_valid: bool;
if let Some(upass) = formdata.instancepass {
pass_valid = upass == pass;
} else {
pass_valid = false
}
if ! pass_valid {
return Ok(
Box::new(
with_status(
"Invalid instance password".to_string(),
warp::http::StatusCode::BAD_REQUEST
)
)
);
}
}
let file = File::create(
formdata.file,
formdata.mime,
formdata.filename.clone(),
state.env.clone(),
formdata.delmode,
formdata.password,
ip
).await.map_err(|x| HttpReject::StringError(x.to_string()))?;
state.file_mgr.save(&file, formdata.lookup_kind).map_err(|x| HttpReject::StringError(x.to_string()))?;
return Ok(
Box::new(
format!(
concat!(
"File uploaded successfully.\n",
"It is available via this link:\n\n",
"{}/upload/{}\n"
),
state.env.instanceurl,
urlencoding::encode(
match formdata.filename {
Some(name) => name,
None => file.hash()
}.as_str()
)
)
)
);
} else {
Ok(
Box::new(
with_status(
"Invalid form".to_string(),
warp::http::StatusCode::BAD_REQUEST
)
)
)
}
}
pub fn get_routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + Clone {
warp::post()
.and(warp::path!("curlapi" / "upload"))
.and(warp::multipart::form())
.and(real_ip(vec![state.env.proxy_addr]))
.and(
warp::any()
.map(move || state.clone())
)
.and_then(upload)
}

View File

@ -17,9 +17,9 @@ use crate::files::{File, lookup::LookupKind, DeleteMode};
use super::{state::SharedState, pages::{UploadSuccessPage, ErrorPage}, rejection::HttpReject};
#[derive(Debug, Serialize, Clone)]
struct FormElement {
data: Vec<u8>,
mime: String
pub struct FormElement {
pub data: Vec<u8>,
pub mime: String
}
impl FormElement {
@ -58,15 +58,15 @@ impl FormElement {
}
}
struct UploadFormData {
filename: Option<String>,
password: Option<String>,
instancepass: Option<String>,
lookup_kind: LookupKind,
delmode: DeleteMode,
file: Vec<u8>,
mime: String,
tos_consent: bool
pub struct UploadFormData {
pub filename: Option<String>,
pub password: Option<String>,
pub instancepass: Option<String>,
pub lookup_kind: LookupKind,
pub delmode: DeleteMode,
pub file: Vec<u8>,
pub mime: String,
pub tos_consent: bool
}
impl Default for UploadFormData {
@ -86,7 +86,7 @@ impl Default for UploadFormData {
impl UploadFormData {
pub fn from_formdata(data: HashMap<String, FormElement>) -> Option<UploadFormData> {
pub fn from_formdata(data: HashMap<String, FormElement>, use_defaults: bool) -> Option<UploadFormData> {
let mut out = Self::default();
// Add a name
@ -125,9 +125,11 @@ impl UploadFormData {
}
},
None => {
if ! use_defaults {
return None
}
}
}
match data.get("instancepass") {
Some(val) => {
@ -169,7 +171,7 @@ pub async fn upload(form: FormData, ip: Option<IpAddr>, state: SharedState) -> R
}
let params: HashMap<String, FormElement> = FormElement::from_formdata(form).await.map_err(|x| HttpReject::WarpError(x))?;
let formdata = UploadFormData::from_formdata(params.clone());
let formdata = UploadFormData::from_formdata(params.clone(), false);
if let Some(formdata) = formdata {

View File

@ -8,16 +8,18 @@ use warp::{Filter, reply::Reply, reject::Rejection};
use crate::{env::Env, files::lookup::FileManager, config::types::Config};
mod pages;
mod forms;
pub mod forms;
pub mod state;
mod rejection;
mod api;
mod uploaded;
mod curlapi;
use state::SharedState;
pub fn routes(state: SharedState) -> impl Filter<Extract = impl Reply, Error = Rejection> + 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()))

View File

@ -88,6 +88,14 @@ pub struct ErrorPage {
pub link_text: Option<String>
}
#[derive(Template)]
#[template( path = "curlapi_help.html" )]
#[allow(dead_code)]
pub struct CurlHelpPage {
pub env: Env,
pub conf: Config
}
pub async fn uploaded(query: HashMap<String, String>, state: SharedState) -> Result<Html<String>, Rejection> {
if ! query.contains_key("file") {

View File

@ -25,3 +25,6 @@
.alert.blue .alert-title {
background: #203050;
}
.alert.green .alert-title {
background: #205030;
}

View File

@ -0,0 +1,23 @@
.code {
display: block;
padding: 1em;
border: 1px solid var(--header-sec-color);
border-radius: 12px;
overflow-x: auto;
}
.code .inner {
width: max-content;
display: block;
}
.code-inline {
display: inline;
background: #00000010;
border: 1px solid #c2c4c210;
border-radius: 4px;
padding: 2px 4px;
}
.code, .code-inline {
font-family: monospace;
line-height: 1.5em;
}

View File

@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block head %}
<link rel='stylesheet' href="/code.css" />
<link rel='stylesheet' href="/alert.css" />
<link rel='stylesheet' href="/js-only.css" />
<style>
.copy-btn { font-size: 70%; transform: translateY(-25%); display: inline-block }
</style>
{% endblock %}
{% block body %}
<div style="max-width:95vw;width:fit-content;margin:0 auto">
<h1 style="text-align:center">Curl API</h1>
<p>
blek! File has an API for uploading files via cURL.
To upload a file via cURL, follow these instructions:
</p>
<p>
To upload a file, POST it like this:
<a href="#" class="copy-btn" data-clipboard-text="curl -X POST {{env.instanceurl}}/curlapi/upload -F'file=@file.txt' -F'tos_consent=on'">
Copy!
</a>
</p>
<div class='code'>
<span class='inner'>
<span style='color:green'>curl</span>
<span style='color:orange'>-X POST</span>
<span style='color:darkcyan'>{{env.instanceurl}}/curlapi/upload</span>
<span style='color:orange'>-F'file=@file.txt'</span>
<span style='color:orange'>-F'tos_consent=on'</span>
</span>
</div>
<p>
To add a password, do it like this:
<a href="#" class="copy-btn" data-clipboard-text="curl -X POST {{env.instanceurl}}/curlapi/upload -F'file=@file.txt' -F'filename=uwu' -F'tos_consent=on' -F'named=on'">
Copy!
</a>
</p>
<div class='code'>
<span class='inner'>
<span style='color:green'>curl</span>
<span style='color:orange'>-X POST</span>
<span style='color:darkcyan'>{{env.instanceurl}}/curlapi/upload</span>
<span style='color:orange'>-F'file=@file.txt'</span>
<span style='color:orange'>-F'tos_consent=on'</span>
<span style='color:orange'>-F'named=on'</span>
</span>
</div>
<p>
Note that the
<span class='code-inline'>named=on</span>
switch is required.
Its needed because the curl API is basically a wrapper of
<a href="/">this</a>
HTML form.
</p>
<div class="alert green">
<h1 class="alert-title">
Important
</h1>
<p class="alert-text">
Read the
<a href="/tos">Terms of Service</a>
<b>before</b>
uploading a file.
<br/>
You agree to them by adding the
<span class="code-inline">tos_consent=on</span>
switch.
</p>
</div>
<div class='alert blue'>
<h1 class='alert-title'>Web UI</h1>
<div class='alert-text'>
<p>
Hey, it looks like you are viewing this page from a browser!<br/>
You can use the Web UI as well to upload a file!
</p>
<p style='margin:32px 0'>
<a href='/' role='button' class='btn'>
Go to the web UI
</a>
</p>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/clipboard@2/dist/clipboard.min.js"></script>
<script>
new ClipboardJS('.copy-btn');
{#- -#}
( {#- -#}
() => { {#- -#}
let btns = document.getElementsByClassName('copy-btn') {#- -#}
for (const button of btns) { {#- -#}
button.onclick = () => { {#- -#}
let old = button.innerHTML; {#- -#}
button.innerHTML = 'Copied!'; {#- -#}
setTimeout(() => { button.innerHTML = old }, 500); {#- -#}
} {#- -#}
} {#- -#}
} {#- -#}
)() {#- -#}
</script>
<script src='/js-only.js'></script>
{% endblock %}