use askama::Template;
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use common::linked_data::Hash;
use common::mount::{Mount, NodeLink};
use common::prelude::Mime;
use serde::Deserialize;
use super::transform::{TransformError, TransformParams};
#[derive(Debug, Deserialize)]
pub struct FileQuery {
#[serde(default)]
pub download: Option<bool>,
#[serde(default)]
pub viewer: Option<bool>,
#[serde(default)]
pub w: Option<u32>,
#[serde(default)]
pub h: Option<u32>,
#[serde(default)]
pub q: Option<u8>,
#[serde(default)]
pub at: Option<String>,
}
#[derive(Template)]
#[template(path = "pages/gateway/viewer.html")]
pub struct GatewayViewerTemplate {
pub bucket_id: String,
pub bucket_id_short: String,
pub bucket_name: String,
pub bucket_link: String,
pub bucket_link_short: String,
pub file_path: String,
pub file_name: String,
pub mime_type: String,
pub size_formatted: String,
pub content: String,
pub back_url: String,
}
pub struct CacheableData {
pub link: Hash,
pub data: Vec<u8>,
pub mime_type: Mime,
}
pub struct FileResponse {
pub response: Response,
pub cacheable: Option<CacheableData>,
}
const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
pub fn serve_cached(data: Bytes, mime_type: &Mime, absolute_path: &str) -> Response {
let filename = std::path::Path::new(absolute_path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file");
(
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, mime_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", filename),
),
(
axum::http::header::CACHE_CONTROL,
CACHE_CONTROL_IMMUTABLE.to_string(),
),
],
data,
)
.into_response()
}
pub async fn handler(
mount: &Mount,
path_buf: &std::path::Path,
absolute_path: &str,
query: &FileQuery,
meta: &super::BucketMeta<'_>,
node_link: NodeLink,
) -> FileResponse {
let (content_link, file_metadata_data) = match &node_link {
NodeLink::Data(link, _, metadata) => (link.hash(), metadata.clone()),
_ => unreachable!("Already checked is_directory"),
};
let mime_type = file_metadata_data
.mime()
.cloned()
.unwrap_or(mime::APPLICATION_OCTET_STREAM);
let filename = path_buf
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let wants_download = query.download.unwrap_or(false);
let wants_viewer = query.viewer.unwrap_or(false);
let transform = TransformParams::from_query(query.w, query.h, query.q);
if let Some(ref params) = transform {
if let Err(msg) = params.validate() {
return FileResponse {
response: (axum::http::StatusCode::BAD_REQUEST, msg.to_string()).into_response(),
cacheable: None,
};
}
}
let file_data = match mount.cat(path_buf).await {
Ok(data) => data,
Err(e) => {
tracing::error!("Failed to read file: {}", e);
return FileResponse {
response: super::error_response("Failed to read file"),
cacheable: None,
};
}
};
let (file_data, mime_type) = if let Some(ref params) = transform {
if TransformParams::is_transformable(&mime_type) {
match super::transform::transform_image(&file_data, &mime_type, params) {
Ok(transformed) => (transformed, mime_type),
Err(TransformError::UnsupportedFormat(_)) => {
(file_data, mime_type)
}
Err(e) => {
tracing::error!("Image transform failed: {}", e);
return FileResponse {
response: super::error_response("Image transform failed"),
cacheable: None,
};
}
}
} else {
(file_data, mime_type)
}
} else {
(file_data, mime_type)
};
let mime_str = mime_type.to_string();
let size_formatted = format_bytes(file_data.len());
if wants_download {
return FileResponse {
response: (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, mime_str.as_str()),
(
axum::http::header::CONTENT_DISPOSITION,
&format!("attachment; filename=\"{}\"", filename),
),
],
file_data,
)
.into_response(),
cacheable: None,
};
}
if query.viewer.is_none() {
let is_html = mime_type == mime::TEXT_HTML;
let is_markdown = mime_type.essence_str() == "text/markdown";
let is_csv = mime_type.essence_str() == "text/csv";
let at_hash = query.at.as_deref();
if is_html || is_markdown || is_csv {
let (final_content, final_mime_type) = if is_markdown {
let content_str = String::from_utf8_lossy(&file_data);
let html = super::rewrite::markdown_to_html(&content_str);
let rewritten = super::rewrite::rewrite_relative_urls(
&html,
absolute_path,
meta.id,
meta.host,
at_hash,
);
(rewritten.into_bytes(), "text/html; charset=utf-8")
} else if is_csv {
let content_str = String::from_utf8_lossy(&file_data);
let html = super::rewrite::csv_to_html(
&content_str,
absolute_path,
meta.id,
meta.host,
at_hash,
);
(html.into_bytes(), "text/html; charset=utf-8")
} else {
let content_str = String::from_utf8_lossy(&file_data);
let rewritten = super::rewrite::rewrite_relative_urls(
&content_str,
absolute_path,
meta.id,
meta.host,
at_hash,
);
(rewritten.into_bytes(), "text/html; charset=utf-8")
};
return FileResponse {
response: (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, final_mime_type),
(
axum::http::header::CONTENT_DISPOSITION,
&format!("inline; filename=\"{}\"", filename),
),
],
final_content,
)
.into_response(),
cacheable: None,
};
}
let cacheable = Some(CacheableData {
link: content_link,
data: file_data.clone(),
mime_type: mime_type.clone(),
});
return FileResponse {
response: (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, mime_str.clone()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", filename),
),
(
axum::http::header::CACHE_CONTROL,
CACHE_CONTROL_IMMUTABLE.to_string(),
),
],
file_data,
)
.into_response(),
cacheable,
};
}
if !wants_viewer {
let is_csv = mime_type.essence_str() == "text/csv";
if is_csv {
let content_str = String::from_utf8_lossy(&file_data);
let rewritten = super::rewrite::rewrite_csv_urls(
&content_str,
absolute_path,
meta.id,
meta.host,
query.at.as_deref(),
);
return FileResponse {
response: (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "text/csv; charset=utf-8"),
(
axum::http::header::CONTENT_DISPOSITION,
&format!("inline; filename=\"{}\"", filename),
),
],
rewritten.into_bytes(),
)
.into_response(),
cacheable: None,
};
}
let cacheable = Some(CacheableData {
link: content_link,
data: file_data.clone(),
mime_type: mime_type.clone(),
});
return FileResponse {
response: (
axum::http::StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, mime_str.clone()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", filename),
),
(
axum::http::header::CACHE_CONTROL,
CACHE_CONTROL_IMMUTABLE.to_string(),
),
],
file_data,
)
.into_response(),
cacheable,
};
}
let content = if mime_type.type_() == mime::TEXT
|| mime_type == mime::APPLICATION_JSON
|| mime_type.essence_str() == "application/xml"
|| mime_type == mime::APPLICATION_JAVASCRIPT
{
String::from_utf8_lossy(&file_data).to_string()
} else {
to_hex_dump(&file_data, 1024)
};
let back_url = format!("/gw/{}{}", meta.id, get_parent_path(absolute_path));
let template = GatewayViewerTemplate {
bucket_id: meta.id_str.to_string(),
bucket_id_short: meta.id_short.to_string(),
bucket_name: meta.name.to_string(),
bucket_link: meta.link.to_string(),
bucket_link_short: meta.link_short.to_string(),
file_path: absolute_path.to_string(),
file_name: filename,
mime_type: mime_str,
size_formatted,
content,
back_url,
};
FileResponse {
response: match template.render() {
Ok(html) => (
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
html,
)
.into_response(),
Err(e) => {
tracing::error!("Failed to render viewer template: {}", e);
super::error_response("Failed to render page")
}
},
cacheable: None,
}
}
fn format_bytes(bytes: usize) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
if bytes == 0 {
return "0 B".to_string();
}
let bytes_f64 = bytes as f64;
let k = 1024_f64;
let i = (bytes_f64.log(k).floor() as usize).min(UNITS.len() - 1);
let size = bytes_f64 / k.powi(i as i32);
format!("{:.2} {}", size, UNITS[i])
}
fn get_parent_path(path: &str) -> String {
if path == "/" {
return "/".to_string();
}
let trimmed = path.trim_end_matches('/');
match trimmed.rfind('/') {
Some(0) => "/".to_string(),
Some(pos) => trimmed[..pos].to_string(),
None => "/".to_string(),
}
}
fn to_hex_dump(data: &[u8], max_bytes: usize) -> String {
let bytes_to_show = data.len().min(max_bytes);
let mut result = String::new();
for (i, chunk) in data[..bytes_to_show].chunks(16).enumerate() {
result.push_str(&format!("{:08x} ", i * 16));
for (j, byte) in chunk.iter().enumerate() {
result.push_str(&format!("{:02x} ", byte));
if j == 7 {
result.push(' ');
}
}
for j in chunk.len()..16 {
result.push_str(" ");
if j == 7 {
result.push(' ');
}
}
result.push(' ');
for byte in chunk {
if *byte >= 32 && *byte < 127 {
result.push(*byte as char);
} else {
result.push('.');
}
}
result.push('\n');
}
if data.len() > max_bytes {
result.push_str(&format!("\n... ({} more bytes)\n", data.len() - max_bytes));
}
result
}