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 },
53}
54
55impl Storage {
56 fn from_env() -> Self {
57 if std::env::var("LFSX_STORAGE").as_deref() != Ok("s3") {
58 return Self::Local;
59 }
60
61 let required = |name: &str| {
62 std::env::var(name)
63 .ok()
64 .filter(|value| !value.is_empty())
65 .unwrap_or_else(|| panic!("LFSX_STORAGE=s3 needs {name}"))
66 };
67
68 Self::Bucket {
69 endpoint: required("LFSX_S3_ENDPOINT"),
70 bucket: required("LFSX_S3_BUCKET"),
71 region: std::env::var("LFSX_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
72 access_key: required("LFSX_S3_ACCESS_KEY"),
73 secret_key: required("LFSX_S3_SECRET_KEY"),
74 path_style: std::env::var("LFSX_S3_PATH_STYLE").as_deref() != Ok("false"),
75 presign: std::env::var("LFSX_S3_PRESIGN").as_deref() == Ok("true"),
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
81pub enum Auth {
82 Forge {
83 provider: Provider,
84 api_url: String,
85 cache_ttl: Duration,
86 rejection_ttl: Duration,
87 anonymous_read: bool,
91 },
92 Disabled,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Provider {
97 Github,
98 Gitlab,
99 Gitea,
103}
104
105impl Provider {
106 fn default_api_url(self) -> Option<&'static str> {
116 match self {
117 Self::Github => Some("https://api.github.com"),
118 Self::Gitlab => Some("https://gitlab.com/api/v4"),
119 Self::Gitea => None,
120 }
121 }
122
123 fn api_url_variable(self) -> &'static str {
124 match self {
125 Self::Github => "LFSX_GITHUB_API_URL",
126 Self::Gitlab => "LFSX_GITLAB_API_URL",
127 Self::Gitea => "LFSX_GITEA_API_URL",
128 }
129 }
130}
131
132const CACHE_TTL: Duration = Duration::from_secs(60);
133const REJECTION_TTL: Duration = Duration::from_secs(10);
134const GC_GRACE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
135const STAGING_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
136
137impl Config {
138 pub fn from_env() -> Self {
139 let bind = std::env::var("LFSX_BIND")
140 .ok()
141 .and_then(|raw| raw.parse().ok())
142 .unwrap_or_else(|| SocketAddr::from(([0, 0, 0, 0], 8080)));
143
144 let storage_root = std::env::var("LFSX_STORAGE_ROOT")
145 .map(PathBuf::from)
146 .unwrap_or_else(|_| PathBuf::from("/var/lib/lfsx"));
147
148 let public_url = std::env::var("LFSX_PUBLIC_URL")
149 .ok()
150 .filter(|url| !url.is_empty())
151 .map(|url| url.trim_end_matches('/').to_owned());
152
153 Self {
154 bind,
155 storage_root,
156 public_url,
157 action_lifetime: 1800,
158 gc_grace: seconds("LFSX_GC_GRACE").unwrap_or(GC_GRACE),
159 staging_max_age: seconds("LFSX_STAGING_MAX_AGE").unwrap_or(STAGING_MAX_AGE),
160 lock_max_age: seconds("LFSX_LOCK_MAX_AGE"),
161 max_object_size: bytes("LFSX_MAX_OBJECT_SIZE"),
162 repo_quota: bytes("LFSX_REPO_QUOTA"),
163 compression: compression(),
164 encryption_key_file: std::env::var("LFSX_ENCRYPTION_KEY_FILE")
165 .ok()
166 .filter(|path| !path.is_empty())
167 .map(PathBuf::from),
168 storage: Storage::from_env(),
169 auth: Auth::from_env(),
170 }
171 }
172
173 pub fn base_url(&self, headers: &HeaderMap) -> String {
174 if let Some(configured) = &self.public_url {
175 return configured.clone();
176 }
177
178 let scheme = headers
179 .get("x-forwarded-proto")
180 .and_then(|value| value.to_str().ok())
181 .and_then(|value| value.split(',').next())
182 .map(str::trim)
183 .filter(|scheme| !scheme.is_empty())
184 .unwrap_or("http");
185
186 let authority = headers
187 .get(header::HOST)
188 .and_then(|value| value.to_str().ok())
189 .map(str::trim)
190 .filter(|host| !host.is_empty())
191 .unwrap_or("localhost");
192
193 format!("{scheme}://{authority}")
194 }
195
196 pub fn object_url(&self, base: &str, ns: &Namespace, oid: &str) -> String {
197 format!("{base}/{ns}/objects/{oid}")
198 }
199
200 pub fn verify_url(&self, base: &str, ns: &Namespace) -> String {
201 format!("{base}/{ns}/objects/verify")
202 }
203
204 pub fn action(&self, href: String) -> Action {
205 Action {
206 href,
207 header: None,
208 expires_in: self.action_lifetime,
209 }
210 }
211
212 pub fn signed_action(&self, href: String, headers: Vec<(String, String)>) -> Action {
213 Action {
214 href,
215 header: Some(headers.into_iter().collect()),
216 expires_in: self.action_lifetime,
217 }
218 }
219}
220
221fn anonymous_read(value: Option<&str>) -> bool {
233 value == Some("true")
234}
235
236impl Auth {
237 fn from_env() -> Self {
238 if std::env::var("LFSX_AUTH").as_deref() == Ok("disabled") {
239 tracing::warn!(
240 "LFSX_AUTH=disabled — every request is accepted, run this on a trusted network only"
241 );
242 return Self::Disabled;
243 }
244
245 let provider = provider(std::env::var("LFSX_AUTH").ok().as_deref());
246
247 Self::Forge {
248 provider,
249 api_url: api_url(
250 provider,
251 std::env::var(provider.api_url_variable()).ok().as_deref(),
252 ),
253 cache_ttl: seconds("LFSX_AUTH_CACHE_TTL").unwrap_or(CACHE_TTL),
254 rejection_ttl: seconds("LFSX_AUTH_REJECTION_TTL").unwrap_or(REJECTION_TTL),
255 anonymous_read: anonymous_read(std::env::var("LFSX_ANONYMOUS_READ").ok().as_deref()),
256 }
257 }
258}
259
260fn provider(value: Option<&str>) -> Provider {
264 match value {
265 Some("gitlab") => Provider::Gitlab,
266 Some("gitea") | Some("forgejo") => Provider::Gitea,
267 _ => Provider::Github,
268 }
269}
270
271fn api_url(provider: Provider, configured: Option<&str>) -> String {
275 let variable = provider.api_url_variable();
276
277 configured
278 .map(str::to_owned)
279 .or_else(|| provider.default_api_url().map(str::to_owned))
280 .unwrap_or_else(|| {
281 panic!(
282 "{variable} must be set: a self-hosted forge has no default API root, and guessing \
283 one would resolve your repositories against somebody else's"
284 )
285 })
286 .trim_end_matches('/')
287 .to_owned()
288}
289
290fn compression() -> Option<i32> {
299 match std::env::var("LFSX_COMPRESSION").ok()?.trim() {
300 "" | "none" | "off" => None,
301 "zstd" => Some(3),
302 other => match other
303 .strip_prefix("zstd:")
304 .and_then(|level| level.parse().ok())
305 {
306 Some(level @ 1..=19) => Some(level),
307 _ => {
308 tracing::warn!(
309 "LFSX_COMPRESSION={other} is not a codec this server knows — storing objects as they arrive"
310 );
311 None
312 }
313 },
314 }
315}
316
317fn bytes(variable: &str) -> Option<u64> {
318 let configured = std::env::var(variable).ok()?.trim().parse().ok()?;
319
320 if configured == 0 {
321 tracing::warn!("{variable}=0 would refuse every upload — ignoring it");
322 return None;
323 }
324
325 Some(configured)
326}
327
328fn seconds(variable: &str) -> Option<Duration> {
329 std::env::var(variable)
330 .ok()
331 .and_then(|raw| raw.parse().ok())
332 .map(Duration::from_secs)
333}
334
335#[cfg(test)]
336mod tests;