lfsx-server 1.23.3

A fast, lightweight, secure Git LFS server
Documentation
mod access;
pub mod session;
pub mod tokens;

use std::path::Path;

use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use prometheus::core::Collector;
use serde::Serialize;
use tower_http::services::{ServeDir, ServeFile};

use crate::config::{Auth, Config, Dialect, Provider, Storage};
use crate::error::Error;
use crate::metrics::Metrics;
use crate::state::Shared;

#[derive(Debug, Serialize)]
pub struct Overview {
    pub version: &'static str,
    pub uptime_seconds: u64,
    pub storage: StorageFigures,
    pub traffic: Traffic,
    pub object_sizes: Vec<SizeBucket>,
    pub cache: Option<CacheFigures>,
    pub settings: Settings,
}

#[derive(Debug, Serialize)]
pub struct StorageFigures {
    pub kind: &'static str,
    pub objects: Option<u64>,
    pub bytes: Option<u64>,
}

#[derive(Debug, Serialize)]
pub struct Traffic {
    pub requests: u64,
    pub server_errors: u64,
    pub rejections: u64,
    pub uploaded_bytes: u64,
    pub downloaded_bytes: u64,
    pub transfers_in_flight: i64,
}

#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct SizeBucket {
    pub up_to: Option<u64>,
    pub objects: u64,
}

#[derive(Debug, Serialize)]
pub struct CacheFigures {
    pub hits: u64,
    pub misses: u64,
    pub bytes: u64,
}

#[derive(Debug, Serialize)]
pub struct Settings {
    pub auth: &'static str,
    pub allowed: Option<Vec<String>>,
    pub restricted: Vec<String>,
    pub anonymous_read: bool,
    pub max_object_size: Option<u64>,
    pub repo_quota: Option<u64>,
    pub max_concurrent_transfers: usize,
    pub compression: bool,
    pub encryption: bool,
    pub presign: bool,
    pub locking: bool,
    pub gc_grace_seconds: u64,
    pub lock_max_age_seconds: Option<u64>,
    pub forges: Vec<NamedForge>,
}

#[derive(Debug, Serialize)]
pub struct NamedForge {
    pub name: String,
    pub auth: &'static str,
    pub api_url: String,
    pub allowed: Option<Vec<String>>,
}

pub struct Refused(Error);

impl From<Error> for Refused {
    fn from(error: Error) -> Self {
        Self(error)
    }
}

impl IntoResponse for Refused {
    fn into_response(self) -> Response {
        match self.0 {
            Error::Unauthenticated => (
                StatusCode::UNAUTHORIZED,
                Json(serde_json::json!({ "message": "sign in to the dashboard" })),
            )
                .into_response(),
            error => error.into_response(),
        }
    }
}

pub fn router(dir: &Path) -> Router<Shared> {
    Router::new()
        .route("/-/api/overview", get(overview))
        .route(
            "/-/api/access",
            get(access::read).put(access::write).delete(access::reset),
        )
        .route(
            "/-/api/session",
            get(session::current)
                .post(session::sign_in)
                .delete(session::sign_out),
        )
        .nest_service(
            "/-/dashboard",
            ServeDir::new(dir).fallback(ServeFile::new(dir.join("index.html"))),
        )
}

async fn overview(
    State(state): State<Shared>,
    headers: HeaderMap,
) -> Result<Json<Overview>, Refused> {
    admit(&state, &headers).await?;

    let capacity = state.store.capacity().await;

    Ok(Json(Overview {
        version: env!("CARGO_PKG_VERSION"),
        uptime_seconds: state.started.elapsed().as_secs(),
        storage: StorageFigures {
            kind: storage_kind(&state.config.storage),
            objects: capacity.map(|(objects, _)| objects),
            bytes: capacity.map(|(_, bytes)| bytes),
        },
        traffic: traffic(&state.metrics),
        object_sizes: object_sizes(&state.metrics),
        cache: state.store.cache_stats().map(|stats| CacheFigures {
            hits: stats.hits,
            misses: stats.misses,
            bytes: stats.bytes,
        }),
        settings: settings(&state.config, state.authorizer.access()),
    }))
}

