Implement cURL API #15
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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,7 +125,9 @@ impl UploadFormData {
|
|||
}
|
||||
},
|
||||
None => {
|
||||
return None
|
||||
if ! use_defaults {
|
||||
return None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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()))
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -24,4 +24,7 @@
|
|||
|
||||
.alert.blue .alert-title {
|
||||
background: #203050;
|
||||
}
|
||||
.alert.green .alert-title {
|
||||
background: #205030;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 %}
|
Loading…
Reference in New Issue