1use std::sync::Arc;
2
3use axum::body::Bytes;
4use axum::extract::{DefaultBodyLimit, Path, State};
5use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE};
6use axum::http::{HeaderMap, HeaderValue, StatusCode};
7use axum::response::{IntoResponse, Response};
8use axum::routing::{get, post, put};
9use axum::{Extension, Json, Router};
10use kcode_k1_access_full_audio::{
11 AccessId, FullAudioState, GroupId, K1AccessFullAudio, PersonId, SpeakerLabelV1, UserId,
12};
13use kcode_k1_access_profiles::{ProfileId, ProfileSelection, TxId};
14use kcode_k1_http::Principal;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18pub fn authenticated_routes(audio: Arc<K1AccessFullAudio>) -> Router<()> {
19 Router::new()
20 .route("/audio", get(list_audio))
21 .route(
22 "/audio/profiles/{profile_id}",
23 post(submit_audio).layer(upload_body_limit()),
24 )
25 .route("/audio/groups/{group_id}", get(list_group_audio))
26 .route("/audio/{access_id}", get(get_audio))
27 .route(
28 "/audio/{access_id}/fragments/{fragment_id}/audio",
29 get(get_fragment_audio),
30 )
31 .route(
32 "/audio/{access_id}/fragments/{fragment_id}/labels",
33 put(submit_labels),
34 )
35 .route(
36 "/audio/{access_id}/fragments/{fragment_id}/retry",
37 post(retry_fragment),
38 )
39 .route(
40 "/audio/{access_id}/fragments/{fragment_id}/discard",
41 post(discard_fragment),
42 )
43 .with_state(audio)
44}
45
46fn upload_body_limit() -> DefaultBodyLimit {
47 DefaultBodyLimit::disable()
48}
49
50#[derive(Debug)]
51struct ApiError {
52 status: StatusCode,
53 code: &'static str,
54 message: String,
55}
56type ApiResult<T> = Result<T, ApiError>;
57
58#[derive(Serialize)]
59struct ErrorDto {
60 error: &'static str,
61 message: String,
62}
63
64impl IntoResponse for ApiError {
65 fn into_response(self) -> Response {
66 json_response(
67 self.status,
68 ErrorDto {
69 error: self.code,
70 message: self.message,
71 },
72 )
73 }
74}
75
76fn problem(status: StatusCode, code: &'static str, message: impl Into<String>) -> ApiError {
77 ApiError {
78 status,
79 code,
80 message: message.into(),
81 }
82}
83
84fn invalid_id(code: &'static str, subject: &str) -> ApiError {
85 problem(
86 StatusCode::BAD_REQUEST,
87 code,
88 format!("{subject} ID is not canonical lowercase hexadecimal"),
89 )
90}
91
92fn dependency_error(operation: &str, message: String) -> ApiError {
93 if message.contains("fragment does not belong to full audio") {
94 return problem(
95 StatusCode::NOT_FOUND,
96 "fragment_not_found",
97 "fragment is unavailable to the caller",
98 );
99 }
100 if message.contains("cannot access full audio") || message == "access denied" {
101 return problem(
102 StatusCode::NOT_FOUND,
103 "audio_not_found",
104 "audio is unavailable to the caller",
105 );
106 }
107 problem(
108 StatusCode::SERVICE_UNAVAILABLE,
109 "audio_unavailable",
110 format!("{operation}: {message}"),
111 )
112}
113
114fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
115 let mut response = (status, Json(value)).into_response();
116 response
117 .headers_mut()
118 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
119 response
120}
121
122fn no_content() -> Response {
123 let mut response = StatusCode::NO_CONTENT.into_response();
124 response
125 .headers_mut()
126 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
127 response
128}
129
130async fn run<T, F>(operation: &'static str, task: F) -> ApiResult<T>
131where
132 T: Send + 'static,
133 F: FnOnce() -> Result<T, String> + Send + 'static,
134{
135 match tokio::task::spawn_blocking(task).await {
136 Ok(Ok(value)) => Ok(value),
137 Ok(Err(message)) => Err(dependency_error(operation, message)),
138 Err(_) => Err(problem(
139 StatusCode::SERVICE_UNAVAILABLE,
140 "audio_unavailable",
141 format!("{operation}: blocking task failed"),
142 )),
143 }
144}
145
146fn caller(principal: &Principal) -> UserId {
147 UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
148}
149
150fn parse_txid(value: &str, code: &'static str, subject: &str) -> ApiResult<TxId> {
151 if value.len() != 24 {
152 return Err(invalid_id(code, subject));
153 }
154 let mut output = [0; 12];
155 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
156 let digit = |byte| match byte {
157 b'0'..=b'9' => Some(byte - b'0'),
158 b'a'..=b'f' => Some(byte - b'a' + 10),
159 _ => None,
160 };
161 output[index] = digit(pair[0])
162 .zip(digit(pair[1]))
163 .map(|(high, low)| high << 4 | low)
164 .ok_or_else(|| invalid_id(code, subject))?;
165 }
166 Ok(TxId::from_bytes(output))
167}
168
169fn parse_access_id(value: &str) -> ApiResult<AccessId> {
170 parse_txid(value, "invalid_access_id", "access").map(AccessId::new)
171}
172
173fn parse_fragment_id(value: &str) -> ApiResult<TxId> {
174 parse_txid(value, "invalid_fragment_id", "fragment")
175}
176
177fn parse_group_id(value: &str) -> ApiResult<GroupId> {
178 parse_txid(value, "invalid_group_id", "group").map(GroupId::new)
179}
180
181fn parse_profile_id(value: &str) -> ApiResult<ProfileId> {
182 parse_txid(value, "invalid_profile_id", "profile").map(ProfileId::new)
183}
184
185fn hex(bytes: &[u8; 12]) -> String {
186 const DIGITS: &[u8; 16] = b"0123456789abcdef";
187 let mut value = String::with_capacity(24);
188 for byte in bytes {
189 value.push(DIGITS[(byte >> 4) as usize] as char);
190 value.push(DIGITS[(byte & 15) as usize] as char);
191 }
192 value
193}
194
195fn access_hex(id: &AccessId) -> String {
196 hex(id.txid().as_bytes())
197}
198
199fn require_content_type(headers: &HeaderMap, expected: &str) -> ApiResult<()> {
200 let mut values = headers.get_all(CONTENT_TYPE).iter();
201 let valid = values
202 .next()
203 .and_then(|value| value.to_str().ok())
204 .is_some_and(|value| {
205 value
206 .split(';')
207 .next()
208 .is_some_and(|media| media.trim().eq_ignore_ascii_case(expected))
209 })
210 && values.next().is_none();
211 if valid {
212 Ok(())
213 } else {
214 Err(problem(
215 StatusCode::UNSUPPORTED_MEDIA_TYPE,
216 "unsupported_media_type",
217 "request content type is unsupported",
218 ))
219 }
220}
221
222#[derive(Serialize)]
223struct SubmittedDto {
224 access_id: String,
225 full_audio_id: String,
226}
227
228#[derive(Serialize)]
229struct FragmentDto {
230 fragment_id: String,
231 start_sample_48k: u64,
232 end_sample_48k: u64,
233 status: Value,
234}
235
236#[derive(Serialize)]
237struct StatusDto {
238 state: &'static str,
239 fragments: Vec<FragmentDto>,
240 final_transcript: Option<String>,
241}
242
243fn state_name(state: FullAudioState) -> &'static str {
244 match state {
245 FullAudioState::Processing => "processing",
246 FullAudioState::AwaitingLabels => "awaiting_labels",
247 FullAudioState::NeedsAttention => "needs_attention",
248 FullAudioState::Complete => "complete",
249 }
250}
251
252#[derive(Deserialize)]
253struct LabelsDto {
254 labels: Vec<LabelDto>,
255}
256
257#[derive(Deserialize)]
258struct LabelDto {
259 speaker: String,
260 person_id: Option<String>,
261}
262
263fn parse_labels(input: LabelsDto) -> ApiResult<Vec<SpeakerLabelV1>> {
264 input
265 .labels
266 .into_iter()
267 .map(|label| {
268 let person_id = label
269 .person_id
270 .map(|value| {
271 parse_txid(&value, "invalid_person_id", "person").map(PersonId::from_tx_id)
272 })
273 .transpose()?;
274 Ok(SpeakerLabelV1 {
275 speaker: label.speaker.parse().map_err(|_| {
276 problem(
277 StatusCode::BAD_REQUEST,
278 "invalid_labels",
279 "speaker label is invalid",
280 )
281 })?,
282 person_id,
283 })
284 })
285 .collect()
286}
287
288async fn submit_audio(
289 State(audio): State<Arc<K1AccessFullAudio>>,
290 Extension(principal): Extension<Principal>,
291 Path(value): Path<String>,
292 headers: HeaderMap,
293 body: Bytes,
294) -> ApiResult<Response> {
295 require_content_type(&headers, "application/octet-stream")?;
296 let profile = ProfileSelection::Saved(parse_profile_id(&value)?);
297 let user = caller(&principal);
298 let submitted = run("submit audio", move || {
299 audio.submit_for_user(user, profile, &body)
300 })
301 .await?;
302 Ok(json_response(
303 StatusCode::CREATED,
304 SubmittedDto {
305 access_id: access_hex(&submitted.access_id),
306 full_audio_id: hex(submitted.full_audio_id.as_bytes()),
307 },
308 ))
309}
310
311async fn list_audio(
312 State(audio): State<Arc<K1AccessFullAudio>>,
313 Extension(principal): Extension<Principal>,
314) -> ApiResult<Response> {
315 let user = caller(&principal);
316 let ids = run("list audio", move || audio.list_for_user(user)).await?;
317 Ok(json_response(
318 StatusCode::OK,
319 ids.iter().map(access_hex).collect::<Vec<_>>(),
320 ))
321}
322
323async fn list_group_audio(
324 State(audio): State<Arc<K1AccessFullAudio>>,
325 Extension(principal): Extension<Principal>,
326 Path(value): Path<String>,
327) -> ApiResult<Response> {
328 let group = parse_group_id(&value)?;
329 let user = caller(&principal);
330 let ids = run("list group audio", move || {
331 audio.list_group_for_user(user, group)
332 })
333 .await?;
334 Ok(json_response(
335 StatusCode::OK,
336 ids.iter().map(access_hex).collect::<Vec<_>>(),
337 ))
338}
339
340async fn get_audio(
341 State(audio): State<Arc<K1AccessFullAudio>>,
342 Extension(principal): Extension<Principal>,
343 Path(value): Path<String>,
344) -> ApiResult<Response> {
345 let access_id = parse_access_id(&value)?;
346 let user = caller(&principal);
347 let status = run("get audio status", move || {
348 audio.status_for_user(user, access_id)
349 })
350 .await?;
351 let state = state_name(status.state);
352 let mut fragments = Vec::with_capacity(status.fragments.len());
353 for fragment in status.fragments {
354 let serialized = serde_json::to_value(fragment.status).map_err(|_| {
355 problem(
356 StatusCode::SERVICE_UNAVAILABLE,
357 "audio_unavailable",
358 "serialize audio status: status serialization failed",
359 )
360 })?;
361 fragments.push(FragmentDto {
362 fragment_id: hex(fragment.fragment_id.as_bytes()),
363 start_sample_48k: fragment.start_sample_48k,
364 end_sample_48k: fragment.end_sample_48k,
365 status: serialized,
366 });
367 }
368 Ok(json_response(
369 StatusCode::OK,
370 StatusDto {
371 state,
372 fragments,
373 final_transcript: status.final_transcript,
374 },
375 ))
376}
377
378async fn get_fragment_audio(
379 State(audio): State<Arc<K1AccessFullAudio>>,
380 Extension(principal): Extension<Principal>,
381 Path((access_value, fragment_value)): Path<(String, String)>,
382) -> ApiResult<Response> {
383 let access_id = parse_access_id(&access_value)?;
384 let fragment_id = parse_fragment_id(&fragment_value)?;
385 let user = caller(&principal);
386 let bytes = run("get fragment audio", move || {
387 audio.fragment_audio_for_user(user, access_id, fragment_id)
388 })
389 .await?;
390 Ok((
391 [
392 (CONTENT_TYPE, HeaderValue::from_static("audio/ogg")),
393 (CACHE_CONTROL, HeaderValue::from_static("no-store")),
394 ],
395 bytes,
396 )
397 .into_response())
398}
399
400async fn submit_labels(
401 State(audio): State<Arc<K1AccessFullAudio>>,
402 Extension(principal): Extension<Principal>,
403 Path((access_value, fragment_value)): Path<(String, String)>,
404 headers: HeaderMap,
405 body: Bytes,
406) -> ApiResult<Response> {
407 require_content_type(&headers, "application/json")?;
408 let input: LabelsDto = serde_json::from_slice(&body).map_err(|_| {
409 problem(
410 StatusCode::BAD_REQUEST,
411 "invalid_json",
412 "request body is invalid",
413 )
414 })?;
415 let labels = parse_labels(input)?;
416 let access_id = parse_access_id(&access_value)?;
417 let fragment_id = parse_fragment_id(&fragment_value)?;
418 let user = caller(&principal);
419 run("submit fragment labels", move || {
420 audio.submit_labels_for_user(user, access_id, fragment_id, labels)
421 })
422 .await?;
423 Ok(no_content())
424}
425
426async fn retry_fragment(
427 State(audio): State<Arc<K1AccessFullAudio>>,
428 Extension(principal): Extension<Principal>,
429 Path((access_value, fragment_value)): Path<(String, String)>,
430) -> ApiResult<Response> {
431 let access_id = parse_access_id(&access_value)?;
432 let fragment_id = parse_fragment_id(&fragment_value)?;
433 let user = caller(&principal);
434 run("retry fragment", move || {
435 audio.retry_fragment_for_user(user, access_id, fragment_id)
436 })
437 .await?;
438 Ok(no_content())
439}
440
441async fn discard_fragment(
442 State(audio): State<Arc<K1AccessFullAudio>>,
443 Extension(principal): Extension<Principal>,
444 Path((access_value, fragment_value)): Path<(String, String)>,
445) -> ApiResult<Response> {
446 let access_id = parse_access_id(&access_value)?;
447 let fragment_id = parse_fragment_id(&fragment_value)?;
448 let user = caller(&principal);
449 run("discard fragment", move || {
450 audio.discard_fragment_for_user(user, access_id, fragment_id)
451 })
452 .await?;
453 Ok(no_content())
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use async_trait::async_trait;
460 use axum::body::{Body, to_bytes};
461 use axum::extract::OriginalUri;
462 use axum::http::Request;
463 use axum::routing::MethodRouter;
464 use kcode_k1_http::{
465 CanonicalUsername, Config, Identity, IdentityError, IdentityProvider, K1Http,
466 RegistrationPrincipal,
467 };
468 use kcode_k1_http_replay::{ReplayConfig, ReplayWindow};
469 use kcode_k1_http_testkit::{Candidate, Fixture, LookupBlock};
470 use serde_json::json;
471 use tower::ServiceExt;
472
473 #[test]
474 fn canonical_ids_and_user_conversion_preserve_bytes() {
475 let id = "00112233445566778899aabb";
476 assert_eq!(hex(parse_fragment_id(id).unwrap().as_bytes()), id);
477 assert!(parse_fragment_id("00112233445566778899AABB").is_err());
478 assert!(parse_fragment_id("0011").is_err());
479 assert_eq!(
480 hex(caller_bytes([1; 12]).as_tx_id().as_bytes()),
481 "010101010101010101010101"
482 );
483 }
484
485 fn caller_bytes(bytes: [u8; 12]) -> UserId {
486 UserId::from_tx_id(TxId::from_bytes(bytes))
487 }
488
489 #[test]
490 fn labels_accept_known_and_unknown_people() {
491 let labels = parse_labels(LabelsDto {
492 labels: vec![
493 LabelDto {
494 speaker: "Speaker 1".to_owned(),
495 person_id: None,
496 },
497 LabelDto {
498 speaker: "Speaker 2".to_owned(),
499 person_id: Some("00112233445566778899aabb".to_owned()),
500 },
501 ],
502 })
503 .unwrap();
504 assert_eq!(labels.len(), 2);
505 assert!(labels[0].person_id.is_none());
506 assert_eq!(
507 hex(labels[1].person_id.unwrap().as_tx_id().as_bytes()),
508 "00112233445566778899aabb"
509 );
510 assert!(
511 parse_labels(LabelsDto {
512 labels: vec![LabelDto {
513 speaker: "Unknown".to_owned(),
514 person_id: None,
515 }]
516 })
517 .is_err()
518 );
519 }
520
521 #[test]
522 fn status_shape_and_hidden_errors_are_stable() {
523 let value = serde_json::to_value(StatusDto {
524 state: "processing",
525 fragments: Vec::new(),
526 final_transcript: None,
527 })
528 .unwrap();
529 assert_eq!(
530 value,
531 json!({"state":"processing","fragments":[],"final_transcript":null})
532 );
533 let error = dependency_error(
534 "get audio status",
535 "principal cannot access full audio".to_owned(),
536 );
537 assert_eq!(error.status, StatusCode::NOT_FOUND);
538 assert_eq!(error.code, "audio_not_found");
539 assert!(!error.message.contains("principal"));
540 }
541
542 #[test]
543 fn content_types_and_public_route_assembler_are_exact() {
544 let mut headers = HeaderMap::new();
545 headers.insert(
546 CONTENT_TYPE,
547 HeaderValue::from_static("application/json; charset=utf-8"),
548 );
549 assert!(require_content_type(&headers, "application/json").is_ok());
550 assert!(require_content_type(&headers, "application/octet-stream").is_err());
551 let _ = authenticated_routes as fn(Arc<K1AccessFullAudio>) -> Router<()>;
552 }
553
554 #[tokio::test]
555 async fn upload_layer_accepts_more_than_axums_default_limit() {
556 let size = 2 * 1024 * 1024 + 1;
557 let app = Router::new().route(
558 "/audio/profiles/{profile_id}",
559 post(|Path(_): Path<String>, body: Bytes| async move { body.len().to_string() })
560 .layer(upload_body_limit()),
561 );
562 let response = app
563 .oneshot(
564 Request::post("/audio/profiles/00112233445566778899aabb")
565 .body(Body::from(vec![7_u8; size]))
566 .unwrap(),
567 )
568 .await
569 .unwrap();
570 assert_eq!(response.status(), StatusCode::OK);
571 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
572 assert_eq!(body.as_ref(), size.to_string().as_bytes());
573 }
574
575 struct Adapter;
576
577 struct Provider {
578 identities: Vec<(CanonicalUsername, Identity)>,
579 blocked: Option<(CanonicalUsername, LookupBlock)>,
580 }
581
582 #[async_trait]
583 impl IdentityProvider for Provider {
584 async fn lookup(
585 &self,
586 username: &CanonicalUsername,
587 ) -> Result<Option<Identity>, IdentityError> {
588 if let Some((blocked_username, block)) = &self.blocked
589 && blocked_username == username
590 {
591 let release = block.release.notified();
592 tokio::pin!(release);
593 release.as_mut().enable();
594 block.entered.notify_one();
595 release.await;
596 }
597 Ok(self
598 .identities
599 .iter()
600 .find(|(candidate, _)| candidate == username)
601 .map(|(_, identity)| *identity))
602 }
603 }
604
605 #[derive(Deserialize)]
606 struct Known {
607 known: String,
608 }
609
610 async fn register(Extension(principal): Extension<RegistrationPrincipal>) -> String {
611 principal.username().as_str().to_owned()
612 }
613
614 async fn whoami(Extension(principal): Extension<Principal>) -> String {
615 principal.username().as_str().to_owned()
616 }
617
618 async fn target(OriginalUri(uri): OriginalUri) -> String {
619 uri.path_and_query()
620 .map(|value| value.as_str())
621 .unwrap_or("/")
622 .to_owned()
623 }
624
625 async fn echo(body: Bytes) -> Bytes {
626 body
627 }
628
629 async fn known(Json(value): Json<Known>) -> String {
630 value.known
631 }
632
633 #[async_trait]
634 impl Candidate for Adapter {
635 async fn open(&self, fixture: Fixture) -> Result<Router, String> {
636 let identities = fixture
637 .identities
638 .into_iter()
639 .map(|identity| {
640 CanonicalUsername::parse(&identity.username)
641 .map(|username| {
642 (
643 username,
644 Identity::new(identity.user_id, identity.public_key),
645 )
646 })
647 .map_err(|_| "invalid fixture identity".to_owned())
648 })
649 .collect::<Result<Vec<_>, _>>()?;
650 let blocked = fixture
651 .blocked_lookup
652 .map(|block| {
653 CanonicalUsername::parse(&block.username)
654 .map(|username| (username, block))
655 .map_err(|_| "invalid blocked identity".to_owned())
656 })
657 .transpose()?;
658 let replay = ReplayWindow::open(ReplayConfig {
659 epoch_file: fixture.epoch_file,
660 max_nonces_per_epoch: fixture.max_nonces_per_epoch,
661 })
662 .await
663 .map_err(|_| "replay unavailable".to_owned())?;
664 let http = K1Http::new(
665 Config {
666 server_id: fixture.server_id,
667 public_origin: fixture.public_origin,
668 max_body_bytes: fixture.max_body_bytes,
669 },
670 replay,
671 Arc::new(Provider {
672 identities,
673 blocked,
674 }),
675 )
676 .map_err(|_| "configuration invalid".to_owned())?;
677 let authenticated = Router::new()
678 .route("/whoami", get(whoami))
679 .route("/target", get(target))
680 .route("/echo", post(echo).layer(upload_body_limit()))
681 .route("/json", post(known));
682 Ok(http.router(
683 post(register),
684 MethodRouter::new().get(|| async { "terms" }),
685 authenticated,
686 ))
687 }
688 }
689
690 #[tokio::test]
691 async fn k1_http_configured_body_limit_remains_authoritative() {
692 kcode_k1_http_testkit::verify_authentication(&Adapter).await;
693 }
694}