pub(crate) async fn admit(state: &Shared, headers: &HeaderMap) -> Result<(), Error> {
    let Some(dashboard) = &state.config.dashboard else {
        return Err(Error::NotServed);
    };

    if session::viewer(state, headers).await?.is_some() {
        return Ok(());
    }

    match (&state.config.auth, &dashboard.admins) {
        (Auth::Forge { .. }, Some(admins)) => state
            .authorizer
            .forge_permission(headers, admins)
            .await?
            .require_admin(),
        _ => Err(Error::Unauthenticated),
    }
}

fn storage_kind(storage: &Storage) -> &'static str {
    match storage {
        Storage::Local => "local",
        Storage::Bucket { dialect, .. } => match dialect {
            Dialect::S3 { .. } => "s3",
            Dialect::Azure { .. } => "azure",
            Dialect::Gcs { .. } => "gcs",
        },
    }
}

fn counted(collector: &impl Collector, keep: impl Fn(&[(&str, &str)]) -> bool) -> u64 {
    collector
        .collect()
        .iter()
        .flat_map(|family| family.get_metric())
        .filter(|metric| {
            let labels: Vec<(&str, &str)> = metric
                .get_label()
                .iter()
                .map(|label| (label.name(), label.value()))
                .collect();
            keep(&labels)
        })
        .map(|metric| metric.get_counter().get_value() as u64)
        .sum()
}

pub(crate) fn traffic(metrics: &Metrics) -> Traffic {
    Traffic {
        requests: counted(&metrics.requests, |_| true),
        server_errors: counted(&metrics.requests, |labels| {
            labels
                .iter()
                .any(|(name, value)| *name == "status" && value.starts_with('5'))
        }),
        rejections: counted(&metrics.rejections, |_| true),
        uploaded_bytes: metrics.uploaded_bytes.get(),
        downloaded_bytes: metrics.downloaded_bytes.get(),
        transfers_in_flight: metrics.transfers_in_flight.get(),
    }
}

pub(crate) fn object_sizes(metrics: &Metrics) -> Vec<SizeBucket> {
    let families = metrics.object_size.collect();
    let Some(histogram) = families
        .first()
        .and_then(|family| family.get_metric().first())
        .map(|metric| metric.get_histogram())
    else {
        return Vec::new();
    };

    let mut buckets = Vec::new();
    let mut below = 0;
    for bucket in histogram.get_bucket() {
        let cumulative = bucket.cumulative_count();
        buckets.push(SizeBucket {
            up_to: Some(bucket.upper_bound() as u64),
            objects: cumulative - below,
        });
        below = cumulative;
    }
    buckets.push(SizeBucket {
        up_to: None,
        objects: histogram.get_sample_count() - below,
    });

    buckets
}

pub fn start(state: Shared) {
    access::keep_fresh(state);
}

pub(crate) fn settings(config: &Config, live: Option<crate::auth::Access>) -> Settings {
    let auth = match &config.auth {
        Auth::Disabled => "disabled",
        Auth::Forge { provider, .. } => provider_name(*provider),
    };
    let (allowed, restricted, anonymous_read) = match live {
        None => (None, Vec::new(), true),
        Some(access) => (
            access.allowed.as_ref().map(|allowed| allowed.entries()),
            access.restricted.entries(),
            access.anonymous_read,
        ),
    };
    let (presign, locking) = match &config.storage {
        Storage::Local => (false, true),
        Storage::Bucket {
            presign, locking, ..
        } => (*presign, *locking),
    };

    Settings {
        auth,
        allowed,
        restricted,
        anonymous_read,
        max_object_size: config.max_object_size,
        repo_quota: config.repo_quota,
        max_concurrent_transfers: config.max_concurrent_transfers,
        compression: config.compression.is_some(),
        encryption: config.encryption_key.is_some(),
        presign,
        locking,
        gc_grace_seconds: config.gc_grace.as_secs(),
        lock_max_age_seconds: config.lock_max_age.map(|age| age.as_secs()),
        forges: config
            .forges
            .iter()
            .filter_map(|forge| match &forge.auth {
                Auth::Forge {
                    provider,
                    api_url,
                    allowed,
                    ..
                } => Some(NamedForge {
                    name: forge.name.clone(),
                    auth: provider_name(*provider),
                    api_url: api_url.clone(),
                    allowed: allowed.as_ref().map(|allowed| allowed.entries()),
                }),
                Auth::Disabled => None,
            })
            .collect(),
    }
}

fn provider_name(provider: Provider) -> &'static str {
    match provider {
        Provider::Github => "github",
        Provider::Gitlab => "gitlab",
        Provider::Gitea => "gitea",
    }
}