Skip to main content

minco_plugin_feedback/
http.rs

1use crate::{
2    AttachmentUpload, AudioInput, ClientReplyInput, CreateFeedbackInput, DeveloperReplyInput,
3    FEEDBACK_BASE_PATH, FeedbackAccessToken, FeedbackAttachment, FeedbackAttachmentKind,
4    FeedbackConfig, FeedbackId, FeedbackListFilter, FeedbackMutationResult, FeedbackService,
5    FeedbackServiceError, FeedbackStatus, FeedbackThread, FeedbackWarning, FeedbackWidgetConfig,
6    Transcript, TransitionFeedbackInput,
7};
8use axum::{
9    Extension, Json, Router,
10    body::Body,
11    extract::{DefaultBodyLimit, Multipart, Path, Query, State},
12    response::{IntoResponse, Response},
13    routing::{get, patch, post},
14};
15use http::{HeaderMap, HeaderValue, StatusCode, header};
16use minco_http::{ApiFailure, Principal};
17use serde::Serialize;
18use sha2::{Digest, Sha256};
19use std::{str::FromStr, sync::Arc};
20use subtle::ConstantTimeEq;
21use uuid::Uuid;
22
23const WIDGET_SOURCE: &str = include_str!("../assets/widget.js");
24const CLIENT_TOKEN_HEADER: &str = "x-minco-feedback-token";
25const PROJECT_KEY_HEADER: &str = "x-minco-feedback-project-key";
26const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
27
28#[derive(Debug, Clone)]
29struct FeedbackHttpState {
30    service: FeedbackService,
31    config: Arc<FeedbackConfig>,
32}
33
34pub fn feedback_router(service: FeedbackService) -> Router {
35    let config = Arc::new(service.config().clone());
36    let maximum_body = feedback_request_body_budget(&config);
37    let state = FeedbackHttpState { service, config };
38    let routes = Router::new()
39        .route("/widget.js", get(widget_source))
40        .route("/widget-config", get(widget_config))
41        .route("/threads", post(create_feedback))
42        .route("/threads/{id}", get(get_client_feedback))
43        .route("/threads/{id}/messages", post(client_reply))
44        .route(
45            "/threads/{id}/attachments/{attachment_id}",
46            get(client_attachment),
47        )
48        .route("/transcriptions", post(transcribe_audio))
49        .route("/developer/threads", get(list_developer_feedback))
50        .route("/developer/threads/{id}", get(get_developer_feedback))
51        .route("/developer/threads/{id}/messages", post(developer_reply))
52        .route("/developer/threads/{id}/status", patch(transition_feedback))
53        .route(
54            "/developer/threads/{id}/ai-context",
55            get(feedback_ai_context),
56        )
57        .route(
58            "/developer/threads/{id}/attachments/{attachment_id}",
59            get(developer_attachment),
60        )
61        .layer(DefaultBodyLimit::max(maximum_body))
62        .with_state(state);
63    Router::new().nest(FEEDBACK_BASE_PATH, routes)
64}
65
66async fn widget_source() -> Response {
67    let mut response = WIDGET_SOURCE.into_response();
68    response.headers_mut().insert(
69        header::CONTENT_TYPE,
70        HeaderValue::from_static("application/javascript; charset=utf-8"),
71    );
72    response.headers_mut().insert(
73        header::CACHE_CONTROL,
74        HeaderValue::from_static("public, max-age=300"),
75    );
76    response
77}
78
79async fn widget_config(State(state): State<FeedbackHttpState>) -> Json<FeedbackWidgetConfig> {
80    Json(state.config.widget_config())
81}
82
83async fn create_feedback(
84    State(state): State<FeedbackHttpState>,
85    principal: Option<Extension<Principal>>,
86    headers: HeaderMap,
87    multipart: Multipart,
88) -> Result<(StatusCode, Json<ClientCreateResponse>), ApiFailure> {
89    let request_id = request_id(&headers);
90    let principal = principal.as_ref().map(|Extension(value)| value);
91    authorize_submission(&headers, &state.config, principal, &request_id)?;
92    let (mut input, attachments) = read_submission(multipart, &state.config, &request_id).await?;
93    bind_client_subject(&mut input, principal);
94    let result = state
95        .service
96        .create(input, attachments, Uuid::now_v7())
97        .await
98        .map_err(|error| map_error(error, &request_id))?;
99    Ok((
100        StatusCode::CREATED,
101        Json(ClientCreateResponse {
102            thread: ClientFeedbackThread::from(&result.thread),
103            client_token: result.client_token.expose().to_owned(),
104            warnings: result.warnings,
105        }),
106    ))
107}
108
109async fn get_client_feedback(
110    State(state): State<FeedbackHttpState>,
111    Path(id): Path<String>,
112    headers: HeaderMap,
113) -> Result<Json<ClientFeedbackThread>, ApiFailure> {
114    let request_id = request_id(&headers);
115    let id = parse_feedback_id(&id, &request_id)?;
116    let token = client_token(&headers, &request_id)?;
117    let thread = state
118        .service
119        .get_for_client(id, &token)
120        .await
121        .map_err(|error| map_error(error, &request_id))?;
122    Ok(Json(ClientFeedbackThread::from(&thread)))
123}
124
125async fn client_reply(
126    State(state): State<FeedbackHttpState>,
127    Path(id): Path<String>,
128    headers: HeaderMap,
129    Json(input): Json<ClientReplyInput>,
130) -> Result<Json<ClientMutationResponse>, ApiFailure> {
131    let request_id = request_id(&headers);
132    let id = parse_feedback_id(&id, &request_id)?;
133    let token = client_token(&headers, &request_id)?;
134    let result = state
135        .service
136        .reply_as_client(id, &token, input.body, Uuid::now_v7())
137        .await
138        .map_err(|error| map_error(error, &request_id))?;
139    Ok(Json(ClientMutationResponse::from(result)))
140}
141
142async fn client_attachment(
143    State(state): State<FeedbackHttpState>,
144    Path((id, attachment_id)): Path<(String, String)>,
145    headers: HeaderMap,
146) -> Result<Response, ApiFailure> {
147    let request_id = request_id(&headers);
148    let id = parse_feedback_id(&id, &request_id)?;
149    let attachment_id = parse_uuid(&attachment_id, "attachment_id", &request_id)?;
150    let token = client_token(&headers, &request_id)?;
151    let object = state
152        .service
153        .attachment_for_client(id, &token, attachment_id)
154        .await
155        .map_err(|error| map_error(error, &request_id))?;
156    Ok(object_response(object))
157}
158
159async fn transcribe_audio(
160    State(state): State<FeedbackHttpState>,
161    principal: Option<Extension<Principal>>,
162    headers: HeaderMap,
163    mut multipart: Multipart,
164) -> Result<Json<Transcript>, ApiFailure> {
165    let request_id = request_id(&headers);
166    authorize_transcription(
167        &headers,
168        &state.config,
169        principal.as_ref().map(|Extension(value)| value),
170        &request_id,
171    )?;
172    let mut audio = None;
173    let mut language = None;
174    let mut prompt = None;
175    while let Some(field) = multipart
176        .next_field()
177        .await
178        .map_err(|error| multipart_failure(&error.to_string(), &request_id))?
179    {
180        let name = field.name().unwrap_or_default().to_owned();
181        match name.as_str() {
182            "language" => {
183                language = Some(
184                    field
185                        .text()
186                        .await
187                        .map_err(|error| multipart_failure(&error.to_string(), &request_id))?,
188                );
189            }
190            "prompt" => {
191                prompt = Some(
192                    field
193                        .text()
194                        .await
195                        .map_err(|error| multipart_failure(&error.to_string(), &request_id))?,
196                );
197            }
198            "audio" => {
199                let file_name = field.file_name().unwrap_or("feedback.webm").to_owned();
200                let content_type = field
201                    .content_type()
202                    .unwrap_or("application/octet-stream")
203                    .to_owned();
204                let bytes = field
205                    .bytes()
206                    .await
207                    .map_err(|error| multipart_failure(&error.to_string(), &request_id))?;
208                if bytes.len() > state.config.max_audio_bytes {
209                    return Err(ApiFailure::new(
210                        StatusCode::PAYLOAD_TOO_LARGE,
211                        "feedback_audio_too_large",
212                        "Audio is too large",
213                        format!(
214                            "Audio is {} bytes; configured limit is {} bytes.",
215                            bytes.len(),
216                            state.config.max_audio_bytes
217                        ),
218                        request_id,
219                    ));
220                }
221                audio = Some(AudioInput {
222                    bytes: bytes.to_vec(),
223                    content_type,
224                    file_name,
225                    language: None,
226                    prompt: None,
227                });
228            }
229            _ => {
230                return Err(ApiFailure::validation(
231                    format!("unsupported transcription multipart field {name:?}"),
232                    request_id,
233                ));
234            }
235        }
236    }
237    let mut audio = audio.ok_or_else(|| {
238        ApiFailure::validation("multipart field `audio` is required", request_id.clone())
239    })?;
240    audio.language = bounded_optional_text(language, "language", 64, &request_id)?;
241    audio.prompt = bounded_optional_text(prompt, "prompt", 2_000, &request_id)?;
242    let transcript = state
243        .service
244        .transcribe(audio)
245        .await
246        .map_err(|error| map_error(error, &request_id))?;
247    Ok(Json(transcript))
248}
249
250async fn list_developer_feedback(
251    State(state): State<FeedbackHttpState>,
252    principal: Option<Extension<Principal>>,
253    Query(filter): Query<FeedbackListFilter>,
254    headers: HeaderMap,
255) -> Result<Json<Vec<crate::FeedbackSummary>>, ApiFailure> {
256    let request_id = request_id(&headers);
257    let _developer_actor = authorize_developer(
258        &headers,
259        &state.config,
260        principal.as_ref().map(|Extension(value)| value),
261        &request_id,
262    )?;
263    Ok(Json(
264        state
265            .service
266            .list(filter)
267            .await
268            .map_err(|error| map_error(error, &request_id))?,
269    ))
270}
271
272async fn get_developer_feedback(
273    State(state): State<FeedbackHttpState>,
274    principal: Option<Extension<Principal>>,
275    Path(id): Path<String>,
276    headers: HeaderMap,
277) -> Result<Json<FeedbackThread>, ApiFailure> {
278    let request_id = request_id(&headers);
279    let _developer_actor = authorize_developer(
280        &headers,
281        &state.config,
282        principal.as_ref().map(|Extension(value)| value),
283        &request_id,
284    )?;
285    let id = parse_feedback_id(&id, &request_id)?;
286    Ok(Json(
287        state
288            .service
289            .get_for_developer(id)
290            .await
291            .map_err(|error| map_error(error, &request_id))?,
292    ))
293}
294
295async fn developer_reply(
296    State(state): State<FeedbackHttpState>,
297    principal: Option<Extension<Principal>>,
298    Path(id): Path<String>,
299    headers: HeaderMap,
300    Json(mut input): Json<DeveloperReplyInput>,
301) -> Result<Json<FeedbackMutationResult>, ApiFailure> {
302    let request_id = request_id(&headers);
303    let developer_actor = authorize_developer(
304        &headers,
305        &state.config,
306        principal.as_ref().map(|Extension(value)| value),
307        &request_id,
308    )?;
309    if let Some(Extension(principal)) = principal {
310        input.author_display = Some(principal.subject);
311    }
312    let id = parse_feedback_id(&id, &request_id)?;
313    Ok(Json(
314        state
315            .service
316            .reply_as_developer(id, input, developer_actor, Uuid::now_v7())
317            .await
318            .map_err(|error| map_error(error, &request_id))?,
319    ))
320}
321
322async fn transition_feedback(
323    State(state): State<FeedbackHttpState>,
324    principal: Option<Extension<Principal>>,
325    Path(id): Path<String>,
326    headers: HeaderMap,
327    Json(mut input): Json<TransitionFeedbackInput>,
328) -> Result<Json<FeedbackMutationResult>, ApiFailure> {
329    let request_id = request_id(&headers);
330    let developer_actor = authorize_developer(
331        &headers,
332        &state.config,
333        principal.as_ref().map(|Extension(value)| value),
334        &request_id,
335    )?;
336    if let Some(Extension(principal)) = principal {
337        input.author_display = Some(principal.subject);
338    }
339    let id = parse_feedback_id(&id, &request_id)?;
340    Ok(Json(
341        state
342            .service
343            .transition(id, input, developer_actor, Uuid::now_v7())
344            .await
345            .map_err(|error| map_error(error, &request_id))?,
346    ))
347}
348
349async fn feedback_ai_context(
350    State(state): State<FeedbackHttpState>,
351    principal: Option<Extension<Principal>>,
352    Path(id): Path<String>,
353    headers: HeaderMap,
354) -> Result<Response, ApiFailure> {
355    let request_id = request_id(&headers);
356    let _developer_actor = authorize_developer(
357        &headers,
358        &state.config,
359        principal.as_ref().map(|Extension(value)| value),
360        &request_id,
361    )?;
362    let id = parse_feedback_id(&id, &request_id)?;
363    let context = state
364        .service
365        .ai_context(id)
366        .await
367        .map_err(|error| map_error(error, &request_id))?;
368    let accept = headers
369        .get(header::ACCEPT)
370        .and_then(|value| value.to_str().ok())
371        .unwrap_or_default();
372    if accept.contains("application/json") {
373        return Ok(Json(context).into_response());
374    }
375    let mut response = context.to_markdown().into_response();
376    response.headers_mut().insert(
377        header::CONTENT_TYPE,
378        HeaderValue::from_static("text/markdown; charset=utf-8"),
379    );
380    Ok(response)
381}
382
383async fn developer_attachment(
384    State(state): State<FeedbackHttpState>,
385    principal: Option<Extension<Principal>>,
386    Path((id, attachment_id)): Path<(String, String)>,
387    headers: HeaderMap,
388) -> Result<Response, ApiFailure> {
389    let request_id = request_id(&headers);
390    let _developer_actor = authorize_developer(
391        &headers,
392        &state.config,
393        principal.as_ref().map(|Extension(value)| value),
394        &request_id,
395    )?;
396    let id = parse_feedback_id(&id, &request_id)?;
397    let attachment_id = parse_uuid(&attachment_id, "attachment_id", &request_id)?;
398    let object = state
399        .service
400        .attachment_for_developer(id, attachment_id)
401        .await
402        .map_err(|error| map_error(error, &request_id))?;
403    Ok(object_response(object))
404}
405
406async fn read_submission(
407    mut multipart: Multipart,
408    config: &FeedbackConfig,
409    request_id: &str,
410) -> Result<(CreateFeedbackInput, Vec<AttachmentUpload>), ApiFailure> {
411    let mut payload = None;
412    let mut attachments = Vec::new();
413    while let Some(field) = multipart
414        .next_field()
415        .await
416        .map_err(|error| multipart_failure(&error.to_string(), request_id))?
417    {
418        let name = field.name().unwrap_or_default().to_owned();
419        match name.as_str() {
420            "payload" => {
421                if payload.is_some() {
422                    return Err(ApiFailure::validation(
423                        "multipart field `payload` may appear only once",
424                        request_id,
425                    ));
426                }
427                let bytes = field
428                    .bytes()
429                    .await
430                    .map_err(|error| multipart_failure(&error.to_string(), request_id))?;
431                if bytes.len() > MAX_PAYLOAD_BYTES {
432                    return Err(ApiFailure::new(
433                        StatusCode::PAYLOAD_TOO_LARGE,
434                        "feedback_payload_too_large",
435                        "Feedback payload is too large",
436                        format!(
437                            "The JSON payload is {} bytes; limit is {MAX_PAYLOAD_BYTES} bytes.",
438                            bytes.len()
439                        ),
440                        request_id,
441                    ));
442                }
443                payload = Some(
444                    serde_json::from_slice::<CreateFeedbackInput>(&bytes)
445                        .map_err(|error| ApiFailure::validation(error.to_string(), request_id))?,
446                );
447            }
448            "screenshot" | "audio" | "file" => {
449                if attachments.len() >= config.max_attachments {
450                    return Err(ApiFailure::validation(
451                        format!(
452                            "no more than {} attachments are allowed",
453                            config.max_attachments
454                        ),
455                        request_id,
456                    ));
457                }
458                let kind = match name.as_str() {
459                    "screenshot" => FeedbackAttachmentKind::Screenshot,
460                    "audio" => FeedbackAttachmentKind::Audio,
461                    _ => FeedbackAttachmentKind::File,
462                };
463                let file_name = field.file_name().unwrap_or("attachment.bin").to_owned();
464                let content_type = field
465                    .content_type()
466                    .unwrap_or("application/octet-stream")
467                    .to_owned();
468                let bytes = field
469                    .bytes()
470                    .await
471                    .map_err(|error| multipart_failure(&error.to_string(), request_id))?;
472                let maximum = match kind {
473                    FeedbackAttachmentKind::Screenshot => config.max_screenshot_bytes,
474                    FeedbackAttachmentKind::Audio => config.max_audio_bytes,
475                    FeedbackAttachmentKind::File => config.max_file_bytes,
476                };
477                if bytes.len() > maximum {
478                    return Err(ApiFailure::new(
479                        StatusCode::PAYLOAD_TOO_LARGE,
480                        "feedback_attachment_too_large",
481                        "Feedback attachment is too large",
482                        format!(
483                            "Attachment is {} bytes; configured limit is {maximum} bytes.",
484                            bytes.len()
485                        ),
486                        request_id,
487                    ));
488                }
489                attachments.push(AttachmentUpload {
490                    kind,
491                    file_name,
492                    content_type,
493                    bytes: bytes.to_vec(),
494                });
495            }
496            _ => {
497                return Err(ApiFailure::validation(
498                    format!("unsupported feedback multipart field {name:?}"),
499                    request_id,
500                ));
501            }
502        }
503    }
504    let payload = payload.ok_or_else(|| {
505        ApiFailure::validation("multipart field `payload` is required", request_id)
506    })?;
507    Ok((payload, attachments))
508}
509
510fn bounded_optional_text(
511    value: Option<String>,
512    field: &str,
513    maximum: usize,
514    request_id: &str,
515) -> Result<Option<String>, ApiFailure> {
516    let Some(value) = value else {
517        return Ok(None);
518    };
519    let value = value.trim().to_owned();
520    if value.is_empty() {
521        return Ok(None);
522    }
523    if value.chars().count() > maximum || value.chars().any(char::is_control) {
524        return Err(ApiFailure::validation(
525            format!("{field} must not exceed {maximum} visible characters"),
526            request_id,
527        ));
528    }
529    Ok(Some(value))
530}
531
532fn authorize_submission(
533    headers: &HeaderMap,
534    config: &FeedbackConfig,
535    principal: Option<&Principal>,
536    request_id: &str,
537) -> Result<(), ApiFailure> {
538    if let Some(principal) = principal {
539        if principal.has_permission("feedback.create") {
540            return Ok(());
541        }
542        return Err(ApiFailure::new(
543            StatusCode::FORBIDDEN,
544            "feedback_submission_forbidden",
545            "Feedback submission is forbidden",
546            "The authenticated principal does not have feedback.create permission.",
547            request_id,
548        ));
549    }
550    if let Some(expected) = config.project_key.as_deref() {
551        let supplied = headers
552            .get(PROJECT_KEY_HEADER)
553            .and_then(|value| value.to_str().ok())
554            .unwrap_or_default();
555        if constant_time_equals(expected, supplied) {
556            return Ok(());
557        }
558        return Err(ApiFailure::new(
559            StatusCode::UNAUTHORIZED,
560            "feedback_project_key_invalid",
561            "Feedback submission is not authorized",
562            "The feedback project key is missing or invalid.",
563            request_id,
564        ));
565    }
566    if config.allow_anonymous {
567        Ok(())
568    } else {
569        Err(ApiFailure::new(
570            StatusCode::UNAUTHORIZED,
571            "feedback_authentication_required",
572            "Feedback authentication is required",
573            "Sign in before submitting feedback.",
574            request_id,
575        ))
576    }
577}
578
579fn authorize_transcription(
580    headers: &HeaderMap,
581    config: &FeedbackConfig,
582    principal: Option<&Principal>,
583    request_id: &str,
584) -> Result<(), ApiFailure> {
585    authorize_submission(headers, config, principal, request_id)?;
586    if principal.is_some() {
587        return Ok(());
588    }
589    Err(ApiFailure::new(
590        StatusCode::FORBIDDEN,
591        "feedback_transcription_authentication_required",
592        "Voice transcription requires authentication",
593        "Sign in with feedback.create permission before requesting voice transcription.",
594        request_id,
595    ))
596}
597
598fn bind_client_subject(input: &mut CreateFeedbackInput, principal: Option<&Principal>) {
599    input.context.client_subject = principal.map(|principal| principal.subject.clone());
600}
601
602fn authorize_developer(
603    headers: &HeaderMap,
604    config: &FeedbackConfig,
605    principal: Option<&Principal>,
606    request_id: &str,
607) -> Result<String, ApiFailure> {
608    if let Some(principal) = principal {
609        if principal.has_permission("feedback.manage") {
610            return Ok(principal.subject.clone());
611        }
612        return Err(ApiFailure::new(
613            StatusCode::FORBIDDEN,
614            "feedback_management_forbidden",
615            "Feedback management is forbidden",
616            "The authenticated principal does not have feedback.manage permission.",
617            request_id,
618        ));
619    }
620    let expected = config.developer_token.as_deref().ok_or_else(|| {
621        ApiFailure::new(
622            StatusCode::SERVICE_UNAVAILABLE,
623            "feedback_developer_access_unconfigured",
624            "Developer access is not configured",
625            "Configure identity middleware or a fallback developer token before exposing management routes.",
626            request_id,
627        )
628    })?;
629    let supplied = headers
630        .get(header::AUTHORIZATION)
631        .and_then(|value| value.to_str().ok())
632        .and_then(|value| value.strip_prefix("Bearer "))
633        .unwrap_or_default();
634    if constant_time_equals(expected, supplied) {
635        Ok("feedback-developer-token".into())
636    } else {
637        Err(ApiFailure::new(
638            StatusCode::UNAUTHORIZED,
639            "feedback_developer_token_invalid",
640            "Developer authentication failed",
641            "The feedback developer token is missing or invalid.",
642            request_id,
643        ))
644    }
645}
646
647fn client_token(headers: &HeaderMap, request_id: &str) -> Result<FeedbackAccessToken, ApiFailure> {
648    let value = headers
649        .get(CLIENT_TOKEN_HEADER)
650        .and_then(|value| value.to_str().ok())
651        .unwrap_or_default();
652    FeedbackAccessToken::parse(value).map_err(|_| {
653        ApiFailure::new(
654            StatusCode::UNAUTHORIZED,
655            "feedback_client_token_invalid",
656            "Feedback access failed",
657            "The client feedback token is missing or invalid.",
658            request_id,
659        )
660    })
661}
662
663fn parse_feedback_id(value: &str, request_id: &str) -> Result<FeedbackId, ApiFailure> {
664    FeedbackId::from_str(value)
665        .map_err(|_| ApiFailure::validation("feedback ID must be a UUID", request_id))
666}
667
668fn parse_uuid(value: &str, field: &str, request_id: &str) -> Result<Uuid, ApiFailure> {
669    Uuid::parse_str(value)
670        .map_err(|_| ApiFailure::validation(format!("{field} must be a UUID"), request_id))
671}
672
673fn request_id(headers: &HeaderMap) -> String {
674    headers
675        .get("x-request-id")
676        .and_then(|value| value.to_str().ok())
677        .filter(|value| !value.trim().is_empty())
678        .map_or_else(|| Uuid::now_v7().to_string(), str::to_owned)
679}
680
681fn constant_time_equals(expected: &str, supplied: &str) -> bool {
682    let expected = Sha256::digest(expected.as_bytes());
683    let supplied = Sha256::digest(supplied.as_bytes());
684    expected.ct_eq(&supplied).into()
685}
686
687fn multipart_failure(detail: &str, request_id: &str) -> ApiFailure {
688    ApiFailure::validation(
689        format!("invalid multipart feedback request: {detail}"),
690        request_id,
691    )
692}
693
694fn map_error(error: FeedbackServiceError, request_id: &str) -> ApiFailure {
695    match error {
696        FeedbackServiceError::Validation(error) => {
697            ApiFailure::validation(error.to_string(), request_id)
698        }
699        FeedbackServiceError::NotFound(_) | FeedbackServiceError::ClientAccessDenied => {
700            ApiFailure::new(
701                StatusCode::NOT_FOUND,
702                "feedback_not_found",
703                "Feedback was not found",
704                "The feedback thread does not exist or is not accessible.",
705                request_id,
706            )
707        }
708        FeedbackServiceError::AttachmentNotFound(_) => ApiFailure::new(
709            StatusCode::NOT_FOUND,
710            "feedback_attachment_not_found",
711            "Feedback attachment was not found",
712            "The requested attachment does not exist or is not accessible.",
713            request_id,
714        ),
715        value @ FeedbackServiceError::AttachmentTooLarge { .. } => ApiFailure::new(
716            StatusCode::PAYLOAD_TOO_LARGE,
717            "feedback_attachment_too_large",
718            "Feedback attachment is too large",
719            value.to_string(),
720            request_id,
721        ),
722        FeedbackServiceError::InvalidAttachment(detail) => {
723            ApiFailure::validation(detail, request_id)
724        }
725        FeedbackServiceError::Transcription(crate::TranscriptionError::NotConfigured) => {
726            ApiFailure::new(
727                StatusCode::NOT_IMPLEMENTED,
728                "feedback_transcription_unavailable",
729                "Voice transcription is unavailable",
730                "This deployment has not enabled a transcription provider.",
731                request_id,
732            )
733        }
734        FeedbackServiceError::Transcription(_) => ApiFailure::new(
735            StatusCode::BAD_GATEWAY,
736            "feedback_transcription_failed",
737            "Voice transcription failed",
738            "The transcription provider did not complete the request. Retry shortly.",
739            request_id,
740        ),
741        FeedbackServiceError::Store(crate::FeedbackStoreError::ConcurrentModification {
742            ..
743        }) => ApiFailure::new(
744            StatusCode::CONFLICT,
745            "feedback_concurrent_update",
746            "Feedback changed concurrently",
747            "Refresh the feedback thread and retry the operation.",
748            request_id,
749        ),
750        FeedbackServiceError::Store(crate::FeedbackStoreError::NotFound(_)) => ApiFailure::new(
751            StatusCode::NOT_FOUND,
752            "feedback_not_found",
753            "Feedback was not found",
754            "The feedback thread no longer exists.",
755            request_id,
756        ),
757        FeedbackServiceError::Configuration(_) => ApiFailure::internal(request_id),
758        FeedbackServiceError::Store(_) | FeedbackServiceError::ObjectStore(_) => ApiFailure::new(
759            StatusCode::SERVICE_UNAVAILABLE,
760            "feedback_infrastructure_unavailable",
761            "Feedback service is temporarily unavailable",
762            "The feedback could not be stored or retrieved. Retry shortly.",
763            request_id,
764        ),
765    }
766}
767
768fn object_response(object: minco_plugin_object_storage::StoredObject) -> Response {
769    let file_name = object
770        .metadata
771        .attributes
772        .get("file_name")
773        .map_or("attachment.bin", String::as_str)
774        .chars()
775        .map(|character| {
776            if matches!(character, '\r' | '\n' | '"') {
777                '-'
778            } else {
779                character
780            }
781        })
782        .collect::<String>();
783    let mut response = Response::new(Body::from(object.bytes));
784    *response.status_mut() = StatusCode::OK;
785    if let Ok(value) = HeaderValue::from_str(&object.metadata.content_type) {
786        response.headers_mut().insert(header::CONTENT_TYPE, value);
787    }
788    if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{file_name}\"")) {
789        response
790            .headers_mut()
791            .insert(header::CONTENT_DISPOSITION, value);
792    }
793    if let Ok(value) = HeaderValue::from_str(&format!("\"{}\"", object.metadata.sha256)) {
794        response.headers_mut().insert(header::ETAG, value);
795    }
796    response.headers_mut().insert(
797        header::CACHE_CONTROL,
798        HeaderValue::from_static("private, no-store"),
799    );
800    response.headers_mut().insert(
801        header::X_CONTENT_TYPE_OPTIONS,
802        HeaderValue::from_static("nosniff"),
803    );
804    response
805}
806
807/// Largest multipart body accepted by the Feedback HTTP module.
808///
809/// The limit is explicit rather than derived from every attachment limit. This keeps the
810/// default Lambda/API-Gateway request path bounded even when direct-object upload profiles allow
811/// larger individual objects.
812#[must_use]
813pub const fn feedback_request_body_budget(config: &FeedbackConfig) -> usize {
814    config.max_http_body_bytes
815}
816
817#[derive(Debug, Clone, Serialize)]
818pub struct ClientFeedbackAttachment {
819    pub id: Uuid,
820    pub kind: FeedbackAttachmentKind,
821    pub file_name: String,
822    pub content_type: String,
823    pub size_bytes: u64,
824    pub created_at: chrono::DateTime<chrono::Utc>,
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub transcript: Option<String>,
827}
828
829impl From<&FeedbackAttachment> for ClientFeedbackAttachment {
830    fn from(value: &FeedbackAttachment) -> Self {
831        Self {
832            id: value.id,
833            kind: value.kind,
834            file_name: value.file_name.clone(),
835            content_type: value.content_type.clone(),
836            size_bytes: value.size_bytes,
837            created_at: value.created_at,
838            transcript: value.transcript.clone(),
839        }
840    }
841}
842
843#[derive(Debug, Clone, Serialize)]
844pub struct ClientFeedbackThread {
845    pub id: FeedbackId,
846    pub kind: crate::FeedbackKind,
847    pub priority: crate::FeedbackPriority,
848    pub status: FeedbackStatus,
849    pub title: String,
850    pub description: String,
851    pub context: crate::FeedbackContext,
852    pub messages: Vec<crate::FeedbackMessage>,
853    pub attachments: Vec<ClientFeedbackAttachment>,
854    pub created_at: chrono::DateTime<chrono::Utc>,
855    pub updated_at: chrono::DateTime<chrono::Utc>,
856    pub revision: u64,
857}
858
859impl From<&FeedbackThread> for ClientFeedbackThread {
860    fn from(value: &FeedbackThread) -> Self {
861        let value = value.client_view();
862        Self {
863            id: value.id,
864            kind: value.kind,
865            priority: value.priority,
866            status: value.status,
867            title: value.title,
868            description: value.description,
869            context: value.context,
870            messages: value.messages,
871            attachments: value
872                .attachments
873                .iter()
874                .map(ClientFeedbackAttachment::from)
875                .collect(),
876            created_at: value.created_at,
877            updated_at: value.updated_at,
878            revision: value.revision,
879        }
880    }
881}
882
883#[derive(Debug, Clone, Serialize)]
884pub struct ClientCreateResponse {
885    pub thread: ClientFeedbackThread,
886    pub client_token: String,
887    #[serde(default, skip_serializing_if = "Vec::is_empty")]
888    pub warnings: Vec<FeedbackWarning>,
889}
890
891#[derive(Debug, Clone, Serialize)]
892pub struct ClientMutationResponse {
893    pub thread: ClientFeedbackThread,
894    #[serde(default, skip_serializing_if = "Vec::is_empty")]
895    pub warnings: Vec<FeedbackWarning>,
896}
897
898impl From<FeedbackMutationResult> for ClientMutationResponse {
899    fn from(value: FeedbackMutationResult) -> Self {
900        Self {
901            thread: ClientFeedbackThread::from(&value.thread),
902            warnings: value.warnings,
903        }
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::{FeedbackKind, FeedbackPriority, FeedbackStoreService, MemoryFeedbackStore};
911    use axum::body::Body;
912    use minco_plugin_audit::{AuditService, MemoryAuditSink};
913    use minco_plugin_events::{EventServices, MemoryEventBus};
914    use minco_plugin_notifications::{MemoryNotificationSink, NotificationService};
915    use minco_plugin_object_storage::{MemoryObjectStore, ObjectStoreService};
916    use std::collections::{BTreeMap, BTreeSet};
917    use tower::ServiceExt;
918
919    fn service_with_config(config: FeedbackConfig) -> FeedbackService {
920        let events = Arc::new(MemoryEventBus::default());
921        FeedbackService::new(
922            FeedbackStoreService::new(Arc::new(MemoryFeedbackStore::default())),
923            ObjectStoreService::new(Arc::new(MemoryObjectStore::default())),
924            NotificationService::new(Arc::new(MemoryNotificationSink::default())),
925            AuditService::new(Arc::new(MemoryAuditSink::default())),
926            EventServices {
927                publisher: events.clone(),
928                outbox: events,
929            },
930            None,
931            config,
932        )
933        .unwrap()
934    }
935
936    fn service() -> FeedbackService {
937        service_with_config(FeedbackConfig {
938            project_id: "example".into(),
939            developer_token: Some("developer-token-with-enough-entropy".into()),
940            ..FeedbackConfig::default()
941        })
942    }
943
944    #[tokio::test]
945    async fn widget_asset_and_configuration_are_served_without_a_frontend_framework() {
946        let app = feedback_router(service());
947        for path in [
948            "/_minco/feedback/widget.js",
949            "/_minco/feedback/widget-config",
950        ] {
951            let response = app
952                .clone()
953                .oneshot(http::Request::get(path).body(Body::empty()).unwrap())
954                .await
955                .unwrap();
956            assert_eq!(response.status(), StatusCode::OK, "{path}");
957        }
958    }
959
960    #[tokio::test]
961    async fn zero_attachment_profile_rejects_multipart_files() {
962        let app = feedback_router(service_with_config(FeedbackConfig {
963            project_id: "text-only".into(),
964            max_attachments: 0,
965            screenshot_enabled: false,
966            voice_enabled: false,
967            allow_anonymous: true,
968            ..FeedbackConfig::default()
969        }));
970        let boundary = "MINCO_ZERO_ATTACHMENT_BOUNDARY";
971        let body = format!(
972            "--{boundary}\r\n\
973             Content-Disposition: form-data; name=\"payload\"\r\n\
974             Content-Type: application/json\r\n\r\n\
975             {{\"project_id\":\"text-only\",\"kind\":\"bug\",\"priority\":\"normal\",\
976             \"title\":\"Example\",\"description\":\"Example description\",\
977             \"context\":{{\"page_url\":\"https://example.test\"}},\"tags\":[]}}\r\n\
978             --{boundary}\r\n\
979             Content-Disposition: form-data; name=\"file\"; filename=\"private.txt\"\r\n\
980             Content-Type: text/plain\r\n\r\n\
981             must-not-persist\r\n\
982             --{boundary}--\r\n"
983        );
984
985        let response = app
986            .oneshot(
987                http::Request::post("/_minco/feedback/threads")
988                    .header(
989                        header::CONTENT_TYPE,
990                        format!("multipart/form-data; boundary={boundary}"),
991                    )
992                    .body(Body::from(body))
993                    .unwrap(),
994            )
995            .await
996            .unwrap();
997
998        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
999    }
1000
1001    #[tokio::test]
1002    async fn a_principal_with_management_permission_can_use_the_inbox_without_a_token() {
1003        let app = feedback_router(service()).layer(Extension(Principal {
1004            subject: "developer-1".into(),
1005            permissions: BTreeSet::from(["feedback.manage".into()]),
1006            claims: BTreeMap::new(),
1007        }));
1008        let response = app
1009            .oneshot(
1010                http::Request::get("/_minco/feedback/developer/threads")
1011                    .body(Body::empty())
1012                    .unwrap(),
1013            )
1014            .await
1015            .unwrap();
1016        assert_eq!(response.status(), StatusCode::OK);
1017    }
1018
1019    #[tokio::test]
1020    async fn developer_routes_fail_closed_without_identity_or_a_bearer_token() {
1021        let response = feedback_router(service())
1022            .oneshot(
1023                http::Request::get("/_minco/feedback/developer/threads")
1024                    .body(Body::empty())
1025                    .unwrap(),
1026            )
1027            .await
1028            .unwrap();
1029        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
1030    }
1031
1032    #[test]
1033    fn feedback_submission_is_not_anonymous_by_default() {
1034        let failure = authorize_submission(
1035            &HeaderMap::new(),
1036            &FeedbackConfig::default(),
1037            None,
1038            "request-1",
1039        )
1040        .expect_err("anonymous feedback must require explicit configuration");
1041
1042        assert_eq!(failure.status, StatusCode::UNAUTHORIZED);
1043        assert_eq!(failure.code.as_ref(), "feedback_authentication_required");
1044    }
1045
1046    #[test]
1047    fn anonymous_submission_cannot_choose_an_audit_or_notification_subject() {
1048        let mut input = CreateFeedbackInput {
1049            project_id: "example".into(),
1050            kind: FeedbackKind::Bug,
1051            priority: FeedbackPriority::Normal,
1052            title: "Example".into(),
1053            description: "Example problem".into(),
1054            context: crate::FeedbackContext {
1055                page_url: "https://example.test".into(),
1056                route_name: None,
1057                release_id: None,
1058                environment: None,
1059                request_id: None,
1060                user_agent: None,
1061                viewport: None,
1062                client_subject: Some("spoofed-subject".into()),
1063            },
1064            tags: BTreeSet::new(),
1065        };
1066
1067        bind_client_subject(&mut input, None);
1068
1069        assert!(input.context.client_subject.is_none());
1070    }
1071
1072    #[test]
1073    fn transcription_requires_an_authenticated_principal() {
1074        let config = FeedbackConfig {
1075            allow_anonymous: true,
1076            ..FeedbackConfig::default()
1077        };
1078        let failure = authorize_transcription(&HeaderMap::new(), &config, None, "request-1")
1079            .expect_err("anonymous transcription must fail closed");
1080
1081        assert_eq!(failure.status, StatusCode::FORBIDDEN);
1082        assert_eq!(
1083            failure.code.as_ref(),
1084            "feedback_transcription_authentication_required"
1085        );
1086    }
1087
1088    #[test]
1089    fn client_projection_excludes_internal_notes_and_object_keys() {
1090        let mut thread = FeedbackThread::create(CreateFeedbackInput {
1091            project_id: "example".into(),
1092            kind: FeedbackKind::Bug,
1093            priority: FeedbackPriority::Normal,
1094            title: "Example".into(),
1095            description: "Example problem".into(),
1096            context: crate::FeedbackContext {
1097                page_url: "https://example.test".into(),
1098                route_name: None,
1099                release_id: None,
1100                environment: None,
1101                request_id: None,
1102                user_agent: None,
1103                viewport: None,
1104                client_subject: None,
1105            },
1106            tags: BTreeSet::new(),
1107        })
1108        .unwrap();
1109        thread.append_message(crate::FeedbackMessage::developer(None, "internal", false).unwrap());
1110        let projected = ClientFeedbackThread::from(&thread);
1111        assert!(projected.messages.is_empty());
1112    }
1113    #[test]
1114    fn request_budget_uses_the_explicit_serverless_http_limit() {
1115        let config = FeedbackConfig {
1116            max_http_body_bytes: 6 * 1024 * 1024,
1117            ..FeedbackConfig::default()
1118        };
1119        assert_eq!(feedback_request_body_budget(&config), 6 * 1024 * 1024);
1120    }
1121
1122    #[test]
1123    fn provider_error_details_are_not_exposed_to_clients() {
1124        let failure = map_error(
1125            FeedbackServiceError::Transcription(crate::TranscriptionError::Provider(
1126                "provider response containing sensitive diagnostics".into(),
1127            )),
1128            "request-1",
1129        );
1130        assert_eq!(failure.code.as_ref(), "feedback_transcription_failed");
1131        assert!(!failure.detail.contains("sensitive diagnostics"));
1132    }
1133
1134    #[test]
1135    fn configured_tokens_are_compared_by_digest() {
1136        assert!(constant_time_equals("configured-token", "configured-token"));
1137        assert!(!constant_time_equals(
1138            "configured-token",
1139            "configured-token-with-a-different-length"
1140        ));
1141    }
1142
1143    #[test]
1144    fn attachment_downloads_are_private_and_not_content_sniffed() {
1145        let response = object_response(minco_plugin_object_storage::StoredObject {
1146            key: minco_plugin_object_storage::ObjectKey::parse("feedback/example/attachment.png")
1147                .unwrap(),
1148            bytes: vec![1, 2, 3],
1149            metadata: minco_plugin_object_storage::ObjectMetadata {
1150                content_type: "image/png".into(),
1151                size_bytes: 3,
1152                sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
1153                created_at: chrono::Utc::now(),
1154                attributes: BTreeMap::from([("file_name".into(), "attachment.png".into())]),
1155            },
1156        });
1157        assert_eq!(
1158            response.headers()[header::CACHE_CONTROL],
1159            "private, no-store"
1160        );
1161        assert_eq!(
1162            response.headers()[header::X_CONTENT_TYPE_OPTIONS],
1163            "nosniff"
1164        );
1165        assert_eq!(
1166            response.headers()[header::CONTENT_DISPOSITION],
1167            "attachment; filename=\"attachment.png\""
1168        );
1169    }
1170}