use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use axum_extra::extract::cookie::SignedCookieJar; use serde::Deserialize; use uuid::Uuid; use crate::{ models::{Package, PackageVersion, Project, PublishPackageRequest}, projects::decode_text_blob, AppState, }; #[derive(Deserialize)] struct Manifest { package: PackageMeta, } #[derive(Deserialize)] struct PackageMeta { name: String, version: String, entrypoint: Option, description: Option, } fn is_valid_name(name: &str) -> bool { !name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') } fn is_valid_version(version: &str) -> bool { let parts: Vec<&str> = version.split('.').collect(); parts.len() == 3 && parts.iter().all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())) } pub async fn publish_package( State(state): State, jar: SignedCookieJar, Json(payload): Json, ) -> Result, (StatusCode, String)> { let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let project = sqlx::query_as::<_, Project>( "SELECT id, owner_id, folder_id, name, entrypoint, thumbnail_svg, public_role, created_at, updated_at FROM projects WHERE id = ? AND owner_id = ?" ) .bind(&payload.project_id) .bind(&user_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Project not found".to_string()))?; let files = sqlx::query_as::<_, (String, String, Option>)>( "SELECT path, kind, content FROM project_files WHERE project_id = ?" ) .bind(&project.id) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut snapshot: Vec<(String, Vec)> = Vec::new(); let mut manifest_text: Option = None; for (path, kind, content) in files { let bytes = if kind == "binary" { content.unwrap_or_default() } else { decode_text_blob(&content.unwrap_or_default()).into_bytes() }; if path == "typst.toml" { manifest_text = Some(String::from_utf8_lossy(&bytes).to_string()); } snapshot.push((path, bytes)); } let manifest_text = manifest_text .ok_or((StatusCode::BAD_REQUEST, "Project has no typst.toml manifest".to_string()))?; let manifest: Manifest = toml::from_str(&manifest_text) .map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid typst.toml: {}", e)))?; let name = manifest.package.name.trim().to_string(); let version = payload.version.unwrap_or(manifest.package.version).trim().to_string(); let entrypoint = manifest.package.entrypoint.unwrap_or_else(|| "lib.typ".to_string()); if !is_valid_name(&name) { return Err((StatusCode::BAD_REQUEST, "Invalid package name (lowercase letters, digits, '-' and '_' only)".to_string())); } if !is_valid_version(&version) { return Err((StatusCode::BAD_REQUEST, "Version must be in the form major.minor.patch".to_string())); } let existing = sqlx::query_as::<_, Package>( "SELECT id, owner_id, namespace, name, description, created_at FROM packages WHERE namespace = 'typstdrive' AND name = ?" ) .bind(&name) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let package = match existing { Some(pkg) => { if pkg.owner_id != user_id { return Err((StatusCode::FORBIDDEN, "A package with this name is owned by another user".to_string())); } pkg } None => { let package_id = Uuid::new_v4().to_string(); sqlx::query_as::<_, Package>( "INSERT INTO packages (id, owner_id, namespace, name, description) VALUES (?, ?, 'typstdrive', ?, ?) RETURNING id, owner_id, namespace, name, description, created_at" ) .bind(&package_id) .bind(&user_id) .bind(&name) .bind(&manifest.package.description) .fetch_one(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? } }; let version_exists = sqlx::query_as::<_, (String,)>( "SELECT id FROM package_versions WHERE package_id = ? AND version = ?" ) .bind(&package.id) .bind(&version) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if version_exists.is_some() { return Err((StatusCode::CONFLICT, format!("Version {} already published; versions are immutable", version))); } let version_id = Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO package_versions (id, package_id, version, entrypoint, manifest) VALUES (?, ?, ?, ?, ?)" ) .bind(&version_id) .bind(&package.id) .bind(&version) .bind(&entrypoint) .bind(manifest_text.into_bytes()) .execute(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; for (path, data) in snapshot { let _ = sqlx::query( "INSERT INTO package_files (id, version_id, path, data) VALUES (?, ?, ?, ?)" ) .bind(Uuid::new_v4().to_string()) .bind(&version_id) .bind(&path) .bind(&data) .execute(&state.db) .await; } Ok(Json(package)) } pub async fn list_packages( State(state): State, jar: SignedCookieJar, ) -> Result>, (StatusCode, String)> { jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let packages = sqlx::query_as::<_, Package>( "SELECT p.id, p.owner_id, p.namespace, p.name, p.description, p.created_at, \ u.username as owner_name, \ (SELECT v.version FROM package_versions v WHERE v.package_id = p.id ORDER BY v.created_at DESC LIMIT 1) as latest_version \ FROM packages p JOIN users u ON u.id = p.owner_id \ ORDER BY p.name ASC" ) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(packages)) } pub async fn list_versions( State(state): State, Path(name): Path, jar: SignedCookieJar, ) -> Result>, (StatusCode, String)> { jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let versions = sqlx::query_as::<_, PackageVersion>( "SELECT v.id, v.package_id, v.version, v.entrypoint, v.created_at \ FROM package_versions v JOIN packages p ON p.id = v.package_id \ WHERE p.namespace = 'typstdrive' AND p.name = ? ORDER BY v.created_at DESC" ) .bind(&name) .fetch_all(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(versions)) } pub async fn delete_package( State(state): State, Path(name): Path, jar: SignedCookieJar, ) -> Result { let user_id = jar.get("session_user_id").map(|c| c.value().to_string()) .ok_or((StatusCode::UNAUTHORIZED, "Not logged in".to_string()))?; let is_admin = sqlx::query_as::<_, (i64,)>("SELECT is_admin FROM users WHERE id = ?") .bind(&user_id) .fetch_optional(&state.db) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map(|(a,)| a != 0) .unwrap_or(false); let result = if is_admin { sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ?") .bind(&name) .execute(&state.db) .await } else { sqlx::query("DELETE FROM packages WHERE namespace = 'typstdrive' AND name = ? AND owner_id = ?") .bind(&name) .bind(&user_id) .execute(&state.db) .await } .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if result.rows_affected() == 0 { return Err((StatusCode::NOT_FOUND, "Package not found or unauthorized".to_string())); } Ok(StatusCode::NO_CONTENT) }