1mod access;
2pub mod session;
3pub mod tokens;
4
5use std::path::Path;
6
7use axum::extract::State;
8use axum::http::{HeaderMap, StatusCode};
9use axum::response::{IntoResponse, Response};
10use axum::routing::get;
11use axum::{Json, Router};
12use prometheus::core::Collector;
13use serde::Serialize;
14use tower_http::services::{ServeDir, ServeFile};
15
16use crate::config::{Auth, Config, Dialect, Provider, Storage};
17use crate::error::Error;
18use crate::metrics::Metrics;
19use crate::state::Shared;
20
21#[derive(Debug, Serialize)]
22pub struct Overview {
23 pub version: &'static str,
24 pub uptime_seconds: u64,
25 pub storage: StorageFigures,
26 pub traffic: Traffic,
27 pub object_sizes: Vec<SizeBucket>,
28 pub cache: Option<CacheFigures>,
29 pub settings: Settings,
30}
31
32#[derive(Debug, Serialize)]
33pub struct StorageFigures {
34 pub kind: &'static str,
35 pub objects: Option<u64>,
36 pub bytes: Option<u64>,
37}
38
39#[derive(Debug, Serialize)]
40pub struct Traffic {
41 pub requests: u64,
42 pub server_errors: u64,
43 pub rejections: u64,
44 pub uploaded_bytes: u64,
45 pub downloaded_bytes: u64,
46 pub transfers_in_flight: i64,
47}
48
49#[derive(Debug, Serialize, PartialEq, Eq)]
50pub struct SizeBucket {
51 pub up_to: Option<u64>,
52 pub objects: u64,
53}
54
55#[derive(Debug, Serialize)]
56pub struct CacheFigures {
57 pub hits: u64,
58 pub misses: u64,
59 pub bytes: u64,
60}
61
62#[derive(Debug, Serialize)]
63pub struct Settings {
64 pub auth: &'static str,
65 pub allowed: Option<Vec<String>>,
66 pub restricted: Vec<String>,
67 pub anonymous_read: bool,
68 pub max_object_size: Option<u64>,
69 pub repo_quota: Option<u64>,
70 pub max_concurrent_transfers: usize,
71 pub compression: bool,
72 pub encryption: bool,
73 pub presign: bool,
74 pub locking: bool,
75 pub gc_grace_seconds: u64,
76 pub lock_max_age_seconds: Option<u64>,
77 pub forges: Vec<NamedForge>,
78}
79
80#[derive(Debug, Serialize)]
81pub struct NamedForge {
82 pub name: String,
83 pub auth: &'static str,
84 pub api_url: String,
85 pub allowed: Option<Vec<String>>,
86}
87
88pub struct Refused(Error);
89
90impl From<Error> for Refused {
91 fn from(error: Error) -> Self {
92 Self(error)
93 }
94}
95
96impl IntoResponse for Refused {
97 fn into_response(self) -> Response {
98 match self.0 {
99 Error::Unauthenticated => (
100 StatusCode::UNAUTHORIZED,
101 Json(serde_json::json!({ "message": "sign in to the dashboard" })),
102 )
103 .into_response(),
104 error => error.into_response(),
105 }
106 }
107}
108
109pub fn router(dir: &Path) -> Router<Shared> {
110 Router::new()
111 .route("/-/api/overview", get(overview))
112 .route(
113 "/-/api/access",
114 get(access::read).put(access::write).delete(access::reset),
115 )
116 .route(
117 "/-/api/session",
118 get(session::current)
119 .post(session::sign_in)
120 .delete(session::sign_out),
121 )
122 .nest_service(
123 "/-/dashboard",
124 ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html"))),
125 )
126}
127
128async fn overview(
129 State(state): State<Shared>,
130 headers: HeaderMap,
131) -> Result<Json<Overview>, Refused> {
132 admit(&state, &headers).await?;
133
134 let capacity = state.store.capacity().await;
135
136 Ok(Json(Overview {
137 version: env!("CARGO_PKG_VERSION"),
138 uptime_seconds: state.started.elapsed().as_secs(),
139 storage: StorageFigures {
140 kind: storage_kind(&state.config.storage),
141 objects: capacity.map(|(objects, _)| objects),
142 bytes: capacity.map(|(_, bytes)| bytes),
143 },
144 traffic: traffic(&state.metrics),
145 object_sizes: object_sizes(&state.metrics),
146 cache: state.store.cache_stats().map(|stats| CacheFigures {
147 hits: stats.hits,
148 misses: stats.misses,
149 bytes: stats.bytes,
150 }),
151 settings: settings(&state.config, state.authorizer.access()),
152 }))
153}
154
155pub(crate) async fn admit(state: &Shared, headers: &HeaderMap) -> Result<(), Error> {
156 let Some(dashboard) = &state.config.dashboard else {
157 return Err(Error::NotServed);
158 };
159
160 if session::viewer(state, headers).await?.is_some() {
161 return Ok(());
162 }
163
164 match (&state.config.auth, &dashboard.admins) {
165 (Auth::Forge { .. }, Some(admins)) => state
166 .authorizer
167 .forge_permission(headers, admins)
168 .await?
169 .require_admin(),
170 _ => Err(Error::Unauthenticated),
171 }
172}
173
174fn storage_kind(storage: &Storage) -> &'static str {
175 match storage {
176 Storage::Local => "local",
177 Storage::Bucket { dialect, .. } => match dialect {
178 Dialect::S3 { .. } => "s3",
179 Dialect::Azure { .. } => "azure",
180 Dialect::Gcs { .. } => "gcs",
181 },
182 }
183}
184
185fn counted(collector: &impl Collector, keep: impl Fn(&[(&str, &str)]) -> bool) -> u64 {
186 collector
187 .collect()
188 .iter()
189 .flat_map(|family| family.get_metric())
190 .filter(|metric| {
191 let labels: Vec<(&str, &str)> = metric
192 .get_label()
193 .iter()
194 .map(|label| (label.name(), label.value()))
195 .collect();
196 keep(&labels)
197 })
198 .map(|metric| metric.get_counter().get_value() as u64)
199 .sum()
200}
201
202pub(crate) fn traffic(metrics: &Metrics) -> Traffic {
203 Traffic {
204 requests: counted(&metrics.requests, |_| true),
205 server_errors: counted(&metrics.requests, |labels| {
206 labels
207 .iter()
208 .any(|(name, value)| *name == "status" && value.starts_with('5'))
209 }),
210 rejections: counted(&metrics.rejections, |_| true),
211 uploaded_bytes: metrics.uploaded_bytes.get(),
212 downloaded_bytes: metrics.downloaded_bytes.get(),
213 transfers_in_flight: metrics.transfers_in_flight.get(),
214 }
215}
216
217pub(crate) fn object_sizes(metrics: &Metrics) -> Vec<SizeBucket> {
218 let families = metrics.object_size.collect();
219 let Some(histogram) = families
220 .first()
221 .and_then(|family| family.get_metric().first())
222 .map(|metric| metric.get_histogram())
223 else {
224 return Vec::new();
225 };
226
227 let mut buckets = Vec::new();
228 let mut below = 0;
229 for bucket in histogram.get_bucket() {
230 let cumulative = bucket.cumulative_count();
231 buckets.push(SizeBucket {
232 up_to: Some(bucket.upper_bound() as u64),
233 objects: cumulative - below,
234 });
235 below = cumulative;
236 }
237 buckets.push(SizeBucket {
238 up_to: None,
239 objects: histogram.get_sample_count() - below,
240 });
241
242 buckets
243}
244
245pub fn start(state: Shared) {
246 access::keep_fresh(state);
247}
248
249pub(crate) fn settings(config: &Config, live: Option<crate::auth::Access>) -> Settings {
250 let auth = match &config.auth {
251 Auth::Disabled => "disabled",
252 Auth::Forge { provider, .. } => provider_name(*provider),
253 };
254 let (allowed, restricted, anonymous_read) = match live {
255 None => (None, Vec::new(), true),
256 Some(access) => (
257 access.allowed.as_ref().map(|allowed| allowed.entries()),
258 access.restricted.entries(),
259 access.anonymous_read,
260 ),
261 };
262 let (presign, locking) = match &config.storage {
263 Storage::Local => (false, true),
264 Storage::Bucket {
265 presign, locking, ..
266 } => (*presign, *locking),
267 };
268
269 Settings {
270 auth,
271 allowed,
272 restricted,
273 anonymous_read,
274 max_object_size: config.max_object_size,
275 repo_quota: config.repo_quota,
276 max_concurrent_transfers: config.max_concurrent_transfers,
277 compression: config.compression.is_some(),
278 encryption: config.encryption_key.is_some(),
279 presign,
280 locking,
281 gc_grace_seconds: config.gc_grace.as_secs(),
282 lock_max_age_seconds: config.lock_max_age.map(|age| age.as_secs()),
283 forges: config
284 .forges
285 .iter()
286 .filter_map(|forge| match &forge.auth {
287 Auth::Forge {
288 provider,
289 api_url,
290 allowed,
291 ..
292 } => Some(NamedForge {
293 name: forge.name.clone(),
294 auth: provider_name(*provider),
295 api_url: api_url.clone(),
296 allowed: allowed.as_ref().map(|allowed| allowed.entries()),
297 }),
298 Auth::Disabled => None,
299 })
300 .collect(),
301 }
302}
303
304fn provider_name(provider: Provider) -> &'static str {
305 match provider {
306 Provider::Github => "github",
307 Provider::Gitlab => "gitlab",
308 Provider::Gitea => "gitea",
309 }
310}