gitdataai/libs/git/http/lfs_routes.rs

351 lines
13 KiB
Rust

use crate::error::GitError;
use crate::http::HttpAppState;
use crate::http::auth::authorize_repo_access;
use crate::http::handler::is_valid_lfs_oid;
use crate::http::lfs::{BatchRequest, CreateLockRequest, LfsHandler};
use crate::http::utils::{get_repo_model, hash_access_key};
use actix_web::{Error, HttpRequest, HttpResponse, web};
use models::users::user_token;
use sea_orm::prelude::*;
use std::path::PathBuf;
fn base_url(req: &HttpRequest) -> String {
let conn_info = req.connection_info();
format!("{}://{}", conn_info.scheme(), conn_info.host())
}
fn bearer_token(req: &HttpRequest) -> Result<String, Error> {
let auth_header = req
.headers()
.get("authorization")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Missing authorization header"))?
.to_str()
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid authorization header"))?;
if let Some(token) = auth_header.strip_prefix("Bearer ") {
Ok(token.to_string())
} else {
Err(actix_web::error::ErrorUnauthorized(
"Invalid authorization format",
))
}
}
fn hash_token(token: &str) -> Result<String, argon2::password_hash::Error> {
hash_access_key(token)
}
/// Derive the acting user from the authenticated bearer token.
async fn user_uid(req: &HttpRequest, db: &db::database::AppDatabase) -> Result<uuid::Uuid, Error> {
let auth_header = req
.headers()
.get("authorization")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Missing authorization header"))?;
let auth_str = auth_header
.to_str()
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid authorization header"))?;
let token = auth_str
.strip_prefix("Bearer ")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Invalid authorization format"))?;
let token_hash = hash_token(token)
.map_err(|_| actix_web::error::ErrorInternalServerError("Token hash failed"))?;
let token_model = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&token_hash))
.filter(user_token::Column::IsRevoked.eq(false))
.one(db.reader())
.await
.map_err(|_| actix_web::error::ErrorUnauthorized("Authentication failed"))?;
let token_model =
token_model.ok_or_else(|| actix_web::error::ErrorUnauthorized("Invalid token"))?;
if let Some(expires_at) = token_model.expires_at {
if expires_at < chrono::Utc::now() {
return Err(actix_web::error::ErrorUnauthorized("Token expired"));
}
}
Ok(token_model.user)
}
/// Store LFS batch-generated token in Redis with TTL.
/// Key format: `lfs:token:{token}` → `{repo_id}:{user_uid}:{operation}`
pub async fn store_lfs_token(
cache: &db::cache::AppCache,
token: &str,
repo_id: uuid::Uuid,
user_uid: uuid::Uuid,
operation: &str,
) {
if let Ok(mut conn) = cache.conn().await {
use redis::AsyncCommands;
let value = format!("{}:{}:{}", repo_id, user_uid, operation);
let _: () = conn
.set_ex(format!("lfs:token:{}", token), value, 3600_u64)
.await
.map_err(|e| tracing::warn!(error = %e, "failed to store lfs token"))
.unwrap_or(());
}
}
/// Validate a bearer token for LFS upload/download.
/// Checks two sources:
/// 1. LFS batch-generated token (stored in Redis under `lfs:token:{token}`)
/// 2. Regular user access token (stored in user_token table)
///
/// Returns (user_uid, repo_id, operation) for batch tokens, or
/// (user_uid, None, None) for regular access tokens.
async fn validate_lfs_token(
token: &str,
cache: &db::cache::AppCache,
db: &db::database::AppDatabase,
expected_repo_id: uuid::Uuid,
expected_operation: &str,
) -> Result<uuid::Uuid, Error> {
// First: check if it's a LFS batch token in Redis
if let Ok(mut conn) = cache.conn().await {
use redis::AsyncCommands;
let stored: Option<String> = conn
.get::<String, String>(format!("lfs:token:{}", token))
.await
.ok();
if let Some(value) = stored {
let parts: Vec<&str> = value.split(':').collect();
if parts.len() == 3 {
let repo_id = uuid::Uuid::parse_str(parts[0])
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid batch token"))?;
let user_uid = uuid::Uuid::parse_str(parts[1])
.map_err(|_| actix_web::error::ErrorUnauthorized("Invalid batch token"))?;
let operation = parts[2];
if repo_id != expected_repo_id {
return Err(actix_web::error::ErrorUnauthorized(
"Token not valid for this repo",
));
}
if operation != expected_operation {
return Err(actix_web::error::ErrorUnauthorized(
"Token not valid for this operation",
));
}
// Consume the token (one-time use)
let _: Result<(), redis::RedisError> =
conn.del(format!("lfs:token:{}", token)).await;
return Ok(user_uid);
}
}
}
// Second: check if it's a regular user access token
let token_hash = hash_token(token)
.map_err(|_| actix_web::error::ErrorInternalServerError("Token hash failed"))?;
let token_model = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&token_hash))
.filter(user_token::Column::IsRevoked.eq(false))
.one(db.reader())
.await
.map_err(|_| actix_web::error::ErrorUnauthorized("Authentication failed"))?
.ok_or_else(|| actix_web::error::ErrorUnauthorized("Invalid token"))?;
if let Some(expires_at) = token_model.expires_at {
if expires_at < chrono::Utc::now() {
return Err(actix_web::error::ErrorUnauthorized("Token expired"));
}
}
Ok(token_model.user)
}
pub async fn lfs_batch(
req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<BatchRequest>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let batch_req = body.into_inner();
let is_write = batch_req.operation == "upload";
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
// Auth check: private repos always require auth; upload always requires auth
if repo.is_private || is_write {
let uid = user_uid(&req, &state.db).await?;
authorize_repo_access(&req, &state.db, &repo, is_write).await?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
let response = handler
.batch_with_auth(batch_req, &base_url(&req), uid, &state.cache)
.await
.map_err(|_| actix_web::error::ErrorInternalServerError("LFS batch failed"))?;
Ok(HttpResponse::Ok()
.content_type("application/vnd.git-lfs+json")
.json(response))
} else {
// Public repo + download: allow anonymous
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
let response = handler
.batch(batch_req, &base_url(&req))
.await
.map_err(|_| actix_web::error::ErrorInternalServerError("LFS batch failed"))?;
Ok(HttpResponse::Ok()
.content_type("application/vnd.git-lfs+json")
.json(response))
}
}
pub async fn lfs_upload(
req: HttpRequest,
path: web::Path<(String, String, String)>,
payload: web::Payload,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let token = bearer_token(&req)?;
// Validate token (batch token or user access token) with write permission
let _uid = validate_lfs_token(&token, &state.cache, &state.db, repo.id, "upload").await?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.upload_object(&oid, payload).await {
Ok(response) => Ok(response),
Err(GitError::InvalidOid(_)) => Err(actix_web::error::ErrorBadRequest("Invalid OID")),
Err(GitError::AuthFailed(_)) => Err(actix_web::error::ErrorUnauthorized("Unauthorized")),
Err(_e) => Err(actix_web::error::ErrorInternalServerError("Upload failed")),
}
}
pub async fn lfs_download(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, oid) = path.into_inner();
if !is_valid_lfs_oid(&oid) {
return Err(actix_web::error::ErrorBadRequest("Invalid OID format"));
}
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
// Auth check: private repos require auth; public repos allow anonymous
if repo.is_private {
let token = bearer_token(&req)?;
let _uid = validate_lfs_token(&token, &state.cache, &state.db, repo.id, "download").await?;
authorize_repo_access(&req, &state.db, &repo, false).await?;
}
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.download_object(&oid).await {
Ok(response) => Ok(response),
Err(GitError::NotFound(_)) => Err(actix_web::error::ErrorNotFound("Object not found")),
Err(GitError::AuthFailed(_)) => Err(actix_web::error::ErrorUnauthorized("Unauthorized")),
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Download failed",
)),
}
}
pub async fn lfs_lock_create(
req: HttpRequest,
path: web::Path<(String, String)>,
body: web::Json<CreateLockRequest>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let uid = user_uid(&req, &state.db).await?;
authorize_repo_access(&req, &state.db, &repo, true).await?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.lock_object(&body.oid, uid).await {
Ok(lock) => Ok(HttpResponse::Created().json(lock)),
Err(GitError::Locked(msg)) => Ok(HttpResponse::Conflict().body(msg)),
Err(_e) => Err(actix_web::error::ErrorInternalServerError("Lock failed")),
}
}
pub async fn lfs_lock_list(
req: HttpRequest,
path: web::Path<(String, String)>,
query: web::Query<std::collections::HashMap<String, String>>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
// Auth check: private repos require auth for lock listing
if repo.is_private {
user_uid(&req, &state.db).await?;
}
let maybe_oid = query.get("oid").map(|s| s.as_str());
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.list_locks(maybe_oid).await {
Ok(list) => Ok(HttpResponse::Ok().json(list)),
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Lock list failed",
)),
}
}
pub async fn lfs_lock_get(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, lock_path) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
// Auth check: private repos require auth for lock viewing
if repo.is_private {
user_uid(&req, &state.db).await?;
}
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.get_lock(&lock_path).await {
Ok(lock) => Ok(HttpResponse::Ok().json(lock)),
Err(GitError::NotFound(_)) => Err(actix_web::error::ErrorNotFound("Lock not found")),
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Lock get failed",
)),
}
}
pub async fn lfs_lock_delete(
req: HttpRequest,
path: web::Path<(String, String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let (namespace, repo_name, lock_id) = path.into_inner();
let repo = get_repo_model(&namespace, &repo_name, &state.db).await?;
let uid = user_uid(&req, &state.db).await?;
authorize_repo_access(&req, &state.db, &repo, true).await?;
let handler = LfsHandler::new(PathBuf::from(&repo.storage_path), repo, state.db.clone());
match handler.unlock_object(&lock_id, uid).await {
Ok(()) => Ok(HttpResponse::NoContent().finish()),
Err(GitError::PermissionDenied(_)) => Err(actix_web::error::ErrorForbidden("Not allowed")),
Err(GitError::NotFound(_)) => Err(actix_web::error::ErrorNotFound("Lock not found")),
Err(_e) => Err(actix_web::error::ErrorInternalServerError(
"Lock delete failed",
)),
}
}