3683 lines
122 KiB
Rust
3683 lines
122 KiB
Rust
use std::collections::{BTreeMap, HashMap};
|
|
use std::io::Cursor;
|
|
use std::path::{Component, Path as FsPath, PathBuf};
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
use axum::body::{to_bytes, Body};
|
|
use axum::extract::{Extension, Path, Query, State};
|
|
use axum::http::{header, HeaderMap, StatusCode};
|
|
use axum::response::{IntoResponse, Response};
|
|
use axum::Json;
|
|
use chrono::{DateTime, Datelike, Timelike, Utc};
|
|
use futures::TryStreamExt;
|
|
use http_body_util::BodyStream;
|
|
use myfsio_auth::sigv4;
|
|
use myfsio_common::constants::{BUCKET_VERSIONS_DIR, SYSTEM_BUCKETS_DIR, SYSTEM_ROOT};
|
|
use myfsio_common::types::{ListParams, PartInfo, Tag};
|
|
use myfsio_crypto::encryption::EncryptionMetadata;
|
|
use myfsio_storage::error::StorageError;
|
|
use myfsio_storage::traits::StorageEngine;
|
|
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
|
use roxmltree::Document;
|
|
use serde::de::DeserializeOwned;
|
|
use serde::Deserialize;
|
|
use serde_json::{json, Value};
|
|
use sysinfo::{Disks, System};
|
|
use tokio::io::AsyncReadExt;
|
|
|
|
use crate::handlers::{self, ObjectQuery};
|
|
use crate::middleware::session::SessionHandle;
|
|
use crate::state::AppState;
|
|
use crate::stores::connections::RemoteConnection;
|
|
|
|
const UI_KEY_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
|
.remove(b'-')
|
|
.remove(b'_')
|
|
.remove(b'.')
|
|
.remove(b'~')
|
|
.remove(b'/');
|
|
|
|
const PATH_SEGMENT_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
|
.remove(b'-')
|
|
.remove(b'_')
|
|
.remove(b'.')
|
|
.remove(b'~');
|
|
|
|
const AWS_QUERY_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
|
|
.remove(b'-')
|
|
.remove(b'_')
|
|
.remove(b'.')
|
|
.remove(b'~');
|
|
|
|
const UI_OBJECT_BROWSER_MAX_KEYS: usize = 5000;
|
|
|
|
fn url_templates_for(bucket: &str) -> Value {
|
|
json!({
|
|
"download": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/download", bucket),
|
|
"preview": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/preview", bucket),
|
|
"delete": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/delete", bucket),
|
|
"presign": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/presign", bucket),
|
|
"metadata": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/metadata", bucket),
|
|
"versions": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/versions", bucket),
|
|
"restore": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/restore/VERSION_ID_PLACEHOLDER", bucket),
|
|
"tags": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/tags", bucket),
|
|
"copy": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/copy", bucket),
|
|
"move": format!("/ui/buckets/{}/objects/KEY_PLACEHOLDER/move", bucket),
|
|
})
|
|
}
|
|
|
|
fn encode_object_key(key: &str) -> String {
|
|
utf8_percent_encode(key, UI_KEY_ENCODE_SET).to_string()
|
|
}
|
|
|
|
fn encode_path_segment(value: &str) -> String {
|
|
utf8_percent_encode(value, PATH_SEGMENT_ENCODE_SET).to_string()
|
|
}
|
|
|
|
fn build_ui_object_url(bucket: &str, key: &str, action: &str) -> String {
|
|
format!(
|
|
"/ui/buckets/{}/objects/{}/{}",
|
|
bucket,
|
|
encode_object_key(key),
|
|
action
|
|
)
|
|
}
|
|
|
|
fn human_size(bytes: u64) -> String {
|
|
const UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
let mut size = bytes as f64;
|
|
let mut idx = 0;
|
|
while size >= 1024.0 && idx < UNITS.len() - 1 {
|
|
size /= 1024.0;
|
|
idx += 1;
|
|
}
|
|
if idx == 0 {
|
|
format!("{} {}", bytes, UNITS[idx])
|
|
} else {
|
|
format!("{:.1} {}", size, UNITS[idx])
|
|
}
|
|
}
|
|
|
|
fn json_error(status: StatusCode, message: impl Into<String>) -> Response {
|
|
(status, Json(json!({ "error": message.into() }))).into_response()
|
|
}
|
|
|
|
fn json_ok(value: Value) -> Response {
|
|
Json(value).into_response()
|
|
}
|
|
|
|
fn push_issue(result: &mut Value, issue: Value) {
|
|
if let Some(items) = result
|
|
.get_mut("issues")
|
|
.and_then(|value| value.as_array_mut())
|
|
{
|
|
items.push(issue);
|
|
}
|
|
}
|
|
|
|
fn storage_status(err: &StorageError) -> StatusCode {
|
|
match err {
|
|
StorageError::BucketNotFound(_)
|
|
| StorageError::ObjectNotFound { .. }
|
|
| StorageError::VersionNotFound { .. }
|
|
| StorageError::UploadNotFound(_) => StatusCode::NOT_FOUND,
|
|
StorageError::DeleteMarker { .. } => StatusCode::NOT_FOUND,
|
|
StorageError::MethodNotAllowed(_) => StatusCode::METHOD_NOT_ALLOWED,
|
|
StorageError::InvalidBucketName(_)
|
|
| StorageError::InvalidObjectKey(_)
|
|
| StorageError::InvalidRange
|
|
| StorageError::QuotaExceeded(_) => StatusCode::BAD_REQUEST,
|
|
StorageError::BucketAlreadyExists(_) => StatusCode::CONFLICT,
|
|
StorageError::BucketNotEmpty(_) => StatusCode::CONFLICT,
|
|
StorageError::Io(_) | StorageError::Json(_) | StorageError::Internal(_) => {
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
}
|
|
}
|
|
}
|
|
|
|
fn storage_json_error(err: StorageError) -> Response {
|
|
json_error(storage_status(&err), err.to_string())
|
|
}
|
|
|
|
fn parse_bool_flag(value: Option<&str>) -> bool {
|
|
matches!(
|
|
value.map(|v| v.trim().to_ascii_lowercase()),
|
|
Some(v) if v == "1" || v == "true" || v == "on" || v == "yes"
|
|
)
|
|
}
|
|
|
|
fn parse_form_body(bytes: &[u8]) -> HashMap<String, String> {
|
|
String::from_utf8_lossy(bytes)
|
|
.split('&')
|
|
.filter(|pair| !pair.is_empty())
|
|
.map(|pair| {
|
|
let mut parts = pair.splitn(2, '=');
|
|
let key = parts.next().unwrap_or_default();
|
|
let value = parts.next().unwrap_or_default();
|
|
(decode_form_value(key), decode_form_value(value))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn decode_form_value(value: &str) -> String {
|
|
percent_encoding::percent_decode_str(&value.replace('+', " "))
|
|
.decode_utf8_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
fn current_access_key(session: &SessionHandle) -> Option<String> {
|
|
session.read(|s| s.user_id.clone())
|
|
}
|
|
|
|
fn owner_id_or_default(session: &SessionHandle) -> String {
|
|
current_access_key(session).unwrap_or_else(|| "myfsio".to_string())
|
|
}
|
|
|
|
fn safe_attachment_filename(key: &str) -> String {
|
|
let raw = key.rsplit('/').next().unwrap_or(key);
|
|
let sanitized = raw
|
|
.replace('"', "'")
|
|
.replace('\\', "_")
|
|
.chars()
|
|
.filter(|c| c.is_ascii_graphic() || *c == ' ')
|
|
.collect::<String>();
|
|
if sanitized.trim().is_empty() {
|
|
"download".to_string()
|
|
} else {
|
|
sanitized
|
|
}
|
|
}
|
|
|
|
fn parse_api_base(state: &AppState) -> String {
|
|
state.config.api_base_url.trim_end_matches('/').to_string()
|
|
}
|
|
|
|
fn aws_query_encode(value: &str) -> String {
|
|
utf8_percent_encode(value, AWS_QUERY_ENCODE_SET).to_string()
|
|
}
|
|
|
|
fn xml_escape(value: &str) -> String {
|
|
value
|
|
.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
fn key_relative_path(key: &str) -> Result<PathBuf, String> {
|
|
let mut out = PathBuf::new();
|
|
for component in FsPath::new(key).components() {
|
|
match component {
|
|
Component::Normal(part) => out.push(part),
|
|
_ => return Err("Invalid object key".to_string()),
|
|
}
|
|
}
|
|
if out.as_os_str().is_empty() {
|
|
return Err("Invalid object key".to_string());
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn object_live_path(state: &AppState, bucket: &str, key: &str) -> Result<PathBuf, String> {
|
|
let rel = key_relative_path(key)?;
|
|
Ok(state.config.storage_root.join(bucket).join(rel))
|
|
}
|
|
|
|
fn version_root_for_bucket(state: &AppState, bucket: &str) -> PathBuf {
|
|
state
|
|
.config
|
|
.storage_root
|
|
.join(SYSTEM_ROOT)
|
|
.join(SYSTEM_BUCKETS_DIR)
|
|
.join(bucket)
|
|
.join(BUCKET_VERSIONS_DIR)
|
|
}
|
|
|
|
fn version_dir_for_object(state: &AppState, bucket: &str, key: &str) -> Result<PathBuf, String> {
|
|
let rel = key_relative_path(key)?;
|
|
Ok(version_root_for_bucket(state, bucket).join(rel))
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
struct VersionManifest {
|
|
#[serde(default)]
|
|
version_id: String,
|
|
#[serde(default)]
|
|
key: String,
|
|
#[serde(default)]
|
|
size: u64,
|
|
#[serde(default)]
|
|
archived_at: Option<String>,
|
|
#[serde(default)]
|
|
etag: Option<String>,
|
|
#[serde(default)]
|
|
metadata: HashMap<String, String>,
|
|
#[serde(default)]
|
|
reason: Option<String>,
|
|
}
|
|
|
|
fn manifest_timestamp(value: &VersionManifest) -> DateTime<Utc> {
|
|
value
|
|
.archived_at
|
|
.as_deref()
|
|
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
|
|
.map(|dt| dt.with_timezone(&Utc))
|
|
.unwrap_or_else(Utc::now)
|
|
}
|
|
|
|
fn manifest_to_json(record: &VersionManifest) -> Value {
|
|
let ts = manifest_timestamp(record);
|
|
json!({
|
|
"version_id": record.version_id,
|
|
"key": record.key,
|
|
"size": record.size,
|
|
"etag": record.etag,
|
|
"archived_at": ts.to_rfc3339(),
|
|
"last_modified": ts.to_rfc3339(),
|
|
"metadata": record.metadata,
|
|
"reason": record.reason.clone().unwrap_or_else(|| "update".to_string()),
|
|
"is_latest": false,
|
|
})
|
|
}
|
|
|
|
fn read_version_manifests_for_object(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
key: &str,
|
|
) -> Result<Vec<VersionManifest>, String> {
|
|
let version_dir = version_dir_for_object(state, bucket, key)?;
|
|
if !version_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut entries = Vec::new();
|
|
for entry in std::fs::read_dir(&version_dir).map_err(|e| e.to_string())? {
|
|
let entry = entry.map_err(|e| e.to_string())?;
|
|
if !entry.file_type().map_err(|e| e.to_string())?.is_file() {
|
|
continue;
|
|
}
|
|
if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
let text = std::fs::read_to_string(entry.path()).map_err(|e| e.to_string())?;
|
|
let mut manifest: VersionManifest =
|
|
serde_json::from_str(&text).map_err(|e| e.to_string())?;
|
|
if manifest.version_id.is_empty() {
|
|
manifest.version_id = entry
|
|
.path()
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
}
|
|
if manifest.key.is_empty() {
|
|
manifest.key = key.to_string();
|
|
}
|
|
entries.push(manifest);
|
|
}
|
|
|
|
entries.sort_by(|a, b| manifest_timestamp(b).cmp(&manifest_timestamp(a)));
|
|
Ok(entries)
|
|
}
|
|
|
|
async fn read_object_bytes_for_zip(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
key: &str,
|
|
) -> Result<Vec<u8>, String> {
|
|
let all_meta = state
|
|
.storage
|
|
.get_object_metadata(bucket, key)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
if let Some(enc_meta) = EncryptionMetadata::from_metadata(&all_meta) {
|
|
let enc_svc = state
|
|
.encryption
|
|
.as_ref()
|
|
.ok_or_else(|| "Encryption service is not available".to_string())?;
|
|
let obj_path = state
|
|
.storage
|
|
.get_object_path(bucket, key)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let tmp_dir = state.config.storage_root.join(SYSTEM_ROOT).join("tmp");
|
|
let _ = tokio::fs::create_dir_all(&tmp_dir).await;
|
|
let dec_tmp = tmp_dir.join(format!("zip-dec-{}", uuid::Uuid::new_v4()));
|
|
enc_svc
|
|
.decrypt_object(&obj_path, &dec_tmp, &enc_meta, None)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let bytes = tokio::fs::read(&dec_tmp).await.map_err(|e| e.to_string())?;
|
|
let _ = tokio::fs::remove_file(&dec_tmp).await;
|
|
return Ok(bytes);
|
|
}
|
|
|
|
let (_meta, mut reader) = state
|
|
.storage
|
|
.get_object(bucket, key)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let mut bytes = Vec::new();
|
|
reader
|
|
.read_to_end(&mut bytes)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn value_to_string_vec(value: Option<&Value>, field_name: &str) -> Vec<String> {
|
|
match value {
|
|
Some(Value::Array(items)) => items
|
|
.iter()
|
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
|
.collect(),
|
|
Some(Value::String(s)) if !s.trim().is_empty() => vec![s.to_string()],
|
|
Some(Value::Null) | None => Vec::new(),
|
|
Some(_) => vec![field_name.to_string()],
|
|
}
|
|
}
|
|
|
|
fn xml_child<'a>(node: roxmltree::Node<'a, 'a>, name: &str) -> Option<roxmltree::Node<'a, 'a>> {
|
|
node.children()
|
|
.find(|child| child.is_element() && child.tag_name().name() == name)
|
|
}
|
|
|
|
fn xml_child_text(node: roxmltree::Node<'_, '_>, name: &str) -> Option<String> {
|
|
xml_child(node, name)
|
|
.and_then(|child| child.text())
|
|
.map(|text| text.trim().to_string())
|
|
.filter(|text| !text.is_empty())
|
|
}
|
|
|
|
fn xml_children_texts(node: roxmltree::Node<'_, '_>, name: &str) -> Vec<String> {
|
|
node.children()
|
|
.filter(|child| child.is_element() && child.tag_name().name() == name)
|
|
.filter_map(|child| child.text().map(|text| text.trim().to_string()))
|
|
.filter(|text| !text.is_empty())
|
|
.collect()
|
|
}
|
|
|
|
fn parse_acl_value(value: Option<&Value>, owner: &str) -> Value {
|
|
let default_grant = json!({
|
|
"grantee": owner,
|
|
"permission": "FULL_CONTROL",
|
|
"grantee_type": "CanonicalUser",
|
|
"display_name": owner,
|
|
"grantee_id": owner,
|
|
"grantee_uri": Value::Null,
|
|
});
|
|
|
|
let Some(value) = value else {
|
|
return json!({
|
|
"owner": owner,
|
|
"grants": [default_grant],
|
|
"canned_acls": ["private", "public-read", "public-read-write", "authenticated-read"],
|
|
});
|
|
};
|
|
|
|
match value {
|
|
Value::String(xml) => {
|
|
let doc = match Document::parse(xml) {
|
|
Ok(doc) => doc,
|
|
Err(_) => {
|
|
return json!({
|
|
"owner": owner,
|
|
"grants": [default_grant],
|
|
"canned_acls": ["private", "public-read", "public-read-write", "authenticated-read"],
|
|
});
|
|
}
|
|
};
|
|
let owner_node = doc
|
|
.descendants()
|
|
.find(|node| node.is_element() && node.tag_name().name() == "Owner");
|
|
let owner_id = owner_node
|
|
.and_then(|node| xml_child_text(node, "ID"))
|
|
.unwrap_or_else(|| owner.to_string());
|
|
|
|
let grants = doc
|
|
.descendants()
|
|
.filter(|node| node.is_element() && node.tag_name().name() == "Grant")
|
|
.map(|grant| {
|
|
let grantee = xml_child(grant, "Grantee");
|
|
let permission = xml_child_text(grant, "Permission").unwrap_or_default();
|
|
let grantee_id = grantee.and_then(|node| xml_child_text(node, "ID"));
|
|
let display_name = grantee.and_then(|node| xml_child_text(node, "DisplayName"));
|
|
let grantee_uri = grantee.and_then(|node| xml_child_text(node, "URI"));
|
|
let grantee_type = grantee
|
|
.and_then(|node| {
|
|
node.attributes()
|
|
.find(|attr| {
|
|
attr.name() == "type" || attr.name().ends_with(":type")
|
|
})
|
|
.map(|attr| attr.value().to_string())
|
|
})
|
|
.or_else(|| {
|
|
if grantee_uri.is_some() {
|
|
Some("Group".to_string())
|
|
} else {
|
|
Some("CanonicalUser".to_string())
|
|
}
|
|
})
|
|
.unwrap_or_else(|| "CanonicalUser".to_string());
|
|
let grantee_label = display_name
|
|
.clone()
|
|
.or_else(|| grantee_id.clone())
|
|
.or_else(|| grantee_uri.clone())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
|
|
json!({
|
|
"grantee": grantee_label,
|
|
"permission": permission,
|
|
"grantee_type": grantee_type,
|
|
"display_name": display_name,
|
|
"grantee_id": grantee_id,
|
|
"grantee_uri": grantee_uri,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
json!({
|
|
"owner": owner_id,
|
|
"grants": if grants.is_empty() { vec![default_grant] } else { grants },
|
|
"canned_acls": ["private", "public-read", "public-read-write", "authenticated-read"],
|
|
})
|
|
}
|
|
Value::Object(map) => {
|
|
let grants = map
|
|
.get("grants")
|
|
.and_then(|value| value.as_array())
|
|
.cloned()
|
|
.unwrap_or_else(|| vec![default_grant]);
|
|
json!({
|
|
"owner": map.get("owner").and_then(|v| v.as_str()).unwrap_or(owner),
|
|
"grants": grants,
|
|
"canned_acls": ["private", "public-read", "public-read-write", "authenticated-read"],
|
|
})
|
|
}
|
|
_ => json!({
|
|
"owner": owner,
|
|
"grants": [default_grant],
|
|
"canned_acls": ["private", "public-read", "public-read-write", "authenticated-read"],
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn parse_cors_value(value: Option<&Value>) -> Value {
|
|
let Some(value) = value else {
|
|
return json!({ "rules": [] });
|
|
};
|
|
|
|
match value {
|
|
Value::String(xml) => {
|
|
let doc = match Document::parse(xml) {
|
|
Ok(doc) => doc,
|
|
Err(_) => return json!({ "rules": [] }),
|
|
};
|
|
let rules = doc
|
|
.descendants()
|
|
.filter(|node| node.is_element() && node.tag_name().name() == "CORSRule")
|
|
.map(|rule| {
|
|
let allowed_origins = xml_children_texts(rule, "AllowedOrigin");
|
|
let allowed_methods = xml_children_texts(rule, "AllowedMethod");
|
|
let allowed_headers = xml_children_texts(rule, "AllowedHeader");
|
|
let expose_headers = xml_children_texts(rule, "ExposeHeader");
|
|
let max_age_seconds =
|
|
xml_child_text(rule, "MaxAgeSeconds").and_then(|v| v.parse::<u64>().ok());
|
|
json!({
|
|
"AllowedOrigins": allowed_origins,
|
|
"AllowedMethods": allowed_methods,
|
|
"AllowedHeaders": allowed_headers,
|
|
"ExposeHeaders": expose_headers,
|
|
"MaxAgeSeconds": max_age_seconds,
|
|
"allowed_origins": allowed_origins,
|
|
"allowed_methods": allowed_methods,
|
|
"allowed_headers": allowed_headers,
|
|
"expose_headers": expose_headers,
|
|
"max_age_seconds": max_age_seconds,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
json!({ "rules": rules })
|
|
}
|
|
Value::Array(rules) => json!({ "rules": rules }),
|
|
Value::Object(map) => {
|
|
if let Some(rules) = map.get("rules").and_then(|value| value.as_array()) {
|
|
json!({ "rules": rules })
|
|
} else {
|
|
json!({ "rules": [map] })
|
|
}
|
|
}
|
|
_ => json!({ "rules": [] }),
|
|
}
|
|
}
|
|
|
|
fn parse_lifecycle_value(value: Option<&Value>) -> Value {
|
|
let Some(value) = value else {
|
|
return json!({ "rules": [] });
|
|
};
|
|
|
|
match value {
|
|
Value::String(xml) => {
|
|
let doc = match Document::parse(xml) {
|
|
Ok(doc) => doc,
|
|
Err(_) => return json!({ "rules": [] }),
|
|
};
|
|
let rules = doc
|
|
.descendants()
|
|
.filter(|node| node.is_element() && node.tag_name().name() == "Rule")
|
|
.map(|rule| {
|
|
let rule_id = xml_child_text(rule, "ID").unwrap_or_default();
|
|
let status = xml_child_text(rule, "Status").unwrap_or_else(|| "Enabled".to_string());
|
|
let prefix = xml_child(rule, "Filter")
|
|
.and_then(|filter| xml_child_text(filter, "Prefix"))
|
|
.or_else(|| xml_child_text(rule, "Prefix"))
|
|
.unwrap_or_default();
|
|
let expiration_days = xml_child(rule, "Expiration")
|
|
.and_then(|node| xml_child_text(node, "Days"))
|
|
.and_then(|v| v.parse::<u64>().ok());
|
|
let noncurrent_days = xml_child(rule, "NoncurrentVersionExpiration")
|
|
.and_then(|node| xml_child_text(node, "NoncurrentDays"))
|
|
.and_then(|v| v.parse::<u64>().ok());
|
|
let abort_days = xml_child(rule, "AbortIncompleteMultipartUpload")
|
|
.and_then(|node| xml_child_text(node, "DaysAfterInitiation"))
|
|
.and_then(|v| v.parse::<u64>().ok());
|
|
|
|
json!({
|
|
"ID": rule_id,
|
|
"Status": status,
|
|
"Filter": { "Prefix": prefix },
|
|
"Expiration": expiration_days.map(|days| json!({ "Days": days })),
|
|
"NoncurrentVersionExpiration": noncurrent_days.map(|days| json!({ "NoncurrentDays": days })),
|
|
"AbortIncompleteMultipartUpload": abort_days.map(|days| json!({ "DaysAfterInitiation": days })),
|
|
"id": rule_id,
|
|
"status": status,
|
|
"prefix": prefix,
|
|
"expiration_days": expiration_days,
|
|
"noncurrent_days": noncurrent_days,
|
|
"abort_mpu_days": abort_days,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
json!({ "rules": rules })
|
|
}
|
|
Value::Array(rules) => json!({ "rules": rules }),
|
|
Value::Object(map) => {
|
|
if let Some(rules) = map.get("rules").and_then(|value| value.as_array()) {
|
|
json!({ "rules": rules })
|
|
} else {
|
|
json!({ "rules": [map] })
|
|
}
|
|
}
|
|
_ => json!({ "rules": [] }),
|
|
}
|
|
}
|
|
|
|
fn bucket_acl_xml_for_canned(owner_id: &str, canned_acl: &str) -> Result<String, String> {
|
|
let mut grants = vec![format!(
|
|
"<Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"CanonicalUser\"><ID>{}</ID><DisplayName>{}</DisplayName></Grantee><Permission>FULL_CONTROL</Permission></Grant>",
|
|
xml_escape(owner_id),
|
|
xml_escape(owner_id),
|
|
)];
|
|
|
|
match canned_acl {
|
|
"private" => {}
|
|
"public-read" => grants.push(
|
|
"<Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\"><URI>http://acs.amazonaws.com/groups/global/AllUsers</URI></Grantee><Permission>READ</Permission></Grant>".to_string()
|
|
),
|
|
"public-read-write" => {
|
|
grants.push(
|
|
"<Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\"><URI>http://acs.amazonaws.com/groups/global/AllUsers</URI></Grantee><Permission>READ</Permission></Grant>".to_string()
|
|
);
|
|
grants.push(
|
|
"<Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\"><URI>http://acs.amazonaws.com/groups/global/AllUsers</URI></Grantee><Permission>WRITE</Permission></Grant>".to_string()
|
|
);
|
|
}
|
|
"authenticated-read" => grants.push(
|
|
"<Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"Group\"><URI>http://acs.amazonaws.com/groups/global/AuthenticatedUsers</URI></Grantee><Permission>READ</Permission></Grant>".to_string()
|
|
),
|
|
_ => return Err(format!("Invalid canned ACL: {}", canned_acl)),
|
|
}
|
|
|
|
Ok(format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><Owner><ID>{}</ID><DisplayName>{}</DisplayName></Owner><AccessControlList>{}</AccessControlList></AccessControlPolicy>",
|
|
xml_escape(owner_id),
|
|
xml_escape(owner_id),
|
|
grants.join("")
|
|
))
|
|
}
|
|
|
|
fn cors_xml_from_rules(rules: &[Value]) -> String {
|
|
let mut xml = String::from(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><CORSConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">",
|
|
);
|
|
for rule in rules {
|
|
xml.push_str("<CORSRule>");
|
|
for origin in value_to_string_vec(rule.get("AllowedOrigins"), "AllowedOrigin") {
|
|
xml.push_str(&format!(
|
|
"<AllowedOrigin>{}</AllowedOrigin>",
|
|
xml_escape(&origin)
|
|
));
|
|
}
|
|
for method in value_to_string_vec(rule.get("AllowedMethods"), "AllowedMethod") {
|
|
xml.push_str(&format!(
|
|
"<AllowedMethod>{}</AllowedMethod>",
|
|
xml_escape(&method)
|
|
));
|
|
}
|
|
for header in value_to_string_vec(rule.get("AllowedHeaders"), "AllowedHeader") {
|
|
xml.push_str(&format!(
|
|
"<AllowedHeader>{}</AllowedHeader>",
|
|
xml_escape(&header)
|
|
));
|
|
}
|
|
for header in value_to_string_vec(rule.get("ExposeHeaders"), "ExposeHeader") {
|
|
xml.push_str(&format!(
|
|
"<ExposeHeader>{}</ExposeHeader>",
|
|
xml_escape(&header)
|
|
));
|
|
}
|
|
if let Some(max_age) = rule.get("MaxAgeSeconds").and_then(|v| v.as_u64()) {
|
|
xml.push_str(&format!("<MaxAgeSeconds>{}</MaxAgeSeconds>", max_age));
|
|
}
|
|
xml.push_str("</CORSRule>");
|
|
}
|
|
xml.push_str("</CORSConfiguration>");
|
|
xml
|
|
}
|
|
|
|
fn lifecycle_xml_from_rules(rules: &[Value]) -> String {
|
|
let mut xml = String::from(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><LifecycleConfiguration xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">",
|
|
);
|
|
for rule in rules {
|
|
xml.push_str("<Rule>");
|
|
|
|
let id = rule.get("ID").and_then(|v| v.as_str()).unwrap_or_default();
|
|
if !id.is_empty() {
|
|
xml.push_str(&format!("<ID>{}</ID>", xml_escape(id)));
|
|
}
|
|
|
|
let status = rule
|
|
.get("Status")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("Enabled");
|
|
xml.push_str(&format!("<Status>{}</Status>", xml_escape(status)));
|
|
|
|
let prefix = rule
|
|
.get("Filter")
|
|
.and_then(|v| v.get("Prefix"))
|
|
.and_then(|v| v.as_str())
|
|
.or_else(|| rule.get("Prefix").and_then(|v| v.as_str()))
|
|
.unwrap_or_default();
|
|
xml.push_str("<Filter>");
|
|
xml.push_str(&format!("<Prefix>{}</Prefix>", xml_escape(prefix)));
|
|
xml.push_str("</Filter>");
|
|
|
|
if let Some(days) = rule
|
|
.get("Expiration")
|
|
.and_then(|v| v.get("Days"))
|
|
.and_then(|v| v.as_u64())
|
|
{
|
|
xml.push_str(&format!("<Expiration><Days>{}</Days></Expiration>", days));
|
|
}
|
|
|
|
if let Some(days) = rule
|
|
.get("NoncurrentVersionExpiration")
|
|
.and_then(|v| v.get("NoncurrentDays"))
|
|
.and_then(|v| v.as_u64())
|
|
{
|
|
xml.push_str(&format!(
|
|
"<NoncurrentVersionExpiration><NoncurrentDays>{}</NoncurrentDays></NoncurrentVersionExpiration>",
|
|
days
|
|
));
|
|
}
|
|
|
|
if let Some(days) = rule
|
|
.get("AbortIncompleteMultipartUpload")
|
|
.and_then(|v| v.get("DaysAfterInitiation"))
|
|
.and_then(|v| v.as_u64())
|
|
{
|
|
xml.push_str(&format!(
|
|
"<AbortIncompleteMultipartUpload><DaysAfterInitiation>{}</DaysAfterInitiation></AbortIncompleteMultipartUpload>",
|
|
days
|
|
));
|
|
}
|
|
|
|
xml.push_str("</Rule>");
|
|
}
|
|
xml.push_str("</LifecycleConfiguration>");
|
|
xml
|
|
}
|
|
|
|
fn zip_dos_time(dt: DateTime<Utc>) -> (u16, u16) {
|
|
let year = dt.year().clamp(1980, 2107) as u16;
|
|
let month = dt.month() as u16;
|
|
let day = dt.day() as u16;
|
|
let hour = dt.hour() as u16;
|
|
let minute = dt.minute() as u16;
|
|
let second = (dt.second() / 2) as u16;
|
|
let dos_time = (hour << 11) | (minute << 5) | second;
|
|
let dos_date = ((year - 1980) << 9) | (month << 5) | day;
|
|
(dos_time, dos_date)
|
|
}
|
|
|
|
fn write_u16(buf: &mut Vec<u8>, value: u16) {
|
|
buf.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn write_u32(buf: &mut Vec<u8>, value: u32) {
|
|
buf.extend_from_slice(&value.to_le_bytes());
|
|
}
|
|
|
|
fn build_zip_archive(entries: Vec<(String, Vec<u8>, DateTime<Utc>)>) -> Result<Vec<u8>, String> {
|
|
#[derive(Clone)]
|
|
struct CentralEntry {
|
|
name: Vec<u8>,
|
|
crc32: u32,
|
|
size: u32,
|
|
offset: u32,
|
|
mod_time: u16,
|
|
mod_date: u16,
|
|
}
|
|
|
|
let mut output = Vec::new();
|
|
let mut central_entries = Vec::new();
|
|
|
|
for (name, data, modified) in entries {
|
|
if data.len() > u32::MAX as usize {
|
|
return Err(format!("Object '{}' is too large for ZIP export", name));
|
|
}
|
|
let offset = output.len();
|
|
if offset > u32::MAX as usize {
|
|
return Err("ZIP archive is too large".to_string());
|
|
}
|
|
|
|
let name_bytes = name.into_bytes();
|
|
let mut hasher = crc32fast::Hasher::new();
|
|
hasher.update(&data);
|
|
let crc32 = hasher.finalize();
|
|
let size = data.len() as u32;
|
|
let (mod_time, mod_date) = zip_dos_time(modified);
|
|
let flags = 0x0800u16;
|
|
|
|
write_u32(&mut output, 0x04034b50);
|
|
write_u16(&mut output, 20);
|
|
write_u16(&mut output, flags);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, mod_time);
|
|
write_u16(&mut output, mod_date);
|
|
write_u32(&mut output, crc32);
|
|
write_u32(&mut output, size);
|
|
write_u32(&mut output, size);
|
|
write_u16(&mut output, name_bytes.len() as u16);
|
|
write_u16(&mut output, 0);
|
|
output.extend_from_slice(&name_bytes);
|
|
output.extend_from_slice(&data);
|
|
|
|
central_entries.push(CentralEntry {
|
|
name: name_bytes,
|
|
crc32,
|
|
size,
|
|
offset: offset as u32,
|
|
mod_time,
|
|
mod_date,
|
|
});
|
|
}
|
|
|
|
let central_start = output.len();
|
|
for entry in ¢ral_entries {
|
|
write_u32(&mut output, 0x02014b50);
|
|
write_u16(&mut output, 20);
|
|
write_u16(&mut output, 20);
|
|
write_u16(&mut output, 0x0800);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, entry.mod_time);
|
|
write_u16(&mut output, entry.mod_date);
|
|
write_u32(&mut output, entry.crc32);
|
|
write_u32(&mut output, entry.size);
|
|
write_u32(&mut output, entry.size);
|
|
write_u16(&mut output, entry.name.len() as u16);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, 0);
|
|
write_u32(&mut output, 0);
|
|
write_u32(&mut output, entry.offset);
|
|
output.extend_from_slice(&entry.name);
|
|
}
|
|
|
|
let central_size = output.len() - central_start;
|
|
if central_entries.len() > u16::MAX as usize
|
|
|| central_start > u32::MAX as usize
|
|
|| central_size > u32::MAX as usize
|
|
{
|
|
return Err("ZIP archive exceeds classic ZIP limits".to_string());
|
|
}
|
|
|
|
write_u32(&mut output, 0x06054b50);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, 0);
|
|
write_u16(&mut output, central_entries.len() as u16);
|
|
write_u16(&mut output, central_entries.len() as u16);
|
|
write_u32(&mut output, central_size as u32);
|
|
write_u32(&mut output, central_start as u32);
|
|
write_u16(&mut output, 0);
|
|
|
|
Ok(output)
|
|
}
|
|
|
|
fn dangerous_preview_content_type(content_type: Option<&str>, key: &str) -> Option<String> {
|
|
let guessed = content_type
|
|
.map(|value| value.to_ascii_lowercase())
|
|
.unwrap_or_else(|| {
|
|
mime_guess::from_path(key)
|
|
.first_raw()
|
|
.unwrap_or("application/octet-stream")
|
|
.to_ascii_lowercase()
|
|
});
|
|
|
|
match guessed.split(';').next().unwrap_or_default().trim() {
|
|
"text/html"
|
|
| "text/xml"
|
|
| "application/xml"
|
|
| "application/xhtml+xml"
|
|
| "image/svg+xml" => Some("text/plain; charset=utf-8".to_string()),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
async fn parse_json_body<T: DeserializeOwned>(body: Body) -> Result<T, Response> {
|
|
let bytes = to_bytes(body, usize::MAX)
|
|
.await
|
|
.map_err(|_| json_error(StatusCode::BAD_REQUEST, "Failed to read request body"))?;
|
|
serde_json::from_slice::<T>(&bytes)
|
|
.map_err(|e| json_error(StatusCode::BAD_REQUEST, format!("Invalid JSON body: {}", e)))
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct ListObjectsQuery {
|
|
#[serde(default)]
|
|
pub max_keys: Option<usize>,
|
|
#[serde(default)]
|
|
pub continuation_token: Option<String>,
|
|
#[serde(default)]
|
|
pub prefix: Option<String>,
|
|
#[serde(default)]
|
|
pub start_after: Option<String>,
|
|
#[serde(default)]
|
|
pub delimiter: Option<String>,
|
|
}
|
|
|
|
fn object_json(bucket_name: &str, o: &myfsio_common::types::ObjectMeta) -> Value {
|
|
json!({
|
|
"key": o.key,
|
|
"size": o.size,
|
|
"last_modified": o.last_modified.to_rfc3339(),
|
|
"last_modified_iso": o.last_modified.to_rfc3339(),
|
|
"last_modified_display": o.last_modified.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
"etag": o.etag.clone().unwrap_or_default(),
|
|
"storage_class": o.storage_class.clone().unwrap_or_else(|| "STANDARD".to_string()),
|
|
"content_type": o.content_type.clone().unwrap_or_default(),
|
|
"download_url": build_ui_object_url(bucket_name, &o.key, "download"),
|
|
"preview_url": build_ui_object_url(bucket_name, &o.key, "preview"),
|
|
"delete_endpoint": build_ui_object_url(bucket_name, &o.key, "delete"),
|
|
"presign_endpoint": build_ui_object_url(bucket_name, &o.key, "presign"),
|
|
"metadata_url": build_ui_object_url(bucket_name, &o.key, "metadata"),
|
|
"versions_endpoint": build_ui_object_url(bucket_name, &o.key, "versions"),
|
|
"restore_template": format!(
|
|
"/ui/buckets/{}/objects/{}/restore/VERSION_ID_PLACEHOLDER",
|
|
bucket_name,
|
|
encode_object_key(&o.key)
|
|
),
|
|
"tags_url": build_ui_object_url(bucket_name, &o.key, "tags"),
|
|
"copy_url": build_ui_object_url(bucket_name, &o.key, "copy"),
|
|
"move_url": build_ui_object_url(bucket_name, &o.key, "move"),
|
|
})
|
|
}
|
|
|
|
pub async fn list_bucket_objects(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(q): Query<ListObjectsQuery>,
|
|
) -> Response {
|
|
if !matches!(state.storage.bucket_exists(&bucket_name).await, Ok(true)) {
|
|
return json_error(StatusCode::NOT_FOUND, "Bucket not found");
|
|
}
|
|
|
|
let max_keys = q.max_keys.unwrap_or(1000).min(5000);
|
|
let versioning_enabled = state
|
|
.storage
|
|
.is_versioning_enabled(&bucket_name)
|
|
.await
|
|
.unwrap_or(false);
|
|
let stats = state.storage.bucket_stats(&bucket_name).await.ok();
|
|
let total_count = stats.as_ref().map(|s| s.objects).unwrap_or(0);
|
|
|
|
let use_shallow = q.delimiter.as_deref() == Some("/");
|
|
|
|
if use_shallow {
|
|
let params = myfsio_common::types::ShallowListParams {
|
|
prefix: q.prefix.clone().unwrap_or_default(),
|
|
delimiter: "/".to_string(),
|
|
max_keys,
|
|
continuation_token: q.continuation_token.clone(),
|
|
};
|
|
return match state
|
|
.storage
|
|
.list_objects_shallow(&bucket_name, ¶ms)
|
|
.await
|
|
{
|
|
Ok(res) => {
|
|
let objects: Vec<Value> = res
|
|
.objects
|
|
.iter()
|
|
.map(|o| object_json(&bucket_name, o))
|
|
.collect();
|
|
Json(json!({
|
|
"versioning_enabled": versioning_enabled,
|
|
"total_count": total_count,
|
|
"is_truncated": res.is_truncated,
|
|
"next_continuation_token": res.next_continuation_token,
|
|
"url_templates": url_templates_for(&bucket_name),
|
|
"objects": objects,
|
|
"common_prefixes": res.common_prefixes,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(e) => storage_json_error(e),
|
|
};
|
|
}
|
|
|
|
let params = ListParams {
|
|
max_keys,
|
|
continuation_token: q.continuation_token.clone(),
|
|
prefix: q.prefix.clone(),
|
|
start_after: q.start_after.clone(),
|
|
};
|
|
|
|
match state.storage.list_objects(&bucket_name, ¶ms).await {
|
|
Ok(res) => {
|
|
let objects: Vec<Value> = res
|
|
.objects
|
|
.iter()
|
|
.map(|o| object_json(&bucket_name, o))
|
|
.collect();
|
|
|
|
Json(json!({
|
|
"versioning_enabled": versioning_enabled,
|
|
"total_count": total_count,
|
|
"is_truncated": res.is_truncated,
|
|
"next_continuation_token": res.next_continuation_token,
|
|
"url_templates": url_templates_for(&bucket_name),
|
|
"objects": objects,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(e) => storage_json_error(e),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct StreamObjectsQuery {
|
|
#[serde(default)]
|
|
pub prefix: Option<String>,
|
|
#[serde(default)]
|
|
pub delimiter: Option<String>,
|
|
}
|
|
|
|
pub async fn stream_bucket_objects(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(q): Query<StreamObjectsQuery>,
|
|
) -> Response {
|
|
if !matches!(state.storage.bucket_exists(&bucket_name).await, Ok(true)) {
|
|
return (StatusCode::NOT_FOUND, "Bucket not found").into_response();
|
|
}
|
|
|
|
let versioning_enabled = state
|
|
.storage
|
|
.is_versioning_enabled(&bucket_name)
|
|
.await
|
|
.unwrap_or(false);
|
|
let stats = state.storage.bucket_stats(&bucket_name).await.ok();
|
|
let total_count = stats.as_ref().map(|s| s.objects).unwrap_or(0);
|
|
|
|
let use_delimiter = q.delimiter.as_deref() == Some("/");
|
|
let prefix = q.prefix.clone().unwrap_or_default();
|
|
|
|
let (tx, rx) = tokio::sync::mpsc::channel::<Result<bytes::Bytes, std::io::Error>>(64);
|
|
|
|
let meta_line = json!({
|
|
"type": "meta",
|
|
"url_templates": url_templates_for(&bucket_name),
|
|
"versioning_enabled": versioning_enabled,
|
|
})
|
|
.to_string()
|
|
+ "\n";
|
|
let count_line = json!({ "type": "count", "total_count": total_count }).to_string() + "\n";
|
|
|
|
let storage = state.storage.clone();
|
|
let bucket = bucket_name.clone();
|
|
|
|
tokio::spawn(async move {
|
|
if tx
|
|
.send(Ok(bytes::Bytes::from(meta_line.into_bytes())))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
if tx
|
|
.send(Ok(bytes::Bytes::from(count_line.into_bytes())))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
|
|
if use_delimiter {
|
|
let mut token: Option<String> = None;
|
|
loop {
|
|
let params = myfsio_common::types::ShallowListParams {
|
|
prefix: prefix.clone(),
|
|
delimiter: "/".to_string(),
|
|
max_keys: UI_OBJECT_BROWSER_MAX_KEYS,
|
|
continuation_token: token.clone(),
|
|
};
|
|
match storage.list_objects_shallow(&bucket, ¶ms).await {
|
|
Ok(res) => {
|
|
for p in &res.common_prefixes {
|
|
let line = json!({ "type": "folder", "prefix": p }).to_string() + "\n";
|
|
if tx
|
|
.send(Ok(bytes::Bytes::from(line.into_bytes())))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
for o in &res.objects {
|
|
let line = json!({
|
|
"type": "object",
|
|
"key": o.key,
|
|
"size": o.size,
|
|
"last_modified": o.last_modified.to_rfc3339(),
|
|
"last_modified_iso": o.last_modified.to_rfc3339(),
|
|
"last_modified_display": o.last_modified.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
"etag": o.etag.clone().unwrap_or_default(),
|
|
"storage_class": o.storage_class.clone().unwrap_or_else(|| "STANDARD".to_string()),
|
|
})
|
|
.to_string()
|
|
+ "\n";
|
|
if tx
|
|
.send(Ok(bytes::Bytes::from(line.into_bytes())))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
if !res.is_truncated || res.next_continuation_token.is_none() {
|
|
break;
|
|
}
|
|
token = res.next_continuation_token;
|
|
}
|
|
Err(e) => {
|
|
let line =
|
|
json!({ "type": "error", "error": e.to_string() }).to_string() + "\n";
|
|
let _ = tx.send(Ok(bytes::Bytes::from(line.into_bytes()))).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let mut token: Option<String> = None;
|
|
loop {
|
|
let params = ListParams {
|
|
max_keys: 1000,
|
|
continuation_token: token.clone(),
|
|
prefix: if prefix.is_empty() {
|
|
None
|
|
} else {
|
|
Some(prefix.clone())
|
|
},
|
|
start_after: None,
|
|
};
|
|
match storage.list_objects(&bucket, ¶ms).await {
|
|
Ok(res) => {
|
|
for o in &res.objects {
|
|
let line = json!({
|
|
"type": "object",
|
|
"key": o.key,
|
|
"size": o.size,
|
|
"last_modified": o.last_modified.to_rfc3339(),
|
|
"last_modified_iso": o.last_modified.to_rfc3339(),
|
|
"last_modified_display": o.last_modified.format("%Y-%m-%d %H:%M:%S").to_string(),
|
|
"etag": o.etag.clone().unwrap_or_default(),
|
|
"storage_class": o.storage_class.clone().unwrap_or_else(|| "STANDARD".to_string()),
|
|
})
|
|
.to_string()
|
|
+ "\n";
|
|
if tx
|
|
.send(Ok(bytes::Bytes::from(line.into_bytes())))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
if !res.is_truncated || res.next_continuation_token.is_none() {
|
|
break;
|
|
}
|
|
token = res.next_continuation_token;
|
|
}
|
|
Err(e) => {
|
|
let line =
|
|
json!({ "type": "error", "error": e.to_string() }).to_string() + "\n";
|
|
let _ = tx.send(Ok(bytes::Bytes::from(line.into_bytes()))).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let done_line = json!({ "type": "done" }).to_string() + "\n";
|
|
let _ = tx
|
|
.send(Ok(bytes::Bytes::from(done_line.into_bytes())))
|
|
.await;
|
|
});
|
|
|
|
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
|
let body = Body::from_stream(stream);
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::CONTENT_TYPE,
|
|
"application/x-ndjson; charset=utf-8".parse().unwrap(),
|
|
);
|
|
headers.insert(header::CACHE_CONTROL, "no-cache".parse().unwrap());
|
|
headers.insert("x-accel-buffering", "no".parse().unwrap());
|
|
|
|
(StatusCode::OK, headers, body).into_response()
|
|
}
|
|
|
|
pub async fn list_bucket_folders(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(q): Query<StreamObjectsQuery>,
|
|
) -> Response {
|
|
if !matches!(state.storage.bucket_exists(&bucket_name).await, Ok(true)) {
|
|
return json_error(StatusCode::NOT_FOUND, "Bucket not found");
|
|
}
|
|
|
|
let prefix = q.prefix.clone().unwrap_or_default();
|
|
let params = myfsio_common::types::ShallowListParams {
|
|
prefix: prefix.clone(),
|
|
delimiter: "/".to_string(),
|
|
max_keys: UI_OBJECT_BROWSER_MAX_KEYS,
|
|
continuation_token: None,
|
|
};
|
|
match state
|
|
.storage
|
|
.list_objects_shallow(&bucket_name, ¶ms)
|
|
.await
|
|
{
|
|
Ok(res) => Json(json!({
|
|
"prefixes": res.common_prefixes,
|
|
"current_prefix": prefix,
|
|
}))
|
|
.into_response(),
|
|
Err(e) => storage_json_error(e),
|
|
}
|
|
}
|
|
|
|
pub async fn list_copy_targets(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(_bucket_name): Path<String>,
|
|
) -> Response {
|
|
let buckets: Vec<String> = state
|
|
.storage
|
|
.list_buckets()
|
|
.await
|
|
.map(|list| list.into_iter().map(|b| b.name).collect())
|
|
.unwrap_or_default();
|
|
Json(json!({ "buckets": buckets })).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ConnectionTestPayload {
|
|
pub endpoint_url: String,
|
|
pub access_key: String,
|
|
pub secret_key: String,
|
|
#[serde(default = "default_region")]
|
|
pub region: String,
|
|
}
|
|
|
|
fn default_region() -> String {
|
|
"us-east-1".to_string()
|
|
}
|
|
|
|
pub async fn test_connection(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: ConnectionTestPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(_) => {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"status": "error",
|
|
"message": "Invalid JSON payload",
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
};
|
|
|
|
if payload.endpoint_url.trim().is_empty()
|
|
|| payload.access_key.trim().is_empty()
|
|
|| payload.secret_key.trim().is_empty()
|
|
{
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"status": "error",
|
|
"message": "Missing credentials",
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let connection = RemoteConnection {
|
|
id: "test".to_string(),
|
|
name: "Test".to_string(),
|
|
endpoint_url: payload.endpoint_url.trim().to_string(),
|
|
access_key: payload.access_key.trim().to_string(),
|
|
secret_key: payload.secret_key.trim().to_string(),
|
|
region: payload.region.trim().to_string(),
|
|
};
|
|
|
|
if state.replication.check_endpoint(&connection).await {
|
|
Json(json!({
|
|
"status": "ok",
|
|
"message": "Connection successful",
|
|
}))
|
|
.into_response()
|
|
} else {
|
|
(
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"status": "error",
|
|
"message": format!("Connection failed or endpoint is unreachable: {}", connection.endpoint_url),
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
pub async fn connection_health(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(connection_id): Path<String>,
|
|
) -> Response {
|
|
let Some(connection) = state.connections.get(&connection_id) else {
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(json!({
|
|
"healthy": false,
|
|
"error": "Connection not found",
|
|
})),
|
|
)
|
|
.into_response();
|
|
};
|
|
|
|
let healthy = state.replication.check_endpoint(&connection).await;
|
|
Json(json!({
|
|
"healthy": healthy,
|
|
"error": if healthy {
|
|
Value::Null
|
|
} else {
|
|
Value::String(format!("Cannot reach endpoint: {}", connection.endpoint_url))
|
|
}
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
async fn peer_health_payload(state: &AppState, site_id: &str) -> Result<Value, Response> {
|
|
let Some(registry) = &state.site_registry else {
|
|
return Err(json_error(
|
|
StatusCode::NOT_FOUND,
|
|
"Site registry not available",
|
|
));
|
|
};
|
|
let Some(peer) = registry.get_peer(site_id) else {
|
|
return Err(json_error(StatusCode::NOT_FOUND, "Peer not found"));
|
|
};
|
|
|
|
let checked_at = chrono::Utc::now().timestamp_millis() as f64 / 1000.0;
|
|
let mut healthy = false;
|
|
let mut error: Option<String> = None;
|
|
|
|
if let Some(connection_id) = peer.connection_id.as_deref() {
|
|
if let Some(connection) = state.connections.get(connection_id) {
|
|
healthy = state.replication.check_endpoint(&connection).await;
|
|
if !healthy {
|
|
error = Some(format!(
|
|
"Cannot reach endpoint: {}",
|
|
connection.endpoint_url
|
|
));
|
|
}
|
|
} else {
|
|
error = Some(format!("Connection '{}' not found", connection_id));
|
|
}
|
|
} else {
|
|
error = Some("No connection configured for this peer".to_string());
|
|
}
|
|
|
|
registry.update_health(site_id, healthy);
|
|
Ok(json!({
|
|
"site_id": site_id,
|
|
"is_healthy": healthy,
|
|
"checked_at": checked_at,
|
|
"error": error,
|
|
}))
|
|
}
|
|
|
|
pub async fn peer_health(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
match peer_health_payload(&state, &site_id).await {
|
|
Ok(payload) => Json(payload).into_response(),
|
|
Err(response) => response,
|
|
}
|
|
}
|
|
|
|
pub async fn peer_sync_stats(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
let Some(registry) = &state.site_registry else {
|
|
return json_error(StatusCode::NOT_FOUND, "Site registry not available");
|
|
};
|
|
let Some(peer) = registry.get_peer(&site_id) else {
|
|
return json_error(StatusCode::NOT_FOUND, "Peer not found");
|
|
};
|
|
let Some(connection_id) = peer.connection_id.as_deref() else {
|
|
return json_error(StatusCode::BAD_REQUEST, "No connection configured");
|
|
};
|
|
|
|
let rules = state.replication.list_rules();
|
|
let mut buckets: Vec<Value> = Vec::new();
|
|
let mut buckets_syncing = 0u64;
|
|
let mut objects_synced = 0u64;
|
|
let mut objects_pending = 0u64;
|
|
let mut objects_failed = 0u64;
|
|
let mut bytes_synced = 0u64;
|
|
let mut last_sync_at: Option<f64> = None;
|
|
|
|
for rule in rules
|
|
.into_iter()
|
|
.filter(|rule| rule.target_connection_id == connection_id)
|
|
{
|
|
buckets_syncing += 1;
|
|
objects_synced += rule.stats.objects_synced;
|
|
objects_pending += rule.stats.objects_pending;
|
|
bytes_synced += rule.stats.bytes_synced;
|
|
if let Some(sync_at) = rule.stats.last_sync_at {
|
|
if last_sync_at
|
|
.map(|current| sync_at > current)
|
|
.unwrap_or(true)
|
|
{
|
|
last_sync_at = Some(sync_at);
|
|
}
|
|
}
|
|
|
|
let failures = state.replication.get_failure_count(&rule.bucket_name) as u64;
|
|
objects_failed += failures;
|
|
buckets.push(json!({
|
|
"bucket_name": rule.bucket_name,
|
|
"target_bucket": rule.target_bucket,
|
|
"mode": rule.mode,
|
|
"enabled": rule.enabled,
|
|
"last_sync_at": rule.stats.last_sync_at,
|
|
"objects_synced": rule.stats.objects_synced,
|
|
"objects_pending": rule.stats.objects_pending,
|
|
"failures": failures,
|
|
}));
|
|
}
|
|
|
|
Json(json!({
|
|
"buckets_syncing": buckets_syncing,
|
|
"objects_synced": objects_synced,
|
|
"objects_pending": objects_pending,
|
|
"objects_failed": objects_failed,
|
|
"bytes_synced": bytes_synced,
|
|
"last_sync_at": last_sync_at,
|
|
"buckets": buckets,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
pub async fn peer_bidirectional_status(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(site_id): Path<String>,
|
|
) -> Response {
|
|
let Some(registry) = &state.site_registry else {
|
|
return json_error(StatusCode::NOT_FOUND, "Site registry not available");
|
|
};
|
|
let Some(peer) = registry.get_peer(&site_id) else {
|
|
return json_error(StatusCode::NOT_FOUND, "Peer not found");
|
|
};
|
|
|
|
let local_site = registry.get_local_site();
|
|
let local_bidirectional_rules: Vec<Value> = state
|
|
.replication
|
|
.list_rules()
|
|
.into_iter()
|
|
.filter(|rule| {
|
|
peer.connection_id
|
|
.as_deref()
|
|
.map(|connection_id| rule.target_connection_id == connection_id)
|
|
.unwrap_or(false)
|
|
&& rule.mode == crate::services::replication::MODE_BIDIRECTIONAL
|
|
})
|
|
.map(|rule| {
|
|
json!({
|
|
"bucket_name": rule.bucket_name,
|
|
"target_bucket": rule.target_bucket,
|
|
"enabled": rule.enabled,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let mut result = json!({
|
|
"site_id": site_id,
|
|
"local_site_id": local_site.as_ref().map(|site| site.site_id.clone()),
|
|
"local_endpoint": local_site.as_ref().map(|site| site.endpoint.clone()),
|
|
"local_bidirectional_rules": local_bidirectional_rules,
|
|
"local_site_sync_enabled": state.config.site_sync_enabled,
|
|
"remote_status": Value::Null,
|
|
"issues": Vec::<Value>::new(),
|
|
"is_fully_configured": false,
|
|
});
|
|
|
|
if local_site
|
|
.as_ref()
|
|
.map(|site| site.site_id.trim().is_empty())
|
|
.unwrap_or(true)
|
|
{
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "NO_LOCAL_SITE_ID",
|
|
"message": "Local site identity not configured",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
}
|
|
if local_site
|
|
.as_ref()
|
|
.map(|site| site.endpoint.trim().is_empty())
|
|
.unwrap_or(true)
|
|
{
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "NO_LOCAL_ENDPOINT",
|
|
"message": "Local site endpoint not configured (remote site cannot reach back)",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
}
|
|
|
|
let Some(connection_id) = peer.connection_id.as_deref() else {
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "NO_CONNECTION",
|
|
"message": "No connection configured for this peer",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
return Json(result).into_response();
|
|
};
|
|
|
|
let Some(connection) = state.connections.get(connection_id) else {
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "CONNECTION_NOT_FOUND",
|
|
"message": format!("Connection '{}' not found", connection_id),
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
return Json(result).into_response();
|
|
};
|
|
|
|
if result["local_bidirectional_rules"]
|
|
.as_array()
|
|
.map(|rules| rules.is_empty())
|
|
.unwrap_or(true)
|
|
{
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "NO_LOCAL_BIDIRECTIONAL_RULES",
|
|
"message": "No bidirectional replication rules configured on this site",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
if !state.config.site_sync_enabled {
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "SITE_SYNC_DISABLED",
|
|
"message": "Site sync worker is disabled (SITE_SYNC_ENABLED=false). Pull operations will not work.",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
if !state.replication.check_endpoint(&connection).await {
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_UNREACHABLE",
|
|
"message": "Remote endpoint is not reachable",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
return Json(result).into_response();
|
|
}
|
|
|
|
let admin_url = format!(
|
|
"{}/admin/sites",
|
|
connection.endpoint_url.trim_end_matches('/')
|
|
);
|
|
match reqwest::Client::new()
|
|
.get(&admin_url)
|
|
.header("accept", "application/json")
|
|
.header("x-access-key", &connection.access_key)
|
|
.header("x-secret-key", &connection.secret_key)
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(resp) if resp.status().is_success() => match resp.json::<Value>().await {
|
|
Ok(remote_data) => {
|
|
let remote_local = remote_data.get("local").cloned().unwrap_or(Value::Null);
|
|
let remote_peers = remote_data
|
|
.get("peers")
|
|
.and_then(|value| value.as_array())
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
let mut has_peer_for_us = false;
|
|
let mut peer_connection_configured = false;
|
|
|
|
for remote_peer in &remote_peers {
|
|
let matches_site = local_site
|
|
.as_ref()
|
|
.map(|site| {
|
|
remote_peer.get("site_id").and_then(|v| v.as_str())
|
|
== Some(site.site_id.as_str())
|
|
|| remote_peer.get("endpoint").and_then(|v| v.as_str())
|
|
== Some(site.endpoint.as_str())
|
|
})
|
|
.unwrap_or(false);
|
|
if matches_site {
|
|
has_peer_for_us = true;
|
|
peer_connection_configured = remote_peer
|
|
.get("connection_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(|v| !v.trim().is_empty())
|
|
.unwrap_or(false);
|
|
break;
|
|
}
|
|
}
|
|
|
|
result["remote_status"] = json!({
|
|
"reachable": true,
|
|
"local_site": remote_local,
|
|
"site_sync_enabled": Value::Null,
|
|
"has_peer_for_us": has_peer_for_us,
|
|
"peer_connection_configured": peer_connection_configured,
|
|
"has_bidirectional_rules_for_us": Value::Null,
|
|
});
|
|
|
|
if !has_peer_for_us {
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_NO_PEER_FOR_US",
|
|
"message": "Remote site does not have this site registered as a peer",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
} else if !peer_connection_configured {
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_NO_CONNECTION_FOR_US",
|
|
"message": "Remote site has us as peer but no connection configured (cannot push back)",
|
|
"severity": "error",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
Err(_) => {
|
|
result["remote_status"] = json!({
|
|
"reachable": true,
|
|
"invalid_response": true,
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_INVALID_RESPONSE",
|
|
"message": "Remote admin API returned invalid JSON",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
Ok(resp)
|
|
if resp.status() == StatusCode::UNAUTHORIZED
|
|
|| resp.status() == StatusCode::FORBIDDEN =>
|
|
{
|
|
result["remote_status"] = json!({
|
|
"reachable": true,
|
|
"admin_access_denied": true,
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_ADMIN_ACCESS_DENIED",
|
|
"message": "Cannot verify remote configuration (admin access denied)",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
Ok(resp) => {
|
|
result["remote_status"] = json!({
|
|
"reachable": true,
|
|
"admin_api_error": resp.status().as_u16(),
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_ADMIN_API_ERROR",
|
|
"message": format!("Remote admin API returned status {}", resp.status().as_u16()),
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
result["remote_status"] = json!({
|
|
"reachable": false,
|
|
"error": "Connection failed",
|
|
});
|
|
push_issue(
|
|
&mut result,
|
|
json!({
|
|
"code": "REMOTE_ADMIN_UNREACHABLE",
|
|
"message": "Could not reach remote admin API",
|
|
"severity": "warning",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
let has_errors = result["issues"]
|
|
.as_array()
|
|
.map(|items| {
|
|
items.iter().any(|issue| {
|
|
issue.get("severity").and_then(|value| value.as_str()) == Some("error")
|
|
})
|
|
})
|
|
.unwrap_or(true);
|
|
result["is_fully_configured"] = json!(
|
|
!has_errors
|
|
&& result["local_bidirectional_rules"]
|
|
.as_array()
|
|
.map(|rules| !rules.is_empty())
|
|
.unwrap_or(false)
|
|
);
|
|
|
|
Json(result).into_response()
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct MetricsSettingsSnapshot {
|
|
enabled: bool,
|
|
retention_hours: u64,
|
|
interval_minutes: u64,
|
|
}
|
|
|
|
static METRICS_SETTINGS: OnceLock<Mutex<MetricsSettingsSnapshot>> = OnceLock::new();
|
|
|
|
fn metrics_settings_snapshot(state: &AppState) -> MetricsSettingsSnapshot {
|
|
*METRICS_SETTINGS
|
|
.get_or_init(|| {
|
|
Mutex::new(MetricsSettingsSnapshot {
|
|
enabled: state.config.metrics_history_enabled,
|
|
retention_hours: state.config.metrics_history_retention_hours,
|
|
interval_minutes: state.config.metrics_history_interval_minutes,
|
|
})
|
|
})
|
|
.lock()
|
|
.unwrap()
|
|
}
|
|
|
|
pub async fn metrics_settings(State(state): State<AppState>) -> Response {
|
|
let settings = metrics_settings_snapshot(&state);
|
|
Json(json!({
|
|
"enabled": settings.enabled,
|
|
"retention_hours": settings.retention_hours,
|
|
"interval_minutes": settings.interval_minutes,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
pub async fn update_metrics_settings(State(state): State<AppState>, body: Body) -> Response {
|
|
let payload: Value = parse_json_body(body).await.unwrap_or_else(|_| json!({}));
|
|
let mut settings = METRICS_SETTINGS
|
|
.get_or_init(|| {
|
|
Mutex::new(MetricsSettingsSnapshot {
|
|
enabled: state.config.metrics_history_enabled,
|
|
retention_hours: state.config.metrics_history_retention_hours,
|
|
interval_minutes: state.config.metrics_history_interval_minutes,
|
|
})
|
|
})
|
|
.lock()
|
|
.unwrap();
|
|
let enabled = payload
|
|
.get("enabled")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(settings.enabled);
|
|
let retention_hours = payload
|
|
.get("retention_hours")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(settings.retention_hours)
|
|
.max(1);
|
|
let interval_minutes = payload
|
|
.get("interval_minutes")
|
|
.and_then(|value| value.as_u64())
|
|
.unwrap_or(settings.interval_minutes)
|
|
.max(1);
|
|
*settings = MetricsSettingsSnapshot {
|
|
enabled,
|
|
retention_hours,
|
|
interval_minutes,
|
|
};
|
|
|
|
Json(json!({
|
|
"enabled": enabled,
|
|
"retention_hours": retention_hours,
|
|
"interval_minutes": interval_minutes,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct MultipartInitPayload {
|
|
#[serde(default)]
|
|
object_key: String,
|
|
#[serde(default)]
|
|
metadata: Option<HashMap<String, String>>,
|
|
}
|
|
|
|
pub async fn upload_object(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
headers: HeaderMap,
|
|
body: Body,
|
|
) -> Response {
|
|
let content_type = match headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
{
|
|
Some(value)
|
|
if value
|
|
.to_ascii_lowercase()
|
|
.starts_with("multipart/form-data") =>
|
|
{
|
|
value.to_string()
|
|
}
|
|
_ => return json_error(StatusCode::BAD_REQUEST, "Expected multipart form upload"),
|
|
};
|
|
|
|
let boundary = match multer::parse_boundary(&content_type) {
|
|
Ok(value) => value,
|
|
Err(_) => return json_error(StatusCode::BAD_REQUEST, "Missing multipart boundary"),
|
|
};
|
|
|
|
let stream = BodyStream::new(body)
|
|
.map_ok(|frame| frame.into_data().unwrap_or_default())
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
|
|
let mut multipart = multer::Multipart::new(stream, boundary);
|
|
|
|
let mut object_key: Option<String> = None;
|
|
let mut metadata_raw: Option<String> = None;
|
|
let mut file_name: Option<String> = None;
|
|
let mut file_content_type: Option<String> = None;
|
|
let mut file_bytes: Option<Vec<u8>> = None;
|
|
|
|
while let Some(field) = match multipart.next_field().await {
|
|
Ok(field) => field,
|
|
Err(e) => {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Malformed multipart body: {}", e),
|
|
)
|
|
}
|
|
} {
|
|
let name = field.name().unwrap_or_default().to_string();
|
|
match name.as_str() {
|
|
"object_key" => match field.text().await {
|
|
Ok(value) if !value.trim().is_empty() => {
|
|
object_key = Some(value.trim().to_string())
|
|
}
|
|
_ => {}
|
|
},
|
|
"metadata" => match field.text().await {
|
|
Ok(value) if !value.trim().is_empty() => metadata_raw = Some(value),
|
|
_ => {}
|
|
},
|
|
"object" => {
|
|
file_name = field.file_name().map(|s| s.to_string());
|
|
file_content_type = field.content_type().map(|mime| mime.to_string());
|
|
match field.bytes().await {
|
|
Ok(bytes) => file_bytes = Some(bytes.to_vec()),
|
|
Err(e) => {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Failed to read upload: {}", e),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
let _ = field.bytes().await;
|
|
}
|
|
}
|
|
}
|
|
|
|
let bytes = match file_bytes {
|
|
Some(bytes) if !bytes.is_empty() => bytes,
|
|
_ => return json_error(StatusCode::BAD_REQUEST, "Choose a file to upload"),
|
|
};
|
|
|
|
let key = object_key
|
|
.or(file_name.clone())
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| json_error(StatusCode::BAD_REQUEST, "Object key is required"));
|
|
let key = match key {
|
|
Ok(key) => key,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let metadata = if let Some(raw) = metadata_raw {
|
|
match serde_json::from_str::<HashMap<String, Value>>(&raw) {
|
|
Ok(map) => Some(
|
|
map.into_iter()
|
|
.map(|(k, v)| (k, v.as_str().unwrap_or(&v.to_string()).to_string()))
|
|
.collect::<HashMap<_, _>>(),
|
|
),
|
|
Err(_) => return json_error(StatusCode::BAD_REQUEST, "Metadata must be a JSON object"),
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut upload_headers = HeaderMap::new();
|
|
if let Some(content_type) = file_content_type.as_deref() {
|
|
if let Ok(value) = content_type.parse() {
|
|
upload_headers.insert(header::CONTENT_TYPE, value);
|
|
}
|
|
}
|
|
if let Some(metadata) = &metadata {
|
|
for (key, value) in metadata {
|
|
let header_name = format!("x-amz-meta-{}", key);
|
|
if let Ok(name) = header_name.parse::<axum::http::HeaderName>() {
|
|
if let Ok(value) = value.parse() {
|
|
upload_headers.insert(name, value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let response = handlers::put_object(
|
|
State(state),
|
|
Path((bucket_name.clone(), key.clone())),
|
|
Query(ObjectQuery::default()),
|
|
upload_headers,
|
|
Body::from(bytes),
|
|
)
|
|
.await;
|
|
|
|
if !response.status().is_success() {
|
|
return response;
|
|
}
|
|
|
|
let mut message = format!("Uploaded '{}'", key);
|
|
if metadata.is_some() {
|
|
message.push_str(" with metadata");
|
|
}
|
|
json_ok(json!({
|
|
"status": "ok",
|
|
"message": message,
|
|
"key": key,
|
|
}))
|
|
}
|
|
|
|
pub async fn initiate_multipart_upload(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: MultipartInitPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let object_key = payload.object_key.trim();
|
|
if object_key.is_empty() {
|
|
return json_error(StatusCode::BAD_REQUEST, "object_key is required");
|
|
}
|
|
|
|
match state
|
|
.storage
|
|
.initiate_multipart(&bucket_name, object_key, payload.metadata)
|
|
.await
|
|
{
|
|
Ok(upload_id) => json_ok(json!({ "upload_id": upload_id })),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct MultipartPartQuery {
|
|
#[serde(rename = "partNumber")]
|
|
part_number: Option<u32>,
|
|
}
|
|
|
|
pub async fn upload_multipart_part(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path((bucket_name, upload_id)): Path<(String, String)>,
|
|
Query(query): Query<MultipartPartQuery>,
|
|
body: Body,
|
|
) -> Response {
|
|
let Some(part_number) = query.part_number else {
|
|
return json_error(StatusCode::BAD_REQUEST, "partNumber is required");
|
|
};
|
|
if !(1..=10_000).contains(&part_number) {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"partNumber must be between 1 and 10000",
|
|
);
|
|
}
|
|
|
|
let bytes = match to_bytes(body, usize::MAX).await {
|
|
Ok(bytes) if !bytes.is_empty() => bytes,
|
|
Ok(_) => return json_error(StatusCode::BAD_REQUEST, "Empty request body"),
|
|
Err(_) => return json_error(StatusCode::BAD_REQUEST, "Failed to read request body"),
|
|
};
|
|
let reader: myfsio_storage::traits::AsyncReadStream = Box::pin(Cursor::new(bytes.to_vec()));
|
|
match state
|
|
.storage
|
|
.upload_part(&bucket_name, &upload_id, part_number, reader)
|
|
.await
|
|
{
|
|
Ok(etag) => json_ok(json!({ "etag": etag, "part_number": part_number })),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct CompleteMultipartPayload {
|
|
#[serde(default)]
|
|
parts: Vec<CompleteMultipartPartPayload>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct CompleteMultipartPartPayload {
|
|
#[serde(default, alias = "PartNumber")]
|
|
part_number: u32,
|
|
#[serde(default, alias = "ETag")]
|
|
etag: String,
|
|
}
|
|
|
|
pub async fn complete_multipart_upload(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path((bucket_name, upload_id)): Path<(String, String)>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: CompleteMultipartPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
if payload.parts.is_empty() {
|
|
return json_error(StatusCode::BAD_REQUEST, "parts array required");
|
|
}
|
|
|
|
let parts = payload
|
|
.parts
|
|
.iter()
|
|
.map(|part| PartInfo {
|
|
part_number: part.part_number,
|
|
etag: part.etag.trim_matches('"').to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
match state
|
|
.storage
|
|
.complete_multipart(&bucket_name, &upload_id, &parts)
|
|
.await
|
|
{
|
|
Ok(meta) => {
|
|
super::trigger_replication(&state, &bucket_name, &meta.key, "write");
|
|
json_ok(json!({
|
|
"key": meta.key,
|
|
"size": meta.size,
|
|
"etag": meta.etag.unwrap_or_default(),
|
|
"last_modified": meta.last_modified.to_rfc3339(),
|
|
}))
|
|
}
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
pub async fn abort_multipart_upload(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path((bucket_name, upload_id)): Path<(String, String)>,
|
|
) -> Response {
|
|
match state
|
|
.storage
|
|
.abort_multipart(&bucket_name, &upload_id)
|
|
.await
|
|
{
|
|
Ok(()) => json_ok(json!({ "status": "aborted" })),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
async fn get_bucket_config_json(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
) -> Result<myfsio_common::types::BucketConfig, StorageError> {
|
|
state.storage.get_bucket_config(bucket).await
|
|
}
|
|
|
|
pub async fn bucket_acl(
|
|
State(state): State<AppState>,
|
|
Extension(session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
match get_bucket_config_json(&state, &bucket_name).await {
|
|
Ok(config) => Json(parse_acl_value(
|
|
config.acl.as_ref(),
|
|
&owner_id_or_default(&session),
|
|
))
|
|
.into_response(),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct BucketAclPayload {
|
|
#[serde(default)]
|
|
canned_acl: String,
|
|
}
|
|
|
|
pub async fn update_bucket_acl(
|
|
State(state): State<AppState>,
|
|
Extension(session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: BucketAclPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
if payload.canned_acl.trim().is_empty() {
|
|
return json_error(StatusCode::BAD_REQUEST, "canned_acl is required");
|
|
}
|
|
|
|
let acl_xml = match bucket_acl_xml_for_canned(
|
|
&owner_id_or_default(&session),
|
|
payload.canned_acl.trim(),
|
|
) {
|
|
Ok(xml) => xml,
|
|
Err(message) => return json_error(StatusCode::BAD_REQUEST, message),
|
|
};
|
|
|
|
match state.storage.get_bucket_config(&bucket_name).await {
|
|
Ok(mut config) => {
|
|
config.acl = Some(Value::String(acl_xml));
|
|
match state.storage.set_bucket_config(&bucket_name, &config).await {
|
|
Ok(()) => json_ok(json!({
|
|
"status": "ok",
|
|
"message": format!("ACL set to {}", payload.canned_acl.trim()),
|
|
})),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
pub async fn bucket_cors(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
match get_bucket_config_json(&state, &bucket_name).await {
|
|
Ok(config) => Json(parse_cors_value(config.cors.as_ref())).into_response(),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct BucketCorsPayload {
|
|
#[serde(default)]
|
|
rules: Vec<Value>,
|
|
}
|
|
|
|
pub async fn update_bucket_cors(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: BucketCorsPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
match state.storage.get_bucket_config(&bucket_name).await {
|
|
Ok(mut config) => {
|
|
config.cors = if payload.rules.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Value::String(cors_xml_from_rules(&payload.rules)))
|
|
};
|
|
match state.storage.set_bucket_config(&bucket_name, &config).await {
|
|
Ok(()) => json_ok(json!({
|
|
"status": "ok",
|
|
"message": "CORS configuration saved",
|
|
"rules": payload.rules,
|
|
})),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
pub async fn bucket_lifecycle(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
match get_bucket_config_json(&state, &bucket_name).await {
|
|
Ok(config) => Json(parse_lifecycle_value(config.lifecycle.as_ref())).into_response(),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct BucketLifecyclePayload {
|
|
#[serde(default)]
|
|
rules: Vec<Value>,
|
|
}
|
|
|
|
pub async fn update_bucket_lifecycle(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: BucketLifecyclePayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
match state.storage.get_bucket_config(&bucket_name).await {
|
|
Ok(mut config) => {
|
|
config.lifecycle = if payload.rules.is_empty() {
|
|
None
|
|
} else {
|
|
Some(Value::String(lifecycle_xml_from_rules(&payload.rules)))
|
|
};
|
|
match state.storage.set_bucket_config(&bucket_name, &config).await {
|
|
Ok(()) => json_ok(json!({
|
|
"status": "ok",
|
|
"message": "Lifecycle rules saved",
|
|
"rules": payload.rules,
|
|
})),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
async fn serve_object_download_or_preview(
|
|
state: AppState,
|
|
bucket: String,
|
|
key: String,
|
|
headers: HeaderMap,
|
|
is_download: bool,
|
|
) -> Response {
|
|
let content_type = state
|
|
.storage
|
|
.head_object(&bucket, &key)
|
|
.await
|
|
.ok()
|
|
.and_then(|meta| meta.content_type);
|
|
|
|
let mut query = ObjectQuery::default();
|
|
if is_download {
|
|
query.response_content_disposition = Some(format!(
|
|
"attachment; filename=\"{}\"",
|
|
safe_attachment_filename(&key)
|
|
));
|
|
} else if let Some(forced) = dangerous_preview_content_type(content_type.as_deref(), &key) {
|
|
query.response_content_type = Some(forced);
|
|
}
|
|
|
|
let mut response =
|
|
handlers::get_object(State(state), Path((bucket, key)), Query(query), headers).await;
|
|
response
|
|
.headers_mut()
|
|
.insert("x-content-type-options", "nosniff".parse().unwrap());
|
|
response
|
|
}
|
|
|
|
async fn object_metadata_json(state: &AppState, bucket: &str, key: &str) -> Response {
|
|
let head = match state.storage.head_object(bucket, key).await {
|
|
Ok(meta) => meta,
|
|
Err(err) => return storage_json_error(err),
|
|
};
|
|
let metadata = state
|
|
.storage
|
|
.get_object_metadata(bucket, key)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let mut out = metadata.clone();
|
|
if let Some(content_type) = head.content_type {
|
|
out.insert("Content-Type".to_string(), content_type);
|
|
}
|
|
let display_length = metadata
|
|
.get("__size__")
|
|
.cloned()
|
|
.unwrap_or_else(|| head.size.to_string());
|
|
out.insert("Content-Length".to_string(), display_length);
|
|
if let Some(algorithm) = metadata.get("x-amz-server-side-encryption") {
|
|
out.insert(
|
|
"x-amz-server-side-encryption".to_string(),
|
|
algorithm.to_string(),
|
|
);
|
|
}
|
|
Json(json!({ "metadata": out })).into_response()
|
|
}
|
|
|
|
async fn object_versions_json(state: &AppState, bucket: &str, key: &str) -> Response {
|
|
match read_version_manifests_for_object(state, bucket, key) {
|
|
Ok(entries) => Json(json!({
|
|
"versions": entries.into_iter().map(|entry| manifest_to_json(&entry)).collect::<Vec<_>>(),
|
|
}))
|
|
.into_response(),
|
|
Err(err) => json_error(StatusCode::BAD_REQUEST, err),
|
|
}
|
|
}
|
|
|
|
async fn object_tags_json(state: &AppState, bucket: &str, key: &str) -> Response {
|
|
match state.storage.get_object_tags(bucket, key).await {
|
|
Ok(tags) => Json(json!({
|
|
"tags": tags.into_iter().map(|tag| json!({ "Key": tag.key, "Value": tag.value })).collect::<Vec<_>>(),
|
|
}))
|
|
.into_response(),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct PresignPayload {
|
|
#[serde(default = "default_presign_method")]
|
|
method: String,
|
|
#[serde(default)]
|
|
expires_in: Option<u64>,
|
|
}
|
|
|
|
fn default_presign_method() -> String {
|
|
"GET".to_string()
|
|
}
|
|
|
|
async fn object_presign_json(
|
|
state: &AppState,
|
|
session: &SessionHandle,
|
|
bucket: &str,
|
|
key: &str,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: PresignPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let method = payload.method.trim().to_ascii_uppercase();
|
|
if !matches!(method.as_str(), "GET" | "PUT" | "DELETE") {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"Method must be GET, PUT, or DELETE",
|
|
);
|
|
}
|
|
|
|
let access_key = match current_access_key(session) {
|
|
Some(key) => key,
|
|
None => return json_error(StatusCode::FORBIDDEN, "Missing authenticated session"),
|
|
};
|
|
let secret_key = match state.iam.get_secret_key(&access_key) {
|
|
Some(secret) => secret,
|
|
None => {
|
|
return json_error(
|
|
StatusCode::FORBIDDEN,
|
|
"Session credentials are no longer valid",
|
|
)
|
|
}
|
|
};
|
|
|
|
let min_expiry = state.config.presigned_url_min_expiry;
|
|
let max_expiry = state.config.presigned_url_max_expiry;
|
|
let expires = payload
|
|
.expires_in
|
|
.unwrap_or(900)
|
|
.clamp(min_expiry, max_expiry);
|
|
|
|
let api_base = parse_api_base(state);
|
|
let parsed = match reqwest::Url::parse(&api_base) {
|
|
Ok(url) => url,
|
|
Err(err) => {
|
|
return json_error(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
format!("Invalid API_BASE_URL: {}", err),
|
|
)
|
|
}
|
|
};
|
|
let host = match parsed.host_str() {
|
|
Some(host) => {
|
|
if let Some(port) = parsed.port() {
|
|
format!("{}:{}", host, port)
|
|
} else {
|
|
host.to_string()
|
|
}
|
|
}
|
|
None => {
|
|
return json_error(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"Unable to determine API host",
|
|
)
|
|
}
|
|
};
|
|
|
|
let now = Utc::now();
|
|
let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string();
|
|
let date_stamp = now.format("%Y%m%d").to_string();
|
|
let region = state.config.region.as_str();
|
|
let credential = format!("{}/{}/{}/s3/aws4_request", access_key, date_stamp, region);
|
|
|
|
let canonical_uri = format!("/{}/{}", bucket, encode_object_key(key));
|
|
let mut query_params = vec![
|
|
(
|
|
"X-Amz-Algorithm".to_string(),
|
|
"AWS4-HMAC-SHA256".to_string(),
|
|
),
|
|
("X-Amz-Credential".to_string(), credential.clone()),
|
|
("X-Amz-Date".to_string(), amz_date.clone()),
|
|
("X-Amz-Expires".to_string(), expires.to_string()),
|
|
("X-Amz-SignedHeaders".to_string(), "host".to_string()),
|
|
];
|
|
query_params.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
|
|
|
let canonical_query = query_params
|
|
.iter()
|
|
.map(|(name, value)| format!("{}={}", aws_query_encode(name), aws_query_encode(value)))
|
|
.collect::<Vec<_>>()
|
|
.join("&");
|
|
let canonical_request = format!(
|
|
"{}\n{}\n{}\nhost:{}\n\nhost\nUNSIGNED-PAYLOAD",
|
|
method, canonical_uri, canonical_query, host
|
|
);
|
|
let scope = format!("{}/{}/s3/aws4_request", date_stamp, region);
|
|
let string_to_sign = sigv4::build_string_to_sign(&amz_date, &scope, &canonical_request);
|
|
let signing_key = sigv4::derive_signing_key(&secret_key, &date_stamp, region, "s3");
|
|
let signature = sigv4::compute_signature(&signing_key, &string_to_sign);
|
|
|
|
let final_query = format!("{}&X-Amz-Signature={}", canonical_query, signature);
|
|
let final_url = format!("{}{}?{}", api_base, canonical_uri, final_query);
|
|
|
|
Json(json!({
|
|
"url": final_url,
|
|
"method": method,
|
|
"expires_in": expires,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct ObjectTagsPayload {
|
|
#[serde(default)]
|
|
tags: Vec<ObjectTagPayload>,
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct ObjectTagPayload {
|
|
#[serde(default, alias = "Key", alias = "key")]
|
|
key: String,
|
|
#[serde(default, alias = "Value", alias = "value")]
|
|
value: String,
|
|
}
|
|
|
|
async fn update_object_tags(state: &AppState, bucket: &str, key: &str, body: Body) -> Response {
|
|
let payload: ObjectTagsPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
if payload.tags.len() > state.config.object_tag_limit {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
format!("Maximum {} tags allowed", state.config.object_tag_limit),
|
|
);
|
|
}
|
|
|
|
let tags = payload
|
|
.tags
|
|
.iter()
|
|
.filter(|tag| !tag.key.trim().is_empty())
|
|
.map(|tag| Tag {
|
|
key: tag.key.trim().to_string(),
|
|
value: tag.value.to_string(),
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let result = if tags.is_empty() {
|
|
state.storage.delete_object_tags(bucket, key).await
|
|
} else {
|
|
state.storage.set_object_tags(bucket, key, &tags).await
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => Json(json!({
|
|
"status": "ok",
|
|
"message": "Tags saved",
|
|
"tags": tags.into_iter().map(|tag| json!({ "Key": tag.key, "Value": tag.value })).collect::<Vec<_>>(),
|
|
}))
|
|
.into_response(),
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct CopyMovePayload {
|
|
#[serde(default)]
|
|
dest_bucket: String,
|
|
#[serde(default)]
|
|
dest_key: String,
|
|
}
|
|
|
|
async fn copy_object_json(state: &AppState, bucket: &str, key: &str, body: Body) -> Response {
|
|
let payload: CopyMovePayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
let dest_bucket = payload.dest_bucket.trim();
|
|
let dest_key = payload.dest_key.trim();
|
|
if dest_bucket.is_empty() || dest_key.is_empty() {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"dest_bucket and dest_key are required",
|
|
);
|
|
}
|
|
|
|
match state
|
|
.storage
|
|
.copy_object(bucket, key, dest_bucket, dest_key)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
super::trigger_replication(state, dest_bucket, dest_key, "write");
|
|
Json(json!({
|
|
"status": "ok",
|
|
"message": format!("Copied to {}/{}", dest_bucket, dest_key),
|
|
"dest_bucket": dest_bucket,
|
|
"dest_key": dest_key,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
async fn move_object_json(state: &AppState, bucket: &str, key: &str, body: Body) -> Response {
|
|
let payload: CopyMovePayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
let dest_bucket = payload.dest_bucket.trim();
|
|
let dest_key = payload.dest_key.trim();
|
|
if dest_bucket.is_empty() || dest_key.is_empty() {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"dest_bucket and dest_key are required",
|
|
);
|
|
}
|
|
if dest_bucket == bucket && dest_key == key {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"Cannot move object to the same location",
|
|
);
|
|
}
|
|
|
|
match state.storage.copy_object(bucket, key, dest_bucket, dest_key).await {
|
|
Ok(_) => match state.storage.delete_object(bucket, key).await {
|
|
Ok(_) => {
|
|
super::trigger_replication(state, dest_bucket, dest_key, "write");
|
|
super::trigger_replication(state, bucket, key, "delete");
|
|
Json(json!({
|
|
"status": "ok",
|
|
"message": format!("Moved to {}/{}", dest_bucket, dest_key),
|
|
"dest_bucket": dest_bucket,
|
|
"dest_key": dest_key,
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(_) => Json(json!({
|
|
"status": "partial",
|
|
"message": format!("Copied to {}/{} but failed to delete source", dest_bucket, dest_key),
|
|
"dest_bucket": dest_bucket,
|
|
"dest_key": dest_key,
|
|
}))
|
|
.into_response(),
|
|
},
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
async fn purge_object_versions_for_key(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
key: &str,
|
|
) -> Result<(), String> {
|
|
if let Ok(version_dir) = version_dir_for_object(state, bucket, key) {
|
|
if version_dir.exists() {
|
|
std::fs::remove_dir_all(&version_dir).map_err(|e| e.to_string())?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete_object_json(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
key: &str,
|
|
headers: &HeaderMap,
|
|
body: Body,
|
|
) -> Response {
|
|
let body_bytes = match to_bytes(body, usize::MAX).await {
|
|
Ok(bytes) => bytes,
|
|
Err(_) => return json_error(StatusCode::BAD_REQUEST, "Failed to read request body"),
|
|
};
|
|
|
|
let content_type = headers
|
|
.get(header::CONTENT_TYPE)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or_default();
|
|
let form = if content_type.starts_with("application/x-www-form-urlencoded") {
|
|
parse_form_body(&body_bytes)
|
|
} else {
|
|
HashMap::new()
|
|
};
|
|
let purge_versions = parse_bool_flag(form.get("purge_versions").map(|s| s.as_str()));
|
|
|
|
if purge_versions {
|
|
if let Err(err) = state.storage.delete_object(bucket, key).await {
|
|
return storage_json_error(err);
|
|
}
|
|
super::trigger_replication(state, bucket, key, "delete");
|
|
if let Err(err) = purge_object_versions_for_key(state, bucket, key).await {
|
|
return json_error(StatusCode::BAD_REQUEST, err);
|
|
}
|
|
return Json(json!({
|
|
"status": "ok",
|
|
"message": format!("Permanently deleted '{}' and all versions", key),
|
|
}))
|
|
.into_response();
|
|
}
|
|
|
|
match state.storage.delete_object(bucket, key).await {
|
|
Ok(_) => {
|
|
super::trigger_replication(state, bucket, key, "delete");
|
|
Json(json!({
|
|
"status": "ok",
|
|
"message": format!("Deleted '{}'", key),
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(err) => storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
async fn restore_object_version_json(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
key: &str,
|
|
version_id: &str,
|
|
) -> Response {
|
|
let version_dir = match version_dir_for_object(state, bucket, key) {
|
|
Ok(path) => path,
|
|
Err(err) => return json_error(StatusCode::BAD_REQUEST, err),
|
|
};
|
|
let data_path = version_dir.join(format!("{}.bin", version_id));
|
|
let meta_path = version_dir.join(format!("{}.json", version_id));
|
|
if !data_path.exists() || !meta_path.exists() {
|
|
return json_error(StatusCode::NOT_FOUND, "Version not found");
|
|
}
|
|
|
|
let manifest_text = match std::fs::read_to_string(&meta_path) {
|
|
Ok(text) => text,
|
|
Err(err) => return json_error(StatusCode::BAD_REQUEST, err.to_string()),
|
|
};
|
|
let manifest: VersionManifest = match serde_json::from_str(&manifest_text) {
|
|
Ok(manifest) => manifest,
|
|
Err(err) => return json_error(StatusCode::BAD_REQUEST, err.to_string()),
|
|
};
|
|
|
|
let live_exists = state.storage.head_object(bucket, key).await.is_ok();
|
|
let versioning_enabled = state
|
|
.storage
|
|
.is_versioning_enabled(bucket)
|
|
.await
|
|
.unwrap_or(false);
|
|
if live_exists {
|
|
if let Err(err) = state.storage.delete_object(bucket, key).await {
|
|
return storage_json_error(err);
|
|
}
|
|
}
|
|
|
|
let destination = match object_live_path(state, bucket, key) {
|
|
Ok(path) => path,
|
|
Err(err) => return json_error(StatusCode::BAD_REQUEST, err),
|
|
};
|
|
if let Some(parent) = destination.parent() {
|
|
if let Err(err) = tokio::fs::create_dir_all(parent).await {
|
|
return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
}
|
|
if let Err(err) = tokio::fs::copy(&data_path, &destination).await {
|
|
return json_error(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
|
}
|
|
if let Err(err) = state
|
|
.storage
|
|
.put_object_metadata(bucket, key, &manifest.metadata)
|
|
.await
|
|
{
|
|
return storage_json_error(err);
|
|
}
|
|
super::trigger_replication(state, bucket, key, "write");
|
|
|
|
let mut message = format!("Restored '{}'", key);
|
|
if live_exists && versioning_enabled {
|
|
message.push_str(" (previous current version was archived)");
|
|
}
|
|
Json(json!({ "status": "ok", "message": message })).into_response()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum ObjectGetAction {
|
|
Download,
|
|
Preview,
|
|
Metadata,
|
|
Versions,
|
|
Tags,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum ObjectPostAction {
|
|
Delete,
|
|
Presign,
|
|
Tags,
|
|
Copy,
|
|
Move,
|
|
Restore(String),
|
|
}
|
|
|
|
fn parse_object_get_action(rest: &str) -> Option<(String, ObjectGetAction)> {
|
|
for (suffix, action) in [
|
|
("/download", ObjectGetAction::Download),
|
|
("/preview", ObjectGetAction::Preview),
|
|
("/metadata", ObjectGetAction::Metadata),
|
|
("/versions", ObjectGetAction::Versions),
|
|
("/tags", ObjectGetAction::Tags),
|
|
] {
|
|
if let Some(key) = rest.strip_suffix(suffix) {
|
|
return Some((key.to_string(), action));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn parse_object_post_action(rest: &str) -> Option<(String, ObjectPostAction)> {
|
|
if let Some((key, version_id)) = rest.rsplit_once("/restore/") {
|
|
return Some((
|
|
key.to_string(),
|
|
ObjectPostAction::Restore(version_id.to_string()),
|
|
));
|
|
}
|
|
if let Some(key_with_version) = rest.strip_suffix("/restore") {
|
|
if let Some((key, version_id)) = key_with_version.rsplit_once("/versions/") {
|
|
return Some((
|
|
key.to_string(),
|
|
ObjectPostAction::Restore(version_id.to_string()),
|
|
));
|
|
}
|
|
}
|
|
for (suffix, action) in [
|
|
("/delete", ObjectPostAction::Delete),
|
|
("/presign", ObjectPostAction::Presign),
|
|
("/tags", ObjectPostAction::Tags),
|
|
("/copy", ObjectPostAction::Copy),
|
|
("/move", ObjectPostAction::Move),
|
|
] {
|
|
if let Some(key) = rest.strip_suffix(suffix) {
|
|
return Some((key.to_string(), action));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub async fn object_get_dispatch(
|
|
State(state): State<AppState>,
|
|
Extension(session): Extension<SessionHandle>,
|
|
Path((bucket_name, rest)): Path<(String, String)>,
|
|
headers: HeaderMap,
|
|
) -> Response {
|
|
let Some((key, action)) = parse_object_get_action(&rest) else {
|
|
return json_error(StatusCode::NOT_FOUND, "Unknown object action");
|
|
};
|
|
|
|
match action {
|
|
ObjectGetAction::Download => {
|
|
serve_object_download_or_preview(state, bucket_name, key, headers, true).await
|
|
}
|
|
ObjectGetAction::Preview => {
|
|
serve_object_download_or_preview(state, bucket_name, key, headers, false).await
|
|
}
|
|
ObjectGetAction::Metadata => object_metadata_json(&state, &bucket_name, &key).await,
|
|
ObjectGetAction::Versions => object_versions_json(&state, &bucket_name, &key).await,
|
|
ObjectGetAction::Tags => {
|
|
let _ = session;
|
|
object_tags_json(&state, &bucket_name, &key).await
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn object_post_dispatch(
|
|
State(state): State<AppState>,
|
|
Extension(session): Extension<SessionHandle>,
|
|
Path((bucket_name, rest)): Path<(String, String)>,
|
|
headers: HeaderMap,
|
|
body: Body,
|
|
) -> Response {
|
|
let Some((key, action)) = parse_object_post_action(&rest) else {
|
|
return json_error(StatusCode::NOT_FOUND, "Unknown object action");
|
|
};
|
|
|
|
match action {
|
|
ObjectPostAction::Delete => {
|
|
delete_object_json(&state, &bucket_name, &key, &headers, body).await
|
|
}
|
|
ObjectPostAction::Presign => {
|
|
object_presign_json(&state, &session, &bucket_name, &key, body).await
|
|
}
|
|
ObjectPostAction::Tags => update_object_tags(&state, &bucket_name, &key, body).await,
|
|
ObjectPostAction::Copy => copy_object_json(&state, &bucket_name, &key, body).await,
|
|
ObjectPostAction::Move => move_object_json(&state, &bucket_name, &key, body).await,
|
|
ObjectPostAction::Restore(version_id) => {
|
|
restore_object_version_json(&state, &bucket_name, &key, &version_id).await
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
struct BulkKeysPayload {
|
|
#[serde(default)]
|
|
keys: Vec<String>,
|
|
#[serde(default)]
|
|
purge_versions: bool,
|
|
}
|
|
|
|
async fn expand_bulk_keys(
|
|
state: &AppState,
|
|
bucket: &str,
|
|
keys: &[String],
|
|
) -> Result<Vec<String>, StorageError> {
|
|
let mut expanded = Vec::new();
|
|
for key in keys {
|
|
if key.ends_with('/') {
|
|
let params = ListParams {
|
|
max_keys: 5000,
|
|
continuation_token: None,
|
|
prefix: Some(key.clone()),
|
|
start_after: None,
|
|
};
|
|
let objects = state.storage.list_objects(bucket, ¶ms).await?;
|
|
for object in objects.objects {
|
|
expanded.push(object.key);
|
|
}
|
|
} else {
|
|
expanded.push(key.clone());
|
|
}
|
|
}
|
|
let mut unique = BTreeMap::new();
|
|
for key in expanded {
|
|
unique.entry(key.clone()).or_insert(key);
|
|
}
|
|
Ok(unique.into_values().collect())
|
|
}
|
|
|
|
pub async fn bulk_delete_objects(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: BulkKeysPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let cleaned = payload
|
|
.keys
|
|
.into_iter()
|
|
.map(|key| key.trim().to_string())
|
|
.filter(|key| !key.is_empty())
|
|
.collect::<Vec<_>>();
|
|
if cleaned.is_empty() {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"Select at least one object to delete",
|
|
);
|
|
}
|
|
|
|
let keys = match expand_bulk_keys(&state, &bucket_name, &cleaned).await {
|
|
Ok(keys) => keys,
|
|
Err(err) => return storage_json_error(err),
|
|
};
|
|
if keys.is_empty() {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"No objects found under the selected folders",
|
|
);
|
|
}
|
|
if keys.len() > state.config.bulk_delete_max_keys {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
format!(
|
|
"Bulk delete supports at most {} keys",
|
|
state.config.bulk_delete_max_keys
|
|
),
|
|
);
|
|
}
|
|
|
|
let mut deleted = Vec::new();
|
|
let mut errors = Vec::new();
|
|
|
|
for key in keys {
|
|
match state.storage.delete_object(&bucket_name, &key).await {
|
|
Ok(_) => {
|
|
super::trigger_replication(&state, &bucket_name, &key, "delete");
|
|
if payload.purge_versions {
|
|
if let Err(err) =
|
|
purge_object_versions_for_key(&state, &bucket_name, &key).await
|
|
{
|
|
errors.push(json!({ "key": key, "error": err }));
|
|
continue;
|
|
}
|
|
}
|
|
deleted.push(key);
|
|
}
|
|
Err(err) => errors.push(json!({ "key": key, "error": err.to_string() })),
|
|
}
|
|
}
|
|
|
|
if deleted.is_empty() && !errors.is_empty() {
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
Json(json!({
|
|
"status": "error",
|
|
"message": "Unable to delete the selected objects",
|
|
"deleted": deleted,
|
|
"errors": errors,
|
|
})),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let mut message = format!(
|
|
"Deleted {} object{}",
|
|
deleted.len(),
|
|
if deleted.len() == 1 { "" } else { "s" }
|
|
);
|
|
if payload.purge_versions && !deleted.is_empty() {
|
|
message.push_str(" (including archived versions)");
|
|
}
|
|
if !errors.is_empty() {
|
|
message.push_str(&format!("; {} failed", errors.len()));
|
|
}
|
|
|
|
Json(json!({
|
|
"status": if errors.is_empty() { "ok" } else { "partial" },
|
|
"message": message,
|
|
"deleted": deleted,
|
|
"errors": errors,
|
|
}))
|
|
.into_response()
|
|
}
|
|
|
|
pub async fn bulk_download_objects(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
body: Body,
|
|
) -> Response {
|
|
let payload: BulkKeysPayload = match parse_json_body(body).await {
|
|
Ok(payload) => payload,
|
|
Err(response) => return response,
|
|
};
|
|
|
|
let cleaned = payload
|
|
.keys
|
|
.into_iter()
|
|
.map(|key| key.trim().to_string())
|
|
.filter(|key| !key.is_empty())
|
|
.collect::<Vec<_>>();
|
|
if cleaned.is_empty() {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"Select at least one object to download",
|
|
);
|
|
}
|
|
|
|
let keys = match expand_bulk_keys(&state, &bucket_name, &cleaned).await {
|
|
Ok(keys) => keys,
|
|
Err(err) => return storage_json_error(err),
|
|
};
|
|
if keys.is_empty() {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"No objects found under the selected folders",
|
|
);
|
|
}
|
|
|
|
let mut total_bytes = 0u64;
|
|
let mut archive_entries = Vec::new();
|
|
for key in keys {
|
|
match state.storage.head_object(&bucket_name, &key).await {
|
|
Ok(meta) => {
|
|
total_bytes = total_bytes.saturating_add(meta.size);
|
|
match read_object_bytes_for_zip(&state, &bucket_name, &key).await {
|
|
Ok(bytes) => archive_entries.push((key, bytes, meta.last_modified)),
|
|
Err(err) => return json_error(StatusCode::BAD_REQUEST, err),
|
|
}
|
|
}
|
|
Err(err) => return storage_json_error(err),
|
|
}
|
|
}
|
|
|
|
let max_total_bytes = 256 * 1024 * 1024u64;
|
|
if total_bytes > max_total_bytes {
|
|
return json_error(
|
|
StatusCode::BAD_REQUEST,
|
|
"Total download size exceeds 256 MB limit. Select fewer objects.",
|
|
);
|
|
}
|
|
|
|
let zip_bytes = match build_zip_archive(archive_entries) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) => return json_error(StatusCode::BAD_REQUEST, err),
|
|
};
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::CONTENT_TYPE, "application/zip".parse().unwrap());
|
|
headers.insert(
|
|
header::CONTENT_DISPOSITION,
|
|
format!("attachment; filename=\"{}-download.zip\"", bucket_name)
|
|
.parse()
|
|
.unwrap(),
|
|
);
|
|
(StatusCode::OK, headers, zip_bytes).into_response()
|
|
}
|
|
|
|
pub async fn archived_objects(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
let versions_root = version_root_for_bucket(&state, &bucket_name);
|
|
if !versions_root.exists() {
|
|
return Json(json!({ "objects": [] })).into_response();
|
|
}
|
|
|
|
let mut grouped: BTreeMap<String, Vec<VersionManifest>> = BTreeMap::new();
|
|
let mut stack = vec![versions_root];
|
|
|
|
while let Some(current) = stack.pop() {
|
|
let read_dir = match std::fs::read_dir(¤t) {
|
|
Ok(entries) => entries,
|
|
Err(_) => continue,
|
|
};
|
|
for entry in read_dir.flatten() {
|
|
let file_type = match entry.file_type() {
|
|
Ok(file_type) => file_type,
|
|
Err(_) => continue,
|
|
};
|
|
if file_type.is_dir() {
|
|
stack.push(entry.path());
|
|
continue;
|
|
}
|
|
if entry.path().extension().and_then(|ext| ext.to_str()) != Some("json") {
|
|
continue;
|
|
}
|
|
let text = match std::fs::read_to_string(entry.path()) {
|
|
Ok(text) => text,
|
|
Err(_) => continue,
|
|
};
|
|
let manifest = match serde_json::from_str::<VersionManifest>(&text) {
|
|
Ok(manifest) => manifest,
|
|
Err(_) => continue,
|
|
};
|
|
if manifest.key.is_empty() {
|
|
continue;
|
|
}
|
|
grouped
|
|
.entry(manifest.key.clone())
|
|
.or_default()
|
|
.push(manifest);
|
|
}
|
|
}
|
|
|
|
let mut objects = Vec::new();
|
|
for (key, mut versions) in grouped {
|
|
let live_exists = object_live_path(&state, &bucket_name, &key)
|
|
.map(|path| path.exists())
|
|
.unwrap_or(false);
|
|
if live_exists {
|
|
continue;
|
|
}
|
|
versions.sort_by(|a, b| manifest_timestamp(b).cmp(&manifest_timestamp(a)));
|
|
let latest = versions.first().map(|record| manifest_to_json(record));
|
|
objects.push(json!({
|
|
"key": key,
|
|
"versions": versions.len(),
|
|
"total_size": versions.iter().map(|entry| entry.size).sum::<u64>(),
|
|
"latest": latest,
|
|
"restore_url": versions.first().map(|record| format!(
|
|
"/ui/buckets/{}/archived/{}/restore/{}",
|
|
bucket_name,
|
|
encode_object_key(&record.key),
|
|
encode_path_segment(&record.version_id)
|
|
)),
|
|
"purge_url": format!(
|
|
"/ui/buckets/{}/archived/{}/purge",
|
|
bucket_name,
|
|
encode_object_key(&key)
|
|
),
|
|
}));
|
|
}
|
|
|
|
Json(json!({ "objects": objects })).into_response()
|
|
}
|
|
|
|
pub async fn archived_post_dispatch(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path((bucket_name, rest)): Path<(String, String)>,
|
|
) -> Response {
|
|
if let Some((key, version_id)) = rest.rsplit_once("/restore/") {
|
|
return restore_object_version_json(&state, &bucket_name, key, version_id).await;
|
|
}
|
|
if let Some(key) = rest.strip_suffix("/purge") {
|
|
match purge_object_versions_for_key(&state, &bucket_name, key).await {
|
|
Ok(()) => {
|
|
let _ = state.storage.delete_object(&bucket_name, key).await;
|
|
super::trigger_replication(&state, &bucket_name, key, "delete");
|
|
Json(json!({
|
|
"status": "ok",
|
|
"message": format!("Removed archived versions for '{}'", key),
|
|
}))
|
|
.into_response()
|
|
}
|
|
Err(err) => json_error(StatusCode::BAD_REQUEST, err),
|
|
}
|
|
} else {
|
|
json_error(StatusCode::NOT_FOUND, "Unknown archived object action")
|
|
}
|
|
}
|
|
|
|
pub async fn gc_status_ui(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
) -> Response {
|
|
match &state.gc {
|
|
Some(gc) => Json(gc.status().await).into_response(),
|
|
None => Json(json!({
|
|
"enabled": false,
|
|
"message": "GC is not enabled. Set GC_ENABLED=true to enable."
|
|
}))
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn gc_run_ui(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
body: Body,
|
|
) -> Response {
|
|
let Some(gc) = &state.gc else {
|
|
return json_error(StatusCode::BAD_REQUEST, "GC is not enabled");
|
|
};
|
|
let payload: Value = parse_json_body(body).await.unwrap_or_else(|_| json!({}));
|
|
let dry_run = payload
|
|
.get("dry_run")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
match gc.run_now(dry_run).await {
|
|
Ok(result) => Json(result).into_response(),
|
|
Err(err) => json_error(StatusCode::CONFLICT, err),
|
|
}
|
|
}
|
|
|
|
pub async fn gc_history_ui(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
) -> Response {
|
|
let limit = params.get("limit").and_then(|v| v.parse::<usize>().ok());
|
|
match &state.gc {
|
|
Some(gc) => Json(apply_history_limit(gc.history().await, limit)).into_response(),
|
|
None => Json(json!({ "executions": [] })).into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn integrity_status_ui(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
) -> Response {
|
|
match &state.integrity {
|
|
Some(checker) => Json(checker.status().await).into_response(),
|
|
None => Json(json!({
|
|
"enabled": false,
|
|
"message": "Integrity checker is not enabled. Set INTEGRITY_ENABLED=true to enable."
|
|
}))
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn integrity_run_ui(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
body: Body,
|
|
) -> Response {
|
|
let Some(checker) = &state.integrity else {
|
|
return json_error(StatusCode::BAD_REQUEST, "Integrity checker is not enabled");
|
|
};
|
|
let payload: Value = parse_json_body(body).await.unwrap_or_else(|_| json!({}));
|
|
let dry_run = payload
|
|
.get("dry_run")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
let auto_heal = payload
|
|
.get("auto_heal")
|
|
.and_then(|value| value.as_bool())
|
|
.unwrap_or(false);
|
|
match checker.run_now(dry_run, auto_heal).await {
|
|
Ok(result) => Json(result).into_response(),
|
|
Err(err) => json_error(StatusCode::CONFLICT, err),
|
|
}
|
|
}
|
|
|
|
pub async fn integrity_history_ui(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
) -> Response {
|
|
let limit = params.get("limit").and_then(|v| v.parse::<usize>().ok());
|
|
match &state.integrity {
|
|
Some(checker) => Json(apply_history_limit(checker.history().await, limit)).into_response(),
|
|
None => Json(json!({ "executions": [] })).into_response(),
|
|
}
|
|
}
|
|
|
|
fn apply_history_limit(mut value: Value, limit: Option<usize>) -> Value {
|
|
if let Some(limit) = limit {
|
|
if let Some(arr) = value.get_mut("executions").and_then(|v| v.as_array_mut()) {
|
|
if arr.len() > limit {
|
|
arr.truncate(limit);
|
|
}
|
|
}
|
|
}
|
|
value
|
|
}
|
|
|
|
pub async fn lifecycle_history(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
) -> Response {
|
|
let limit = params
|
|
.get("limit")
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.unwrap_or(50);
|
|
let offset = params
|
|
.get("offset")
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
.unwrap_or(0);
|
|
if !state.config.lifecycle_enabled {
|
|
return Json(json!({
|
|
"executions": [],
|
|
"total": 0,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"enabled": false,
|
|
}))
|
|
.into_response();
|
|
}
|
|
Json(crate::services::lifecycle::read_history(
|
|
&state.config.storage_root,
|
|
&bucket_name,
|
|
limit,
|
|
offset,
|
|
))
|
|
.into_response()
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct ReplicationFailuresQuery {
|
|
#[serde(default)]
|
|
pub limit: Option<usize>,
|
|
#[serde(default)]
|
|
pub offset: Option<usize>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ReplicationObjectKeyQuery {
|
|
pub object_key: String,
|
|
}
|
|
|
|
pub async fn replication_status(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
let Some(rule) = state.replication.get_rule(&bucket_name) else {
|
|
return json_error(StatusCode::NOT_FOUND, "No replication rule");
|
|
};
|
|
|
|
let (endpoint_healthy, endpoint_error) = match state.connections.get(&rule.target_connection_id)
|
|
{
|
|
Some(conn) => {
|
|
let healthy = state.replication.check_endpoint(&conn).await;
|
|
let error = if healthy {
|
|
None
|
|
} else {
|
|
Some(format!("Cannot reach endpoint: {}", conn.endpoint_url))
|
|
};
|
|
(healthy, error)
|
|
}
|
|
None => (false, Some("Target connection not found".to_string())),
|
|
};
|
|
|
|
json_ok(json!({
|
|
"enabled": rule.enabled,
|
|
"target_bucket": rule.target_bucket,
|
|
"target_connection_id": rule.target_connection_id,
|
|
"mode": rule.mode,
|
|
"objects_synced": rule.stats.objects_synced,
|
|
"objects_pending": rule.stats.objects_pending,
|
|
"objects_orphaned": rule.stats.objects_orphaned,
|
|
"bytes_synced": rule.stats.bytes_synced,
|
|
"last_sync_at": rule.stats.last_sync_at,
|
|
"last_sync_key": rule.stats.last_sync_key,
|
|
"endpoint_healthy": endpoint_healthy,
|
|
"endpoint_error": endpoint_error,
|
|
}))
|
|
}
|
|
|
|
pub async fn replication_failures(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(q): Query<ReplicationFailuresQuery>,
|
|
) -> Response {
|
|
let limit = q.limit.unwrap_or(50).clamp(1, 500);
|
|
let offset = q.offset.unwrap_or(0);
|
|
let failures = state
|
|
.replication
|
|
.get_failed_items(&bucket_name, limit, offset);
|
|
let total = state.replication.get_failure_count(&bucket_name);
|
|
json_ok(json!({
|
|
"failures": failures,
|
|
"total": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}))
|
|
}
|
|
|
|
pub async fn retry_replication_failure(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(q): Query<ReplicationObjectKeyQuery>,
|
|
) -> Response {
|
|
retry_replication_failure_key(&state, &bucket_name, q.object_key.trim()).await
|
|
}
|
|
|
|
pub async fn retry_replication_failure_path(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path((bucket_name, rest)): Path<(String, String)>,
|
|
) -> Response {
|
|
let Some(object_key) = rest.strip_suffix("/retry") else {
|
|
return json_error(StatusCode::NOT_FOUND, "Unknown replication failure action");
|
|
};
|
|
retry_replication_failure_key(&state, &bucket_name, object_key.trim()).await
|
|
}
|
|
|
|
async fn retry_replication_failure_key(
|
|
state: &AppState,
|
|
bucket_name: &str,
|
|
object_key: &str,
|
|
) -> Response {
|
|
if object_key.is_empty() {
|
|
return json_error(StatusCode::BAD_REQUEST, "object_key is required");
|
|
}
|
|
|
|
if state
|
|
.replication
|
|
.retry_failed(bucket_name, object_key)
|
|
.await
|
|
{
|
|
json_ok(json!({
|
|
"status": "submitted",
|
|
"object_key": object_key,
|
|
}))
|
|
} else {
|
|
json_error(StatusCode::BAD_REQUEST, "Failed to submit retry")
|
|
}
|
|
}
|
|
|
|
pub async fn retry_all_replication_failures(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
let (submitted, skipped) = state.replication.retry_all(&bucket_name).await;
|
|
json_ok(json!({
|
|
"status": "submitted",
|
|
"submitted": submitted,
|
|
"skipped": skipped,
|
|
}))
|
|
}
|
|
|
|
pub async fn dismiss_replication_failure(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
Query(q): Query<ReplicationObjectKeyQuery>,
|
|
) -> Response {
|
|
dismiss_replication_failure_key(&state, &bucket_name, q.object_key.trim())
|
|
}
|
|
|
|
pub async fn dismiss_replication_failure_path(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path((bucket_name, object_key)): Path<(String, String)>,
|
|
) -> Response {
|
|
dismiss_replication_failure_key(&state, &bucket_name, object_key.trim())
|
|
}
|
|
|
|
fn dismiss_replication_failure_key(
|
|
state: &AppState,
|
|
bucket_name: &str,
|
|
object_key: &str,
|
|
) -> Response {
|
|
if object_key.is_empty() {
|
|
return json_error(StatusCode::BAD_REQUEST, "object_key is required");
|
|
}
|
|
|
|
if state.replication.dismiss_failure(bucket_name, object_key) {
|
|
json_ok(json!({
|
|
"status": "dismissed",
|
|
"object_key": object_key,
|
|
}))
|
|
} else {
|
|
json_error(StatusCode::NOT_FOUND, "Failure not found")
|
|
}
|
|
}
|
|
|
|
pub async fn clear_replication_failures(
|
|
State(state): State<AppState>,
|
|
Extension(_session): Extension<SessionHandle>,
|
|
Path(bucket_name): Path<String>,
|
|
) -> Response {
|
|
state.replication.clear_failures(&bucket_name);
|
|
json_ok(json!({ "status": "cleared" }))
|
|
}
|
|
|
|
static SERVER_START_TIME: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
|
static SYSINFO: std::sync::OnceLock<Mutex<System>> = std::sync::OnceLock::new();
|
|
|
|
async fn sample_system() -> (f64, u64, u64) {
|
|
let lock = SYSINFO.get_or_init(|| {
|
|
let mut system = System::new();
|
|
system.refresh_cpu_usage();
|
|
system.refresh_memory();
|
|
Mutex::new(system)
|
|
});
|
|
{
|
|
let mut system = lock.lock().unwrap();
|
|
system.refresh_cpu_usage();
|
|
}
|
|
tokio::time::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await;
|
|
let mut system = lock.lock().unwrap();
|
|
system.refresh_cpu_usage();
|
|
system.refresh_memory();
|
|
let cpu_percent = system.global_cpu_usage() as f64;
|
|
let mem_total = system.total_memory();
|
|
let mem_used = system.used_memory();
|
|
(cpu_percent, mem_used, mem_total)
|
|
}
|
|
|
|
fn normalize_path_for_mount(path: &FsPath) -> String {
|
|
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
|
let raw = canonical.to_string_lossy().to_string();
|
|
let stripped = raw.strip_prefix(r"\\?\").unwrap_or(&raw);
|
|
stripped.to_lowercase()
|
|
}
|
|
|
|
fn sample_disk(path: &FsPath) -> (u64, u64) {
|
|
let disks = Disks::new_with_refreshed_list();
|
|
let path_str = normalize_path_for_mount(path);
|
|
let mut best: Option<(usize, u64, u64)> = None;
|
|
for disk in disks.list() {
|
|
let mount_raw = disk.mount_point().to_string_lossy().to_string();
|
|
let mount = mount_raw
|
|
.strip_prefix(r"\\?\")
|
|
.unwrap_or(&mount_raw)
|
|
.to_lowercase();
|
|
let total = disk.total_space();
|
|
let free = disk.available_space();
|
|
if path_str.starts_with(&mount) {
|
|
let len = mount.len();
|
|
match best {
|
|
Some((best_len, _, _)) if len <= best_len => {}
|
|
_ => best = Some((len, total, free)),
|
|
}
|
|
}
|
|
}
|
|
best.map(|(_, total, free)| (total, free)).unwrap_or((0, 0))
|
|
}
|
|
|
|
pub async fn collect_metrics(state: &AppState) -> Value {
|
|
let start_time = *SERVER_START_TIME.get_or_init(std::time::Instant::now);
|
|
let uptime_days = start_time.elapsed().as_secs_f64() / 86400.0;
|
|
|
|
let buckets_list = state.storage.list_buckets().await.unwrap_or_default();
|
|
let bucket_count = buckets_list.len() as u64;
|
|
|
|
let mut total_objects: u64 = 0;
|
|
let mut total_bytes: u64 = 0;
|
|
let mut total_versions: u64 = 0;
|
|
for bucket in &buckets_list {
|
|
if let Ok(stats) = state.storage.bucket_stats(&bucket.name).await {
|
|
total_objects += stats.objects;
|
|
total_bytes += stats.bytes;
|
|
total_versions += stats.version_count;
|
|
}
|
|
}
|
|
|
|
let (cpu_percent, mem_used, mem_total) = sample_system().await;
|
|
let mem_pct = if mem_total > 0 {
|
|
(mem_used as f64 / mem_total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let (disk_total, disk_free) = sample_disk(&state.config.storage_root);
|
|
let disk_used = disk_total.saturating_sub(disk_free);
|
|
let disk_pct = if disk_total > 0 {
|
|
(disk_used as f64 / disk_total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
json!({
|
|
"cpu_percent": cpu_percent,
|
|
"memory": {
|
|
"percent": mem_pct,
|
|
"used": human_size(mem_used),
|
|
"total": human_size(mem_total),
|
|
},
|
|
"disk": {
|
|
"percent": disk_pct,
|
|
"free": human_size(disk_free),
|
|
"total": human_size(disk_total),
|
|
},
|
|
"app": {
|
|
"storage_used": human_size(total_bytes),
|
|
"buckets": bucket_count,
|
|
"objects": total_objects,
|
|
"versions": total_versions,
|
|
"uptime_days": uptime_days.floor() as u64,
|
|
},
|
|
})
|
|
}
|
|
|
|
pub async fn metrics_api(State(state): State<AppState>) -> Response {
|
|
Json(collect_metrics(&state).await).into_response()
|
|
}
|
|
|
|
#[derive(Deserialize, Default)]
|
|
pub struct HoursQuery {
|
|
#[serde(default)]
|
|
pub hours: Option<u64>,
|
|
}
|
|
|
|
pub async fn metrics_history(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<HoursQuery>,
|
|
) -> Response {
|
|
let settings = metrics_settings_snapshot(&state);
|
|
match &state.system_metrics {
|
|
Some(metrics) => Json(json!({
|
|
"enabled": settings.enabled,
|
|
"history": metrics.get_history(q.hours).await,
|
|
"interval_minutes": settings.interval_minutes,
|
|
"retention_hours": settings.retention_hours,
|
|
"hours_requested": q.hours.unwrap_or(settings.retention_hours),
|
|
}))
|
|
.into_response(),
|
|
None => Json(json!({
|
|
"enabled": settings.enabled,
|
|
"history": [],
|
|
"interval_minutes": settings.interval_minutes,
|
|
"retention_hours": settings.retention_hours,
|
|
"hours_requested": q.hours.unwrap_or(settings.retention_hours),
|
|
}))
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn metrics_operations(State(state): State<AppState>) -> Response {
|
|
match &state.metrics {
|
|
Some(metrics) => {
|
|
let stats = metrics.get_current_stats();
|
|
Json(json!({
|
|
"enabled": true,
|
|
"stats": stats,
|
|
}))
|
|
.into_response()
|
|
}
|
|
None => Json(json!({
|
|
"enabled": false,
|
|
"stats": null,
|
|
}))
|
|
.into_response(),
|
|
}
|
|
}
|
|
|
|
pub async fn metrics_operations_history(
|
|
State(state): State<AppState>,
|
|
Query(q): Query<HoursQuery>,
|
|
) -> Response {
|
|
match &state.metrics {
|
|
Some(metrics) => {
|
|
let history = metrics.get_history(q.hours.or(Some(24)));
|
|
Json(json!({
|
|
"enabled": true,
|
|
"history": history,
|
|
"interval_minutes": 5,
|
|
}))
|
|
.into_response()
|
|
}
|
|
None => Json(json!({
|
|
"enabled": false,
|
|
"history": [],
|
|
"interval_minutes": 5,
|
|
}))
|
|
.into_response(),
|
|
}
|
|
}
|