Every error response in the { error, code, details? } format (step 2)
Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.
- 500s: the body was the error's text (anyhow / diesel / {:?}); now
ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
statuses are unchanged. Shared helpers per module (faces_unavailable,
person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.
Closes #202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1836f1b9ca
commit
431c3a7492
23 files changed
+894
-542
No files matched your search
+21
-16
@@ -2,6 +2,7 @@
|
||||
//! and shared across libraries — a favorite created in lib1 is visible
|
||||
//! under lib2 if the same rel_path resolves there too.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::sync::Mutex;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
@@ -53,10 +54,9 @@ pub async fn favorites(
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
span.set_status(Status::error(format!("Error getting favorites: {:?}", e)));
|
||||
error!("Error getting favorites: {:?}", e);
|
||||
HttpResponse::InternalServerError().finish()
|
||||
ApiError::internal(format!("Error getting favorites: {:?}", e)).into_response()
|
||||
}
|
||||
Err(_) => HttpResponse::InternalServerError().finish(),
|
||||
Err(e) => ApiError::internal(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,24 +78,24 @@ pub async fn put_add_favorite(
|
||||
{
|
||||
Ok(Err(e)) if e.kind == DbErrorKind::AlreadyExists => {
|
||||
warn!("Favorite: {} exists for user: {}", &body.path, user_id);
|
||||
HttpResponse::Ok()
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
error!("{:?} {}. for user: {}", e, body.path, user_id);
|
||||
HttpResponse::BadRequest()
|
||||
HttpResponse::Ok().finish()
|
||||
}
|
||||
Ok(Err(e)) => ApiError::internal(format!(
|
||||
"{:?} adding favorite {}. for user: {}",
|
||||
e, body.path, user_id
|
||||
))
|
||||
.into_response(),
|
||||
Ok(Ok(_)) => {
|
||||
info!("Adding favorite \"{}\" for userid: {}", body.path, user_id);
|
||||
HttpResponse::Created()
|
||||
HttpResponse::Created().finish()
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Blocking error while inserting favorite: {:?}", e);
|
||||
HttpResponse::InternalServerError()
|
||||
ApiError::internal(format!("Blocking error while inserting favorite: {:?}", e))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
||||
HttpResponse::BadRequest()
|
||||
bad_user(&claims).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,9 +120,14 @@ pub async fn delete_favorite(
|
||||
"Removing favorite \"{}\" for userid: {}",
|
||||
body.path, user_id
|
||||
);
|
||||
HttpResponse::Ok()
|
||||
HttpResponse::Ok().finish()
|
||||
} else {
|
||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
||||
HttpResponse::BadRequest()
|
||||
bad_user(&claims).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// A token whose subject isn't a user id (favorites are per user).
|
||||
fn bad_user(claims: &Claims) -> ApiError {
|
||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
||||
ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||
}
|
||||
+66
-29
@@ -2,6 +2,7 @@
|
||||
//! legacy thumbnail lookup), upload, EXIF metadata read + GPS / date
|
||||
//! mutation, and the full exiftool dump used by Apollo's details modal.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::error::Error;
|
||||
use std::fs::File;
|
||||
use std::io::ErrorKind;
|
||||
@@ -56,7 +57,7 @@ pub async fn get_image(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,7 +232,11 @@ pub async fn get_image(
|
||||
return resp;
|
||||
}
|
||||
span.set_status(Status::error("Thumbnail unavailable"));
|
||||
return HttpResponse::NotFound().finish();
|
||||
return ApiError::not_found(
|
||||
"thumbnail_unavailable",
|
||||
"No thumbnail for this file yet",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,11 +269,11 @@ pub async fn get_image(
|
||||
}
|
||||
|
||||
span.set_status(Status::error("Not found"));
|
||||
HttpResponse::NotFound().finish()
|
||||
file_not_found().into_response()
|
||||
} else {
|
||||
span.set_status(Status::error("Not found"));
|
||||
error!("Path does not exist in any library: {}", req.path);
|
||||
HttpResponse::NotFound().finish()
|
||||
file_not_found().into_response()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +562,7 @@ pub async fn get_file_metadata(
|
||||
error!("{}", message);
|
||||
span.set_status(Status::error(message));
|
||||
|
||||
HttpResponse::InternalServerError().finish()
|
||||
ApiError::internal("internal error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -609,19 +614,23 @@ pub async fn set_image_gps(
|
||||
Some(v) => v,
|
||||
None => {
|
||||
span.set_status(Status::error("file not found"));
|
||||
return HttpResponse::NotFound().body("File not found");
|
||||
return file_not_found().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if !exif::supports_exif(&full_path) {
|
||||
return HttpResponse::BadRequest().body("File format does not support EXIF GPS write");
|
||||
return ApiError::bad_request(
|
||||
"gps_write_unsupported",
|
||||
"Location can't be saved into this file type",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) {
|
||||
let msg = format!("exiftool write failed: {}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return HttpResponse::InternalServerError().body(msg);
|
||||
return ApiError::internal(msg).into_response();
|
||||
}
|
||||
|
||||
// Re-read EXIF from disk (the write path doesn't tell us the rest of
|
||||
@@ -637,7 +646,7 @@ pub async fn set_image_gps(
|
||||
// next file scan rewrites it.
|
||||
let msg = format!("EXIF re-read failed after write: {}", e);
|
||||
error!("{}", msg);
|
||||
return HttpResponse::InternalServerError().body(msg);
|
||||
return ApiError::internal(msg).into_response();
|
||||
}
|
||||
};
|
||||
let now = Utc::now().timestamp();
|
||||
@@ -742,7 +751,7 @@ pub async fn set_image_gps(
|
||||
let msg = format!("EXIF DB update failed: {:?}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
HttpResponse::InternalServerError().body(msg)
|
||||
ApiError::internal(msg).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,7 +799,7 @@ pub async fn get_full_exif(
|
||||
Some(v) => v,
|
||||
None => {
|
||||
span.set_status(Status::error("file not found"));
|
||||
return HttpResponse::NotFound().body("File not found");
|
||||
return file_not_found().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -825,13 +834,17 @@ pub async fn get_full_exif(
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
// 503 — typically "exiftool isn't on PATH" or a transient spawn
|
||||
// failure. Apollo surfaces a hint in the modal.
|
||||
HttpResponse::ServiceUnavailable().body(msg)
|
||||
ApiError::unavailable(
|
||||
"exiftool_unavailable",
|
||||
"Couldn't write to the file: exiftool isn't available on the server",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("blocking-pool error: {}", e);
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
HttpResponse::InternalServerError().body(msg)
|
||||
ApiError::internal(msg).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -965,7 +978,7 @@ pub async fn set_image_date(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1008,8 +1021,8 @@ pub async fn set_image_date(
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
match e.kind {
|
||||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
||||
_ => HttpResponse::InternalServerError().body(msg),
|
||||
DbErrorKind::NotFound => not_indexed().into_response(),
|
||||
_ => ApiError::internal(msg).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1031,7 +1044,7 @@ pub async fn clear_image_date(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1058,8 +1071,8 @@ pub async fn clear_image_date(
|
||||
error!("{}", msg);
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
match e.kind {
|
||||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
||||
_ => HttpResponse::InternalServerError().body(msg),
|
||||
DbErrorKind::NotFound => not_indexed().into_response(),
|
||||
_ => ApiError::internal(msg).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1089,7 +1102,7 @@ pub async fn upload_image(
|
||||
Ok(None) => app_state.primary_library(),
|
||||
Err(msg) => {
|
||||
span.set_status(Status::error(msg.clone()));
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
return libraries::unknown_library(msg).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1126,7 +1139,8 @@ pub async fn upload_image(
|
||||
if !file_content.is_empty() {
|
||||
if file_name.is_none() {
|
||||
span.set_status(Status::error("No filename provided"));
|
||||
return HttpResponse::BadRequest().body("No filename provided");
|
||||
return ApiError::bad_request("no_file_name", "The upload has no file name")
|
||||
.into_response();
|
||||
}
|
||||
let full_path = PathBuf::from(&path).join(file_name.unwrap());
|
||||
if let Some(full_path) = is_valid_full_path(
|
||||
@@ -1154,14 +1168,22 @@ pub async fn upload_image(
|
||||
.find(|l| l.id == existing.library_id)
|
||||
.map(|l| l.name);
|
||||
span.set_status(Status::Ok);
|
||||
return HttpResponse::Conflict().json(serde_json::json!({
|
||||
"duplicate_of": {
|
||||
// The extra fields predate the error format; clients
|
||||
// read them at the top level.
|
||||
return ApiError::conflict(
|
||||
"duplicate_upload",
|
||||
"This photo is already in your library",
|
||||
)
|
||||
.with_legacy_field(
|
||||
"duplicate_of",
|
||||
serde_json::json!({
|
||||
"library_id": existing.library_id,
|
||||
"rel_path": existing.file_path,
|
||||
},
|
||||
"content_hash": upload_hash,
|
||||
"library_name": library_name,
|
||||
}));
|
||||
}),
|
||||
)
|
||||
.with_legacy_field("content_hash", &upload_hash)
|
||||
.with_legacy_field("library_name", &library_name)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,11 +1296,13 @@ pub async fn upload_image(
|
||||
} else {
|
||||
error!("Invalid path for upload: {:?}", full_path);
|
||||
span.set_status(Status::error("Invalid path for upload"));
|
||||
return HttpResponse::BadRequest().body("Path was not valid");
|
||||
return ApiError::bad_request("invalid_path", "That folder isn't in the library")
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
span.set_status(Status::error("No file body read"));
|
||||
return HttpResponse::BadRequest().body("No file body read");
|
||||
return ApiError::bad_request("empty_upload", "The upload has no file in it")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
app_state.stream_manager.do_send(RefreshThumbnailsMessage);
|
||||
@@ -1318,6 +1342,19 @@ fn sniff_image_mime(head: &[u8]) -> Option<actix_web::mime::Mime> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The path names no file in any library.
|
||||
fn file_not_found() -> ApiError {
|
||||
ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||
}
|
||||
|
||||
/// The file exists but has no indexed row yet.
|
||||
fn not_indexed() -> ApiError {
|
||||
ApiError::not_found(
|
||||
"photo_not_indexed",
|
||||
"This photo is still being indexed. Try again in a minute.",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+42
-21
@@ -1,6 +1,7 @@
|
||||
//! Video-related endpoints: HLS playlist generation, segment streaming,
|
||||
//! and the short-clip preview pipeline.
|
||||
|
||||
use crate::error::ApiError;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
@@ -75,7 +76,7 @@ pub async fn generate_video(
|
||||
let resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref());
|
||||
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||
return HttpResponse::BadRequest().finish();
|
||||
return invalid_path().into_response();
|
||||
};
|
||||
|
||||
// Build the rel_path used to look up the row. Forward-slash normalized
|
||||
@@ -112,7 +113,7 @@ pub async fn generate_video(
|
||||
e
|
||||
);
|
||||
span.set_status(Status::error(format!("hash compute failed: {}", e)));
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
return ApiError::internal("internal error").into_response();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -198,11 +199,13 @@ pub async fn stream_hls_file(
|
||||
let (hash, file) = path.into_inner();
|
||||
if !is_valid_hash(&hash) {
|
||||
span.set_status(Status::error("invalid hash"));
|
||||
return HttpResponse::BadRequest().body("invalid hash");
|
||||
return ApiError::bad_request("invalid_path", "Invalid video stream address")
|
||||
.into_response();
|
||||
}
|
||||
if !is_allowed_hls_filename(&file) {
|
||||
span.set_status(Status::error("invalid file"));
|
||||
return HttpResponse::BadRequest().body("invalid file");
|
||||
return ApiError::bad_request("invalid_path", "Invalid video stream address")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let shard = &hash[..2];
|
||||
@@ -220,7 +223,7 @@ pub async fn stream_hls_file(
|
||||
Err(e) => {
|
||||
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
|
||||
span.set_status(Status::error("VIDEO_PATH not canonicalisable"));
|
||||
return HttpResponse::InternalServerError().finish();
|
||||
return ApiError::internal("internal error").into_response();
|
||||
}
|
||||
};
|
||||
let canonical_file = match std::fs::canonicalize(&file_path) {
|
||||
@@ -228,7 +231,7 @@ pub async fn stream_hls_file(
|
||||
Err(_) => {
|
||||
debug!("HLS file not found: {}", file_path.display());
|
||||
span.set_status(Status::error("not found"));
|
||||
return HttpResponse::NotFound().finish();
|
||||
return stream_not_found().into_response();
|
||||
}
|
||||
};
|
||||
if !canonical_file.starts_with(&canonical_base) {
|
||||
@@ -237,7 +240,7 @@ pub async fn stream_hls_file(
|
||||
file_path.display()
|
||||
);
|
||||
span.set_status(Status::error("traversal"));
|
||||
return HttpResponse::Forbidden().finish();
|
||||
return ApiError::forbidden("invalid_path", "Invalid video stream address").into_response();
|
||||
}
|
||||
|
||||
match NamedFile::open(&canonical_file) {
|
||||
@@ -247,7 +250,7 @@ pub async fn stream_hls_file(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("not found"));
|
||||
HttpResponse::NotFound().finish()
|
||||
stream_not_found().into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,11 +297,11 @@ pub async fn original_video_url(
|
||||
resolve_request_path(&app_state, &body.path, body.library.as_deref())
|
||||
else {
|
||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||
return HttpResponse::BadRequest().finish();
|
||||
return invalid_path().into_response();
|
||||
};
|
||||
if !file_types::is_video_file(&full_path) {
|
||||
span.set_status(Status::error("not a video"));
|
||||
return HttpResponse::BadRequest().body("not a video");
|
||||
return ApiError::bad_request("not_a_video", "That file isn't a video").into_response();
|
||||
}
|
||||
|
||||
let rel_path = rel_path_for_lookup(&full_path.to_string_lossy(), &root);
|
||||
@@ -372,7 +375,11 @@ pub async fn stream_original_video(
|
||||
.and_then(|t| original_url::verify(&t.0));
|
||||
let Some(claims) = claims else {
|
||||
span.set_status(Status::error("bad token"));
|
||||
return HttpResponse::Unauthorized().finish();
|
||||
return ApiError::unauthorized(
|
||||
"stream_link_expired",
|
||||
"This video link has expired. Open the video again.",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let Some(path) = app_state
|
||||
.libraries
|
||||
@@ -382,7 +389,7 @@ pub async fn stream_original_video(
|
||||
.filter(|p| p.is_file())
|
||||
else {
|
||||
span.set_status(Status::error("not found"));
|
||||
return HttpResponse::NotFound().finish();
|
||||
return file_not_found().into_response();
|
||||
};
|
||||
|
||||
match NamedFile::open(&path) {
|
||||
@@ -394,7 +401,7 @@ pub async fn stream_original_video(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("not found"));
|
||||
HttpResponse::NotFound().finish()
|
||||
stream_not_found().into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,7 +479,7 @@ pub async fn get_video_preview(
|
||||
Some(path) => path,
|
||||
None => {
|
||||
span.set_status(Status::error("Invalid path"));
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Invalid path"}));
|
||||
return invalid_path().into_response();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -532,9 +539,7 @@ pub async fn get_video_preview(
|
||||
.error_message
|
||||
.unwrap_or_else(|| "Unknown error".to_string());
|
||||
span.set_status(Status::error(format!("Generation failed: {}", error_msg)));
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Generation failed: {}", error_msg)
|
||||
}))
|
||||
ApiError::internal(format!("Generation failed: {}", error_msg)).into_response()
|
||||
}
|
||||
_ => {
|
||||
// pending or unknown status - trigger generation
|
||||
@@ -569,7 +574,7 @@ pub async fn get_video_preview(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("Database error"));
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
ApiError::internal("Database error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,8 +592,9 @@ pub async fn get_preview_status(
|
||||
// Limit to 200 paths per request
|
||||
if body.paths.len() > 200 {
|
||||
span.set_status(Status::error("Too many paths"));
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "Maximum 200 paths per request"}));
|
||||
return ApiError::bad_request("too_many_files", "At most 200 videos per request")
|
||||
.with_details(serde_json::json!({ "max": 200 }))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let previews = {
|
||||
@@ -665,11 +671,26 @@ pub async fn get_preview_status(
|
||||
}
|
||||
Err(_) => {
|
||||
span.set_status(Status::error("Database error"));
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
||||
ApiError::internal("Database error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The path names no file in the library.
|
||||
fn file_not_found() -> ApiError {
|
||||
ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||
}
|
||||
|
||||
/// The path isn't a file in the library (400, as it has always been here).
|
||||
fn invalid_path() -> ApiError {
|
||||
ApiError::bad_request("invalid_path", "That file isn't in the library")
|
||||
}
|
||||
|
||||
/// An HLS playlist/segment that isn't (or is no longer) generated.
|
||||
fn stream_not_found() -> ApiError {
|
||||
ApiError::not_found("stream_not_found", "This video isn't ready to stream yet")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in new issue
Block a user