use apiplant_abi::FunctionAccess;
use ntex::web::types::State;
use ntex::web::{HttpRequest, HttpResponse};
use serde_json::json;
use crate::response::{error, ok};
use crate::state::AppState;
pub async fn upload(
req: HttpRequest,
state: State<AppState>,
body: ntex::util::Bytes,
) -> HttpResponse {
let Some(storage) = state.storage.clone() else {
return error(404, "this app does not store files");
};
if let Err(response) = crate::access::check(
&state,
&req,
&FunctionAccess::Authenticated.into(),
"this app does not store files",
)
.await
{
return response;
}
if body.is_empty() {
return error(400, "the request body is empty");
}
if body.len() as u64 > storage.max_bytes() {
return error(
413,
format!(
"the file is larger than the {} MB limit",
storage.max_bytes() / (1024 * 1024)
),
);
}
let content_type = req
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
if !storage.allows_type(&content_type) {
return error(
415,
format!("this app does not accept {content_type} uploads"),
);
}
let filename = query_param(req.query_string(), "filename").unwrap_or_default();
let key = storage.key_for(&filename);
match storage.put(&key, body.to_vec(), &content_type).await {
Ok(()) => ok(&json!({
"url": storage.url_for(&key),
"key": key,
"size": body.len(),
"content_type": content_type,
})),
Err(e) => {
crate::telemetry::record_error("storage", &e);
tracing::error!(error = %e, %key, "failed to store an upload");
error(500, "the file could not be stored")
}
}
}
pub async fn serve(req: HttpRequest, state: State<AppState>) -> HttpResponse {
let Some(storage) = state.storage.clone() else {
return HttpResponse::NotFound().finish();
};
let Some(key) = storage.key_from_path(req.path()) else {
return HttpResponse::NotFound().finish();
};
match storage.get(&key).await {
Ok(Some(object)) => HttpResponse::Ok()
.content_type(object.content_type.as_str())
.header("cache-control", "public, max-age=31536000, immutable")
.header("x-content-type-options", "nosniff")
.header("content-disposition", "inline")
.body(object.bytes),
Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => {
crate::telemetry::record_error("storage", &e);
tracing::error!(error = %e, %key, "failed to read a stored file");
HttpResponse::InternalServerError().finish()
}
}
}
fn query_param(query: &str, name: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let (key, value) = pair.split_once('=')?;
(key == name).then(|| percent_decode(value))
})
}
fn percent_decode(raw: &str) -> String {
let bytes = raw.replace('+', " ").into_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
match u8::from_str_radix(&String::from_utf8_lossy(&bytes[i + 1..i + 3]), 16) {
Ok(byte) => {
out.push(byte);
i += 3;
}
Err(_) => {
out.push(bytes[i]);
i += 1;
}
}
}
byte => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_filename_survives_the_query_string() {
assert_eq!(
query_param("filename=Logo%20Final.png", "filename").as_deref(),
Some("Logo Final.png")
);
assert_eq!(
query_param("a=1&filename=x.png&b=2", "filename").as_deref(),
Some("x.png")
);
assert_eq!(query_param("a=1", "filename"), None);
assert_eq!(
query_param("filename=a%zz", "filename").as_deref(),
Some("a%zz")
);
}
}