1use std::sync::Arc;
2
3use axum::Json;
4use axum::extract::{Path, State};
5use axum::http::StatusCode;
6use axum::response::Redirect;
7use kellnr_appstate::{AppState, DbState, DocsStorageState, SettingsState};
8use kellnr_auth::crate_access::can_read_crate;
9use kellnr_auth::maybe_user::MaybeUser;
10use kellnr_auth::token::Token;
11use kellnr_common::normalized_name::NormalizedName;
12use kellnr_common::original_name::OriginalName;
13use kellnr_common::version::Version;
14use kellnr_db::DbProvider;
15use kellnr_error::api_error::ApiResult;
16use kellnr_registry::kellnr_api::{check_can_modify, check_ownership};
17use tracing::error;
18
19use crate::doc_archive::DocArchive;
20use crate::doc_queue_response::DocQueueResponse;
21use crate::docs_error::DocsError;
22use crate::upload::upload_dir_and_prune;
23use crate::upload_response::DocUploadResponse;
24use crate::{compute_doc_url, get_latest_version_with_doc};
25
26#[utoipa::path(
31 get,
32 path = "/builds",
33 tag = "docs",
34 responses(
35 (status = 200, description = "Documentation build queue", body = DocQueueResponse)
36 ),
37 security(("session_cookie" = []))
38)]
39pub async fn docs_in_queue(
40 user: Result<MaybeUser, StatusCode>,
41 State(db): DbState,
42) -> ApiResult<Json<DocQueueResponse>> {
43 let user = user.ok();
44 let mut visible = Vec::new();
45 for entry in db.get_doc_queue().await? {
46 if can_read_docs(&entry.normalized_name, user.as_ref(), &db).await {
47 visible.push(entry);
48 }
49 }
50 Ok(Json(DocQueueResponse::from(visible)))
51}
52
53#[utoipa::path(
57 get,
58 path = "/{package}/latest",
59 tag = "docs",
60 params(
61 ("package" = String, Path, description = "Package name")
62 ),
63 responses(
64 (status = 302, description = "Redirect to latest documentation")
65 ),
66 security(("session_cookie" = []))
67)]
68pub async fn latest_docs(
69 Path(package): Path<OriginalName>,
70 user: Result<MaybeUser, StatusCode>,
71 State(settings): SettingsState,
72 State(docs_storage): DocsStorageState,
73 State(db): DbState,
74) -> Redirect {
75 let name = package.to_normalized();
76
77 if !can_read_docs(&name, user.ok().as_ref(), &db).await {
79 return Redirect::temporary("/");
80 }
81
82 let opt_doc_version = get_latest_version_with_doc(&name, &docs_storage).await;
83 let res_db_version = db.get_max_version_from_name(&name).await;
84
85 if let Some(doc_version) = opt_doc_version
86 && let Ok(db_version) = res_db_version
87 && doc_version == db_version
88 {
89 return Redirect::temporary(&compute_doc_url(&name, &db_version, &settings.origin.path));
90 }
91
92 Redirect::temporary("/")
93}
94
95async fn can_read_docs(
97 crate_name: &NormalizedName,
98 user: Option<&MaybeUser>,
99 db: &Arc<dyn DbProvider>,
100) -> bool {
101 can_read_crate(crate_name, user, db)
102 .await
103 .unwrap_or_else(|e| {
104 error!("Failed to check docs access for {crate_name}: {e}");
105 false
106 })
107}
108
109#[utoipa::path(
114 put,
115 path = "/{package}/{version}",
116 tag = "docs",
117 params(
118 ("package" = String, Path, description = "Package name"),
119 ("version" = String, Path, description = "Package version")
120 ),
121 request_body(content = Vec<u8>, description = "Documentation archive (tar.gz or zip)", content_type = "application/octet-stream"),
122 responses(
123 (status = 200, description = "Documentation published successfully", body = DocUploadResponse),
124 (status = 400, description = "Crate or version does not exist"),
125 (status = 401, description = "Not authorized"),
126 (status = 403, description = "Not an owner of the crate")
127 ),
128 security(("cargo_token" = []))
129)]
130pub async fn publish_docs(
131 Path((package, version)): Path<(OriginalName, Version)>,
132 token: Token,
133 State(state): AppState,
134 mut docs: DocArchive,
135) -> ApiResult<Json<DocUploadResponse>> {
136 let db = state.db;
137 let settings = state.settings;
138 let docs_storage = state.docs_storage;
139 let normalized_name = package.to_normalized();
140 let crate_version = &version.to_string();
141
142 if let Some(id) = db.get_crate_id(&normalized_name).await? {
144 if !db.crate_version_exists(id, crate_version).await? {
145 return crate_does_not_exist(&normalized_name, crate_version);
146 }
147 } else {
148 return crate_does_not_exist(&normalized_name, crate_version);
149 }
150
151 let user = MaybeUser::from_token(token);
154 check_can_modify(&user)?;
155 check_ownership(&normalized_name, &user, &db).await?;
156
157 let tmp_dir = tempfile::tempdir().map_err(DocsError::IoError)?;
158 let extract_path = tmp_dir.path().to_path_buf();
159
160 tokio::task::spawn_blocking(move || docs.extract(&extract_path))
161 .await
162 .map_err(|_| DocsError::ExtractFailed)??;
163
164 upload_dir_and_prune(tmp_dir.path(), "", &package, crate_version, &docs_storage).await?;
165
166 db.update_docs_link(
167 &normalized_name,
168 &version,
169 &compute_doc_url(&package, &version, &settings.origin.path),
170 )
171 .await?;
172
173 Ok(Json(DocUploadResponse::new(
174 "Successfully published docs.".to_string(),
175 &package,
176 &version,
177 &settings.origin.path,
178 )))
179}
180
181fn crate_does_not_exist(
182 crate_name: &str,
183 crate_version: &str,
184) -> ApiResult<Json<DocUploadResponse>> {
185 Err(DocsError::CrateDoesNotExist(crate_name.to_string(), crate_version.to_string()).into())
186}
187
188#[cfg(test)]
189mod tests {
190 use std::io::Write;
191 use std::path::PathBuf;
192 use std::sync::Arc;
193
194 use axum::Router;
195 use axum::body::Body;
196 use axum::http::{Request, StatusCode, header};
197 use axum::routing::{get, put};
198 use http_body_util::BodyExt;
199 use kellnr_appstate::AppStateData;
200 use kellnr_common::normalized_name::NormalizedName;
201 use kellnr_db::error::DbError;
202 use kellnr_db::mock::MockDb;
203 use kellnr_db::{DbProvider, DocQueueEntry, User};
204 use kellnr_storage::cached_crate_storage::DynStorage;
205 use kellnr_storage::docs_storage::DocsStorage;
206 use kellnr_storage::fs_storage::FSStorage;
207 use mockall::predicate::eq;
208 use tower::ServiceExt;
209
210 use super::*;
211 use crate::doc_queue_response::DocQueueEntryResponse;
212
213 #[tokio::test]
214 async fn doc_in_queue_returns_queue_entries() {
215 let mut db = MockDb::new();
216 db.expect_is_download_restricted().returning(|_| Ok(false));
217 db.expect_get_doc_queue().returning(|| {
218 Ok(vec![
219 DocQueueEntry {
220 id: 0,
221 normalized_name: NormalizedName::from_unchecked("crate1".to_string()),
222 version: "0.0.1".to_string(),
223 path: PathBuf::default(),
224 },
225 DocQueueEntry {
226 id: 1,
227 normalized_name: NormalizedName::from_unchecked("crate2".to_string()),
228 version: "0.0.2".to_string(),
229 path: PathBuf::default(),
230 },
231 ])
232 });
233
234 let kellnr = app(Arc::new(db));
235 let r = kellnr
236 .oneshot(Request::get("/queue").body(Body::empty()).unwrap())
237 .await
238 .unwrap();
239
240 let actual = r.into_body().collect().await.unwrap().to_bytes();
241 let actual = serde_json::from_slice::<DocQueueResponse>(&actual).unwrap();
242 assert_eq!(
243 DocQueueResponse {
244 queue: vec![
245 DocQueueEntryResponse {
246 name: "crate1".to_string(),
247 version: "0.0.1".to_string()
248 },
249 DocQueueEntryResponse {
250 name: "crate2".to_string(),
251 version: "0.0.2".to_string()
252 }
253 ]
254 },
255 actual
256 );
257 }
258
259 fn queue_entry(id: i64, name: &str) -> DocQueueEntry {
260 DocQueueEntry {
261 id,
262 normalized_name: NormalizedName::from_unchecked(name.to_string()),
263 version: "1.0.0".to_string(),
264 path: PathBuf::default(),
265 }
266 }
267
268 fn db_with_restricted_queue_entry() -> MockDb {
270 let mut db = MockDb::new();
271 db.expect_get_doc_queue()
272 .returning(|| Ok(vec![queue_entry(0, "public"), queue_entry(1, "secret")]));
273 db.expect_is_download_restricted()
274 .returning(|name| Ok(name.to_string() == "secret"));
275 db
276 }
277
278 async fn queued_names(db: MockDb, token: Option<&str>) -> Vec<String> {
279 let mut request = Request::get("/queue");
280 if let Some(token) = token {
281 request = request.header(header::AUTHORIZATION, token);
282 }
283 let r = app(Arc::new(db))
284 .oneshot(request.body(Body::empty()).unwrap())
285 .await
286 .unwrap();
287 let body = r.into_body().collect().await.unwrap().to_bytes();
288 serde_json::from_slice::<DocQueueResponse>(&body)
289 .unwrap()
290 .queue
291 .into_iter()
292 .map(|e| e.name)
293 .collect()
294 }
295
296 #[tokio::test]
297 async fn doc_queue_hides_restricted_crate_from_anonymous_user() {
298 let names = queued_names(db_with_restricted_queue_entry(), None).await;
299
300 assert_eq!(names, vec!["public"]);
301 }
302
303 #[tokio::test]
304 async fn doc_queue_hides_restricted_crate_from_user_without_access() {
305 let mut db = db_with_restricted_queue_entry();
306 expect_token_user(&mut db, "bob");
307 db.expect_is_crate_user().returning(|_, _| Ok(false));
308 db.expect_is_crate_group_user().returning(|_, _| Ok(false));
309 db.expect_is_owner().returning(|_, _| Ok(false));
310
311 let names = queued_names(db, Some("token")).await;
312
313 assert_eq!(names, vec!["public"]);
314 }
315
316 #[tokio::test]
317 async fn doc_queue_lists_restricted_crate_for_crate_user() {
318 let mut db = db_with_restricted_queue_entry();
319 expect_token_user(&mut db, "alice");
320 db.expect_is_crate_user()
321 .returning(|_, user| Ok(user == "alice"));
322
323 let names = queued_names(db, Some("token")).await;
324
325 assert_eq!(names, vec!["public", "secret"]);
326 }
327
328 fn app(db: Arc<dyn DbProvider>) -> Router {
329 Router::new()
330 .route("/queue", get(docs_in_queue))
331 .with_state(AppStateData {
332 db,
333 ..kellnr_appstate::test_state()
334 })
335 }
336
337 fn build_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
338 let mut buf = Vec::new();
339 let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
340 let options = zip::write::SimpleFileOptions::default();
341 for (name, content) in files {
342 zip.start_file(*name, options).unwrap();
343 zip.write_all(content).unwrap();
344 }
345 zip.finish().unwrap();
346 buf
347 }
348
349 fn publish_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
350 Router::new()
351 .route("/{package}/{version}", put(publish_docs))
352 .with_state(AppStateData {
353 db,
354 docs_storage,
355 ..kellnr_appstate::test_state()
356 })
357 }
358
359 fn expect_admin_owner(db: &mut MockDb, normalized: &NormalizedName) {
360 db.expect_get_crate_id()
361 .with(eq(normalized.clone()))
362 .returning(|_| Ok(Some(1)));
363 db.expect_crate_version_exists()
364 .with(eq(1), eq("1.0.0"))
365 .returning(|_, _| Ok(true));
366 db.expect_get_user_from_token().returning(|_| {
367 Ok(User {
368 is_admin: true,
369 name: "admin".to_string(),
370 ..User::default()
371 })
372 });
373 }
374
375 #[tokio::test]
376 async fn publish_docs_uploads_files_and_updates_docs_link() {
377 let normalized = NormalizedName::from_unchecked("test-crate".to_string());
378 let mut db = MockDb::new();
379 expect_admin_owner(&mut db, &normalized);
380 db.expect_update_docs_link()
381 .with(
382 eq(normalized.clone()),
383 eq(Version::try_from("1.0.0").unwrap()),
384 eq("/docs/test-crate/1.0.0/doc/test_crate/index.html".to_string()),
385 )
386 .returning(|_, _, _| Ok(()));
387
388 let tmp = tempfile::tempdir().unwrap();
389 let docs_storage = Arc::new(DocsStorage::new(Box::new(
390 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
391 ) as DynStorage));
392
393 let kellnr = publish_app(Arc::new(db), docs_storage.clone());
394 let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
395
396 let r = kellnr
397 .oneshot(
398 Request::put("/test-crate/1.0.0")
399 .header(header::AUTHORIZATION, "sometoken")
400 .body(Body::from(zip_bytes))
401 .unwrap(),
402 )
403 .await
404 .unwrap();
405
406 assert_eq!(r.status(), StatusCode::OK);
407
408 let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
409 let stored = docs_storage.get(&key).await.unwrap();
410 assert_eq!(stored, bytes::Bytes::from_static(b"<html>docs</html>"));
411 }
412
413 #[tokio::test]
414 async fn publish_docs_rejects_read_only_owner() {
415 let normalized = NormalizedName::from_unchecked("test-crate".to_string());
416 let mut db = MockDb::new();
417 db.expect_get_crate_id()
418 .with(eq(normalized.clone()))
419 .returning(|_| Ok(Some(1)));
420 db.expect_crate_version_exists()
421 .with(eq(1), eq("1.0.0"))
422 .returning(|_, _| Ok(true));
423 db.expect_get_user_from_token().returning(|_| {
424 Ok(User {
425 name: "reader".to_string(),
426 is_read_only: true,
427 ..User::default()
428 })
429 });
430 db.expect_is_owner().returning(|_, _| Ok(true));
431 db.expect_update_docs_link().never();
432
433 let tmp = tempfile::tempdir().unwrap();
434 let docs_storage = Arc::new(DocsStorage::new(Box::new(
435 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
436 ) as DynStorage));
437
438 let kellnr = publish_app(Arc::new(db), docs_storage.clone());
439 let zip_bytes = build_zip(&[("doc/test_crate/index.html", b"<html>docs</html>")]);
440
441 let r = kellnr
442 .oneshot(
443 Request::put("/test-crate/1.0.0")
444 .header(header::AUTHORIZATION, "sometoken")
445 .body(Body::from(zip_bytes))
446 .unwrap(),
447 )
448 .await
449 .unwrap();
450
451 assert_eq!(r.status(), StatusCode::BAD_REQUEST);
453 let key = DocsStorage::file_key("test-crate", "1.0.0", "doc/test_crate/index.html");
454 assert!(!docs_storage.exists(&key).await.unwrap());
455 }
456
457 #[tokio::test]
458 async fn publish_docs_propagates_extraction_failure() {
459 let normalized = NormalizedName::from_unchecked("test-crate".to_string());
464 let mut db = MockDb::new();
465 expect_admin_owner(&mut db, &normalized);
466
467 let tmp = tempfile::tempdir().unwrap();
468 let docs_storage = Arc::new(DocsStorage::new(Box::new(
469 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
470 ) as DynStorage));
471
472 let kellnr = publish_app(Arc::new(db), docs_storage);
473 let zip_bytes = build_zip(&[("../escape.html", b"evil")]);
474
475 let r = kellnr
476 .oneshot(
477 Request::put("/test-crate/1.0.0")
478 .header(header::AUTHORIZATION, "sometoken")
479 .body(Body::from(zip_bytes))
480 .unwrap(),
481 )
482 .await
483 .unwrap();
484
485 assert_eq!(r.status(), StatusCode::BAD_REQUEST);
488 }
489
490 fn latest_app(db: Arc<dyn DbProvider>, docs_storage: Arc<DocsStorage>) -> Router {
491 Router::new()
492 .route("/{package}/latest", get(latest_docs))
493 .with_state(AppStateData {
494 db,
495 docs_storage,
496 ..kellnr_appstate::test_state()
497 })
498 }
499
500 async fn docs_storage_with_latest_docs() -> (tempfile::TempDir, Arc<DocsStorage>) {
501 let tmp = tempfile::tempdir().unwrap();
502 let docs_storage = Arc::new(DocsStorage::new(Box::new(
503 FSStorage::new(tmp.path().to_str().unwrap()).unwrap(),
504 ) as DynStorage));
505 let key = DocsStorage::file_key("secret", "1.0.0", "doc/secret/index.html");
506 docs_storage
507 .put(&key, bytes::Bytes::from_static(b"<html>docs</html>"))
508 .await
509 .unwrap();
510 (tmp, docs_storage)
511 }
512
513 fn expect_restricted_crate(db: &mut MockDb, restricted: bool) {
514 db.expect_is_download_restricted()
515 .returning(move |_| Ok(restricted));
516 db.expect_get_max_version_from_name()
517 .returning(|_| Ok(Version::try_from("1.0.0").unwrap()));
518 }
519
520 fn expect_token_user(db: &mut MockDb, name: &'static str) {
521 db.expect_get_user_from_token().returning(move |_| {
522 Ok(User {
523 name: name.to_string(),
524 ..User::default()
525 })
526 });
527 }
528
529 async fn latest_location(
530 db: MockDb,
531 docs_storage: Arc<DocsStorage>,
532 token: Option<&str>,
533 ) -> String {
534 let mut request = Request::get("/secret/latest");
535 if let Some(token) = token {
536 request = request.header(header::AUTHORIZATION, token);
537 }
538 let r = latest_app(Arc::new(db), docs_storage)
539 .oneshot(request.body(Body::empty()).unwrap())
540 .await
541 .unwrap();
542 assert_eq!(r.status(), StatusCode::TEMPORARY_REDIRECT);
543 r.headers()[header::LOCATION].to_str().unwrap().to_string()
544 }
545
546 #[tokio::test]
547 async fn latest_docs_redirects_to_docs_for_unrestricted_crate() {
548 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
549 let mut db = MockDb::new();
550 expect_restricted_crate(&mut db, false);
551
552 let location = latest_location(db, docs_storage, None).await;
553
554 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
555 }
556
557 #[tokio::test]
558 async fn latest_docs_hides_restricted_crate_from_anonymous_user() {
559 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
560 let mut db = MockDb::new();
561 expect_restricted_crate(&mut db, true);
562
563 let location = latest_location(db, docs_storage, None).await;
564
565 assert_eq!(location, "/");
566 }
567
568 #[tokio::test]
569 async fn latest_docs_hides_restricted_crate_from_user_without_access() {
570 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
571 let mut db = MockDb::new();
572 expect_restricted_crate(&mut db, true);
573 expect_token_user(&mut db, "bob");
574 db.expect_is_crate_user().returning(|_, _| Ok(false));
575 db.expect_is_crate_group_user().returning(|_, _| Ok(false));
576 db.expect_is_owner().returning(|_, _| Ok(false));
577
578 let location = latest_location(db, docs_storage, Some("token")).await;
579
580 assert_eq!(location, "/");
581 }
582
583 #[tokio::test]
584 async fn latest_docs_redirects_crate_user_of_restricted_crate() {
585 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
586 let mut db = MockDb::new();
587 expect_restricted_crate(&mut db, true);
588 expect_token_user(&mut db, "alice");
589 db.expect_is_crate_user()
590 .with(
591 eq(NormalizedName::from_unchecked("secret".to_string())),
592 eq("alice"),
593 )
594 .returning(|_, _| Ok(true));
595
596 let location = latest_location(db, docs_storage, Some("token")).await;
597
598 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
599 }
600
601 #[tokio::test]
602 async fn latest_docs_redirects_owner_of_restricted_crate() {
603 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
604 let mut db = MockDb::new();
605 expect_restricted_crate(&mut db, true);
606 expect_token_user(&mut db, "olivia");
607 db.expect_is_crate_user().returning(|_, _| Ok(false));
608 db.expect_is_crate_group_user().returning(|_, _| Ok(false));
609 db.expect_is_owner()
610 .with(
611 eq(NormalizedName::from_unchecked("secret".to_string())),
612 eq("olivia"),
613 )
614 .returning(|_, _| Ok(true));
615
616 let location = latest_location(db, docs_storage, Some("token")).await;
617
618 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
619 }
620
621 #[tokio::test]
622 async fn latest_docs_redirects_group_member_of_restricted_crate() {
623 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
624 let mut db = MockDb::new();
625 expect_restricted_crate(&mut db, true);
626 expect_token_user(&mut db, "greg");
627 db.expect_is_crate_user().returning(|_, _| Ok(false));
628 db.expect_is_crate_group_user()
629 .with(
630 eq(NormalizedName::from_unchecked("secret".to_string())),
631 eq("greg"),
632 )
633 .returning(|_, _| Ok(true));
634
635 let location = latest_location(db, docs_storage, Some("token")).await;
636
637 assert_eq!(location, "/docs/secret/1.0.0/doc/secret/index.html");
638 }
639
640 #[tokio::test]
641 async fn latest_docs_hides_crate_when_restriction_check_fails() {
642 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
643 let mut db = MockDb::new();
644 db.expect_is_download_restricted()
645 .returning(|_| Err(DbError::CrateNotFound("secret".to_string())));
646
647 let location = latest_location(db, docs_storage, None).await;
648
649 assert_eq!(location, "/");
650 }
651
652 #[tokio::test]
653 async fn latest_docs_hides_restricted_crate_when_access_check_fails() {
654 let (_tmp, docs_storage) = docs_storage_with_latest_docs().await;
655 let mut db = MockDb::new();
656 expect_restricted_crate(&mut db, true);
657 expect_token_user(&mut db, "alice");
658 db.expect_is_crate_user()
659 .returning(|_, _| Err(DbError::CrateNotFound("secret".to_string())));
660
661 let location = latest_location(db, docs_storage, Some("token")).await;
662
663 assert_eq!(location, "/");
664 }
665}