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 lock_max_age: Option<Duration>,
22 pub max_object_size: Option<u64>,
23 pub repo_quota: Option<u64>,
24 pub compression: Option<i32>,
25 pub encryption_key_file: Option<PathBuf>,
29 pub storage: Storage,
30 pub auth: Auth,
31}
32
33#[derive(Debug, Clone)]
34pub enum Storage {
35 Local,
36 Bucket {
41 endpoint: String,
42 bucket: String,
43 region: String,
44 access_key: String,
45 secret_key: String,
46 path_style: bool,
47 presign: bool,
52 locking: bool,
57 },
58}
59
60impl Storage {
61 fn from_env() -> Self {
62 if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
63 return Self::Local;
64 }
65
66 let required = |name: &str| {
67 std::env::var(name)
68 .ok()
69 .filter(|value| !value.is_empty())
70 .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
71 };
72
73 Self::Bucket {
74 endpoint: required("LFSX_S3_ENDPOINT"),
75 bucket: required("LFSX_S3_BUCKET"),
76 region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
77 access_key: required("LFSX_S3_ACCESS_KEY"),
78 secret_key: required("LFSX_S3_SECRET_KEY"),
79 path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
80 presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
81 locking: true,
82 }
83 }
84}
85
86#[derive(Debug, Clone)]
87pub enum Auth {
88 Forge {
89 provider: Provider,
90 api_url: String,
91 cache_ttl: Duration,
92 rejection_ttl: Duration,
93 lookup_budget: Option<u32>,
96 anonymous_read: bool,
100 },
101 Disabled,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Provider {
106 Github,
107 Gitlab,
108 Gitea,
112}
113
114impl Provider {
115 fn default_api_url(self) -> Option<&'static str> {
125 match self {
126 Self::Github => Some("https://api.github.com"),
127 Self::Gitlab => Some("https://gitlab.com/api/v4"),
128 Self::Gitea => None,
129 }
130 }
131
132 fn api_url_variable(self) -> &'static str {
133 match self {
134 Self::Github => "LFSX_GITHUB_API_URL",
135 Self::Gitlab => "LFSX_GITLAB_API_URL",
136 Self::Gitea => "LFSX_GITEA_API_URL",
137 }
138 }
139}
140
141const CACHE_TTL: Duration = Duration::from_secs(60);
142const REJECTION_TTL: Duration = Duration::from_secs(10);
143const LOOKUP_BUDGET: u32 = 600;
148const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
149const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
150
151impl Config {
152 pub fn from_env() -> Self {
153 let bind = std::env::var("LFSX_BIND")
154 .ok()
155 .and_then(|raw| raw.parse().ok())
156 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
157
158 let storage_root = std::env::var("LFSX_STORAGE_ROOT")
159 .map(PathBuf::from)
160 .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
161
162 let public_url = std::env::var("LFSX_PUBLIC_URL")
163 .ok()
164 .filter(|url| !url.is_empty())
165 .map(|url| url.trim_end_matches('/').to_owned());
166
167 Self {
168 bind,
169 storage_root,
170 public_url,
171 action_lifetime: 1800,
172 gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
173 staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
174 lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
175 max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
176 repo_quota: bytes("LFSX_REPO_QUOTA"),
177 compression: compression(),
178 encryption_key_file: std::env::var("LFSX_ENCRYPTION_KEY_FILE")
179 .ok()
180 .filter(|path| !path.is_empty())
181 .map(PathBuf::from),
182 storage: Storage::from_env(),
183 auth: Auth::from_env(),
184 }
185 }
186
187 pub fn base_url(&self, headers: &HeaderMap) -> String {
188 if let Some(configured) = &self.public_url {
189 return configured.clone();
190 }
191
192 let scheme = headers
197 .get("x-forwarded-proto")
198 .and_then(|value| value.to_str().ok())
199 .and_then(|value| value.split(',').next())
200 .map(str::trim)
201 .filter(|scheme| matches!(*scheme, "http" | "https"))
202 .unwrap_or("http");
203
204 let authority = headers
205 .get(header::HOST)
206 .and_then(|value| value.to_str().ok())
207 .map(str::trim)
208 .filter(|host| is_an_authority(host))
209 .unwrap_or("localhost");
210
211 format!("{scheme}://{authority}")
212 }
213
214 pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
215 format!("{base}/{ns}/objects/{oid}")
216 }
217
218 pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
219 format!("{base}/{ns}/objects/verify")
220 }
221
222 pub fn action(&self, href: String) -> Action {
223 Action {
224 href,
225 header: None,
226 expires_in: self.action_lifetime,
227 }
228 }
229
230 pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
231 Action {
232 href,
233 header: Some(headers.into_iter().collect()),
234 expires_in: self.action_lifetime,
235 }
236 }
237}
238
239fn anonymous_read(value: Option<&str>) -> bool {
251 value == Some("true")
252}
253
254impl Auth {
255 fn from_env() -> Self {
256 if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
257 tracing::warn!(
258 "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
259 );
260 return Self::Disabled;
261 }
262
263 let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
264
265 Self::Forge {
266 provider,
267 api_url: api_url(
268 provider,
269 std::env::var(provider.api_url_variable()).ok().as_deref(),
270 ),
271 cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
272 rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
273 lookup_budget: lookup_budget(std::env::var("LFSX_AUTH_LOOKUP_BUDGET").ok().as_deref()),
274 anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
275 }
276 }
277}
278
279fn lookup_budget(value: Option<&str>) -> Option<u32> {
285 match value.map(str::trim).map(str::parse::<u32>) {
286 Some(Ok(0)) => None,
287 Some(Ok(budget)) => Some(budget),
288 Some(Err(_)) | None => Some(LOOKUP_BUDGET),
289 }
290}
291
292fn provider(value: Option<&str>) -> Provider {
296 match value {
297 Some("gitlab") => Provider::Gitlab,
298 Some("gitea") | Some("forgejo") => Provider::Gitea,
299 _ => Provider::Github,
300 }
301}
302
303fn api_url(provider: Provider, configured: Option<&str>) -> String {
307 let variable = provider.api_url_variable();
308
309 configured
310 .map(str::to_owned)
311 .or_else(|| provider.default_api_url().map(str::to_owned))
312 .unwrap_or_else(|| {
313 panic!(
314 "{variable} must be set: a self-hosted forge has no default API root, and guessing \
315 one would resolve your repositories against somebody else's"
316 )
317 })
318 .trim_end_matches('/')
319 .to_owned()
320}
321
322fn is_an_authority(host: &str) -> bool {
333 !host.is_empty()
334 && host.len() <= 255
335 && host.bytes().all(|byte| {
336 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b':' | b'[' | b']')
337 })
338}
339
340fn compression() -> Option<i32> {
349 match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
350 "" | "none" | "off" => None,
351 "zstd" => Some(3),
352 other => match other
353 .strip_prefix("zstd:")
354 .and_then(|level| level.parse().ok())
355 {
356 Some(level @ 1..=19) => Some(level),
357 _ => {
358 tracing::warn!(
359 "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
360 );
361 None
362 }
363 },
364 }
365}
366
367fn bytes(variable: &str) -> Option<u64> {
368 let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
369
370 if configured == 0 {
371 tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
372 return None;
373 }
374
375 Some(configured)
376}
377
378fn seconds(variable: &str) -> Option<Duration> {
379 std::env::var(variable)
380 .ok()
381 .and_then(|raw| raw.parse().ok())
382 .map(Duration::from_secs)
383}
384
385#[cfg(test)]
386mod tests;