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#[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}