1use axum::http::{HeaderMap, header};
2
3use std::net::SocketAddr;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use crate::model::Action;
8use crate::namespace::Namespace;
9
10#[derive(Debug, Clone)]
11pub struct Config {
12 pub bind: SocketAddr,
13 pub storage_root: PathBuf,
14 pub public_url: Option<String>,
15 pub action_lifetime: u32,
16 pub gc_grace: Duration,
17 pub staging_max_age: Duration,
18 pub max_object_size: Option<u64>,
19 pub repo_quota: Option<u64>,
20 pub compression: Option<i32>,
21 pub storage: Storage,
22 pub auth: Auth,
23}
24
25#[derive(Debug, Clone)]
26pub enum Storage {
27 Local,
28 Bucket {
33 endpoint: String,
34 bucket: String,
35 region: String,
36 access_key: String,
37 secret_key: String,
38 path_style: bool,
39 },
40}
41
42impl Storage {
43 fn from_env() -> Self {
44 if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
45 return Self::Local;
46 }
47
48 let required = |name: &str| {
49 std::env::var(name)
50 .ok()
51 .filter(|value| !value.is_empty())
52 .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
53 };
54
55 Self::Bucket {
56 endpoint: required("LFSX_S3_ENDPOINT"),
57 bucket: required("LFSX_S3_BUCKET"),
58 region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
59 access_key: required("LFSX_S3_ACCESS_KEY"),
60 secret_key: required("LFSX_S3_SECRET_KEY"),
61 path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
62 }
63 }
64}
65
66#[derive(Debug, Clone)]
67pub enum Auth {
68 Forge {
69 provider: Provider,
70 api_url: String,
71 cache_ttl: Duration,
72 rejection_ttl: Duration,
73 },
74 Disabled,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Provider {
79 Github,
80 Gitlab,
81}
82
83impl Provider {
84 fn default_api_url(self) -> &'static str {
85 match self {
86 Self::Github => "https://api.github.com",
87 Self::Gitlab => "https://gitlab.com/api/v4",
88 }
89 }
90
91 fn api_url_variable(self) -> &'static str {
92 match self {
93 Self::Github => "LFSX_GITHUB_API_URL",
94 Self::Gitlab => "LFSX_GITLAB_API_URL",
95 }
96 }
97}
98
99const CACHE_TTL: Duration = Duration::from_secs(60);
100const REJECTION_TTL: Duration = Duration::from_secs(10);
101const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
102const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
103
104impl Config {
105 pub fn from_env() -> Self {
106 let bind = std::env::var("LFSX_BIND")
107 .ok()
108 .and_then(|raw| raw.parse().ok())
109 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
110
111 let storage_root = std::env::var("LFSX_STORAGE_ROOT")
112 .map(PathBuf::from)
113 .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
114
115 let public_url = std::env::var("LFSX_PUBLIC_URL")
116 .ok()
117 .filter(|url| !url.is_empty())
118 .map(|url| url.trim_end_matches('/').to_owned());
119
120 Self {
121 bind,
122 storage_root,
123 public_url,
124 action_lifetime: 1800,
125 gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
126 staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
127 max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
128 repo_quota: bytes("LFSX_REPO_QUOTA"),
129 compression: compression(),
130 storage: Storage::from_env(),
131 auth: Auth::from_env(),
132 }
133 }
134
135 pub fn base_url(&self, headers: &HeaderMap) -> String {
136 if let Some(configured) = &self.public_url {
137 return configured.clone();
138 }
139
140 let scheme = headers
141 .get("x-forwarded-proto")
142 .and_then(|value| value.to_str().ok())
143 .and_then(|value| value.split(',').next())
144 .map(str::trim)
145 .filter(|scheme| !scheme.is_empty())
146 .unwrap_or("http");
147
148 let authority = headers
149 .get(header::HOST)
150 .and_then(|value| value.to_str().ok())
151 .map(str::trim)
152 .filter(|host| !host.is_empty())
153 .unwrap_or("localhost");
154
155 format!("{scheme}://{authority}")
156 }
157
158 pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
159 format!("{base}/{ns}/objects/{oid}")
160 }
161
162 pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
163 format!("{base}/{ns}/objects/verify")
164 }
165
166 pub fn action(&self, href: String) -> Action {
167 Action {
168 href,
169 expires_in: self.action_lifetime,
170 }
171 }
172}
173
174impl Auth {
175 fn from_env() -> Self {
176 if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
177 tracing::warn!(
178 "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
179 );
180 return Self::Disabled;
181 }
182
183 let provider = match std::env::var("LFSX_AUTH").as_deref() {
184 Ok("gitlab") => Provider::Gitlab,
185 _ => Provider::Github,
186 };
187
188 let api_url = std::env::var(provider.api_url_variable())
189 .unwrap_or_else(|_| provider.default_api_url().to_owned())
190 .trim_end_matches('/')
191 .to_owned();
192
193 Self::Forge {
194 provider,
195 api_url,
196 cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
197 rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
198 }
199 }
200}
201
202fn compression() -> Option<i32> {
211 match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
212 "" | "none" | "off" => None,
213 "zstd" => Some(3),
214 other => match other
215 .strip_prefix("zstd:")
216 .and_then(|level| level.parse().ok())
217 {
218 Some(level @ 1..=19) => Some(level),
219 _ => {
220 tracing::warn!(
221 "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
222 );
223 None
224 }
225 },
226 }
227}
228
229fn bytes(variable: &str) -> Option<u64> {
230 let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
231
232 if configured == 0 {
233 tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
234 return None;
235 }
236
237 Some(configured)
238}
239
240fn seconds(variable: &str) -> Option<Duration> {
241 std::env::var(variable)
242 .ok()
243 .and_then(|raw| raw.parse().ok())
244 .map(Duration::from_secs)
245}