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