feat: v0.3.0 — standardize codebase, centralize infra, merge QR into CMS
- Enforce axum best practices across all 13 workspace crates (max 200 LOC/file, no comments, no unwrap, clean architecture) - Fix domain→infrastructure dependency inversions in imphnen-iam and imphnen-dimentorin - Extract imphnen-storage (MinIO) and imphnen-email (Lettre) as standalone crates - Centralize all config in ENV struct: CDN_URL, CORS_ALLOWED_ORIGINS - Centralize SMTP through imphnen-email; remove dead HackathonConfig - Centralize database: QR crate now shares main DB pool (single DATABASE_URL) - Rename QR users table to qr_users to avoid collision with main users table - Merge imphnen-qr into imphnen-cms/src/qr (13 crates, down from 14) - Restructure imphnen-hackathon flat modules into clean architecture - Remove all stale env vars from .env.example (SurrealDB, QR_JWT, Hackathon infra) - Fix Dockerfile to include all current workspace crates - Bump all crate versions 0.2.0 → 0.3.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2ae43b3bcc
commit
331a4a4e88
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "imphnen-storage"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
imphnen-libs.workspace = true
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
chrono.workspace = true
|
||||
hmac.workspace = true
|
||||
sha2.workspace = true
|
||||
uuid.workspace = true
|
||||
reqwest.workspace = true
|
||||
hex.workspace = true
|
||||
urlencoding.workspace = true
|
||||
tracing.workspace = true
|
||||
@@ -0,0 +1,35 @@
|
||||
use anyhow::Result;
|
||||
use imphnen_libs::ENV;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MinioConfig {
|
||||
pub endpoint: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
pub bucket_name: String,
|
||||
pub region: String,
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
impl MinioConfig {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
Ok(Self {
|
||||
endpoint: ENV.minio_endpoint.clone(),
|
||||
access_key: ENV.minio_access_key.clone(),
|
||||
secret_key: ENV.minio_secret_key.clone(),
|
||||
bucket_name: ENV.minio_bucket_name.clone(),
|
||||
region: ENV.minio_region.clone(),
|
||||
secure: ENV.minio_secure,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn endpoint_url(&self) -> String {
|
||||
if self.endpoint.starts_with("http://") || self.endpoint.starts_with("https://")
|
||||
{
|
||||
self.endpoint.clone()
|
||||
} else {
|
||||
let protocol = if self.secure { "https" } else { "http" };
|
||||
format!("{protocol}://{}", self.endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
|
||||
use crate::config::MinioConfig;
|
||||
use crate::service::MinioService;
|
||||
|
||||
pub async fn create_minio_service_from_config(
|
||||
config: MinioConfig,
|
||||
) -> Result<MinioService> {
|
||||
MinioService::new(
|
||||
&config.endpoint,
|
||||
&config.access_key,
|
||||
&config.secret_key,
|
||||
&config.bucket_name,
|
||||
&config.region,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn decode_base64_file(base64_data: &str) -> Result<Vec<u8>> {
|
||||
let clean_data = if base64_data.contains(',') {
|
||||
base64_data.split(',').nth(1).unwrap_or(base64_data)
|
||||
} else {
|
||||
base64_data
|
||||
};
|
||||
general_purpose::STANDARD
|
||||
.decode(clean_data)
|
||||
.map_err(|e| anyhow!("Failed to decode base64 data: {}", e))
|
||||
}
|
||||
|
||||
pub fn extract_content_type_from_data_url(data_url: &str) -> Option<String> {
|
||||
if data_url.starts_with("data:")
|
||||
&& let Some(type_part) = data_url.split(';').next()
|
||||
{
|
||||
return Some(type_part.replace("data:", ""));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod config;
|
||||
pub mod helpers;
|
||||
pub mod service;
|
||||
pub mod signing;
|
||||
pub mod types;
|
||||
|
||||
pub use config::MinioConfig;
|
||||
pub use helpers::{
|
||||
create_minio_service_from_config, decode_base64_file,
|
||||
extract_content_type_from_data_url,
|
||||
};
|
||||
pub use service::MinioService;
|
||||
pub use types::{FileMetadata, FileType, UploadRequest, UploadResult};
|
||||
@@ -0,0 +1,253 @@
|
||||
use anyhow::{Result, bail};
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::helpers::decode_base64_file;
|
||||
use crate::signing::{compute_header_auth, compute_presigned_url};
|
||||
use crate::types::{get_file_extension, validate_file_type};
|
||||
|
||||
pub struct MinioService {
|
||||
pub(crate) endpoint: String,
|
||||
pub(crate) access_key: String,
|
||||
pub(crate) secret_key: String,
|
||||
pub(crate) bucket_name: String,
|
||||
pub(crate) region: String,
|
||||
pub(crate) client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl MinioService {
|
||||
pub async fn new(
|
||||
endpoint: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
bucket_name: &str,
|
||||
region: &str,
|
||||
) -> Result<Self> {
|
||||
Ok(Self {
|
||||
endpoint: endpoint.to_string(),
|
||||
access_key: access_key.to_string(),
|
||||
secret_key: secret_key.to_string(),
|
||||
bucket_name: bucket_name.to_string(),
|
||||
region: region.to_string(),
|
||||
client: reqwest::Client::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upload_file_with_deduplication(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
validate_file_type(content_type, file_data)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(file_data);
|
||||
let file_hash = format!("{:x}", hasher.finalize());
|
||||
let short_hash = &file_hash[..16];
|
||||
if let Some(existing) =
|
||||
self.check_file_exists_by_hash(folder, short_hash).await?
|
||||
{
|
||||
return Ok(existing);
|
||||
}
|
||||
let ext = get_file_extension(original_filename);
|
||||
let unique_filename = format!("{folder}/{short_hash}-{}.{ext}", Uuid::new_v4());
|
||||
self
|
||||
.put_object(&unique_filename, file_data, content_type)
|
||||
.await?;
|
||||
tracing::info!("Uploaded {} bytes to {}", file_data.len(), unique_filename);
|
||||
Ok(unique_filename)
|
||||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
&self,
|
||||
file_data: &[u8],
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
validate_file_type(content_type, file_data)?;
|
||||
let ext = get_file_extension(original_filename);
|
||||
let unique_filename = format!("{folder}/{}.{ext}", Uuid::new_v4());
|
||||
self
|
||||
.put_object(&unique_filename, file_data, content_type)
|
||||
.await?;
|
||||
tracing::info!("Uploaded {} bytes to {}", file_data.len(), unique_filename);
|
||||
Ok(unique_filename)
|
||||
}
|
||||
|
||||
pub async fn upload_base64_file(
|
||||
&self,
|
||||
base64_data: &str,
|
||||
content_type: &str,
|
||||
folder: &str,
|
||||
original_filename: &str,
|
||||
) -> Result<String> {
|
||||
let file_data = decode_base64_file(base64_data)?;
|
||||
self
|
||||
.upload_file(&file_data, content_type, folder, original_filename)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_presigned_url(
|
||||
&self,
|
||||
object_name: &str,
|
||||
expiry_seconds: u32,
|
||||
) -> Result<String> {
|
||||
let host = self.strip_protocol();
|
||||
compute_presigned_url(
|
||||
host,
|
||||
&self.bucket_name,
|
||||
object_name,
|
||||
expiry_seconds,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn check_file_exists_by_hash(
|
||||
&self,
|
||||
folder: &str,
|
||||
file_hash: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let host = self.strip_protocol();
|
||||
let url = format!(
|
||||
"https://{host}/{}?list-type=2&prefix={folder}",
|
||||
self.bucket_name
|
||||
);
|
||||
let payload_hash = hex::encode(Sha256::digest(b""));
|
||||
let canonical_query =
|
||||
format!("list-type=2&prefix={}", urlencoding::encode(folder));
|
||||
let (auth_header, amz_date) = compute_header_auth(
|
||||
"GET",
|
||||
host,
|
||||
&format!("/{}", self.bucket_name),
|
||||
&canonical_query,
|
||||
&payload_hash,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region,
|
||||
)?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
if body.contains(file_hash) {
|
||||
for line in body.lines() {
|
||||
if line.contains("<Key>")
|
||||
&& line.contains(file_hash)
|
||||
&& let Some(start) = line.find("<Key>")
|
||||
&& let Some(end) = line.find("</Key>")
|
||||
{
|
||||
return Ok(Some(line[start + 5..end].to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn delete_file(&self, object_name: &str) -> Result<()> {
|
||||
let host = self.strip_protocol();
|
||||
let url = format!("https://{host}/{}/{object_name}", self.bucket_name);
|
||||
let payload_hash = hex::encode(Sha256::digest(b""));
|
||||
let canonical_uri = format!("/{}/{object_name}", self.bucket_name);
|
||||
let (auth_header, amz_date) = compute_header_auth(
|
||||
"DELETE",
|
||||
host,
|
||||
&canonical_uri,
|
||||
"",
|
||||
&payload_hash,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region,
|
||||
)?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("Host", host)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await?;
|
||||
bail!(
|
||||
"Failed to delete from MinIO. Status: {}. Message: {}",
|
||||
status,
|
||||
error_body
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("Deleted file: {}", object_name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn put_object(
|
||||
&self,
|
||||
object_name: &str,
|
||||
file_data: &[u8],
|
||||
content_type: &str,
|
||||
) -> Result<()> {
|
||||
let host = self.strip_protocol();
|
||||
let url = format!("https://{host}/{}/{object_name}", self.bucket_name);
|
||||
let payload_hash = "UNSIGNED-PAYLOAD".to_string();
|
||||
let canonical_uri = format!("/{}/{object_name}", self.bucket_name);
|
||||
let (auth_header, amz_date) = compute_header_auth(
|
||||
"PUT",
|
||||
host,
|
||||
&canonical_uri,
|
||||
"",
|
||||
&payload_hash,
|
||||
&self.access_key,
|
||||
&self.secret_key,
|
||||
&self.region,
|
||||
)?;
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.put(&url)
|
||||
.header("x-amz-date", &amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", content_type)
|
||||
.header("X-Forwarded-Proto", "https")
|
||||
.header("X-Forwarded-Host", host)
|
||||
.body(file_data.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_body = response.text().await?;
|
||||
bail!(
|
||||
"Failed to upload to MinIO. Status: {}. Message: {}",
|
||||
status,
|
||||
error_body
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_protocol(&self) -> &str {
|
||||
self
|
||||
.endpoint
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_header_auth(
|
||||
method: &str,
|
||||
host: &str,
|
||||
canonical_uri: &str,
|
||||
canonical_query: &str,
|
||||
payload_hash: &str,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
region: &str,
|
||||
) -> Result<(String, String)> {
|
||||
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 scope = format!("{date_stamp}/{region}/s3/aws4_request");
|
||||
|
||||
let canonical_headers = format!(
|
||||
"host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amz_date}\n"
|
||||
);
|
||||
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
|
||||
let canonical_request = format!(
|
||||
"{method}\n{canonical_uri}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{payload_hash}"
|
||||
);
|
||||
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
let signing_key = derive_signing_key(secret_key, &date_stamp, region)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
let auth_header = format!(
|
||||
"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, SignedHeaders={signed_headers}, Signature={signature}"
|
||||
);
|
||||
Ok((auth_header, amz_date))
|
||||
}
|
||||
|
||||
pub fn compute_presigned_url(
|
||||
host: &str,
|
||||
bucket: &str,
|
||||
object_name: &str,
|
||||
expiry_seconds: u32,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
region: &str,
|
||||
) -> Result<String> {
|
||||
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 scope = format!("{date_stamp}/{region}/s3/aws4_request");
|
||||
let credential = format!("{access_key}/{scope}");
|
||||
let expires_str = expiry_seconds.to_string();
|
||||
|
||||
let mut query_params = std::collections::BTreeMap::new();
|
||||
query_params.insert("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
|
||||
query_params.insert("X-Amz-Credential", &credential);
|
||||
query_params.insert("X-Amz-Date", &amz_date);
|
||||
query_params.insert("X-Amz-Expires", &expires_str);
|
||||
query_params.insert("X-Amz-SignedHeaders", "host");
|
||||
|
||||
let canonical_query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
let canonical_request = format!(
|
||||
"GET\n/{bucket}/{object_name}\n{canonical_query_string}\nhost:{host}\n\nhost\nUNSIGNED-PAYLOAD"
|
||||
);
|
||||
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{}",
|
||||
hex::encode(Sha256::digest(canonical_request.as_bytes()))
|
||||
);
|
||||
|
||||
let signing_key = derive_signing_key(secret_key, &date_stamp, region)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(&signing_key)?;
|
||||
mac.update(string_to_sign.as_bytes());
|
||||
let signature = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
Ok(format!(
|
||||
"https://{host}/{bucket}/{object_name}?{canonical_query_string}&X-Amz-Signature={signature}"
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn derive_signing_key(
|
||||
secret_key: &str,
|
||||
date_stamp: &str,
|
||||
region: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let secret = format!("AWS4{secret_key}");
|
||||
let mut mac1 = Hmac::<Sha256>::new_from_slice(secret.as_bytes())?;
|
||||
mac1.update(date_stamp.as_bytes());
|
||||
let date_key = mac1.finalize().into_bytes();
|
||||
|
||||
let mut mac2 = Hmac::<Sha256>::new_from_slice(&date_key)?;
|
||||
mac2.update(region.as_bytes());
|
||||
let date_region_key = mac2.finalize().into_bytes();
|
||||
|
||||
let mut mac3 = Hmac::<Sha256>::new_from_slice(&date_region_key)?;
|
||||
mac3.update(b"s3");
|
||||
let date_region_service_key = mac3.finalize().into_bytes();
|
||||
|
||||
let mut mac4 = Hmac::<Sha256>::new_from_slice(&date_region_service_key)?;
|
||||
mac4.update(b"aws4_request");
|
||||
Ok(mac4.finalize().into_bytes().to_vec())
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadResult {
|
||||
pub object_name: String,
|
||||
pub url: String,
|
||||
pub size: usize,
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadRequest {
|
||||
pub user_id: String,
|
||||
pub file_type: FileType,
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMetadata {
|
||||
pub filename: String,
|
||||
pub content_type: String,
|
||||
pub size: usize,
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FileType {
|
||||
Jpeg,
|
||||
Png,
|
||||
Webp,
|
||||
Gif,
|
||||
Pdf,
|
||||
Doc,
|
||||
Docx,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FileType {
|
||||
pub fn as_folder(&self) -> &str {
|
||||
match self {
|
||||
FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => "profiles",
|
||||
FileType::Pdf | FileType::Doc | FileType::Docx => "documents",
|
||||
FileType::Unknown => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn max_size(&self) -> usize {
|
||||
match self {
|
||||
FileType::Jpeg | FileType::Png | FileType::Webp | FileType::Gif => {
|
||||
5 * 1024 * 1024
|
||||
}
|
||||
FileType::Pdf | FileType::Doc | FileType::Docx => 10 * 1024 * 1024,
|
||||
FileType::Unknown => 5 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allowed_types(&self) -> Vec<&str> {
|
||||
match self {
|
||||
FileType::Jpeg => vec!["image/jpeg", "image/jpg"],
|
||||
FileType::Png => vec!["image/png"],
|
||||
FileType::Webp => vec!["image/webp"],
|
||||
FileType::Gif => vec!["image/gif"],
|
||||
FileType::Pdf => vec!["application/pdf"],
|
||||
FileType::Doc => vec!["application/msword"],
|
||||
FileType::Docx => vec![
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
],
|
||||
FileType::Unknown => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_content_type(content_type: &str) -> Self {
|
||||
match content_type {
|
||||
"image/jpeg" | "image/jpg" => FileType::Jpeg,
|
||||
"image/png" => FileType::Png,
|
||||
"image/webp" => FileType::Webp,
|
||||
"image/gif" => FileType::Gif,
|
||||
"application/pdf" => FileType::Pdf,
|
||||
"application/msword" => FileType::Doc,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
|
||||
FileType::Docx
|
||||
}
|
||||
_ => FileType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_filename(filename: &str) -> Self {
|
||||
let f = filename.to_lowercase();
|
||||
if f.ends_with(".jpg") || f.ends_with(".jpeg") {
|
||||
FileType::Jpeg
|
||||
} else if f.ends_with(".png") {
|
||||
FileType::Png
|
||||
} else if f.ends_with(".webp") {
|
||||
FileType::Webp
|
||||
} else if f.ends_with(".gif") {
|
||||
FileType::Gif
|
||||
} else if f.ends_with(".pdf") {
|
||||
FileType::Pdf
|
||||
} else if f.ends_with(".doc") {
|
||||
FileType::Doc
|
||||
} else if f.ends_with(".docx") {
|
||||
FileType::Docx
|
||||
} else {
|
||||
FileType::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_file_type(content_type: &str, file_data: &[u8]) -> Result<()> {
|
||||
const MAX_SIZE: usize = 10 * 1024 * 1024;
|
||||
if file_data.len() > MAX_SIZE {
|
||||
bail!("File size exceeds 10MB limit");
|
||||
}
|
||||
match content_type {
|
||||
"image/jpeg" | "image/jpg" => {
|
||||
if !file_data.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
||||
bail!("Invalid JPEG file");
|
||||
}
|
||||
}
|
||||
"image/png" => {
|
||||
if !file_data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
|
||||
bail!("Invalid PNG file");
|
||||
}
|
||||
}
|
||||
"application/pdf" => {
|
||||
if !file_data.starts_with(b"%PDF") {
|
||||
bail!("Invalid PDF file");
|
||||
}
|
||||
}
|
||||
"image/webp" => {
|
||||
if !file_data.starts_with(b"RIFF")
|
||||
|| file_data.get(8..12).is_none_or(|s| s != b"WEBP")
|
||||
{
|
||||
bail!("Invalid WebP file");
|
||||
}
|
||||
}
|
||||
"application/msword"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
|
||||
if file_data.len() < 512 {
|
||||
bail!("Invalid document file");
|
||||
}
|
||||
}
|
||||
_ => bail!("Unsupported file type: {}", content_type),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_file_extension(filename: &str) -> String {
|
||||
std::path::Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("bin")
|
||||
.to_lowercase()
|
||||
}
|
||||
Reference in New Issue
Block a user