Skip to main content

kcode_k1_http_audio/
lib.rs

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}