Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I2d1f04f13970d21c36067f30bc04a9176a6a6964
1109 lines
32 KiB
Rust
1109 lines
32 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Expand environment variables in a string.
|
|
/// Supports both ${VAR_NAME} and $VAR_NAME syntax.
|
|
/// Returns an error if a referenced variable is not set.
|
|
fn expand_env_var_string(input: &str) -> crate::error::Result<String> {
|
|
let mut result = String::new();
|
|
let mut chars = input.chars().peekable();
|
|
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '$' {
|
|
// Check if it's ${VAR} or $VAR syntax
|
|
let use_braces = chars.peek() == Some(&'{');
|
|
if use_braces {
|
|
chars.next(); // consume '{'
|
|
}
|
|
|
|
// Collect variable name
|
|
let mut var_name = String::new();
|
|
while let Some(&next_ch) = chars.peek() {
|
|
if use_braces {
|
|
if next_ch == '}' {
|
|
chars.next(); // consume '}'
|
|
break;
|
|
}
|
|
var_name.push(next_ch);
|
|
chars.next();
|
|
} else {
|
|
// For $VAR syntax, stop at non-alphanumeric/underscore
|
|
if next_ch.is_alphanumeric() || next_ch == '_' {
|
|
var_name.push(next_ch);
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if var_name.is_empty() {
|
|
return Err(crate::error::PinakesError::Config(
|
|
"empty environment variable name".to_string(),
|
|
));
|
|
}
|
|
|
|
// Look up the environment variable
|
|
match std::env::var(&var_name) {
|
|
Ok(value) => result.push_str(&value),
|
|
Err(_) => {
|
|
return Err(crate::error::PinakesError::Config(format!(
|
|
"environment variable not set: {}",
|
|
var_name
|
|
)));
|
|
}
|
|
}
|
|
} else if ch == '\\' {
|
|
// Handle escaped characters
|
|
if let Some(&next_ch) = chars.peek() {
|
|
if next_ch == '$' {
|
|
chars.next(); // consume the escaped $
|
|
result.push('$');
|
|
} else {
|
|
result.push(ch);
|
|
}
|
|
} else {
|
|
result.push(ch);
|
|
}
|
|
} else {
|
|
result.push(ch);
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Config {
|
|
pub storage: StorageConfig,
|
|
pub directories: DirectoryConfig,
|
|
pub scanning: ScanningConfig,
|
|
pub server: ServerConfig,
|
|
#[serde(default)]
|
|
pub ui: UiConfig,
|
|
#[serde(default)]
|
|
pub accounts: AccountsConfig,
|
|
#[serde(default)]
|
|
pub jobs: JobsConfig,
|
|
#[serde(default)]
|
|
pub thumbnails: ThumbnailConfig,
|
|
#[serde(default)]
|
|
pub webhooks: Vec<WebhookConfig>,
|
|
#[serde(default)]
|
|
pub scheduled_tasks: Vec<ScheduledTaskConfig>,
|
|
#[serde(default)]
|
|
pub plugins: PluginsConfig,
|
|
#[serde(default)]
|
|
pub transcoding: TranscodingConfig,
|
|
#[serde(default)]
|
|
pub enrichment: EnrichmentConfig,
|
|
#[serde(default)]
|
|
pub cloud: CloudConfig,
|
|
#[serde(default)]
|
|
pub analytics: AnalyticsConfig,
|
|
#[serde(default)]
|
|
pub photos: PhotoConfig,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ScheduledTaskConfig {
|
|
pub id: String,
|
|
pub enabled: bool,
|
|
pub schedule: crate::scheduler::Schedule,
|
|
pub last_run: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JobsConfig {
|
|
#[serde(default = "default_worker_count")]
|
|
pub worker_count: usize,
|
|
#[serde(default = "default_cache_ttl")]
|
|
pub cache_ttl_secs: u64,
|
|
}
|
|
|
|
fn default_worker_count() -> usize {
|
|
2
|
|
}
|
|
fn default_cache_ttl() -> u64 {
|
|
60
|
|
}
|
|
|
|
impl Default for JobsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
worker_count: default_worker_count(),
|
|
cache_ttl_secs: default_cache_ttl(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ThumbnailConfig {
|
|
#[serde(default = "default_thumb_size")]
|
|
pub size: u32,
|
|
#[serde(default = "default_thumb_quality")]
|
|
pub quality: u8,
|
|
#[serde(default)]
|
|
pub ffmpeg_path: Option<String>,
|
|
#[serde(default = "default_video_seek")]
|
|
pub video_seek_secs: u32,
|
|
}
|
|
|
|
fn default_thumb_size() -> u32 {
|
|
320
|
|
}
|
|
fn default_thumb_quality() -> u8 {
|
|
80
|
|
}
|
|
fn default_video_seek() -> u32 {
|
|
2
|
|
}
|
|
|
|
impl Default for ThumbnailConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
size: default_thumb_size(),
|
|
quality: default_thumb_quality(),
|
|
ffmpeg_path: None,
|
|
video_seek_secs: default_video_seek(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct WebhookConfig {
|
|
pub url: String,
|
|
pub events: Vec<String>,
|
|
#[serde(default)]
|
|
pub secret: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UiConfig {
|
|
#[serde(default = "default_theme")]
|
|
pub theme: String,
|
|
#[serde(default = "default_view")]
|
|
pub default_view: String,
|
|
#[serde(default = "default_page_size")]
|
|
pub default_page_size: usize,
|
|
#[serde(default = "default_view_mode")]
|
|
pub default_view_mode: String,
|
|
#[serde(default)]
|
|
pub auto_play_media: bool,
|
|
#[serde(default = "default_true")]
|
|
pub show_thumbnails: bool,
|
|
#[serde(default)]
|
|
pub sidebar_collapsed: bool,
|
|
}
|
|
|
|
fn default_theme() -> String {
|
|
"dark".to_string()
|
|
}
|
|
fn default_view() -> String {
|
|
"library".to_string()
|
|
}
|
|
fn default_page_size() -> usize {
|
|
48
|
|
}
|
|
fn default_view_mode() -> String {
|
|
"grid".to_string()
|
|
}
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Default for UiConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
theme: default_theme(),
|
|
default_view: default_view(),
|
|
default_page_size: default_page_size(),
|
|
default_view_mode: default_view_mode(),
|
|
auto_play_media: false,
|
|
show_thumbnails: true,
|
|
sidebar_collapsed: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct AccountsConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub users: Vec<UserAccount>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct UserAccount {
|
|
pub username: String,
|
|
pub password_hash: String,
|
|
#[serde(default)]
|
|
pub role: UserRole,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum UserRole {
|
|
Admin,
|
|
Editor,
|
|
#[default]
|
|
Viewer,
|
|
}
|
|
|
|
impl UserRole {
|
|
pub fn can_read(self) -> bool {
|
|
true
|
|
}
|
|
|
|
pub fn can_write(self) -> bool {
|
|
matches!(self, Self::Admin | Self::Editor)
|
|
}
|
|
|
|
pub fn can_admin(self) -> bool {
|
|
matches!(self, Self::Admin)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for UserRole {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Admin => write!(f, "admin"),
|
|
Self::Editor => write!(f, "editor"),
|
|
Self::Viewer => write!(f, "viewer"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Plugin Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PluginsConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_plugin_data_dir")]
|
|
pub data_dir: PathBuf,
|
|
#[serde(default = "default_plugin_cache_dir")]
|
|
pub cache_dir: PathBuf,
|
|
#[serde(default)]
|
|
pub plugin_dirs: Vec<PathBuf>,
|
|
#[serde(default)]
|
|
pub enable_hot_reload: bool,
|
|
#[serde(default)]
|
|
pub allow_unsigned: bool,
|
|
#[serde(default = "default_max_concurrent_ops")]
|
|
pub max_concurrent_ops: usize,
|
|
#[serde(default = "default_plugin_timeout")]
|
|
pub plugin_timeout_secs: u64,
|
|
}
|
|
|
|
fn default_plugin_data_dir() -> PathBuf {
|
|
Config::default_data_dir().join("plugins").join("data")
|
|
}
|
|
|
|
fn default_plugin_cache_dir() -> PathBuf {
|
|
Config::default_data_dir().join("plugins").join("cache")
|
|
}
|
|
|
|
fn default_max_concurrent_ops() -> usize {
|
|
4
|
|
}
|
|
|
|
fn default_plugin_timeout() -> u64 {
|
|
30
|
|
}
|
|
|
|
impl Default for PluginsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
data_dir: default_plugin_data_dir(),
|
|
cache_dir: default_plugin_cache_dir(),
|
|
plugin_dirs: vec![],
|
|
enable_hot_reload: false,
|
|
allow_unsigned: false,
|
|
max_concurrent_ops: default_max_concurrent_ops(),
|
|
plugin_timeout_secs: default_plugin_timeout(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Transcoding Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TranscodingConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub cache_dir: Option<PathBuf>,
|
|
#[serde(default = "default_cache_ttl_hours")]
|
|
pub cache_ttl_hours: u64,
|
|
#[serde(default = "default_max_concurrent_transcodes")]
|
|
pub max_concurrent: usize,
|
|
#[serde(default)]
|
|
pub hardware_acceleration: Option<String>,
|
|
#[serde(default)]
|
|
pub profiles: Vec<TranscodeProfile>,
|
|
}
|
|
|
|
fn default_cache_ttl_hours() -> u64 {
|
|
48
|
|
}
|
|
|
|
fn default_max_concurrent_transcodes() -> usize {
|
|
2
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TranscodeProfile {
|
|
pub name: String,
|
|
pub video_codec: String,
|
|
pub audio_codec: String,
|
|
pub max_bitrate_kbps: u32,
|
|
pub max_resolution: String,
|
|
}
|
|
|
|
impl Default for TranscodingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
cache_dir: None,
|
|
cache_ttl_hours: default_cache_ttl_hours(),
|
|
max_concurrent: default_max_concurrent_transcodes(),
|
|
hardware_acceleration: None,
|
|
profiles: vec![
|
|
TranscodeProfile {
|
|
name: "high".to_string(),
|
|
video_codec: "h264".to_string(),
|
|
audio_codec: "aac".to_string(),
|
|
max_bitrate_kbps: 8000,
|
|
max_resolution: "1080p".to_string(),
|
|
},
|
|
TranscodeProfile {
|
|
name: "medium".to_string(),
|
|
video_codec: "h264".to_string(),
|
|
audio_codec: "aac".to_string(),
|
|
max_bitrate_kbps: 4000,
|
|
max_resolution: "720p".to_string(),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Enrichment Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct EnrichmentConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub auto_enrich_on_import: bool,
|
|
#[serde(default)]
|
|
pub sources: EnrichmentSources,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct EnrichmentSources {
|
|
#[serde(default)]
|
|
pub musicbrainz: EnrichmentSource,
|
|
#[serde(default)]
|
|
pub tmdb: EnrichmentSource,
|
|
#[serde(default)]
|
|
pub lastfm: EnrichmentSource,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct EnrichmentSource {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub api_key: Option<String>,
|
|
#[serde(default)]
|
|
pub api_endpoint: Option<String>,
|
|
}
|
|
|
|
// ===== Cloud Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloudConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_auto_sync_interval")]
|
|
pub auto_sync_interval_mins: u64,
|
|
#[serde(default)]
|
|
pub accounts: Vec<CloudAccount>,
|
|
}
|
|
|
|
fn default_auto_sync_interval() -> u64 {
|
|
60
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloudAccount {
|
|
pub id: String,
|
|
pub provider: String,
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub sync_rules: Vec<CloudSyncRule>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CloudSyncRule {
|
|
pub local_path: PathBuf,
|
|
pub remote_path: String,
|
|
pub direction: CloudSyncDirection,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CloudSyncDirection {
|
|
Upload,
|
|
Download,
|
|
Bidirectional,
|
|
}
|
|
|
|
impl Default for CloudConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
auto_sync_interval_mins: default_auto_sync_interval(),
|
|
accounts: vec![],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Analytics Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AnalyticsConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub track_usage: bool,
|
|
#[serde(default = "default_retention_days")]
|
|
pub retention_days: u64,
|
|
}
|
|
|
|
fn default_retention_days() -> u64 {
|
|
90
|
|
}
|
|
|
|
impl Default for AnalyticsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
track_usage: true,
|
|
retention_days: default_retention_days(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Photo Management Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PhotoConfig {
|
|
/// Generate perceptual hashes for image duplicate detection (CPU-intensive)
|
|
#[serde(default = "default_true")]
|
|
pub generate_perceptual_hash: bool,
|
|
|
|
/// Automatically create tags from EXIF keywords
|
|
#[serde(default)]
|
|
pub auto_tag_from_exif: bool,
|
|
|
|
/// Generate multi-resolution thumbnails (tiny, grid, preview)
|
|
#[serde(default)]
|
|
pub multi_resolution_thumbnails: bool,
|
|
|
|
/// Auto-detect photo events/albums based on time and location
|
|
#[serde(default)]
|
|
pub enable_event_detection: bool,
|
|
|
|
/// Minimum number of photos to form an event
|
|
#[serde(default = "default_min_event_photos")]
|
|
pub min_event_photos: usize,
|
|
|
|
/// Maximum time gap between photos in the same event (in seconds)
|
|
#[serde(default = "default_event_time_gap")]
|
|
pub event_time_gap_secs: i64,
|
|
|
|
/// Maximum distance between photos in the same event (in kilometers)
|
|
#[serde(default = "default_event_distance")]
|
|
pub event_max_distance_km: f64,
|
|
}
|
|
|
|
fn default_min_event_photos() -> usize {
|
|
5
|
|
}
|
|
|
|
fn default_event_time_gap() -> i64 {
|
|
2 * 60 * 60 // 2 hours
|
|
}
|
|
|
|
fn default_event_distance() -> f64 {
|
|
1.0 // 1 km
|
|
}
|
|
|
|
impl Default for PhotoConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
generate_perceptual_hash: true,
|
|
auto_tag_from_exif: false,
|
|
multi_resolution_thumbnails: false,
|
|
enable_event_detection: false,
|
|
min_event_photos: default_min_event_photos(),
|
|
event_time_gap_secs: default_event_time_gap(),
|
|
event_max_distance_km: default_event_distance(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== Storage Configuration =====
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct StorageConfig {
|
|
pub backend: StorageBackendType,
|
|
pub sqlite: Option<SqliteConfig>,
|
|
pub postgres: Option<PostgresConfig>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum StorageBackendType {
|
|
Sqlite,
|
|
Postgres,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SqliteConfig {
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PostgresConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub database: String,
|
|
pub username: String,
|
|
pub password: String,
|
|
pub max_connections: usize,
|
|
/// Enable TLS for PostgreSQL connections
|
|
#[serde(default)]
|
|
pub tls_enabled: bool,
|
|
/// Verify TLS certificates (default: true)
|
|
#[serde(default = "default_true")]
|
|
pub tls_verify_ca: bool,
|
|
/// Path to custom CA certificate file (PEM format)
|
|
#[serde(default)]
|
|
pub tls_ca_cert_path: Option<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct DirectoryConfig {
|
|
pub roots: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ScanningConfig {
|
|
pub watch: bool,
|
|
pub poll_interval_secs: u64,
|
|
pub ignore_patterns: Vec<String>,
|
|
#[serde(default = "default_import_concurrency")]
|
|
pub import_concurrency: usize,
|
|
}
|
|
|
|
fn default_import_concurrency() -> usize {
|
|
8
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ServerConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
/// Optional API key for bearer token authentication.
|
|
/// If set, all requests (except /health) must include `Authorization: Bearer <key>`.
|
|
/// Can also be set via `PINAKES_API_KEY` environment variable.
|
|
pub api_key: Option<String>,
|
|
/// Explicitly disable authentication (INSECURE - use only for development).
|
|
/// When true, all requests are allowed without authentication.
|
|
/// This must be explicitly set to true; empty api_key alone is not sufficient.
|
|
#[serde(default)]
|
|
pub authentication_disabled: bool,
|
|
/// TLS/HTTPS configuration
|
|
#[serde(default)]
|
|
pub tls: TlsConfig,
|
|
}
|
|
|
|
/// TLS/HTTPS configuration for secure connections
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TlsConfig {
|
|
/// Enable TLS (HTTPS)
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
/// Path to the TLS certificate file (PEM format)
|
|
#[serde(default)]
|
|
pub cert_path: Option<PathBuf>,
|
|
/// Path to the TLS private key file (PEM format)
|
|
#[serde(default)]
|
|
pub key_path: Option<PathBuf>,
|
|
/// Enable HTTP to HTTPS redirect (starts a second listener on http_port)
|
|
#[serde(default)]
|
|
pub redirect_http: bool,
|
|
/// Port for HTTP redirect listener (default: 80)
|
|
#[serde(default = "default_http_port")]
|
|
pub http_port: u16,
|
|
/// Enable HSTS (HTTP Strict Transport Security) header
|
|
#[serde(default = "default_true")]
|
|
pub hsts_enabled: bool,
|
|
/// HSTS max-age in seconds (default: 1 year)
|
|
#[serde(default = "default_hsts_max_age")]
|
|
pub hsts_max_age: u64,
|
|
}
|
|
|
|
fn default_http_port() -> u16 {
|
|
80
|
|
}
|
|
|
|
fn default_hsts_max_age() -> u64 {
|
|
31536000 // 1 year in seconds
|
|
}
|
|
|
|
impl Default for TlsConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
cert_path: None,
|
|
key_path: None,
|
|
redirect_http: false,
|
|
http_port: default_http_port(),
|
|
hsts_enabled: true,
|
|
hsts_max_age: default_hsts_max_age(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TlsConfig {
|
|
/// Validate TLS configuration
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
if self.enabled {
|
|
if self.cert_path.is_none() {
|
|
return Err("TLS enabled but cert_path not specified".into());
|
|
}
|
|
if self.key_path.is_none() {
|
|
return Err("TLS enabled but key_path not specified".into());
|
|
}
|
|
if let Some(ref cert_path) = self.cert_path
|
|
&& !cert_path.exists()
|
|
{
|
|
return Err(format!(
|
|
"TLS certificate file not found: {}",
|
|
cert_path.display()
|
|
));
|
|
}
|
|
if let Some(ref key_path) = self.key_path
|
|
&& !key_path.exists()
|
|
{
|
|
return Err(format!("TLS key file not found: {}", key_path.display()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Config {
|
|
pub fn from_file(path: &Path) -> crate::error::Result<Self> {
|
|
let content = std::fs::read_to_string(path).map_err(|e| {
|
|
crate::error::PinakesError::Config(format!("failed to read config file: {e}"))
|
|
})?;
|
|
let mut config: Self = toml::from_str(&content).map_err(|e| {
|
|
crate::error::PinakesError::Config(format!("failed to parse config: {e}"))
|
|
})?;
|
|
config.expand_env_vars()?;
|
|
Ok(config)
|
|
}
|
|
|
|
/// Expand environment variables in secret fields.
|
|
/// Supports ${VAR_NAME} and $VAR_NAME syntax.
|
|
fn expand_env_vars(&mut self) -> crate::error::Result<()> {
|
|
// Postgres password
|
|
if let Some(ref mut postgres) = self.storage.postgres {
|
|
postgres.password = expand_env_var_string(&postgres.password)?;
|
|
}
|
|
|
|
// Server API key
|
|
if let Some(ref api_key) = self.server.api_key {
|
|
self.server.api_key = Some(expand_env_var_string(api_key)?);
|
|
}
|
|
|
|
// Webhook secrets
|
|
for webhook in &mut self.webhooks {
|
|
if let Some(ref secret) = webhook.secret {
|
|
webhook.secret = Some(expand_env_var_string(secret)?);
|
|
}
|
|
}
|
|
|
|
// Enrichment API keys
|
|
if let Some(ref api_key) = self.enrichment.sources.musicbrainz.api_key {
|
|
self.enrichment.sources.musicbrainz.api_key = Some(expand_env_var_string(api_key)?);
|
|
}
|
|
if let Some(ref api_key) = self.enrichment.sources.tmdb.api_key {
|
|
self.enrichment.sources.tmdb.api_key = Some(expand_env_var_string(api_key)?);
|
|
}
|
|
if let Some(ref api_key) = self.enrichment.sources.lastfm.api_key {
|
|
self.enrichment.sources.lastfm.api_key = Some(expand_env_var_string(api_key)?);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Try loading from file, falling back to defaults if the file doesn't exist.
|
|
pub fn load_or_default(path: &Path) -> crate::error::Result<Self> {
|
|
if path.exists() {
|
|
Self::from_file(path)
|
|
} else {
|
|
let config = Self::default();
|
|
// Ensure the data directory exists for the default SQLite database
|
|
config.ensure_dirs()?;
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
/// Save the current config to a TOML file.
|
|
pub fn save_to_file(&self, path: &Path) -> crate::error::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let content = toml::to_string_pretty(self).map_err(|e| {
|
|
crate::error::PinakesError::Config(format!("failed to serialize config: {e}"))
|
|
})?;
|
|
std::fs::write(path, content)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure all directories needed by this config exist and are writable.
|
|
pub fn ensure_dirs(&self) -> crate::error::Result<()> {
|
|
if let Some(ref sqlite) = self.storage.sqlite
|
|
&& let Some(parent) = sqlite.path.parent()
|
|
{
|
|
std::fs::create_dir_all(parent)?;
|
|
let metadata = std::fs::metadata(parent)?;
|
|
if metadata.permissions().readonly() {
|
|
return Err(crate::error::PinakesError::Config(format!(
|
|
"directory is not writable: {}",
|
|
parent.display()
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the default config file path following XDG conventions.
|
|
pub fn default_config_path() -> PathBuf {
|
|
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
|
|
PathBuf::from(xdg).join("pinakes").join("pinakes.toml")
|
|
} else if let Ok(home) = std::env::var("HOME") {
|
|
PathBuf::from(home)
|
|
.join(".config")
|
|
.join("pinakes")
|
|
.join("pinakes.toml")
|
|
} else {
|
|
PathBuf::from("pinakes.toml")
|
|
}
|
|
}
|
|
|
|
/// Validate configuration values for correctness.
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
if self.server.port == 0 {
|
|
return Err("server port cannot be 0".into());
|
|
}
|
|
if self.server.host.is_empty() {
|
|
return Err("server host cannot be empty".into());
|
|
}
|
|
if self.scanning.poll_interval_secs == 0 {
|
|
return Err("poll interval cannot be 0".into());
|
|
}
|
|
if self.scanning.import_concurrency == 0 || self.scanning.import_concurrency > 256 {
|
|
return Err("import_concurrency must be between 1 and 256".into());
|
|
}
|
|
|
|
// Validate authentication configuration
|
|
let has_api_key = self.server.api_key.as_ref().is_some_and(|k| !k.is_empty());
|
|
let has_accounts = !self.accounts.users.is_empty();
|
|
let auth_disabled = self.server.authentication_disabled;
|
|
|
|
if !auth_disabled && !has_api_key && !has_accounts {
|
|
return Err(
|
|
"authentication is not configured: set an api_key, configure user accounts, \
|
|
or explicitly set authentication_disabled = true"
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// Empty API key is not allowed (must use authentication_disabled flag)
|
|
if let Some(ref api_key) = self.server.api_key
|
|
&& api_key.is_empty()
|
|
{
|
|
return Err("empty api_key is not allowed. To disable authentication, \
|
|
set authentication_disabled = true instead"
|
|
.into());
|
|
}
|
|
|
|
// Require TLS when authentication is enabled on non-localhost
|
|
let is_localhost = self.server.host == "127.0.0.1"
|
|
|| self.server.host == "localhost"
|
|
|| self.server.host == "::1";
|
|
|
|
if (has_api_key || has_accounts)
|
|
&& !auth_disabled
|
|
&& !is_localhost
|
|
&& !self.server.tls.enabled
|
|
{
|
|
return Err(
|
|
"TLS must be enabled when authentication is used on non-localhost hosts. \
|
|
Set server.tls.enabled = true or bind to localhost only"
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
// Validate TLS configuration
|
|
self.server.tls.validate()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the default data directory following XDG conventions.
|
|
pub fn default_data_dir() -> PathBuf {
|
|
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
|
|
PathBuf::from(xdg).join("pinakes")
|
|
} else if let Ok(home) = std::env::var("HOME") {
|
|
PathBuf::from(home)
|
|
.join(".local")
|
|
.join("share")
|
|
.join("pinakes")
|
|
} else {
|
|
PathBuf::from("pinakes-data")
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let data_dir = Self::default_data_dir();
|
|
Self {
|
|
storage: StorageConfig {
|
|
backend: StorageBackendType::Sqlite,
|
|
sqlite: Some(SqliteConfig {
|
|
path: data_dir.join("pinakes.db"),
|
|
}),
|
|
postgres: None,
|
|
},
|
|
directories: DirectoryConfig { roots: vec![] },
|
|
scanning: ScanningConfig {
|
|
watch: false,
|
|
poll_interval_secs: 300,
|
|
ignore_patterns: vec![
|
|
".*".to_string(),
|
|
"node_modules".to_string(),
|
|
"__pycache__".to_string(),
|
|
"target".to_string(),
|
|
],
|
|
import_concurrency: default_import_concurrency(),
|
|
},
|
|
server: ServerConfig {
|
|
host: "127.0.0.1".to_string(),
|
|
port: 3000,
|
|
api_key: None,
|
|
authentication_disabled: false,
|
|
tls: TlsConfig::default(),
|
|
},
|
|
ui: UiConfig::default(),
|
|
accounts: AccountsConfig::default(),
|
|
jobs: JobsConfig::default(),
|
|
thumbnails: ThumbnailConfig::default(),
|
|
webhooks: vec![],
|
|
scheduled_tasks: vec![],
|
|
plugins: PluginsConfig::default(),
|
|
transcoding: TranscodingConfig::default(),
|
|
enrichment: EnrichmentConfig::default(),
|
|
cloud: CloudConfig::default(),
|
|
analytics: AnalyticsConfig::default(),
|
|
photos: PhotoConfig::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_config_with_concurrency(concurrency: usize) -> Config {
|
|
let mut config = Config::default();
|
|
config.scanning.import_concurrency = concurrency;
|
|
config.server.authentication_disabled = true; // Disable auth for concurrency tests
|
|
config
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_import_concurrency_zero() {
|
|
let config = test_config_with_concurrency(0);
|
|
assert!(config.validate().is_err());
|
|
assert!(
|
|
config
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("import_concurrency")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_import_concurrency_too_high() {
|
|
let config = test_config_with_concurrency(257);
|
|
assert!(config.validate().is_err());
|
|
assert!(
|
|
config
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("import_concurrency")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_import_concurrency_valid() {
|
|
let config = test_config_with_concurrency(8);
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_import_concurrency_boundary_low() {
|
|
let config = test_config_with_concurrency(1);
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_import_concurrency_boundary_high() {
|
|
let config = test_config_with_concurrency(256);
|
|
assert!(config.validate().is_ok());
|
|
}
|
|
|
|
// Environment variable expansion tests
|
|
#[test]
|
|
fn test_expand_env_var_simple() {
|
|
unsafe {
|
|
std::env::set_var("TEST_VAR_SIMPLE", "test_value");
|
|
}
|
|
let result = expand_env_var_string("$TEST_VAR_SIMPLE");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "test_value");
|
|
unsafe {
|
|
std::env::remove_var("TEST_VAR_SIMPLE");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_braces() {
|
|
unsafe {
|
|
std::env::set_var("TEST_VAR_BRACES", "test_value");
|
|
}
|
|
let result = expand_env_var_string("${TEST_VAR_BRACES}");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "test_value");
|
|
unsafe {
|
|
std::env::remove_var("TEST_VAR_BRACES");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_embedded() {
|
|
unsafe {
|
|
std::env::set_var("TEST_VAR_EMBEDDED", "value");
|
|
}
|
|
let result = expand_env_var_string("prefix_${TEST_VAR_EMBEDDED}_suffix");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "prefix_value_suffix");
|
|
unsafe {
|
|
std::env::remove_var("TEST_VAR_EMBEDDED");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_multiple() {
|
|
unsafe {
|
|
std::env::set_var("VAR1", "value1");
|
|
std::env::set_var("VAR2", "value2");
|
|
}
|
|
let result = expand_env_var_string("${VAR1}_${VAR2}");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "value1_value2");
|
|
unsafe {
|
|
std::env::remove_var("VAR1");
|
|
std::env::remove_var("VAR2");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_missing() {
|
|
let result = expand_env_var_string("${NONEXISTENT_VAR}");
|
|
assert!(result.is_err());
|
|
assert!(
|
|
result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("environment variable not set")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_empty_name() {
|
|
let result = expand_env_var_string("${}");
|
|
assert!(result.is_err());
|
|
assert!(
|
|
result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("empty environment variable name")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_escaped() {
|
|
let result = expand_env_var_string("\\$NOT_A_VAR");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "$NOT_A_VAR");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_no_vars() {
|
|
let result = expand_env_var_string("plain_text");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "plain_text");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_underscore() {
|
|
unsafe {
|
|
std::env::set_var("TEST_VAR_NAME", "value");
|
|
}
|
|
let result = expand_env_var_string("$TEST_VAR_NAME");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "value");
|
|
unsafe {
|
|
std::env::remove_var("TEST_VAR_NAME");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_mixed_syntax() {
|
|
unsafe {
|
|
std::env::set_var("VAR1_MIXED", "v1");
|
|
std::env::set_var("VAR2_MIXED", "v2");
|
|
}
|
|
let result = expand_env_var_string("$VAR1_MIXED and ${VAR2_MIXED}");
|
|
assert!(result.is_ok());
|
|
assert_eq!(result.unwrap(), "v1 and v2");
|
|
unsafe {
|
|
std::env::remove_var("VAR1_MIXED");
|
|
std::env::remove_var("VAR2_MIXED");
|
|
}
|
|
}
|
|
}
|