use crate::error::AppError; use crate::models::repos::{RepoRelease, RepoReleaseAsset}; use crate::service::RepoService; use crate::session::Session; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::util::clamp_limit_offset; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ReleaseAssetData { pub id: Uuid, pub filename: String, pub size_bytes: i64, pub mime_type: String, pub download_count: i64, pub created_at: chrono::DateTime, } impl From for ReleaseAssetData { fn from(a: RepoReleaseAsset) -> Self { Self { id: a.id, filename: a.filename, size_bytes: a.size_bytes, mime_type: a.mime_type, download_count: a.download_count, created_at: a.created_at, } } } impl RepoService { pub async fn repo_upload_release_asset( &self, ctx: &Session, wk_name: &str, repo_name: &str, release_id: Uuid, filename: &str, data: Vec, content_type: &str, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) .await?; let release: Option = sqlx::query_as( "SELECT * FROM repo_release WHERE id = $1 AND repo_id = $2 AND deleted_at IS NULL", ) .bind(release_id) .bind(repo.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if release.is_none() { return Err(AppError::NotFound("release not found".into())); } let asset_id = Uuid::now_v7(); let storage_key = format!("repos/{}/releases/{}/{}", repo.id, release_id, asset_id); let size = data.len() as i64; self.ctx.storage.put(&storage_key, data).await?; let now = chrono::Utc::now(); let asset = sqlx::query_as::<_, RepoReleaseAsset>( "INSERT INTO repo_release_asset (id, release_id, filename, size_bytes, mime_type, storage_path, uploaded_by, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ RETURNING *", ) .bind(asset_id) .bind(release_id) .bind(filename) .bind(size) .bind(content_type) .bind(&storage_key) .bind(user_uid) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(|e| { let storage_key = storage_key.clone(); let storage = self.ctx.storage.clone(); tokio::spawn(async move { let _ = storage.delete(&storage_key).await; }); AppError::Database(e) })?; Ok(ReleaseAssetData::from(asset)) } pub async fn repo_list_release_assets( &self, ctx: &Session, wk_name: &str, repo_name: &str, release_id: Uuid, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let (limit, offset) = clamp_limit_offset(limit, offset); let assets = sqlx::query_as::<_, RepoReleaseAsset>( "SELECT * FROM repo_release_asset WHERE release_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3", ) .bind(release_id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; Ok(assets.into_iter().map(ReleaseAssetData::from).collect()) } pub async fn repo_delete_release_asset( &self, ctx: &Session, wk_name: &str, repo_name: &str, release_id: Uuid, asset_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) .await?; let asset: Option = sqlx::query_as( "SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL", ) .bind(asset_id) .bind(release_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; let Some(asset) = asset else { return Err(AppError::NotFound("asset not found".into())); }; let now = chrono::Utc::now(); sqlx::query("UPDATE repo_release_asset SET deleted_at = $1, updated_at = $1 WHERE id = $2") .bind(now) .bind(asset_id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; let _ = self.ctx.storage.delete(&asset.storage_path).await; Ok(()) } pub async fn repo_get_release_asset_download_url( &self, ctx: &Session, wk_name: &str, repo_name: &str, release_id: Uuid, asset_id: Uuid, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo = self.resolve_repo(wk_name, repo_name).await?; self.ensure_repo_readable(user_uid, &repo).await?; let asset: Option = sqlx::query_as( "SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL", ) .bind(asset_id) .bind(release_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; let Some(asset) = asset else { return Err(AppError::NotFound("asset not found".into())); }; if let Some(url) = asset.url { return Ok(url); } let url = self .ctx .storage .presigned_get_url(&asset.storage_path, None) .await?; // Update download count and cache the URL let _ = sqlx::query( "UPDATE repo_release_asset SET download_count = download_count + 1, url = $1, updated_at = NOW() WHERE id = $2", ) .bind(&url) .bind(asset_id) .execute(self.ctx.db.writer()) .await; Ok(url) } }