Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ia16e7e34cda6ae3e12590ea1ea9268486a6a6964
1329 lines
36 KiB
Rust
1329 lines
36 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Expand environment variables in a string using `std::env::var` for lookup.
|
|
/// 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> {
|
|
expand_env_vars(input, |name| {
|
|
std::env::var(name).map_err(|_| {
|
|
crate::error::PinakesError::Config(format!(
|
|
"environment variable not set: {name}"
|
|
))
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Expand environment variables in a string using the provided lookup function.
|
|
/// Supports both `${VAR_NAME}` and `$VAR_NAME` syntax.
|
|
fn expand_env_vars(
|
|
input: &str,
|
|
lookup: impl Fn(&str) -> crate::error::Result<String>,
|
|
) -> 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(),
|
|
));
|
|
}
|
|
|
|
result.push_str(&lookup(&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,
|
|
#[serde(default)]
|
|
pub managed_storage: ManagedStorageConfig,
|
|
#[serde(default)]
|
|
pub sync: SyncConfig,
|
|
#[serde(default)]
|
|
pub sharing: SharingConfig,
|
|
#[serde(default)]
|
|
pub trash: TrashConfig,
|
|
}
|
|
|
|
#[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"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
#[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![],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ManagedStorageConfig {
|
|
/// Enable managed storage for file uploads
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
/// Directory where managed files are stored
|
|
#[serde(default = "default_managed_storage_dir")]
|
|
pub storage_dir: PathBuf,
|
|
/// Maximum upload size in bytes (default: 10GB)
|
|
#[serde(default = "default_max_upload_size")]
|
|
pub max_upload_size: u64,
|
|
/// Allowed MIME types for uploads (empty = allow all)
|
|
#[serde(default)]
|
|
pub allowed_mime_types: Vec<String>,
|
|
/// Automatically clean up orphaned blobs
|
|
#[serde(default = "default_true")]
|
|
pub auto_cleanup: bool,
|
|
/// Verify file integrity on read
|
|
#[serde(default)]
|
|
pub verify_on_read: bool,
|
|
}
|
|
|
|
fn default_managed_storage_dir() -> PathBuf {
|
|
Config::default_data_dir().join("managed")
|
|
}
|
|
|
|
fn default_max_upload_size() -> u64 {
|
|
10 * 1024 * 1024 * 1024 // 10GB
|
|
}
|
|
|
|
impl Default for ManagedStorageConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
storage_dir: default_managed_storage_dir(),
|
|
max_upload_size: default_max_upload_size(),
|
|
allowed_mime_types: vec![],
|
|
auto_cleanup: true,
|
|
verify_on_read: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(
|
|
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConflictResolution {
|
|
ServerWins,
|
|
ClientWins,
|
|
#[default]
|
|
KeepBoth,
|
|
Manual,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SyncConfig {
|
|
/// Enable cross-device sync functionality
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
/// Default conflict resolution strategy
|
|
#[serde(default)]
|
|
pub default_conflict_resolution: ConflictResolution,
|
|
/// Maximum file size for sync in MB
|
|
#[serde(default = "default_max_sync_file_size")]
|
|
pub max_file_size_mb: u64,
|
|
/// Chunk size for chunked uploads in KB
|
|
#[serde(default = "default_chunk_size")]
|
|
pub chunk_size_kb: u64,
|
|
/// Upload session timeout in hours
|
|
#[serde(default = "default_upload_timeout")]
|
|
pub upload_timeout_hours: u64,
|
|
/// Maximum concurrent uploads per device
|
|
#[serde(default = "default_max_concurrent_uploads")]
|
|
pub max_concurrent_uploads: usize,
|
|
/// Sync log retention in days
|
|
#[serde(default = "default_sync_log_retention")]
|
|
pub sync_log_retention_days: u64,
|
|
/// Temporary directory for chunked upload storage
|
|
#[serde(default = "default_temp_upload_dir")]
|
|
pub temp_upload_dir: PathBuf,
|
|
}
|
|
|
|
fn default_max_sync_file_size() -> u64 {
|
|
4096 // 4GB
|
|
}
|
|
|
|
fn default_chunk_size() -> u64 {
|
|
4096 // 4MB
|
|
}
|
|
|
|
fn default_upload_timeout() -> u64 {
|
|
24 // 24 hours
|
|
}
|
|
|
|
fn default_max_concurrent_uploads() -> usize {
|
|
3
|
|
}
|
|
|
|
fn default_sync_log_retention() -> u64 {
|
|
90 // 90 days
|
|
}
|
|
|
|
fn default_temp_upload_dir() -> PathBuf {
|
|
Config::default_data_dir().join("temp_uploads")
|
|
}
|
|
|
|
impl Default for SyncConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
default_conflict_resolution: ConflictResolution::default(),
|
|
max_file_size_mb: default_max_sync_file_size(),
|
|
chunk_size_kb: default_chunk_size(),
|
|
upload_timeout_hours: default_upload_timeout(),
|
|
max_concurrent_uploads: default_max_concurrent_uploads(),
|
|
sync_log_retention_days: default_sync_log_retention(),
|
|
temp_upload_dir: default_temp_upload_dir(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SharingConfig {
|
|
/// Enable sharing functionality
|
|
#[serde(default = "default_true")]
|
|
pub enabled: bool,
|
|
/// Allow creating public share links
|
|
#[serde(default = "default_true")]
|
|
pub allow_public_links: bool,
|
|
/// Require password for public share links
|
|
#[serde(default)]
|
|
pub require_public_link_password: bool,
|
|
/// Maximum expiry time for public links in hours (0 = unlimited)
|
|
#[serde(default)]
|
|
pub max_public_link_expiry_hours: u64,
|
|
/// Allow users to reshare content shared with them
|
|
#[serde(default = "default_true")]
|
|
pub allow_reshare: bool,
|
|
/// Enable share notifications
|
|
#[serde(default = "default_true")]
|
|
pub notifications_enabled: bool,
|
|
/// Notification retention in days
|
|
#[serde(default = "default_notification_retention")]
|
|
pub notification_retention_days: u64,
|
|
/// Share activity log retention in days
|
|
#[serde(default = "default_activity_retention")]
|
|
pub activity_retention_days: u64,
|
|
}
|
|
|
|
fn default_notification_retention() -> u64 {
|
|
30
|
|
}
|
|
|
|
fn default_activity_retention() -> u64 {
|
|
90
|
|
}
|
|
|
|
impl Default for SharingConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: true,
|
|
allow_public_links: true,
|
|
require_public_link_password: false,
|
|
max_public_link_expiry_hours: 0,
|
|
allow_reshare: true,
|
|
notifications_enabled: true,
|
|
notification_retention_days: default_notification_retention(),
|
|
activity_retention_days: default_activity_retention(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct TrashConfig {
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_trash_retention_days")]
|
|
pub retention_days: u64,
|
|
#[serde(default)]
|
|
pub auto_empty: bool,
|
|
}
|
|
|
|
fn default_trash_retention_days() -> u64 {
|
|
30
|
|
}
|
|
|
|
impl Default for TrashConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
retention_days: default_trash_retention_days(),
|
|
auto_empty: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
impl StorageBackendType {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Sqlite => "sqlite",
|
|
Self::Postgres => "postgres",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for StorageBackendType {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
#[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()
|
|
{
|
|
// Skip if parent is empty string (happens with bare filenames like
|
|
// "pinakes.db")
|
|
if !parent.as_os_str().is_empty() {
|
|
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(),
|
|
managed_storage: ManagedStorageConfig::default(),
|
|
sync: SyncConfig::default(),
|
|
sharing: SharingConfig::default(),
|
|
trash: TrashConfig::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 using expand_env_vars with a
|
|
// HashMap lookup. This avoids unsafe std::env::set_var and is
|
|
// thread-safe for parallel test execution.
|
|
fn test_lookup<'a>(
|
|
vars: &'a std::collections::HashMap<&str, &str>,
|
|
) -> impl Fn(&str) -> crate::error::Result<String> + 'a {
|
|
move |name| {
|
|
vars.get(name).map(|v| v.to_string()).ok_or_else(|| {
|
|
crate::error::PinakesError::Config(format!(
|
|
"environment variable not set: {name}"
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_simple() {
|
|
let vars =
|
|
std::collections::HashMap::from([("TEST_VAR_SIMPLE", "test_value")]);
|
|
let result = expand_env_vars("$TEST_VAR_SIMPLE", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "test_value");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_braces() {
|
|
let vars =
|
|
std::collections::HashMap::from([("TEST_VAR_BRACES", "test_value")]);
|
|
let result = expand_env_vars("${TEST_VAR_BRACES}", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "test_value");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_embedded() {
|
|
let vars =
|
|
std::collections::HashMap::from([("TEST_VAR_EMBEDDED", "value")]);
|
|
let result =
|
|
expand_env_vars("prefix_${TEST_VAR_EMBEDDED}_suffix", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "prefix_value_suffix");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_multiple() {
|
|
let vars =
|
|
std::collections::HashMap::from([("VAR1", "value1"), ("VAR2", "value2")]);
|
|
let result = expand_env_vars("${VAR1}_${VAR2}", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "value1_value2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_missing() {
|
|
let vars = std::collections::HashMap::new();
|
|
let result = expand_env_vars("${NONEXISTENT_VAR}", test_lookup(&vars));
|
|
assert!(result.is_err());
|
|
assert!(
|
|
result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("environment variable not set")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_empty_name() {
|
|
let vars = std::collections::HashMap::new();
|
|
let result = expand_env_vars("${}", test_lookup(&vars));
|
|
assert!(result.is_err());
|
|
assert!(
|
|
result
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("empty environment variable name")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_escaped() {
|
|
let vars = std::collections::HashMap::new();
|
|
let result = expand_env_vars("\\$NOT_A_VAR", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "$NOT_A_VAR");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_no_vars() {
|
|
let vars = std::collections::HashMap::new();
|
|
let result = expand_env_vars("plain_text", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "plain_text");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_underscore() {
|
|
let vars = std::collections::HashMap::from([("TEST_VAR_NAME", "value")]);
|
|
let result = expand_env_vars("$TEST_VAR_NAME", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "value");
|
|
}
|
|
|
|
#[test]
|
|
fn test_expand_env_var_mixed_syntax() {
|
|
let vars = std::collections::HashMap::from([
|
|
("VAR1_MIXED", "v1"),
|
|
("VAR2_MIXED", "v2"),
|
|
]);
|
|
let result =
|
|
expand_env_vars("$VAR1_MIXED and ${VAR2_MIXED}", test_lookup(&vars));
|
|
assert_eq!(result.unwrap(), "v1 and v2");
|
|
}
|
|
}
|