use serde::Serialize;
use std::io::Read;
pub trait FileStorage: Send + Sync {
fn store(
&self,
name: &str,
content: &[u8],
content_type: &str,
) -> Result<StoredFile, FileStorageError>;
fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError>;
fn delete(&self, id: &str) -> Result<bool, FileStorageError>;
fn presigned_upload_url(
&self,
_name: &str,
_content_type: &str,
_expires_secs: u64,
) -> Result<Option<String>, FileStorageError> {
Ok(None)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct StoredFile {
pub id: String,
pub url: String,
pub size: usize,
}
#[derive(Debug, Clone)]
pub struct FileStorageError {
pub code: String,
pub message: String,
}
impl std::fmt::Display for FileStorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for FileStorageError {}
pub struct LocalFileStorage {
dir: std::path::PathBuf,
url_prefix: String,
}
impl LocalFileStorage {
pub fn new(dir: &str, url_prefix: &str) -> Self {
let path = std::path::PathBuf::from(dir);
let _ = std::fs::create_dir_all(&path);
Self {
dir: path,
url_prefix: url_prefix.to_string(),
}
}
}
impl FileStorage for LocalFileStorage {
fn store(
&self,
name: &str,
content: &[u8],
_content_type: &str,
) -> Result<StoredFile, FileStorageError> {
let id = format!(
"file_{}_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
name.replace(['/', '\\', '.'], "_")
);
let path = self.dir.join(&id);
std::fs::write(&path, content).map_err(|e| FileStorageError {
code: "WRITE_FAILED".into(),
message: format!("Failed to write file: {e}"),
})?;
Ok(StoredFile {
url: format!("{}/{}", self.url_prefix, id),
size: content.len(),
id,
})
}
fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError> {
if id.contains("..") || id.contains('/') || id.contains('\\') {
return Err(FileStorageError {
code: "INVALID_ID".into(),
message: "Invalid file ID".into(),
});
}
let path = self.dir.join(id);
std::fs::read(&path).map_err(|_| FileStorageError {
code: "NOT_FOUND".into(),
message: "File not found".into(),
})
}
fn delete(&self, id: &str) -> Result<bool, FileStorageError> {
if id.contains("..") || id.contains('/') || id.contains('\\') {
return Err(FileStorageError {
code: "INVALID_ID".into(),
message: "Invalid file ID".into(),
});
}
let path = self.dir.join(id);
match std::fs::remove_file(&path) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(FileStorageError {
code: "DELETE_FAILED".into(),
message: format!("Failed to delete file: {e}"),
}),
}
}
}
#[derive(Debug, Clone)]
pub struct S3Config {
pub bucket: String,
pub region: String,
pub endpoint: Option<String>,
pub access_key: String,
pub secret_key: String,
pub public_url_prefix: Option<String>,
}
impl S3Config {
pub fn from_env() -> Option<Self> {
Some(Self {
bucket: std::env::var("PYLON_S3_BUCKET").ok()?,
region: std::env::var("PYLON_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
endpoint: std::env::var("PYLON_S3_ENDPOINT").ok(),
access_key: std::env::var("PYLON_S3_ACCESS_KEY").ok()?,
secret_key: std::env::var("PYLON_S3_SECRET_KEY").ok()?,
public_url_prefix: std::env::var("PYLON_S3_PUBLIC_URL").ok(),
})
}
}
pub struct Stack0FileStorage {
api_key: String,
base_url: String,
folder: Option<String>,
}
impl Stack0FileStorage {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
base_url: "https://api.stack0.dev".into(),
folder: None,
}
}
pub fn with_folder(mut self, folder: impl Into<String>) -> Self {
self.folder = Some(folder.into());
self
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn from_env() -> Option<Self> {
let api_key = std::env::var("PYLON_STACK0_API_KEY").ok()?;
let mut s = Self::new(api_key);
if let Ok(folder) = std::env::var("PYLON_STACK0_FOLDER") {
s = s.with_folder(folder);
}
if let Ok(base) = std::env::var("PYLON_STACK0_BASE_URL") {
s = s.with_base_url(base);
}
Some(s)
}
pub fn build_upload_init_body(
&self,
filename: &str,
content_type: &str,
size: usize,
) -> serde_json::Value {
let mut body = serde_json::json!({
"filename": filename,
"mimeType": content_type,
"size": size,
});
if let Some(folder) = &self.folder {
body["folder"] = serde_json::Value::String(folder.clone());
}
body
}
}
fn stack0_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(10))
.timeout_read(std::time::Duration::from_secs(30))
.timeout_write(std::time::Duration::from_secs(30))
.user_agent("pylon-storage/0.1")
.build()
}
fn stack0_err(code: &str, e: impl std::fmt::Display) -> FileStorageError {
FileStorageError {
code: code.into(),
message: e.to_string(),
}
}
impl FileStorage for Stack0FileStorage {
fn store(
&self,
name: &str,
content: &[u8],
content_type: &str,
) -> Result<StoredFile, FileStorageError> {
let agent = stack0_agent();
let init_body = self.build_upload_init_body(name, content_type, content.len());
let init_resp: serde_json::Value = agent
.post(&format!("{}/cdn/upload", self.base_url))
.set("Authorization", &format!("Bearer {}", self.api_key))
.set("Content-Type", "application/json")
.send_string(&init_body.to_string())
.map_err(|e| stack0_err("STACK0_UPLOAD_INIT_FAILED", e))?
.into_json()
.map_err(|e| stack0_err("STACK0_UPLOAD_INIT_PARSE", e))?;
let upload_url = init_resp["uploadUrl"]
.as_str()
.ok_or_else(|| stack0_err("STACK0_UPLOAD_INIT_BAD_RESPONSE", "missing uploadUrl"))?;
let asset_id = init_resp["assetId"]
.as_str()
.ok_or_else(|| stack0_err("STACK0_UPLOAD_INIT_BAD_RESPONSE", "missing assetId"))?
.to_string();
let cdn_url = init_resp["cdnUrl"]
.as_str()
.ok_or_else(|| stack0_err("STACK0_UPLOAD_INIT_BAD_RESPONSE", "missing cdnUrl"))?
.to_string();
agent
.put(upload_url)
.set("Content-Type", content_type)
.send_bytes(content)
.map_err(|e| stack0_err("STACK0_UPLOAD_PUT_FAILED", e))?;
agent
.post(&format!("{}/cdn/upload/{}/confirm", self.base_url, asset_id))
.set("Authorization", &format!("Bearer {}", self.api_key))
.call()
.map_err(|e| stack0_err("STACK0_UPLOAD_CONFIRM_FAILED", e))?;
Ok(StoredFile {
id: asset_id,
url: cdn_url,
size: content.len(),
})
}
fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError> {
let agent = stack0_agent();
let meta: serde_json::Value = agent
.get(&format!("{}/cdn/assets/{}", self.base_url, id))
.set("Authorization", &format!("Bearer {}", self.api_key))
.call()
.map_err(|e| match &e {
ureq::Error::Status(404, _) => stack0_err("NOT_FOUND", "Asset not found"),
_ => stack0_err("STACK0_GET_FAILED", e),
})?
.into_json()
.map_err(|e| stack0_err("STACK0_GET_PARSE", e))?;
let cdn_url = meta["cdnUrl"]
.as_str()
.ok_or_else(|| stack0_err("STACK0_GET_BAD_RESPONSE", "missing cdnUrl"))?;
let mut buf = Vec::new();
agent
.get(cdn_url)
.call()
.map_err(|e| stack0_err("STACK0_FETCH_FAILED", e))?
.into_reader()
.read_to_end(&mut buf)
.map_err(|e| stack0_err("STACK0_FETCH_READ", e))?;
Ok(buf)
}
fn delete(&self, id: &str) -> Result<bool, FileStorageError> {
let agent = stack0_agent();
match agent
.delete(&format!("{}/cdn/assets/{}", self.base_url, id))
.set("Authorization", &format!("Bearer {}", self.api_key))
.call()
{
Ok(_) => Ok(true),
Err(ureq::Error::Status(404, _)) => Ok(false),
Err(e) => Err(stack0_err("STACK0_DELETE_FAILED", e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_store_and_get() {
let dir = std::env::temp_dir().join(format!("pylon_files_{}", std::process::id()));
let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
let stored = storage
.store("test.txt", b"hello world", "text/plain")
.unwrap();
assert_eq!(stored.size, 11);
assert!(stored.url.starts_with("/api/files/"));
let content = storage.get(&stored.id).unwrap();
assert_eq!(content, b"hello world");
let deleted = storage.delete(&stored.id).unwrap();
assert!(deleted);
let not_found = storage.get(&stored.id);
assert!(not_found.is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn local_rejects_traversal() {
let dir = std::env::temp_dir().join(format!("pylon_files2_{}", std::process::id()));
let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
assert!(storage.get("../etc/passwd").is_err());
assert!(storage.delete("../etc/passwd").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stack0_upload_init_body_shape() {
let storage = Stack0FileStorage::new("sk_test_123");
let body = storage.build_upload_init_body("photo.jpg", "image/jpeg", 4096);
assert_eq!(body["filename"], "photo.jpg");
assert_eq!(body["mimeType"], "image/jpeg");
assert_eq!(body["size"], 4096);
assert!(body.get("folder").is_none());
}
#[test]
fn stack0_upload_init_body_includes_folder() {
let storage = Stack0FileStorage::new("sk_test_123").with_folder("avatars");
let body = storage.build_upload_init_body("photo.jpg", "image/jpeg", 4096);
assert_eq!(body["folder"], "avatars");
}
#[test]
fn stack0_default_base_url() {
let storage = Stack0FileStorage::new("sk_test_123");
assert_eq!(storage.base_url, "https://api.stack0.dev");
}
#[test]
fn stack0_with_base_url_override() {
let storage = Stack0FileStorage::new("sk_test_123").with_base_url("http://localhost:9999");
assert_eq!(storage.base_url, "http://localhost:9999");
}
}