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 { 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 { hash_access_key(token) } /// Derive the acting user from the authenticated bearer token. async fn user_uid( req: &HttpRequest, db: &db::database::AppDatabase, ) -> Result { 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 { // 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 = conn .get::(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, state: web::Data, ) -> Result { 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, ) -> Result { 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, ) -> Result { 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, state: web::Data, ) -> Result { 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>, state: web::Data, ) -> Result { 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, ) -> Result { 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, ) -> Result { 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")), } }