use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::Redirect;
use kellnr_appstate::{AppState, DbState, DocsStorageState, SettingsState};
use kellnr_auth::crate_access::can_read_crate;
use kellnr_auth::maybe_user::MaybeUser;
use kellnr_auth::token::Token;
use kellnr_common::normalized_name::NormalizedName;
use kellnr_common::original_name::OriginalName;
use kellnr_common::version::Version;
use kellnr_db::DbProvider;
use kellnr_error::api_error::ApiResult;
use kellnr_registry::kellnr_api::{check_can_modify, check_ownership};
use tracing::error;
use crate::doc_archive::DocArchive;
use crate::doc_queue_response::DocQueueResponse;
use crate::docs_error::DocsError;
use crate::upload::upload_dir_and_prune;
use crate::upload_response::DocUploadResponse;
use crate::{compute_doc_url, get_latest_version_with_doc};
#[utoipa::path(
get,
path = "/builds",
tag = "docs",
responses(
(status = 200, description = "Documentation build queue", body = DocQueueResponse)
),
security(("session_cookie" = []))
)]
pub async fn docs_in_queue(
user: Result<MaybeUser, StatusCode>,
State(db): DbState,
) -> ApiResult<Json<DocQueueResponse>> {
let user = user.ok();
let mut visible = Vec::new();
for entry in db.get_doc_queue().await? {
if can_read_docs(&entry.normalized_name, user.as_ref(), &db).await {
visible.push(entry);
}
}
Ok(Json(DocQueueResponse::from(visible)))
}
#[utoipa::path(
get,
path = "/{package}/latest",
tag = "docs",
params(
("package" = String, Path, description = "Package name")
),
responses(
(status = 302, description = "Redirect to latest documentation")
),
security(("session_cookie" = []))
)]
pub async fn latest_docs(
Path(package): Path<OriginalName>,
user: Result<MaybeUser, StatusCode>,
State(settings): SettingsState,
State(docs_storage): DocsStorageState,
State(db): DbState,
) -> Redirect {
let name = package.to_normalized();
if !can_read_docs(&name, user.ok().as_ref(), &db).await {
return Redirect::temporary("/");
}
let opt_doc_version = get_latest_version_with_doc(&name, &docs_storage).await;
let res_db_version = db.get_max_version_from_name(&name).await;
if let Some(doc_version) = opt_doc_version
&& let Ok(db_version) = res_db_version
&& doc_version == db_version
{
return Redirect::temporary(&compute_doc_url(&name, &db_version, &settings.origin.path));
}
Redirect::temporary("/")
}
async fn can_read_docs(
crate_name: &NormalizedName,
user: Option<&MaybeUser>,
db: &Arc<dyn DbProvider>,
) -> bool {
can_read_crate(crate_name, user, db)
.await
.unwrap_or_else(|e| {
error!("Failed to check docs access for {crate_name}: {e}");
false
})
}
#[utoipa::path(
put,
path = "/{package}/{version}",
tag = "docs",
params(
("package" = String, Path, description = "Package name"),
("version" = String, Path, description = "Package version")
),
request_body(content = Vec<u8>, description = "Documentation archive (tar.gz or zip)", content_type = "application/octet-stream"),
responses(
(status = 200, description = "Documentation published successfully", body = DocUploadResponse),
(status = 400, description = "Crate or version does not exist"),
(status = 401, description = "Not authorized"),
(status = 403, description = "Not an owner of the crate")
),
security(("cargo_token" = []))
)]
pub async fn publish_docs(
Path((package, version)): Path<(OriginalName, Version)>,
token: Token,
State(state): AppState,
mut docs: DocArchive,
) -> ApiResult<Json<DocUploadResponse>> {
let db = state.db;
let settings = state.settings;
let docs_storage = state.docs_storage;
let normalized_name = package.to_normalized();
let crate_version = &version.to_string();
let user = MaybeUser::from_token(token);
check_can_modify(&user)?;
check_ownership(&normalized_name, &user, &db).await?;
if let Some(id) = db.get_crate_id(&normalized_name).await? {
if !db.crate_version_exists(id, crate_version).await? {
return crate_does_not_exist(&normalized_name, crate_version);
}
} else {
return crate_does_not_exist(&normalized_name, crate_version);
}
let tmp_dir = tempfile::tempdir().map_err(DocsError::IoError)?;
let extract_path = tmp_dir.path().to_path_buf();
tokio::task::spawn_blocking(move || docs.extract(&extract_path))
.await
.map_err(|_| DocsError::ExtractFailed)??;
upload_dir_and_prune(tmp_dir.path(), "", &package, crate_version, &docs_storage).await?;
db.update_docs_link(
&normalized_name,
&version,
&compute_doc_url(&package, &version, &settings.origin.path),
)
.await?;
Ok(Json(DocUploadResponse::new(
"Successfully published docs.".to_string(),
&package,
&version,
&settings.origin.path,
)))
}
fn crate_does_not_exist(
crate_name: &str,
crate_version: &str,
) -> ApiResult<Json<DocUploadResponse>> {
Err(DocsError::CrateDoesNotExist(crate_name.to_string(), crate_version.to_string()).into())
}
#[cfg(test)]
mod tests {
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use axum::routing::{get, put};
use http_body_util::BodyExt;
use kellnr_appstate::AppStateData;
use kellnr_common::normalized_name::NormalizedName;
use kellnr_db::error::DbError;
use kellnr_db::mock::MockDb;
use kellnr_db::{DbProvider, DocQueueEntry, User};
use kellnr_storage::cached_crate_storage::DynStorage;
use kellnr_storage::docs_storage::DocsStorage;
use kellnr_storage::fs_storage::FSStorage;
use mockall::predicate::eq;
use tower::ServiceExt;
use super::*;
use crate::doc_queue_response::DocQueueEntryResponse;
#[tokio::test]
async fn doc_in_queue_returns_queue_entries() {
let mut db = MockDb::new();
db.expect_is_download_restricted().returning(|_| Ok(false));
db.expect_get_doc_queue().returning(|| {
Ok(vec![
DocQueueEntry {
id: 0,
normalized_name: NormalizedName::from_unchecked("crate1".to_string()),
version: "0.0.1".to_string(),
path: PathBuf::default(),
},
DocQueueEntry {
id: 1,
normalized_name: NormalizedName::from_unchecked("crate2".to_string()),
version: "0.0.2".to_string(),
path: PathBuf::default(),
},
])
});
let kellnr = app(Arc::new(db));
let r = kellnr
.oneshot(Request::get("/queue").body(Body::empty()).unwrap())
.await
.unwrap();
let actual = r.into_body().collect().await.unwrap().to_bytes();
let actual = serde_json::from_slice::<DocQueueResponse>(&actual).unwrap();
assert_eq!(
DocQueueResponse {
queue: vec![
DocQueueEntryResponse {
name: "crate1".to_string(),
version: "0.0.1".to_string()
},
DocQueueEntryResponse {
name: "crate2".to_string(),
version: "0.0.2".to_string()
}
]
},
actual
);
}
fn queue_entry(id: i64, name: &str) -> DocQueueEntry {
DocQueueEntry {
id,
normalized_name: NormalizedName::from_unchecked(name.to_string()),
version: "1.0.0".to_string(),
path: PathBuf::default(),
}
}
fn db_with_restricted_queue_entry() -> MockDb {
let mut db = MockDb::new();
db.expect_get_doc_queue()
.returning(|| Ok(vec![queue_entry(0, "public"), queue_entry(1, "secret")]));
db.expect_is_download_restricted()
.returning(|name| Ok(name.to_string() == "secret"));
db
}
async fn queued_names(db: MockDb, token: Option<&str>) -> Vec<String> {
let mut request = Request::get("/queue");
if let Some(token) = token {
request = request.header(header::AUTHORIZATION, token);
}
let r = app(Arc::new(db))
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
let body = r.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice::<DocQueueResponse>(&body)
.unwrap()
.queue
.into_iter()
.map(|e| e.name)
.collect()
}
#[tokio::test]
async fn doc_queue_hides_restricted_crate_from_anonymous_user() {
let names = queued_names(db_with_restricted_queue_entry(), None).await;
assert_eq!(names, vec!["public"]);
}
#[tokio::test]
async fn doc_queue_hides_restricted_crate_from_user_without_access() {
let mut db = db_with_restricted_queue_entry();
expect_token_user(&mut db, "bob");
db.expect_is_crate_user().returning(|_, _| Ok(false));
db.expect_is_crate_group_user().returning(|_, _| Ok(false));
db.expect_is_owner().returning(|_, _| Ok(false));
let names = queued_names(db, Some("token")).await;
assert_eq!(names, vec!["public"]);
}
#[tokio::test]
async fn doc_queue_lists_restricted_crate_for_crate_user() {
let mut db = db_with_restricted_queue_entry();
expect_token_user(&mut db, "alice");
db.expect_is_crate_user()
.returning(|_, user| Ok(user == "alice"));
let names = queued_names(db, Some("token")).await;
assert_eq!(names, vec!["public", "secret"]);
}
fn app(db: Arc<dyn DbProvider>) -> Router {
Router::new()
.route("/queue", get(docs_in_queue))
.with_state(AppStateData {
db,
..kellnr_appstate::test_state()
})
}
fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Vec::new();
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let options = zip::write::SimpleFileOptions::default();
for (name, content) in files {
zip.start_file(*name, options).unwrap();
zip.write_all(content).unwrap();
}
zip.finish().unwrap();
buf
}
fn publish_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
Router::new()
.route("/{package}/{version}", put(publish_docs))
.with_state(AppStateData {
db,
docs_storage,
..kellnr_appstate::test_state()
})
}
fn expect_admin_owner(db: &mut MockDb, normalized: &NormalizedName) {
db.expect_get_crate_id()
.with(eq(normalized.clone()))
.returning(|_| Ok(Some(1)));
db.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(|_, _| Ok(true));
db.expect_get_user_from_token().returning(|_| {
Ok(User {
is_admin: true,
name: "admin".to_string(),
..User::default()
})
});
}
#[tokio::test]
async fn publish_docs_uploads_files_and_updates_docs_link() {
let normalized = NormalizedName::from_unchecked("test-crate".to_string());
let mut db = MockDb::new();
expect_admin_owner(&mut db, &normalized);
db.expect_update_docs_link()
.with(
eq(normalized.clone()),
eq(Version::try_from("1.0.0").unwrap()),
eq("/docs/test-crate/1.0.0/doc/test_crate/index.html".to_string()),
)
.returning(|_, _, _| Ok(()));
let tmp = tempfile::tempdir().unwrap();
let docs_storage = Arc::new(DocsStorage::new(Box::new(
FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
) as DynStorage));
let kellnr = publish_app(Arc::new(db), docs_storage.clone());
let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
let r = kellnr
.oneshot(
Request::put("/test-crate/1.0.0")
.header(header::AUTHORIZATION, "sometoken")
.body(Body::from(zip_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
let stored = docs_storage.get(&key).await.unwrap();
assert_eq!(stored, bytes::Bytes::from_static(b"<html>docs</html>"));
}
#[tokio::test]
async fn publish_docs_rejects_read_only_owner() {
let normalized = NormalizedName::from_unchecked("test-crate".to_string());
let mut db = MockDb::new();
db.expect_get_crate_id()
.with(eq(normalized.clone()))
.returning(|_| Ok(Some(1)));
db.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(|_, _| Ok(true));
db.expect_get_user_from_token().returning(|_| {
Ok(User {
name: "reader".to_string(),
is_read_only: true,
..User::default()
})
});
db.expect_is_owner().returning(|_, _| Ok(true));
db.expect_update_docs_link().never();
let tmp = tempfile::tempdir().unwrap();
let docs_storage = Arc::new(DocsStorage::new(Box::new(
FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
) as DynStorage));
let kellnr = publish_app(Arc::new(db), docs_storage.clone());
let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
let r = kellnr
.oneshot(
Request::put("/test-crate/1.0.0")
.header(header::AUTHORIZATION, "sometoken")
.body(Body::from(zip_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
assert!(!docs_storage.exists(&key).await.unwrap());
}
#[tokio::test]
async fn publish_docs_non_owner_cannot_tell_missing_from_existing_crate() {
let mut db = MockDb::new();
db.expect_get_crate_id()
.returning(|name| Ok((name.as_str() == "test-crate").then_some(1)));
db.expect_crate_version_exists()
.returning(|_, version| Ok(version == "1.0.0"));
db.expect_get_user_from_token().returning(|_| {
Ok(User {
name: "other".to_string(),
..User::default()
})
});
db.expect_is_owner().returning(|_, _| Ok(false));
db.expect_update_docs_link().never();
let db: Arc<dyn DbProvider> = Arc::new(db);
let tmp = tempfile::tempdir().unwrap();
let docs_storage = Arc::new(DocsStorage::new(Box::new(
FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
) as DynStorage));
for uri in ["/test-crate/1.0.0", "/test-crate/9.9.9", "/missing/1.0.0"] {
let zip_bytes = build_zip(&[("doc/x/index.html", b"<html>docs</html>")]);
let r = publish_app(db.clone(), docs_storage.clone())
.oneshot(
Request::put(uri)
.header(header::AUTHORIZATION, "sometoken")
.body(Body::from(zip_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::FORBIDDEN, "{uri}");
}
}
#[tokio::test]
async fn publish_docs_propagates_extraction_failure() {
let normalized = NormalizedName::from_unchecked("test-crate".to_string());
let mut db = MockDb::new();
expect_admin_owner(&mut db, &normalized);
let tmp = tempfile::tempdir().unwrap();
let docs_storage = Arc::new(DocsStorage::new(Box::new(
FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
) as DynStorage));
let kellnr = publish_app(Arc::new(db), docs_storage);
let zip_bytes = build_zip(&[("../escape.html", b"evil")]);
let r = kellnr
.oneshot(
Request::put("/test-crate/1.0.0")
.header(header::AUTHORIZATION, "sometoken")
.body(Body::from(zip_bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
}
fn latest_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
Router::new()
.route("/{package}/latest", get(latest_docs))
.with_state(AppStateData {
db,
docs_storage,
..kellnr_appstate::test_state()
})
}
async fn docs_storage_with_latest_docs() -> (tempfile::TempDir, Arc<DocsStorage>) {
let tmp = tempfile::tempdir().unwrap();
let docs_storage = Arc::new(DocsStorage::new(Box::new(
FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
) as DynStorage));
let key = DocsStorage::file_key("secret", "1.0.0", "doc/secret/index.html");
docs_storage
.put(&key, bytes::Bytes::from_static(b"<html>docs</html>"))
.await
.unwrap();
(tmp, docs_storage)
}
fn expect_restricted_crate(db: &mut MockDb, restricted: bool) {
db.expect_is_download_restricted()
.returning(move |_| Ok(restricted));
db.expect_get_max_version_from_name()
.returning(|_| Ok(Version::try_from("1.0.0").unwrap()));
}
fn expect_token_user(db: &mut MockDb, name: &'static str) {
db.expect_get_user_from_token().returning(move |_| {
Ok(User {
name: name.to_string(),
..User::default()
})
});
}
async fn latest_location(
db: MockDb,
docs_storage: Arc<DocsStorage>,
token: Option<&str>,
) -> String {
let mut request = Request::get("/secret/latest");
if let Some(token) = token {
request = request.header(header::AUTHORIZATION, token);
}
let r = latest_app(Arc::new(db), docs_storage)
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(r.status(), StatusCode::TEMPORARY_REDIRECT);
r.headers()[header::LOCATION].to_str().unwrap().to_string()
}
#[tokio::test]
async fn latest_docs_redirects_to_docs_for_unrestricted_crate() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, false);
let location = latest_location(db, docs_storage, None).await;
assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
}
#[tokio::test]
async fn latest_docs_hides_restricted_crate_from_anonymous_user() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, true);
let location = latest_location(db, docs_storage, None).await;
assert_eq!(location, "/");
}
#[tokio::test]
async fn latest_docs_hides_restricted_crate_from_user_without_access() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, true);
expect_token_user(&mut db, "bob");
db.expect_is_crate_user().returning(|_, _| Ok(false));
db.expect_is_crate_group_user().returning(|_, _| Ok(false));
db.expect_is_owner().returning(|_, _| Ok(false));
let location = latest_location(db, docs_storage, Some("token")).await;
assert_eq!(location, "/");
}
#[tokio::test]
async fn latest_docs_redirects_crate_user_of_restricted_crate() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, true);
expect_token_user(&mut db, "alice");
db.expect_is_crate_user()
.with(
eq(NormalizedName::from_unchecked("secret".to_string())),
eq("alice"),
)
.returning(|_, _| Ok(true));
let location = latest_location(db, docs_storage, Some("token")).await;
assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
}
#[tokio::test]
async fn latest_docs_redirects_owner_of_restricted_crate() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, true);
expect_token_user(&mut db, "olivia");
db.expect_is_crate_user().returning(|_, _| Ok(false));
db.expect_is_crate_group_user().returning(|_, _| Ok(false));
db.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("secret".to_string())),
eq("olivia"),
)
.returning(|_, _| Ok(true));
let location = latest_location(db, docs_storage, Some("token")).await;
assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
}
#[tokio::test]
async fn latest_docs_redirects_group_member_of_restricted_crate() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, true);
expect_token_user(&mut db, "greg");
db.expect_is_crate_user().returning(|_, _| Ok(false));
db.expect_is_crate_group_user()
.with(
eq(NormalizedName::from_unchecked("secret".to_string())),
eq("greg"),
)
.returning(|_, _| Ok(true));
let location = latest_location(db, docs_storage, Some("token")).await;
assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
}
#[tokio::test]
async fn latest_docs_hides_crate_when_restriction_check_fails() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
db.expect_is_download_restricted()
.returning(|_| Err(DbError::CrateNotFound("secret".to_string())));
let location = latest_location(db, docs_storage, None).await;
assert_eq!(location, "/");
}
#[tokio::test]
async fn latest_docs_hides_restricted_crate_when_access_check_fails() {
let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
let mut db = MockDb::new();
expect_restricted_crate(&mut db, true);
expect_token_user(&mut db, "alice");
db.expect_is_crate_user()
.returning(|_, _| Err(DbError::CrateNotFound("secret".to_string())));
let location = latest_location(db, docs_storage, Some("token")).await;
assert_eq!(location, "/");
}
}