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:
maulanasdqn
2026-04-02 22:29:08 +07:00
co-authored by Claude Sonnet 4.6
parent 2ae43b3bcc
commit 331a4a4e88
442 changed files with 22226 additions and 18700 deletions
+17
View File
@@ -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
+35
View File
@@ -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)
}
}
}
+38
View File
@@ -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
}
+13
View File
@@ -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};
+253
View File
@@ -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://")
}
}
+115
View File
@@ -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())
}
+157
View File
@@ -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()
}