use std::{collections::HashMap, sync::Arc};
use fraiseql_storage::{
StorageMetadataRepo, StorageRlsEvaluator, StorageState,
config::{BucketAccess, BucketConfig, StorageConfig},
};
use sqlx::postgres::PgPoolOptions;
use super::{ServerConfig, StorageSectionConfig};
const STORAGE_METADATA_POOL_MAX: u32 = 5;
#[derive(Debug, Clone)]
pub struct ResolvedStorage {
pub backend: StorageConfig,
pub bucket: BucketConfig,
}
pub fn resolve_storage_section(config: &ServerConfig) -> Result<Option<ResolvedStorage>, String> {
resolve_from_map(&config.storage)
}
fn resolve_from_map(
storage: &HashMap<String, StorageSectionConfig>,
) -> Result<Option<ResolvedStorage>, String> {
let mut iter = storage.iter();
let Some((name, section)) = iter.next() else {
return Ok(None);
};
if iter.next().is_some() {
let mut names: Vec<&str> = storage.keys().map(String::as_str).collect();
names.sort_unstable();
return Err(format!(
"multiple [storage.<name>] sections configured ({}); the fraiseql-server binary \
currently supports a single storage backend — configure exactly one [storage.<name>].",
names.join(", "),
));
}
if fraiseql_storage::config::RESERVED_BUCKET_NAMES.contains(&name.as_str()) {
return Err(format!(
"[storage.{name}] uses a bucket name FraiseQL reserves for its own namespaces \
({}); objects would land inside the upload staging area or the render cache. \
Rename the bucket.",
fraiseql_storage::config::RESERVED_BUCKET_NAMES.join(", ")
));
}
let access = parse_access(section.access.as_deref())?;
let backend = StorageConfig {
backend: section.backend.clone(),
path: section.path.clone(),
bucket: section.bucket.clone(),
region: section.region.clone(),
endpoint: section.endpoint.clone(),
project_id: section.project_id.clone(),
account_name: section.account_name.clone(),
};
#[cfg(not(feature = "storage-transforms"))]
if section.transform_presets.is_some() {
return Err(format!(
"[storage.{name}] declares transform_presets, but this server binary was built without the `storage-transforms` feature — the render endpoint does not exist, so the presets could never be served. Rebuild with the feature or remove the key.",
));
}
#[cfg(not(feature = "storage-transforms"))]
for (key, present) in [
("default_resize_mode", section.default_resize_mode.is_some()),
("watermark_font", section.watermark_font.is_some()),
] {
if present {
return Err(format!(
"[storage.{name}] declares {key}, but this server binary was built without the \
`storage-transforms` feature — the render endpoint does not exist, so it could \
never take effect. Rebuild with the feature or remove the key."
));
}
}
#[cfg(not(feature = "storage-transforms"))]
let (transform_presets, watermark_font) = (
section.transform_presets.as_ref().map(|_| Vec::new()),
Option::<std::sync::Arc<Vec<u8>>>::None,
);
#[cfg(feature = "storage-transforms")]
let transform_presets = parse_transform_presets(name, section)?;
#[cfg(feature = "storage-transforms")]
if let Some(ref mode) = section.default_resize_mode {
if fraiseql_storage::ResizeMode::parse(mode).is_none() {
return Err(format!(
"[storage.{name}] default_resize_mode = '{mode}' is not one of contain, stretch, \
fit, fill, cover-blur, cover-mirror"
));
}
}
#[cfg(feature = "storage-transforms")]
let watermark_font = match section.watermark_font {
None => None,
Some(ref path) => {
let bytes = std::fs::read(path).map_err(|e| {
format!("[storage.{name}] watermark_font '{path}' could not be read: {e}")
})?;
fraiseql_storage::transforms::text::parse_font(bytes.clone())
.map_err(|e| format!("[storage.{name}] watermark_font '{path}': {e}"))?;
Some(std::sync::Arc::new(bytes))
},
};
let policies = match section.policies {
None => None,
Some(ref rules) => Some(
fraiseql_storage::parse_policy(rules).map_err(|e| format!("[storage.{name}] {e}"))?,
),
};
let bucket = BucketConfig {
name: name.clone(),
max_object_bytes: section.max_object_bytes,
allowed_mime_types: section.allowed_mime_types.clone(),
access,
transform_presets,
policies,
serve_inline: section.serve_inline.unwrap_or(false),
upload_ttl_secs: section.upload_ttl_secs,
default_resize_mode: section.default_resize_mode.clone(),
watermark_font,
};
Ok(Some(ResolvedStorage { backend, bucket }))
}
pub async fn build_storage_state(config: &ServerConfig) -> Result<Option<StorageState>, String> {
let Some(resolved) = resolve_storage_section(config)? else {
return Ok(None);
};
let bucket_name = resolved.bucket.name.clone();
let pool = PgPoolOptions::new()
.max_connections(STORAGE_METADATA_POOL_MAX)
.connect(&config.database_url)
.await
.map_err(|e| {
format!("storage: failed to connect to PostgreSQL for object metadata: {e}")
})?;
fraiseql_storage::migrations::run_storage_migration(&pool)
.await
.map_err(|e| format!("storage: failed to ensure the object-metadata table exists: {e}"))?;
let backend = fraiseql_storage::create_backend(&resolved.backend).await.map_err(|e| {
format!("storage: failed to create backend for bucket '{bucket_name}': {e}")
})?;
let mut buckets = HashMap::new();
buckets.insert(bucket_name, resolved.bucket);
let state = StorageState::new(
Arc::new(backend),
Arc::new(StorageMetadataRepo::new(pool.clone())),
StorageRlsEvaluator::new(),
buckets,
Arc::new(fraiseql_storage::UploadSessionRepo::new(pool.clone())),
Arc::new(fraiseql_storage::StoragePolicyStore::new(pool)),
);
apply_stored_policies(&state).await?;
Ok(Some(state))
}
async fn apply_stored_policies(state: &StorageState) -> Result<(), String> {
let report = state
.reload_policies()
.await
.map_err(|e| format!("storage: failed to read stored bucket policies: {e}"))?;
if let Some((bucket, error)) = report.invalid.first() {
return Err(format!(
"storage: the stored policy for bucket '{bucket}' is not valid ({error}). It was \
written outside the admin API or by a different version; fix or delete the \
_fraiseql_storage_policies row before starting."
));
}
report.log_problems();
for (bucket, source) in &report.sources {
tracing::info!(
bucket = %bucket,
policy_source = source.as_str(),
"storage bucket access is governed by this policy source"
);
}
Ok(())
}
fn parse_access(access: Option<&str>) -> Result<BucketAccess, String> {
let Some(value) = access else {
return Ok(BucketAccess::Private);
};
match value.to_ascii_lowercase().as_str() {
"private" => Ok(BucketAccess::Private),
"public_read" | "public-read" => Ok(BucketAccess::PublicRead),
other => Err(format!(
"invalid storage access policy {other:?}; expected \"private\" or \"public_read\""
)),
}
}
#[cfg(feature = "storage-transforms")]
fn parse_transform_presets(
name: &str,
section: &StorageSectionConfig,
) -> Result<Option<Vec<fraiseql_storage::config::TransformPreset>>, String> {
let Some(ref presets) = section.transform_presets else {
return Ok(None);
};
let mut parsed = Vec::with_capacity(presets.len());
for preset in presets {
if let Some(ref mode) = preset.resize_mode {
if fraiseql_storage::ResizeMode::parse(mode).is_none() {
return Err(format!(
"[storage.{name}] preset '{}' declares resize_mode = '{mode}', which is not \
one of contain, stretch, fit, fill, cover-blur, cover-mirror",
preset.name
));
}
}
if let Some(ref gravity) = preset.gravity {
if fraiseql_storage::Gravity::parse(gravity).is_none() {
return Err(format!(
"[storage.{name}] preset '{}' declares gravity = '{gravity}', which is not a \
compass point, center, or smart",
preset.name
));
}
}
if let (Some(quality), Some(format)) = (preset.quality, preset.format.as_deref()) {
if matches!(format.to_ascii_lowercase().as_str(), "png" | "webp") {
return Err(format!(
"[storage.{name}] preset '{}' declares quality = {quality} with format = \
'{format}', which this server encodes losslessly — the quality could never \
take effect. Use jpeg or avif, or drop the quality.",
preset.name
));
}
}
parsed.push(fraiseql_storage::config::TransformPreset {
name: preset.name.clone(),
width: preset.width,
height: preset.height,
format: preset.format.clone(),
quality: preset.quality,
resize_mode: preset.resize_mode.clone(),
gravity: preset.gravity.clone(),
});
}
Ok(Some(parsed))
}