Files
TypstDrive/server/src/packages.rs
T

245 lines
8.4 KiB
Rust

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<String>,
description: Option<String>,
}
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<AppState>,
jar: SignedCookieJar,
Json(payload): Json<PublishPackageRequest>,
) -> Result<Json<Package>, (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<Vec<u8>>)>(
"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<u8>)> = Vec::new();
let mut manifest_text: Option<String> = 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<AppState>,
jar: SignedCookieJar,
) -> Result<Json<Vec<Package>>, (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<AppState>,
Path(name): Path<String>,
jar: SignedCookieJar,
) -> Result<Json<Vec<PackageVersion>>, (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<AppState>,
Path(name): Path<String>,
jar: SignedCookieJar,
) -> Result<StatusCode, (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 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)
}