Merge pull request 'Every error response in the { error, code, details? } format (step 2)' (#204) from feat/error-contract-step2 into master
Reviewed-on: #204
This commit was merged in pull request #204.
This commit is contained in:
commit
ce101a3b2c
23 files changed
+894
-542
No files matched your search
@@ -797,6 +797,15 @@ GET /timezones (IANA zone list from chrono-tz; unauthentic
|
|||||||
**Service Builder Pattern:**
|
**Service Builder Pattern:**
|
||||||
Routes are registered via composable `ServiceBuilder` trait in `service.rs`. Allows modular feature addition.
|
Routes are registered via composable `ServiceBuilder` trait in `service.rs`. Allows modular feature addition.
|
||||||
|
|
||||||
|
**Error responses (`src/error.rs`, #202):**
|
||||||
|
Every error body is `{"error": "<message safe to show a user>", "code": "<stable_snake_case>", "details"?: {...}}`.
|
||||||
|
Build them with `ApiError` (`bad_request` / `not_found` / `conflict` / `unavailable` / `bad_gateway` / ... `(code, message)`),
|
||||||
|
returned as `Err(..)` from an `ApiResult` handler or via `.into_response()`. Unexpected failures go through
|
||||||
|
`ApiError::internal(cause)` (or `?` on anyhow / diesel): a 500 `internal` whose cause is logged, never sent, since it can
|
||||||
|
hold SQL and library paths. Never rename a code a client may branch on; add a new one. Don't put file paths in `details`.
|
||||||
|
Request-parsing failures, the auth extractor, multipart uploads and unknown routes use the format too (configs in
|
||||||
|
`error.rs`, registered in `main.rs`). A test fails if a handler builds `HttpResponse::NotFound()` etc. by hand.
|
||||||
|
|
||||||
**Path Validation:**
|
**Path Validation:**
|
||||||
Always use `is_valid_full_path(&base_path, &requested_path, check_exists)` to prevent directory traversal attacks.
|
Always use `is_valid_full_path(&base_path, &requested_path, check_exists)` to prevent directory traversal attacks.
|
||||||
|
|
||||||
|
|||||||
+77
-89
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use opentelemetry::trace::Status;
|
use opentelemetry::trace::Status;
|
||||||
@@ -110,15 +111,12 @@ pub async fn generation_status_handler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::NotFound().json(serde_json::json!({
|
return ApiError::not_found("job_not_found", "That insight job no longer exists")
|
||||||
"error": format!("Job {} not found", jid)
|
.into_response();
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to look up job {}: {:?}", jid, e);
|
log::error!("Failed to look up job {}: {:?}", jid, e);
|
||||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
return ApiError::internal("Failed to look up job").into_response();
|
||||||
"error": "Failed to look up job"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,16 +151,12 @@ pub async fn generation_status_handler(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
|
log::error!("Failed to look up active job for {}: {:?}", normalized, e);
|
||||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
return ApiError::internal("Failed to look up active job").into_response();
|
||||||
"error": "Failed to look up active job"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::BadRequest().json(serde_json::json!({
|
ApiError::bad_request("invalid_request", "Give a job_id or a path").into_response()
|
||||||
"error": "Provide either job_id or path query parameter"
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -212,9 +206,7 @@ pub async fn cancel_generation_handler(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to cancel job {}: {:?}", jid, e);
|
log::error!("Failed to cancel job {}: {:?}", jid, e);
|
||||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
return ApiError::internal("Failed to cancel job").into_response();
|
||||||
"error": "Failed to cancel job"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,9 +259,7 @@ pub async fn cancel_generation_handler(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::BadRequest().json(serde_json::json!({
|
ApiError::bad_request("invalid_request", "Give a job_id or a file_path").into_response()
|
||||||
"error": "Provide either job_id or file_path in the request body"
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -491,9 +481,7 @@ pub async fn generate_insight_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to create generation job: {:?}", e);
|
log::error!("Failed to create generation job: {:?}", e);
|
||||||
span.set_status(Status::error("Failed to create generation job"));
|
span.set_status(Status::error("Failed to create generation job"));
|
||||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
return ApiError::internal("Failed to create generation job").into_response();
|
||||||
"error": "Failed to create generation job"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -640,14 +628,10 @@ pub async fn get_insight_handler(
|
|||||||
|
|
||||||
match dao.get_insight_for_paths(&sibling_paths) {
|
match dao.get_insight_for_paths(&sibling_paths) {
|
||||||
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
|
Ok(Some(insight)) => HttpResponse::Ok().json(PhotoInsightResponse::from(insight)),
|
||||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
|
Ok(None) => insight_not_found().into_response(),
|
||||||
"error": "Insight not found"
|
|
||||||
})),
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to fetch insight ({}): {:?}", &query.path, e);
|
log::error!("Failed to fetch insight ({}): {:?}", &query.path, e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Failed to fetch insight: {:?}", e)).into_response()
|
||||||
"error": format!("Failed to fetch insight: {:?}", e)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -671,9 +655,7 @@ pub async fn delete_insight_handler(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to delete insight: {:?}", e);
|
log::error!("Failed to delete insight: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Failed to delete insight: {:?}", e)).into_response()
|
||||||
"error": format!("Failed to delete insight: {:?}", e)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,9 +681,7 @@ pub async fn get_all_insights_handler(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to fetch all insights: {:?}", e);
|
log::error!("Failed to fetch all insights: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Failed to fetch insights: {:?}", e)).into_response()
|
||||||
"error": format!("Failed to fetch insights: {:?}", e)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -731,9 +711,7 @@ pub async fn get_insight_history_handler(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
|
log::error!("Failed to fetch insight history ({}): {:?}", &query.path, e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Failed to fetch insight history: {:?}", e)).into_response()
|
||||||
"error": format!("Failed to fetch insight history: {:?}", e)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -822,9 +800,7 @@ pub async fn generate_agentic_insight_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to create agentic generation job: {:?}", e);
|
log::error!("Failed to create agentic generation job: {:?}", e);
|
||||||
span.set_status(Status::error("Failed to create generation job"));
|
span.set_status(Status::error("Failed to create generation job"));
|
||||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
return ApiError::internal("Failed to create generation job").into_response();
|
||||||
"error": "Failed to create generation job"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1159,9 +1135,7 @@ pub async fn rate_insight_handler(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to rate insight: {:?}", e);
|
log::error!("Failed to rate insight: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Failed to rate insight: {:?}", e)).into_response()
|
||||||
"error": format!("Failed to rate insight: {:?}", e)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1213,9 +1187,7 @@ pub async fn export_training_data_handler(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to export training data: {:?}", e);
|
log::error!("Failed to export training data: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Failed to export training data: {:?}", e)).into_response()
|
||||||
"error": format!("Failed to export training data: {:?}", e)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1308,9 +1280,7 @@ pub async fn chat_turn_handler(
|
|||||||
Ok(Some(lib)) => lib,
|
Ok(Some(lib)) => lib,
|
||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return libraries::unknown_library(e).into_response();
|
||||||
"error": format!("invalid library: {}", e)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1361,17 +1331,17 @@ pub async fn chat_turn_handler(
|
|||||||
|
|
||||||
// Map well-known errors to client-facing 4xx codes.
|
// Map well-known errors to client-facing 4xx codes.
|
||||||
if msg.contains("no insight found") {
|
if msg.contains("no insight found") {
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
insight_not_found().into_response()
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
no_chat_history().into_response()
|
||||||
} else if msg.contains("user_message")
|
} else if msg.contains("user_message")
|
||||||
|| msg.contains("unknown backend")
|
|| msg.contains("unknown backend")
|
||||||
|| msg.contains("switching from local to hybrid")
|
|| msg.contains("switching from local to hybrid")
|
||||||
|| msg.contains("hybrid backend unavailable")
|
|| msg.contains("hybrid backend unavailable")
|
||||||
{
|
{
|
||||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
ApiError::bad_request("invalid_chat_request", msg).into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
ApiError::internal(msg).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1456,9 +1426,7 @@ pub async fn chat_rewind_handler(
|
|||||||
Ok(Some(lib)) => lib,
|
Ok(Some(lib)) => lib,
|
||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return libraries::unknown_library(e).into_response();
|
||||||
"error": format!("invalid library: {}", e)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1476,13 +1444,18 @@ pub async fn chat_rewind_handler(
|
|||||||
let msg = format!("{}", e);
|
let msg = format!("{}", e);
|
||||||
log::error!("Chat rewind failed: {}", msg);
|
log::error!("Chat rewind failed: {}", msg);
|
||||||
if msg.contains("no insight found") {
|
if msg.contains("no insight found") {
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
insight_not_found().into_response()
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
no_chat_history().into_response()
|
||||||
} else if msg.contains("cannot discard the initial") || msg.contains("out of range") {
|
} else if msg.contains("cannot discard the initial") || msg.contains("out of range") {
|
||||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
ApiError::bad_request(
|
||||||
|
"stale_branch",
|
||||||
|
"This conversation changed. Reload it and try again.",
|
||||||
|
)
|
||||||
|
.with_details(serde_json::json!({ "reason": msg }))
|
||||||
|
.into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
ApiError::internal(msg).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1553,11 +1526,11 @@ pub async fn chat_history_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{}", e);
|
let msg = format!("{}", e);
|
||||||
if msg.contains("no insight found") {
|
if msg.contains("no insight found") {
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
insight_not_found().into_response()
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
no_chat_history().into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
ApiError::internal(msg).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1606,14 +1579,18 @@ pub async fn chat_branches_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{}", e);
|
let msg = format!("{}", e);
|
||||||
if msg.contains("no insight found") {
|
if msg.contains("no insight found") {
|
||||||
return HttpResponse::NotFound().json(serde_json::json!({ "error": msg }));
|
return insight_not_found().into_response();
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
return HttpResponse::Conflict().json(serde_json::json!({ "error": msg }));
|
return no_chat_history().into_response();
|
||||||
} else if msg.contains("not found in tree") {
|
} else if msg.contains("not found in tree") {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }));
|
return ApiError::bad_request(
|
||||||
|
"stale_branch",
|
||||||
|
"This conversation changed. Reload it and try again.",
|
||||||
|
)
|
||||||
|
.with_details(serde_json::json!({ "reason": msg }))
|
||||||
|
.into_response();
|
||||||
} else {
|
} else {
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal(msg).into_response();
|
||||||
.json(serde_json::json!({ "error": msg }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1638,9 +1615,7 @@ pub async fn chat_switch_branch_handler(
|
|||||||
Ok(Some(lib)) => lib,
|
Ok(Some(lib)) => lib,
|
||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return libraries::unknown_library(e).into_response();
|
||||||
"error": format!("invalid library: {}", e)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1654,13 +1629,18 @@ pub async fn chat_switch_branch_handler(
|
|||||||
let msg = format!("{}", e);
|
let msg = format!("{}", e);
|
||||||
log::error!("Switch branch failed: {}", msg);
|
log::error!("Switch branch failed: {}", msg);
|
||||||
if msg.contains("no insight found") {
|
if msg.contains("no insight found") {
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
insight_not_found().into_response()
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
no_chat_history().into_response()
|
||||||
} else if msg.contains("not found") || msg.contains("not a leaf") {
|
} else if msg.contains("not found") || msg.contains("not a leaf") {
|
||||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
ApiError::bad_request(
|
||||||
|
"stale_branch",
|
||||||
|
"This conversation changed. Reload it and try again.",
|
||||||
|
)
|
||||||
|
.with_details(serde_json::json!({ "reason": msg }))
|
||||||
|
.into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
ApiError::internal(msg).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1698,9 +1678,7 @@ pub async fn chat_stream_handler(
|
|||||||
Ok(Some(lib)) => lib,
|
Ok(Some(lib)) => lib,
|
||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return libraries::unknown_library(e).into_response();
|
||||||
"error": format!("invalid library: {}", e)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1853,9 +1831,7 @@ pub async fn turn_async_handler(
|
|||||||
Ok(Some(lib)) => lib,
|
Ok(Some(lib)) => lib,
|
||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return libraries::unknown_library(e).into_response();
|
||||||
"error": format!("invalid library: {}", e)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1936,9 +1912,7 @@ pub(crate) async fn turn_replay_impl(
|
|||||||
Some(e) => e,
|
Some(e) => e,
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("turn not found"));
|
span.set_status(Status::error("turn not found"));
|
||||||
return HttpResponse::NotFound().json(serde_json::json!({
|
return turn_not_found().into_response();
|
||||||
"error": format!("turn {} not found", turn_id)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1953,9 +1927,11 @@ pub(crate) async fn turn_replay_impl(
|
|||||||
let (initial_frames, start_skip) = match entry.replay_from(skip_before).await {
|
let (initial_frames, start_skip) = match entry.replay_from(skip_before).await {
|
||||||
ReplayOutcome::Gone => {
|
ReplayOutcome::Gone => {
|
||||||
span.set_status(Status::error("buffer evicted"));
|
span.set_status(Status::error("buffer evicted"));
|
||||||
return HttpResponse::Gone().json(serde_json::json!({
|
return ApiError::gone(
|
||||||
"error": "turn history has expired (buffer evicted)"
|
"turn_expired",
|
||||||
}));
|
"This reply is too old to resume. Reload the chat.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
ReplayOutcome::CaughtUp { next_skip } => (Vec::new(), next_skip),
|
ReplayOutcome::CaughtUp { next_skip } => (Vec::new(), next_skip),
|
||||||
ReplayOutcome::Events { events, next_skip } => {
|
ReplayOutcome::Events { events, next_skip } => {
|
||||||
@@ -2100,9 +2076,7 @@ pub(crate) async fn cancel_turn_impl(
|
|||||||
Some(e) => e,
|
Some(e) => e,
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("turn not found"));
|
span.set_status(Status::error("turn not found"));
|
||||||
return HttpResponse::NotFound().json(serde_json::json!({
|
return turn_not_found().into_response();
|
||||||
"error": format!("turn {} not found", turn_id)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2441,3 +2415,17 @@ mod owner_defaults_tests {
|
|||||||
assert_eq!(persona.as_deref(), Some("journal"));
|
assert_eq!(persona.as_deref(), Some("journal"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- error responses (#202) ---------------------------------------------
|
||||||
|
|
||||||
|
fn insight_not_found() -> ApiError {
|
||||||
|
ApiError::not_found("insight_not_found", "There's no insight for this photo yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_chat_history() -> ApiError {
|
||||||
|
ApiError::conflict("no_chat_history", "There's no chat for this insight yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn turn_not_found() -> ApiError {
|
||||||
|
ApiError::not_found("turn_not_found", "That reply is no longer running")
|
||||||
|
}
|
||||||
+60
-50
@@ -22,6 +22,7 @@
|
|||||||
//! registry.
|
//! registry.
|
||||||
//! - The HTTP handlers under ` /persona_chat/*`.
|
//! - The HTTP handlers under ` /persona_chat/*`.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -85,6 +86,39 @@ impl std::fmt::Display for PersonaChatError {
|
|||||||
|
|
||||||
impl std::error::Error for PersonaChatError {}
|
impl std::error::Error for PersonaChatError {}
|
||||||
|
|
||||||
|
impl From<&PersonaChatError> for ApiError {
|
||||||
|
fn from(e: &PersonaChatError) -> Self {
|
||||||
|
match e {
|
||||||
|
PersonaChatError::UnknownPersona(_) => {
|
||||||
|
ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||||
|
}
|
||||||
|
PersonaChatError::UnknownConversation(_) => ApiError::not_found(
|
||||||
|
"conversation_not_found",
|
||||||
|
"That conversation no longer exists",
|
||||||
|
),
|
||||||
|
PersonaChatError::EmptyMessage => {
|
||||||
|
ApiError::bad_request("empty_message", "Type a message first")
|
||||||
|
}
|
||||||
|
PersonaChatError::MessageTooLong => ApiError::bad_request(
|
||||||
|
"message_too_long",
|
||||||
|
"That message is too long (8192 characters at most)",
|
||||||
|
),
|
||||||
|
PersonaChatError::ConcurrentTurn => ApiError::conflict(
|
||||||
|
"turn_in_progress",
|
||||||
|
"A reply is still being written. Wait for it to finish.",
|
||||||
|
),
|
||||||
|
PersonaChatError::SmsThread(detail) => {
|
||||||
|
log::warn!("persona chat: SMS thread unreadable: {detail}");
|
||||||
|
ApiError::bad_gateway(
|
||||||
|
"sms_thread_unavailable",
|
||||||
|
"Couldn't read that message thread",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PersonaChatError::Db(err) => ApiError::internal(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<anyhow::Error> for PersonaChatError {
|
impl From<anyhow::Error> for PersonaChatError {
|
||||||
fn from(e: anyhow::Error) -> Self {
|
fn from(e: anyhow::Error) -> Self {
|
||||||
PersonaChatError::Db(e)
|
PersonaChatError::Db(e)
|
||||||
@@ -1463,12 +1497,14 @@ pub async fn persona_chat_history_handler(
|
|||||||
) {
|
) {
|
||||||
Ok(view) => HttpResponse::Ok().json(view),
|
Ok(view) => HttpResponse::Ok().json(view),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{e}");
|
if format!("{e}").contains("not found") {
|
||||||
if msg.contains("not found") {
|
ApiError::not_found(
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
"conversation_not_found",
|
||||||
|
"That conversation no longer exists",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
} else {
|
} else {
|
||||||
log::error!("persona chat history load failed: {msg}");
|
ApiError::internal(e).into_response()
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1494,11 +1530,7 @@ pub async fn persona_chat_turn_handler(
|
|||||||
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
match libraries::resolve_library_param_state(&app_state, request.library.as_deref()) {
|
||||||
Ok(Some(lib)) => lib,
|
Ok(Some(lib)) => lib,
|
||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(e) => {
|
Err(e) => return crate::libraries::unknown_library(e).into_response(),
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
|
||||||
"error": format!("invalid library: {e}")
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
let user_id = claims.sub.parse::<i32>().unwrap_or(1);
|
||||||
@@ -1519,21 +1551,7 @@ pub async fn persona_chat_turn_handler(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error(format!("{e}")));
|
span.set_status(Status::error(format!("{e}")));
|
||||||
match &e {
|
ApiError::from(&e).into_response()
|
||||||
PersonaChatError::UnknownPersona(_) | PersonaChatError::UnknownConversation(_) => {
|
|
||||||
HttpResponse::NotFound()
|
|
||||||
}
|
|
||||||
PersonaChatError::EmptyMessage | PersonaChatError::MessageTooLong => {
|
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
|
||||||
PersonaChatError::ConcurrentTurn => HttpResponse::Conflict(),
|
|
||||||
PersonaChatError::SmsThread(_) => HttpResponse::BadGateway(),
|
|
||||||
PersonaChatError::Db(_) => {
|
|
||||||
log::error!("persona chat dispatch failed: {e}");
|
|
||||||
HttpResponse::InternalServerError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.json(serde_json::json!({ "error": format!("{e}") }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1567,20 +1585,7 @@ pub async fn persona_chat_create_conversation_handler(
|
|||||||
Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({
|
Ok(conversation_id) => HttpResponse::Created().json(serde_json::json!({
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
})),
|
})),
|
||||||
Err(e) => match &e {
|
Err(e) => ApiError::from(&e).into_response(),
|
||||||
PersonaChatError::UnknownPersona(_) => {
|
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": format!("{e}") }))
|
|
||||||
}
|
|
||||||
PersonaChatError::SmsThread(_) => {
|
|
||||||
log::warn!("anchored persona chat create failed: {e}");
|
|
||||||
HttpResponse::BadGateway().json(serde_json::json!({ "error": format!("{e}") }))
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::error!("persona chat create failed: {e}");
|
|
||||||
HttpResponse::InternalServerError()
|
|
||||||
.json(serde_json::json!({ "error": format!("{e}") }))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1600,9 +1605,7 @@ pub async fn persona_chat_delete_conversation_handler(
|
|||||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })),
|
Ok(()) => HttpResponse::Ok().json(serde_json::json!({ "deleted": true })),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("persona chat delete failed: {e}");
|
log::error!("persona chat delete failed: {e}");
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("{e}")).into_response()
|
||||||
"error": format!("{e}")
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1626,9 +1629,7 @@ pub async fn persona_chat_conversations_handler(
|
|||||||
})),
|
})),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("persona chat conversation list failed: {e}");
|
log::error!("persona chat conversation list failed: {e}");
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("{e}")).into_response()
|
||||||
"error": format!("{e}")
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1669,20 +1670,29 @@ pub struct PersonaChatBranchesQuery {
|
|||||||
/// equivalents return, so the client can share its error handling.
|
/// equivalents return, so the client can share its error handling.
|
||||||
fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse {
|
fn branch_error_response(context: &str, e: &anyhow::Error) -> HttpResponse {
|
||||||
let msg = format!("{e}");
|
let msg = format!("{e}");
|
||||||
log::error!("{context}: {msg}");
|
log::warn!("{context}: {msg}");
|
||||||
if msg.contains("conversation not found") {
|
if msg.contains("conversation not found") {
|
||||||
HttpResponse::NotFound().json(serde_json::json!({ "error": msg }))
|
ApiError::not_found(
|
||||||
|
"conversation_not_found",
|
||||||
|
"That conversation no longer exists",
|
||||||
|
)
|
||||||
} else if msg.contains("no chat history") {
|
} else if msg.contains("no chat history") {
|
||||||
HttpResponse::Conflict().json(serde_json::json!({ "error": msg }))
|
ApiError::conflict("no_chat_history", "There's no chat here yet")
|
||||||
} else if msg.contains("not found")
|
} else if msg.contains("not found")
|
||||||
|| msg.contains("not a leaf")
|
|| msg.contains("not a leaf")
|
||||||
|| msg.contains("out of range")
|
|| msg.contains("out of range")
|
||||||
|| msg.contains("cannot rewind past")
|
|| msg.contains("cannot rewind past")
|
||||||
{
|
{
|
||||||
HttpResponse::BadRequest().json(serde_json::json!({ "error": msg }))
|
// The client's view of the branches is stale.
|
||||||
|
ApiError::bad_request(
|
||||||
|
"stale_branch",
|
||||||
|
"This conversation changed. Reload it and try again.",
|
||||||
|
)
|
||||||
|
.with_details(serde_json::json!({ "reason": msg }))
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({ "error": msg }))
|
ApiError::internal(msg)
|
||||||
}
|
}
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /persona_chat/rewind — move the tail of the conversation off the
|
/// POST /persona_chat/rewind — move the tail of the conversation off the
|
||||||
|
|||||||
+64
-46
@@ -5,6 +5,7 @@
|
|||||||
// voice from either an uploaded clip (device) or an existing library file
|
// voice from either an uploaded clip (device) or an existing library file
|
||||||
// (audio read directly; video has its audio track extracted via ffmpeg).
|
// (audio read directly; video has its audio track extracted via ffmpeg).
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use actix_multipart::Multipart;
|
use actix_multipart::Multipart;
|
||||||
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
use actix_web::{HttpResponse, Responder, delete, get, post, web};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
@@ -386,12 +387,11 @@ pub async fn tts_speech_handler(
|
|||||||
let text = prepare_for_tts(&req.text);
|
let text = prepare_for_tts(&req.text);
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
span.set_status(Status::error("text is required"));
|
span.set_status(Status::error("text is required"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
|
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
|
||||||
}
|
}
|
||||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||||
span.set_status(Status::error("tts backend not configured"));
|
span.set_status(Status::error("tts backend not configured"));
|
||||||
return HttpResponse::ServiceUnavailable()
|
return tts_unavailable().into_response();
|
||||||
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let format = req
|
let format = req
|
||||||
@@ -419,9 +419,11 @@ pub async fn tts_speech_handler(
|
|||||||
// One synthesis at a time (see TTS_PERMIT) — fast-fail when busy.
|
// One synthesis at a time (see TTS_PERMIT) — fast-fail when busy.
|
||||||
let Ok(_permit) = TTS_PERMIT.try_acquire() else {
|
let Ok(_permit) = TTS_PERMIT.try_acquire() else {
|
||||||
span.set_status(Status::error("tts busy"));
|
span.set_status(Status::error("tts busy"));
|
||||||
return HttpResponse::TooManyRequests().json(json!({
|
return ApiError::too_many_requests(
|
||||||
"error": "TTS is busy with another request — try again shortly"
|
"tts_busy",
|
||||||
}));
|
"Already reading something. Try again shortly.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for the LLM side to release the GPU before sending — the synthesis
|
// Wait for the LLM side to release the GPU before sending — the synthesis
|
||||||
@@ -444,7 +446,7 @@ pub async fn tts_speech_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("tts synthesis failed"));
|
span.set_status(Status::error("tts synthesis failed"));
|
||||||
log::error!("TTS synth failed: {:?}", e);
|
log::error!("TTS synth failed: {:?}", e);
|
||||||
HttpResponse::BadGateway().json(json!({ "error": format!("TTS failed: {e}") }))
|
tts_failed(&e).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,12 +518,11 @@ pub async fn create_speech_job_handler(
|
|||||||
let text = prepare_for_tts(&req.text);
|
let text = prepare_for_tts(&req.text);
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
span.set_status(Status::error("text is required"));
|
span.set_status(Status::error("text is required"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "text is required" }));
|
return ApiError::bad_request("empty_text", "There's no text to read").into_response();
|
||||||
}
|
}
|
||||||
if app_state.llamacpp.is_none() {
|
if app_state.llamacpp.is_none() {
|
||||||
span.set_status(Status::error("tts backend not configured"));
|
span.set_status(Status::error("tts backend not configured"));
|
||||||
return HttpResponse::ServiceUnavailable()
|
return tts_unavailable().into_response();
|
||||||
.json(json!({ "error": "TTS backend not configured (set llamacpp.url in llm.toml)" }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let format = req
|
let format = req
|
||||||
@@ -660,7 +661,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
|
|||||||
|
|
||||||
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
||||||
span.set_status(Status::error("invalid job id"));
|
span.set_status(Status::error("invalid job id"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
|
||||||
};
|
};
|
||||||
let resp = {
|
let resp = {
|
||||||
let jobs = TTS_JOBS.lock().unwrap();
|
let jobs = TTS_JOBS.lock().unwrap();
|
||||||
@@ -679,8 +680,7 @@ pub async fn speech_job_status_handler(_claims: Claims, path: web::Path<String>)
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("job not found"));
|
span.set_status(Status::error("job not found"));
|
||||||
HttpResponse::NotFound()
|
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
|
||||||
.json(json!({ "error": "TTS job not found (it may have expired)" }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -696,7 +696,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
|
|||||||
|
|
||||||
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
let Ok(id) = Uuid::parse_str(&path.into_inner()) else {
|
||||||
span.set_status(Status::error("invalid job id"));
|
span.set_status(Status::error("invalid job id"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
return ApiError::bad_request("invalid_job_id", "Invalid read-aloud job").into_response();
|
||||||
};
|
};
|
||||||
let status = with_job(id, |job| {
|
let status = with_job(id, |job| {
|
||||||
if !job.status.is_terminal() {
|
if !job.status.is_terminal() {
|
||||||
@@ -715,8 +715,7 @@ pub async fn cancel_speech_job_handler(_claims: Claims, path: web::Path<String>)
|
|||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("job not found"));
|
span.set_status(Status::error("job not found"));
|
||||||
HttpResponse::NotFound()
|
ApiError::not_found("job_not_found", "That read-aloud job has expired").into_response()
|
||||||
.json(json!({ "error": "TTS job not found (it may have expired)" }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -753,8 +752,7 @@ pub async fn list_voices_handler(
|
|||||||
|
|
||||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||||
span.set_status(Status::error("tts backend not configured"));
|
span.set_status(Status::error("tts backend not configured"));
|
||||||
return HttpResponse::ServiceUnavailable()
|
return tts_unavailable().into_response();
|
||||||
.json(json!({ "error": "TTS backend not configured" }));
|
|
||||||
};
|
};
|
||||||
match client.list_voices().await {
|
match client.list_voices().await {
|
||||||
Ok(v) => {
|
Ok(v) => {
|
||||||
@@ -766,7 +764,7 @@ pub async fn list_voices_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("list_voices failed"));
|
span.set_status(Status::error("list_voices failed"));
|
||||||
log::error!("list_voices failed: {:?}", e);
|
log::error!("list_voices failed: {:?}", e);
|
||||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
tts_failed(&e).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -783,8 +781,7 @@ pub async fn delete_voice_handler(
|
|||||||
|
|
||||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||||
span.set_status(Status::error("tts backend not configured"));
|
span.set_status(Status::error("tts backend not configured"));
|
||||||
return HttpResponse::ServiceUnavailable()
|
return tts_unavailable().into_response();
|
||||||
.json(json!({ "error": "TTS backend not configured" }));
|
|
||||||
};
|
};
|
||||||
// Same charset rule as creation — a name that sanitizes differently was
|
// Same charset rule as creation — a name that sanitizes differently was
|
||||||
// never a voice we created, and must not reach the upstream URL.
|
// never a voice we created, and must not reach the upstream URL.
|
||||||
@@ -793,7 +790,7 @@ pub async fn delete_voice_handler(
|
|||||||
Some(n) if n == raw => n,
|
Some(n) if n == raw => n,
|
||||||
_ => {
|
_ => {
|
||||||
span.set_status(Status::error("invalid voice name"));
|
span.set_status(Status::error("invalid voice name"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid voice name" }));
|
return invalid_voice_name().into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
span.set_attribute("tts.voice_name", name.clone());
|
span.set_attribute("tts.voice_name", name.clone());
|
||||||
@@ -807,7 +804,7 @@ pub async fn delete_voice_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("delete_voice failed"));
|
span.set_status(Status::error("delete_voice failed"));
|
||||||
log::error!("delete_voice failed: {:?}", e);
|
log::error!("delete_voice failed: {:?}", e);
|
||||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
tts_failed(&e).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -827,8 +824,7 @@ pub async fn create_voice_upload_handler(
|
|||||||
|
|
||||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||||
span.set_status(Status::error("tts backend not configured"));
|
span.set_status(Status::error("tts backend not configured"));
|
||||||
return HttpResponse::ServiceUnavailable()
|
return tts_unavailable().into_response();
|
||||||
.json(json!({ "error": "TTS backend not configured" }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut voice_name: Option<String> = None;
|
let mut voice_name: Option<String> = None;
|
||||||
@@ -853,8 +849,12 @@ pub async fn create_voice_upload_handler(
|
|||||||
while let Some(Ok(data)) = part.next().await {
|
while let Some(Ok(data)) = part.next().await {
|
||||||
if file_bytes.len() + data.len() > MAX_VOICE_UPLOAD_BYTES {
|
if file_bytes.len() + data.len() > MAX_VOICE_UPLOAD_BYTES {
|
||||||
span.set_status(Status::error("voice clip exceeds limit"));
|
span.set_status(Status::error("voice clip exceeds limit"));
|
||||||
return HttpResponse::PayloadTooLarge()
|
return ApiError::new(
|
||||||
.json(json!({ "error": "voice clip exceeds 25 MB" }));
|
actix_web::http::StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"body_too_large",
|
||||||
|
"The voice clip is too big (25 MB at most)",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
file_bytes.put(data);
|
file_bytes.put(data);
|
||||||
}
|
}
|
||||||
@@ -897,21 +897,20 @@ pub async fn create_voice_upload_handler(
|
|||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error("invalid reference window"));
|
span.set_status(Status::error("invalid reference window"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
return ApiError::bad_request("invalid_clip_window", msg).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(name) = voice_name.as_deref().and_then(sanitize_voice_name) else {
|
let Some(name) = voice_name.as_deref().and_then(sanitize_voice_name) else {
|
||||||
span.set_status(Status::error("voice_name is required"));
|
span.set_status(Status::error("voice_name is required"));
|
||||||
return HttpResponse::BadRequest()
|
return invalid_voice_name().into_response();
|
||||||
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
|
|
||||||
};
|
};
|
||||||
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
||||||
// library shows which reference length produced each clone.
|
// library shows which reference length produced each clone.
|
||||||
let name = append_ref_window(&name, ref_start, ref_duration.round().max(1.0) as u32);
|
let name = append_ref_window(&name, ref_start, ref_duration.round().max(1.0) as u32);
|
||||||
if file_bytes.is_empty() {
|
if file_bytes.is_empty() {
|
||||||
span.set_status(Status::error("voice_file is required"));
|
span.set_status(Status::error("voice_file is required"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "voice_file is required" }));
|
return ApiError::bad_request("no_file", "Choose an audio file").into_response();
|
||||||
}
|
}
|
||||||
span.set_attribute("tts.voice_name", name.clone());
|
span.set_attribute("tts.voice_name", name.clone());
|
||||||
span.set_attribute("tts.upload_bytes", file_bytes.len() as i64);
|
span.set_attribute("tts.upload_bytes", file_bytes.len() as i64);
|
||||||
@@ -925,8 +924,7 @@ pub async fn create_voice_upload_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("audio decode failed"));
|
span.set_status(Status::error("audio decode failed"));
|
||||||
log::error!("voice upload transcode failed: {:?}", e);
|
log::error!("voice upload transcode failed: {:?}", e);
|
||||||
return HttpResponse::BadRequest()
|
return undecodable_audio().into_response();
|
||||||
.json(json!({ "error": "couldn't decode that audio file" }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -942,7 +940,7 @@ pub async fn create_voice_upload_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("create_voice failed"));
|
span.set_status(Status::error("create_voice failed"));
|
||||||
log::error!("create_voice (upload) failed: {:?}", e);
|
log::error!("create_voice (upload) failed: {:?}", e);
|
||||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
tts_failed(&e).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -978,20 +976,18 @@ pub async fn create_voice_from_library_handler(
|
|||||||
|
|
||||||
let Some(client) = app_state.llamacpp.as_ref() else {
|
let Some(client) = app_state.llamacpp.as_ref() else {
|
||||||
span.set_status(Status::error("tts backend not configured"));
|
span.set_status(Status::error("tts backend not configured"));
|
||||||
return HttpResponse::ServiceUnavailable()
|
return tts_unavailable().into_response();
|
||||||
.json(json!({ "error": "TTS backend not configured" }));
|
|
||||||
};
|
};
|
||||||
let Some(voice_name) = sanitize_voice_name(&req.voice_name) else {
|
let Some(voice_name) = sanitize_voice_name(&req.voice_name) else {
|
||||||
span.set_status(Status::error("voice_name is required"));
|
span.set_status(Status::error("voice_name is required"));
|
||||||
return HttpResponse::BadRequest()
|
return invalid_voice_name().into_response();
|
||||||
.json(json!({ "error": "voice_name is required (alphanumerics, - and _ only)" }));
|
|
||||||
};
|
};
|
||||||
let (ref_start, ref_duration) =
|
let (ref_start, ref_duration) =
|
||||||
match resolve_ref_window(req.start_seconds, req.duration_seconds) {
|
match resolve_ref_window(req.start_seconds, req.duration_seconds) {
|
||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error("invalid reference window"));
|
span.set_status(Status::error("invalid reference window"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
return ApiError::bad_request("invalid_clip_window", msg).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
// Tag the name with the ref-clip length (e.g. `grandma-30s`) so the
|
||||||
@@ -1004,7 +1000,7 @@ pub async fn create_voice_from_library_handler(
|
|||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error("invalid library"));
|
span.set_status(Status::error("invalid library"));
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": msg }));
|
return libraries::unknown_library(msg).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1013,7 +1009,8 @@ pub async fn create_voice_from_library_handler(
|
|||||||
Some(p) if p.exists() => p,
|
Some(p) if p.exists() => p,
|
||||||
_ => {
|
_ => {
|
||||||
span.set_status(Status::error("file not found"));
|
span.set_status(Status::error("file not found"));
|
||||||
return HttpResponse::NotFound().json(json!({ "error": "file not found in library" }));
|
return ApiError::not_found("file_not_found", "That file doesn't exist")
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1021,8 +1018,7 @@ pub async fn create_voice_from_library_handler(
|
|||||||
// slurp arbitrary library files into memory / ffmpeg.
|
// slurp arbitrary library files into memory / ffmpeg.
|
||||||
if !is_audio_file(&abs) && !is_video_file(&abs) {
|
if !is_audio_file(&abs) && !is_video_file(&abs) {
|
||||||
span.set_status(Status::error("not an audio/video file"));
|
span.set_status(Status::error("not an audio/video file"));
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("not_audio", "That file has no audio").into_response();
|
||||||
.json(json!({ "error": "file is not an audio or video file" }));
|
|
||||||
}
|
}
|
||||||
span.set_attribute("tts.voice_name", voice_name.clone());
|
span.set_attribute("tts.voice_name", voice_name.clone());
|
||||||
|
|
||||||
@@ -1031,8 +1027,7 @@ pub async fn create_voice_from_library_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("audio decode failed"));
|
span.set_status(Status::error("audio decode failed"));
|
||||||
log::error!("voice reference prep failed for {:?}: {:?}", abs, e);
|
log::error!("voice reference prep failed for {:?}: {:?}", abs, e);
|
||||||
return HttpResponse::BadRequest()
|
return undecodable_audio().into_response();
|
||||||
.json(json!({ "error": "couldn't decode that file's audio" }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1048,7 +1043,7 @@ pub async fn create_voice_from_library_handler(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
span.set_status(Status::error("create_voice failed"));
|
span.set_status(Status::error("create_voice failed"));
|
||||||
log::error!("create_voice (from-library) failed: {:?}", e);
|
log::error!("create_voice (from-library) failed: {:?}", e);
|
||||||
HttpResponse::BadGateway().json(json!({ "error": format!("{e}") }))
|
tts_failed(&e).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1060,6 +1055,29 @@ async fn prepare_reference_audio(abs: &Path, start: f64, duration: f64) -> anyho
|
|||||||
run_ffmpeg_to_wav(&abs.to_string_lossy(), start, duration).await
|
run_ffmpeg_to_wav(&abs.to_string_lossy(), start, duration).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- error responses (#202) ---------------------------------------------
|
||||||
|
|
||||||
|
fn tts_unavailable() -> ApiError {
|
||||||
|
ApiError::unavailable("tts_unavailable", "Read-aloud isn't set up on this server")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The voice backend failed; its message is kept in `details` for debugging.
|
||||||
|
fn tts_failed(e: &impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError::bad_gateway("tts_failed", "The voice service failed. Try again.")
|
||||||
|
.with_details(json!({ "reason": e.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalid_voice_name() -> ApiError {
|
||||||
|
ApiError::bad_request(
|
||||||
|
"invalid_voice_name",
|
||||||
|
"Voice names can only use letters, numbers, - and _",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn undecodable_audio() -> ApiError {
|
||||||
|
ApiError::bad_request("undecodable_audio", "Couldn't read the audio in that file")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+11
-7
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::Responder;
|
use actix_web::Responder;
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
@@ -40,20 +41,24 @@ async fn register<D: UserDao>(
|
|||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
if let Err(msg) = validate_password(&user.password) {
|
if let Err(msg) = validate_password(&user.password) {
|
||||||
return HttpResponse::BadRequest().body(msg);
|
return ApiError::bad_request("weak_password", msg).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.username.is_empty() && user.password == user.confirmation {
|
if !user.username.is_empty() && user.password == user.confirmation {
|
||||||
let mut dao = user_dao.lock().expect("Unable to get UserDao");
|
let mut dao = user_dao.lock().expect("Unable to get UserDao");
|
||||||
if dao.user_exists(&user.username) {
|
if dao.user_exists(&user.username) {
|
||||||
HttpResponse::BadRequest().finish()
|
ApiError::conflict("username_taken", "That username is taken").into_response()
|
||||||
} else if let Some(_user) = dao.create_user(&user.username, &user.password) {
|
} else if let Some(_user) = dao.create_user(&user.username, &user.password) {
|
||||||
HttpResponse::Ok().finish()
|
HttpResponse::Ok().finish()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().finish()
|
ApiError::internal("create_user failed").into_response()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::BadRequest().finish()
|
ApiError::bad_request(
|
||||||
|
"invalid_registration",
|
||||||
|
"Enter a username and the same password twice",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,15 +82,14 @@ pub async fn login<D: UserDao>(
|
|||||||
) {
|
) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to encode JWT: {}", e);
|
return ApiError::internal(format!("Failed to encode JWT: {}", e)).into_response();
|
||||||
return HttpResponse::InternalServerError().finish();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
HttpResponse::Ok().json(Token { token: &token })
|
HttpResponse::Ok().json(Token { token: &token })
|
||||||
} else {
|
} else {
|
||||||
error!("Failed login attempt for user: '{}'", creds.username);
|
error!("Failed login attempt for user: '{}'", creds.username);
|
||||||
HttpResponse::Unauthorized().finish()
|
ApiError::unauthorized("invalid_credentials", "Wrong username or password").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-23
@@ -24,6 +24,7 @@
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use crate::ai::clip_client::ClipError;
|
use crate::ai::clip_client::ClipError;
|
||||||
use crate::database::ExifDao;
|
use crate::database::ExifDao;
|
||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, Result as ActixResult, web};
|
use actix_web::{HttpResponse, Result as ActixResult, web};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -104,11 +105,6 @@ pub struct SearchResponse {
|
|||||||
pub results: Vec<SearchHit>,
|
pub results: Vec<SearchHit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
|
||||||
struct SearchError {
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode a stored `clip_embedding` BLOB back into a `Vec<f32>`. Returns
|
/// Decode a stored `clip_embedding` BLOB back into a `Vec<f32>`. Returns
|
||||||
/// `None` on malformed bytes — those rows get skipped rather than
|
/// `None` on malformed bytes — those rows get skipped rather than
|
||||||
/// failing the whole query.
|
/// failing the whole query.
|
||||||
@@ -316,9 +312,7 @@ pub async fn search_photos(
|
|||||||
) -> ActixResult<HttpResponse> {
|
) -> ActixResult<HttpResponse> {
|
||||||
let q_text = query.q.trim().to_string();
|
let q_text = query.q.trim().to_string();
|
||||||
if q_text.is_empty() {
|
if q_text.is_empty() {
|
||||||
return Ok(HttpResponse::BadRequest().json(SearchError {
|
return Ok(empty_query().into_response());
|
||||||
error: "query parameter `q` is required".into(),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = query.limit.clamp(1, 200);
|
let limit = query.limit.clamp(1, 200);
|
||||||
@@ -327,7 +321,7 @@ pub async fn search_photos(
|
|||||||
|
|
||||||
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
Err(msg) => return Ok(HttpResponse::BadRequest().json(SearchError { error: msg })),
|
Err(msg) => return Ok(crate::libraries::unknown_library(msg).into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let scored = match score_photos(
|
let scored = match score_photos(
|
||||||
@@ -370,20 +364,32 @@ pub async fn search_photos(
|
|||||||
/// returned for each failure mode. Reused by the unified endpoint.
|
/// returned for each failure mode. Reused by the unified endpoint.
|
||||||
pub fn score_error_response(e: ScoreError) -> HttpResponse {
|
pub fn score_error_response(e: ScoreError) -> HttpResponse {
|
||||||
match e {
|
match e {
|
||||||
ScoreError::Disabled => HttpResponse::ServiceUnavailable().json(SearchError {
|
ScoreError::Disabled => ApiError::unavailable(
|
||||||
error: "CLIP search is disabled (no Apollo CLIP endpoint configured)".into(),
|
"search_unavailable",
|
||||||
}),
|
"Photo search isn't set up on this server",
|
||||||
ScoreError::Rejected(msg) => HttpResponse::BadRequest().json(SearchError {
|
),
|
||||||
error: format!("query rejected: {msg}"),
|
ScoreError::Rejected(msg) => ApiError::bad_request(
|
||||||
}),
|
"query_rejected",
|
||||||
ScoreError::Unavailable(msg) => HttpResponse::BadGateway().json(SearchError {
|
"The search service couldn't use that query",
|
||||||
error: format!("CLIP service unavailable: {msg}"),
|
)
|
||||||
}),
|
.with_details(serde_json::json!({ "reason": msg })),
|
||||||
ScoreError::MalformedEmbedding => HttpResponse::BadGateway().json(SearchError {
|
ScoreError::Unavailable(msg) => {
|
||||||
error: "CLIP service returned a malformed query embedding".into(),
|
log::warn!("CLIP service unavailable: {msg}");
|
||||||
}),
|
ApiError::bad_gateway(
|
||||||
ScoreError::Internal(msg) => {
|
"search_unavailable",
|
||||||
HttpResponse::InternalServerError().json(SearchError { error: msg })
|
"Photo search is down right now. Try again shortly.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
ScoreError::MalformedEmbedding => ApiError::bad_gateway(
|
||||||
|
"search_bad_response",
|
||||||
|
"The search service sent an answer this server couldn't read",
|
||||||
|
),
|
||||||
|
ScoreError::Internal(msg) => ApiError::internal(msg),
|
||||||
}
|
}
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `q` is missing or blank.
|
||||||
|
pub fn empty_query() -> ApiError {
|
||||||
|
ApiError::bad_request("empty_query", "Type something to search for")
|
||||||
}
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
use std::{fs, str::FromStr};
|
use std::{fs, str::FromStr};
|
||||||
|
|
||||||
use crate::database::models::ImageExif;
|
use crate::database::models::ImageExif;
|
||||||
|
use crate::error::ApiError;
|
||||||
use anyhow::{Context, anyhow};
|
use anyhow::{Context, anyhow};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -89,7 +90,7 @@ impl FromRequest for Claims {
|
|||||||
.map_or_else(
|
.map_or_else(
|
||||||
|e| {
|
|e| {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
err(crate::error::ApiError::unauthorized(
|
err(ApiError::unauthorized(
|
||||||
"unauthorized",
|
"unauthorized",
|
||||||
"Your session has expired. Sign in again.",
|
"Your session has expired. Sign in again.",
|
||||||
)
|
)
|
||||||
|
|||||||
+20
-21
@@ -16,6 +16,7 @@
|
|||||||
//! 5-minute window picks them up). For a single-user personal tool
|
//! 5-minute window picks them up). For a single-user personal tool
|
||||||
//! that's the right trade-off.
|
//! that's the right trade-off.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -246,9 +247,7 @@ async fn list_exact_handler(
|
|||||||
match dao.list_duplicates_exact(library_id, include_resolved) {
|
match dao.list_duplicates_exact(library_id, include_resolved) {
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return actix_web::ResponseError::error_response(
|
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||||
&crate::error::ApiError::internal(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -289,9 +288,7 @@ async fn list_perceptual_handler(
|
|||||||
match dao.list_perceptual_candidates(library_id, include_resolved) {
|
match dao.list_perceptual_candidates(library_id, include_resolved) {
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return actix_web::ResponseError::error_response(
|
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||||
&crate::error::ApiError::internal(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -318,7 +315,8 @@ async fn resolve_handler(
|
|||||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if body.demoted.is_empty() {
|
if body.demoted.is_empty() {
|
||||||
return HttpResponse::BadRequest().body("demoted list is empty");
|
return ApiError::bad_request("no_files", "Choose at least one duplicate to hide")
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dao = exif_dao.lock().expect("exif dao lock");
|
let mut dao = exif_dao.lock().expect("exif dao lock");
|
||||||
@@ -328,16 +326,23 @@ async fn resolve_handler(
|
|||||||
let survivor = match dao.lookup_duplicate_row(body.survivor.library_id, &body.survivor.rel_path)
|
let survivor = match dao.lookup_duplicate_row(body.survivor.library_id, &body.survivor.rel_path)
|
||||||
{
|
{
|
||||||
Ok(Some(row)) => row,
|
Ok(Some(row)) => row,
|
||||||
Ok(None) => return HttpResponse::NotFound().body("survivor not found"),
|
Ok(None) => {
|
||||||
|
return ApiError::not_found("file_not_found", "The photo to keep no longer exists")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return actix_web::ResponseError::error_response(&crate::error::ApiError::internal(e));
|
return ApiError::internal(e).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Survivor must not itself be soft-marked — otherwise the modal is
|
// Survivor must not itself be soft-marked — otherwise the modal is
|
||||||
// pointing at a row we've already demoted, which would create a chain.
|
// pointing at a row we've already demoted, which would create a chain.
|
||||||
if survivor.duplicate_of_hash.is_some() {
|
if survivor.duplicate_of_hash.is_some() {
|
||||||
return HttpResponse::Conflict().body("survivor is itself soft-marked as a duplicate");
|
return ApiError::conflict(
|
||||||
|
"survivor_is_duplicate",
|
||||||
|
"The photo to keep is already hidden as a duplicate",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = chrono::Utc::now().timestamp();
|
let now = chrono::Utc::now().timestamp();
|
||||||
@@ -355,9 +360,7 @@ async fn resolve_handler(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return actix_web::ResponseError::error_response(
|
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||||
&crate::error::ApiError::internal(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -395,7 +398,7 @@ async fn resolve_handler(
|
|||||||
&survivor.content_hash,
|
&survivor.content_hash,
|
||||||
now,
|
now,
|
||||||
) {
|
) {
|
||||||
return actix_web::ResponseError::error_response(&crate::error::ApiError::internal(e));
|
return ApiError::internal(e).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved_count += 1;
|
resolved_count += 1;
|
||||||
@@ -416,7 +419,7 @@ async fn unresolve_handler(
|
|||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let mut dao = exif_dao.lock().expect("exif dao lock");
|
let mut dao = exif_dao.lock().expect("exif dao lock");
|
||||||
if let Err(e) = dao.clear_duplicate_of(body.library_id, &body.rel_path) {
|
if let Err(e) = dao.clear_duplicate_of(body.library_id, &body.rel_path) {
|
||||||
return actix_web::ResponseError::error_response(&crate::error::ApiError::internal(e));
|
return ApiError::internal(e).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(dao);
|
drop(dao);
|
||||||
@@ -459,17 +462,13 @@ async fn list_folder_pairs_handler(
|
|||||||
let dup_rows = match dao.list_duplicates_exact(library_id, include_resolved) {
|
let dup_rows = match dao.list_duplicates_exact(library_id, include_resolved) {
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return actix_web::ResponseError::error_response(
|
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||||
&crate::error::ApiError::internal(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let all_paths = match dao.list_image_paths(library_id, include_resolved) {
|
let all_paths = match dao.list_image_paths(library_id, include_resolved) {
|
||||||
Ok(rows) => rows,
|
Ok(rows) => rows,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return actix_web::ResponseError::error_response(
|
return actix_web::ResponseError::error_response(&ApiError::internal(e));
|
||||||
&crate::error::ApiError::internal(e),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(dup_rows, all_paths)
|
(dup_rows, all_paths)
|
||||||
|
|||||||
+115
@@ -74,6 +74,29 @@ impl ApiError {
|
|||||||
Self::new(StatusCode::CONFLICT, code, message)
|
Self::new(StatusCode::CONFLICT, code, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn forbidden(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::FORBIDDEN, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gone(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::GONE, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn too_many_requests(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::TOO_MANY_REQUESTS, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 502: a service this server depends on (Apollo, ComfyUI, an LLM, SMS-API)
|
||||||
|
/// failed or answered badly.
|
||||||
|
pub fn bad_gateway(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::BAD_GATEWAY, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 503: a feature that isn't configured, or a dependency that's down.
|
||||||
|
pub fn unavailable(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self::new(StatusCode::SERVICE_UNAVAILABLE, code, message)
|
||||||
|
}
|
||||||
|
|
||||||
/// A 500 for an unexpected failure. `cause` is logged, never sent.
|
/// A 500 for an unexpected failure. `cause` is logged, never sent.
|
||||||
pub fn internal(cause: impl fmt::Debug) -> Self {
|
pub fn internal(cause: impl fmt::Debug) -> Self {
|
||||||
log::error!("Internal error: {:?}", cause);
|
log::error!("Internal error: {:?}", cause);
|
||||||
@@ -105,6 +128,12 @@ impl ApiError {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The response, for handlers that return `HttpResponse` rather than
|
||||||
|
/// `ApiResult`.
|
||||||
|
pub fn into_response(self) -> HttpResponse {
|
||||||
|
self.error_response()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn code(&self) -> &'static str {
|
pub fn code(&self) -> &'static str {
|
||||||
self.code
|
self.code
|
||||||
}
|
}
|
||||||
@@ -215,6 +244,25 @@ pub fn path_config() -> web::PathConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Multipart upload failures (malformed form, over the size limit).
|
||||||
|
pub fn multipart_config(total_limit: usize) -> actix_multipart::form::MultipartFormConfig {
|
||||||
|
actix_multipart::form::MultipartFormConfig::default()
|
||||||
|
.total_limit(total_limit)
|
||||||
|
.error_handler(|err, req: &HttpRequest| {
|
||||||
|
log::warn!("Upload error on {} {}: {}", req.method(), req.uri(), err);
|
||||||
|
let api = if err.to_string().contains("limit") {
|
||||||
|
ApiError::new(
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"body_too_large",
|
||||||
|
"The upload is too large",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ApiError::bad_request("invalid_upload", format!("Invalid upload: {}", err))
|
||||||
|
};
|
||||||
|
actix_web::error::InternalError::from_response(err, api.error_response()).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The App's default service: an unknown route answers in the same format.
|
/// The App's default service: an unknown route answers in the same format.
|
||||||
pub async fn unknown_route() -> HttpResponse {
|
pub async fn unknown_route() -> HttpResponse {
|
||||||
ApiError::not_found("unknown_route", "No such endpoint").error_response()
|
ApiError::not_found("unknown_route", "No such endpoint").error_response()
|
||||||
@@ -268,6 +316,73 @@ mod tests {
|
|||||||
assert!(!body.to_string().contains("secret"), "{body}");
|
assert!(!body.to_string().contains("secret"), "{body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every error response goes through `ApiError` (#202). A handler that
|
||||||
|
/// builds one by hand (`HttpResponse::NotFound()...`) skips the format;
|
||||||
|
/// this fails with the file and line so it can be converted.
|
||||||
|
#[test]
|
||||||
|
fn no_handler_builds_an_error_response_by_hand() {
|
||||||
|
const BUILDERS: &[&str] = &[
|
||||||
|
"BadRequest",
|
||||||
|
"Unauthorized",
|
||||||
|
"Forbidden",
|
||||||
|
"NotFound",
|
||||||
|
"MethodNotAllowed",
|
||||||
|
"Conflict",
|
||||||
|
"Gone",
|
||||||
|
"PayloadTooLarge",
|
||||||
|
"UnprocessableEntity",
|
||||||
|
"TooManyRequests",
|
||||||
|
"RangeNotSatisfiable",
|
||||||
|
"InternalServerError",
|
||||||
|
"NotImplemented",
|
||||||
|
"BadGateway",
|
||||||
|
"ServiceUnavailable",
|
||||||
|
"GatewayTimeout",
|
||||||
|
];
|
||||||
|
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||||
|
for entry in std::fs::read_dir(dir).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.is_dir() {
|
||||||
|
walk(&path, out);
|
||||||
|
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
walk(&src, &mut files);
|
||||||
|
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
for file in files {
|
||||||
|
let name = file.to_string_lossy();
|
||||||
|
if name.ends_with("tests.rs") || name.ends_with("/error.rs") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = std::fs::read_to_string(&file).unwrap();
|
||||||
|
// Test modules may assert on hand-built responses.
|
||||||
|
let code = match text.find("#[cfg(test)]\nmod ") {
|
||||||
|
Some(i) => &text[..i],
|
||||||
|
None => &text[..],
|
||||||
|
};
|
||||||
|
for (n, line) in code.lines().enumerate() {
|
||||||
|
if line.trim_start().starts_with("//") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for b in BUILDERS {
|
||||||
|
if line.contains(&format!("HttpResponse::{b}()")) {
|
||||||
|
offenders.push(format!("{}:{}: {}", name, n + 1, line.trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
offenders.is_empty(),
|
||||||
|
"build these with crate::error::ApiError instead:\n{}",
|
||||||
|
offenders.join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct Q {
|
struct Q {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
+98
-38
@@ -21,6 +21,7 @@
|
|||||||
use crate::Claims;
|
use crate::Claims;
|
||||||
use crate::ai::face_client::{DetectMeta, FaceClient, FaceDetectError};
|
use crate::ai::face_client::{DetectMeta, FaceClient, FaceDetectError};
|
||||||
use crate::database::schema::{face_detections, image_exif, persons};
|
use crate::database::schema::{face_detections, image_exif, persons};
|
||||||
|
use crate::error::ApiError;
|
||||||
use crate::error::IntoHttpError;
|
use crate::error::IntoHttpError;
|
||||||
use crate::exif;
|
use crate::exif;
|
||||||
use crate::file_types;
|
use crate::file_types;
|
||||||
@@ -1685,11 +1686,11 @@ async fn list_faces_handler<D: FaceDao>(
|
|||||||
// the watcher catches up.
|
// the watcher catches up.
|
||||||
return HttpResponse::Ok().json(Vec::<FaceWithPerson>::new());
|
return HttpResponse::Ok().json(Vec::<FaceWithPerson>::new());
|
||||||
}
|
}
|
||||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => return ApiError::internal(e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
match dao.list_for_content_hash(&hash) {
|
match dao.list_for_content_hash(&hash) {
|
||||||
Ok(faces) => HttpResponse::Ok().json(faces),
|
Ok(faces) => HttpResponse::Ok().json(faces),
|
||||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1745,7 +1746,7 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
// strict path does. Surface the disabled state only when we'd
|
// strict path does. Surface the disabled state only when we'd
|
||||||
// actually use the client.
|
// actually use the client.
|
||||||
if !body.force && !face_client.is_enabled() {
|
if !body.force && !face_client.is_enabled() {
|
||||||
return HttpResponse::ServiceUnavailable().body("face client disabled");
|
return faces_unavailable().into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized_path = normalize_path(&body.path);
|
let normalized_path = normalize_path(&body.path);
|
||||||
@@ -1763,10 +1764,13 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
match dao.resolve_content_hash(library.id, &normalized_path) {
|
match dao.resolve_content_hash(library.id, &normalized_path) {
|
||||||
Ok(Some(h)) => h,
|
Ok(Some(h)) => h,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::Conflict()
|
return ApiError::conflict(
|
||||||
.body("photo not yet hashed; wait for next watcher pass");
|
"photo_not_indexed",
|
||||||
|
"This photo is still being indexed. Try again in a minute.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => return ApiError::internal(e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1798,7 +1802,7 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("crop_image_to_bbox failed for {:?}: {:?}", abs_path, e);
|
warn!("crop_image_to_bbox failed for {:?}: {:?}", abs_path, e);
|
||||||
return HttpResponse::BadRequest().body(format!("cannot crop photo: {}", e));
|
return bad_crop(e).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1812,13 +1816,14 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
let detect = match face_client.embed(crop_bytes, meta).await {
|
let detect = match face_client.embed(crop_bytes, meta).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(FaceDetectError::Permanent(e)) => {
|
Err(FaceDetectError::Permanent(e)) => {
|
||||||
return HttpResponse::UnprocessableEntity().body(format!("{}", e));
|
return no_face_found(e).into_response();
|
||||||
}
|
}
|
||||||
Err(FaceDetectError::Transient(e)) => {
|
Err(FaceDetectError::Transient(e)) => {
|
||||||
return HttpResponse::ServiceUnavailable().body(format!("{}", e));
|
warn!("manual face: face service transient error: {}", e);
|
||||||
|
return faces_busy().into_response();
|
||||||
}
|
}
|
||||||
Err(FaceDetectError::Disabled) => {
|
Err(FaceDetectError::Disabled) => {
|
||||||
return HttpResponse::ServiceUnavailable().body("face client disabled");
|
return faces_unavailable().into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1826,14 +1831,18 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
Some(f) => f.clone(),
|
Some(f) => f.clone(),
|
||||||
None => {
|
None => {
|
||||||
// Apollo would have returned 422 on no_face_in_crop; defensive.
|
// Apollo would have returned 422 on no_face_in_crop; defensive.
|
||||||
return HttpResponse::UnprocessableEntity().body("no face in crop");
|
return no_face_found("no face in crop").into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let bytes = match detected.decode_embedding() {
|
let bytes = match detected.decode_embedding() {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("manual face: decode embedding failed: {:?}", e);
|
warn!("manual face: decode embedding failed: {:?}", e);
|
||||||
return HttpResponse::BadGateway().body("invalid embedding from face service");
|
return ApiError::bad_gateway(
|
||||||
|
"faces_bad_response",
|
||||||
|
"The face service sent an answer this server couldn't read",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(bytes, detect.model_version, detected.confidence)
|
(bytes, detect.model_version, detected.confidence)
|
||||||
@@ -1857,7 +1866,7 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
model_version,
|
model_version,
|
||||||
}) {
|
}) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => return ApiError::internal(e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
info!(
|
info!(
|
||||||
"Created manual face id={} library={} hash={} person_id={:?}",
|
"Created manual face id={} library={} hash={} person_id={:?}",
|
||||||
@@ -1865,7 +1874,7 @@ async fn create_face_handler<D: FaceDao>(
|
|||||||
);
|
);
|
||||||
match hydrate_face_with_person(&mut *dao, row) {
|
match hydrate_face_with_person(&mut *dao, row) {
|
||||||
Ok(joined) => HttpResponse::Created().json(joined),
|
Ok(joined) => HttpResponse::Created().json(joined),
|
||||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1902,18 +1911,17 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
Bbox edit requires Apollo to re-embed.",
|
Bbox edit requires Apollo to re-embed.",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
return HttpResponse::ServiceUnavailable()
|
return faces_unavailable().into_response();
|
||||||
.body("face client disabled — bbox edit requires Apollo");
|
|
||||||
}
|
}
|
||||||
// Look up the current row so we know which photo to crop.
|
// Look up the current row so we know which photo to crop.
|
||||||
let current = {
|
let current = {
|
||||||
let mut dao = face_dao.lock().expect("face dao lock");
|
let mut dao = face_dao.lock().expect("face dao lock");
|
||||||
match dao.get_face(id) {
|
match dao.get_face(id) {
|
||||||
Ok(Some(r)) => r,
|
Ok(Some(r)) => r,
|
||||||
Ok(None) => return HttpResponse::NotFound().finish(),
|
Ok(None) => return face_not_found().into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("PATCH /image/faces/{}: 500 — get_face failed: {:#}", id, e);
|
warn!("PATCH /image/faces/{}: 500 — get_face failed: {:#}", id, e);
|
||||||
return HttpResponse::InternalServerError().body(e.to_string());
|
return ApiError::internal(e.to_string()).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1924,10 +1932,11 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
"PATCH /image/faces/{}: 500 — face row references unknown library_id {}",
|
"PATCH /image/faces/{}: 500 — face row references unknown library_id {}",
|
||||||
id, current.library_id
|
id, current.library_id
|
||||||
);
|
);
|
||||||
return HttpResponse::InternalServerError().body(format!(
|
return ApiError::internal(format!(
|
||||||
"face row references unknown library_id {}",
|
"face row references unknown library_id {}",
|
||||||
current.library_id
|
current.library_id
|
||||||
));
|
))
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let abs_path = library.resolve(¤t.rel_path);
|
let abs_path = library.resolve(¤t.rel_path);
|
||||||
@@ -1938,7 +1947,7 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
"PATCH /image/faces/{}: crop failed for {:?}: {:?}",
|
"PATCH /image/faces/{}: crop failed for {:?}: {:?}",
|
||||||
id, abs_path, e
|
id, abs_path, e
|
||||||
);
|
);
|
||||||
return HttpResponse::BadRequest().body(format!("cannot crop new bbox: {}", e));
|
return bad_crop(e).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let meta = DetectMeta {
|
let meta = DetectMeta {
|
||||||
@@ -1990,7 +1999,7 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
error during re-embed: {}",
|
error during re-embed: {}",
|
||||||
id, e
|
id, e
|
||||||
);
|
);
|
||||||
return HttpResponse::ServiceUnavailable().body(format!("{}", e));
|
return faces_busy().into_response();
|
||||||
}
|
}
|
||||||
Err(FaceDetectError::Disabled) => {
|
Err(FaceDetectError::Disabled) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -1998,7 +2007,7 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
mid-flight",
|
mid-flight",
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
return HttpResponse::ServiceUnavailable().body("face client disabled mid-flight");
|
return faces_unavailable().into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2015,7 +2024,7 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
"PATCH /image/faces/{}: 500 — update_face failed: {:#}",
|
"PATCH /image/faces/{}: 500 — update_face failed: {:#}",
|
||||||
id, e
|
id, e
|
||||||
);
|
);
|
||||||
return HttpResponse::InternalServerError().body(e.to_string());
|
return ApiError::internal(e.to_string()).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// Hydrate person_name so the response shape matches GET /image/faces
|
// Hydrate person_name so the response shape matches GET /image/faces
|
||||||
@@ -2029,7 +2038,7 @@ async fn update_face_handler<D: FaceDao>(
|
|||||||
"PATCH /image/faces/{}: 500 — hydrate_face_with_person failed: {:#}",
|
"PATCH /image/faces/{}: 500 — hydrate_face_with_person failed: {:#}",
|
||||||
id, e
|
id, e
|
||||||
);
|
);
|
||||||
HttpResponse::InternalServerError().body(e.to_string())
|
ApiError::internal(e.to_string()).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2043,8 +2052,8 @@ async fn delete_face_handler<D: FaceDao>(
|
|||||||
let mut dao = face_dao.lock().expect("face dao lock");
|
let mut dao = face_dao.lock().expect("face dao lock");
|
||||||
match dao.delete_face(path.into_inner()) {
|
match dao.delete_face(path.into_inner()) {
|
||||||
Ok(true) => HttpResponse::NoContent().finish(),
|
Ok(true) => HttpResponse::NoContent().finish(),
|
||||||
Ok(false) => HttpResponse::NotFound().finish(),
|
Ok(false) => face_not_found().into_response(),
|
||||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2078,6 +2087,53 @@ async fn ignore_bucket_handler<D: FaceDao>(
|
|||||||
.into_http_internal_err()
|
.into_http_internal_err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- error responses (#202) ---------------------------------------------
|
||||||
|
|
||||||
|
/// Apollo's face service isn't configured.
|
||||||
|
fn faces_unavailable() -> ApiError {
|
||||||
|
ApiError::unavailable(
|
||||||
|
"faces_unavailable",
|
||||||
|
"Face recognition isn't set up on this server",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apollo's face service failed in a way a retry may fix.
|
||||||
|
fn faces_busy() -> ApiError {
|
||||||
|
ApiError::unavailable(
|
||||||
|
"faces_busy",
|
||||||
|
"Face recognition is busy or down. Try again shortly.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_face_found(reason: impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError::new(
|
||||||
|
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"no_face_found",
|
||||||
|
"No face was found in that box",
|
||||||
|
)
|
||||||
|
.with_details(serde_json::json!({ "reason": reason.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The crop error can name the file's absolute path; the caller logs it.
|
||||||
|
fn bad_crop(_e: impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError::bad_request("invalid_face_box", "Couldn't crop the photo to that box")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn face_not_found() -> ApiError {
|
||||||
|
ApiError::not_found("face_not_found", "That face no longer exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn person_not_found() -> ApiError {
|
||||||
|
ApiError::not_found("person_not_found", "That person no longer exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn person_name_taken() -> ApiError {
|
||||||
|
ApiError::conflict(
|
||||||
|
"person_name_taken",
|
||||||
|
"There's already a person with that name",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "persons.create", skip_all)]
|
#[tracing::instrument(name = "persons.create", skip_all)]
|
||||||
async fn create_person_handler<D: FaceDao>(
|
async fn create_person_handler<D: FaceDao>(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
@@ -2085,7 +2141,7 @@ async fn create_person_handler<D: FaceDao>(
|
|||||||
face_dao: web::Data<Mutex<D>>,
|
face_dao: web::Data<Mutex<D>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if body.name.trim().is_empty() {
|
if body.name.trim().is_empty() {
|
||||||
return HttpResponse::BadRequest().body("name required");
|
return ApiError::bad_request("invalid_person_name", "Enter a name").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dao = face_dao.lock().expect("face dao lock");
|
let mut dao = face_dao.lock().expect("face dao lock");
|
||||||
@@ -2098,9 +2154,9 @@ async fn create_person_handler<D: FaceDao>(
|
|||||||
// the outermost context ("insert person ...") which hides the
|
// the outermost context ("insert person ...") which hides the
|
||||||
// diesel "UNIQUE constraint failed" we're keying on.
|
// diesel "UNIQUE constraint failed" we're keying on.
|
||||||
if is_unique_violation(&e) {
|
if is_unique_violation(&e) {
|
||||||
HttpResponse::Conflict().body("person name already exists")
|
person_name_taken().into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().body(format!("{:#}", e))
|
ApiError::internal(format!("{:#}", e)).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2115,8 +2171,8 @@ async fn get_person_handler<D: FaceDao>(
|
|||||||
let mut dao = face_dao.lock().expect("face dao lock");
|
let mut dao = face_dao.lock().expect("face dao lock");
|
||||||
match dao.get_person(path.into_inner()) {
|
match dao.get_person(path.into_inner()) {
|
||||||
Ok(Some(p)) => HttpResponse::Ok().json(p),
|
Ok(Some(p)) => HttpResponse::Ok().json(p),
|
||||||
Ok(None) => HttpResponse::NotFound().finish(),
|
Ok(None) => person_not_found().into_response(),
|
||||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2132,9 +2188,9 @@ async fn update_person_handler<D: FaceDao>(
|
|||||||
Ok(p) => HttpResponse::Ok().json(p),
|
Ok(p) => HttpResponse::Ok().json(p),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if is_unique_violation(&e) {
|
if is_unique_violation(&e) {
|
||||||
HttpResponse::Conflict().body("person name already exists")
|
person_name_taken().into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().body(format!("{:#}", e))
|
ApiError::internal(format!("{:#}", e)).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2153,8 +2209,8 @@ async fn delete_person_handler<D: FaceDao>(
|
|||||||
let mut dao = face_dao.lock().expect("face dao lock");
|
let mut dao = face_dao.lock().expect("face dao lock");
|
||||||
match dao.delete_person(path.into_inner(), cascade) {
|
match dao.delete_person(path.into_inner(), cascade) {
|
||||||
Ok(true) => HttpResponse::NoContent().finish(),
|
Ok(true) => HttpResponse::NoContent().finish(),
|
||||||
Ok(false) => HttpResponse::NotFound().finish(),
|
Ok(false) => person_not_found().into_response(),
|
||||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
Err(e) => ApiError::internal(e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2172,9 +2228,13 @@ async fn merge_persons_handler<D: FaceDao>(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
let msg = format!("{:#}", e);
|
let msg = format!("{:#}", e);
|
||||||
if msg.contains("itself") {
|
if msg.contains("itself") {
|
||||||
HttpResponse::BadRequest().body(msg)
|
ApiError::bad_request(
|
||||||
|
"merge_into_self",
|
||||||
|
"A person can't be merged into themselves",
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().body(msg)
|
ApiError::internal(msg).into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-15
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::ApiError;
|
||||||
use ::anyhow;
|
use ::anyhow;
|
||||||
use actix::{Handler, Message};
|
use actix::{Handler, Message};
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
@@ -190,6 +191,11 @@ fn in_memory_date_sort(
|
|||||||
(result, result_libs, total_count)
|
(result, result_libs, total_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The requested folder is outside every library (or traverses out of one).
|
||||||
|
fn folder_not_found() -> ApiError {
|
||||||
|
ApiError::bad_request("folder_not_found", "That folder isn't in the library")
|
||||||
|
}
|
||||||
|
|
||||||
#[tracing::instrument(name = "list_photos", skip_all)]
|
#[tracing::instrument(name = "list_photos", skip_all)]
|
||||||
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
@@ -288,7 +294,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
Ok(lib) => lib,
|
Ok(lib) => lib,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
log::warn!("Rejecting /photos request: {}", msg);
|
log::warn!("Rejecting /photos request: {}", msg);
|
||||||
return HttpResponse::BadRequest().body(msg);
|
return crate::libraries::unknown_library(msg).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -308,7 +314,11 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
{
|
{
|
||||||
warn!("GPS search requires lat, lon, and radius_km to all be specified");
|
warn!("GPS search requires lat, lon, and radius_km to all be specified");
|
||||||
span.set_status(Status::error("Invalid GPS parameters"));
|
span.set_status(Status::error("Invalid GPS parameters"));
|
||||||
return HttpResponse::BadRequest().body("GPS search requires lat, lon, and radius_km");
|
return ApiError::bad_request(
|
||||||
|
"incomplete_gps_filter",
|
||||||
|
"A location filter needs a latitude, longitude and radius",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate GPS bounding box if GPS search is requested
|
// Calculate GPS bounding box if GPS search is requested
|
||||||
@@ -722,7 +732,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
if !any_library_resolved {
|
if !any_library_resolved {
|
||||||
error!("Bad photos request: {}", req.path);
|
error!("Bad photos request: {}", req.path);
|
||||||
span.set_status(Status::error("Invalid path"));
|
span.set_status(Status::error("Invalid path"));
|
||||||
return HttpResponse::BadRequest().finish();
|
return folder_not_found().into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A folder whose indexed contents are all hidden versions (typically
|
// A folder whose indexed contents are all hidden versions (typically
|
||||||
@@ -1205,9 +1215,7 @@ pub async fn get_gps_summary(
|
|||||||
if !validated {
|
if !validated {
|
||||||
warn!("Invalid path for GPS summary: {}", req.path);
|
warn!("Invalid path for GPS summary: {}", req.path);
|
||||||
span.set_status(Status::error("Invalid path"));
|
span.set_status(Status::error("Invalid path"));
|
||||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({
|
return Ok(folder_not_found().into_response());
|
||||||
"error": "Invalid path"
|
|
||||||
})));
|
|
||||||
}
|
}
|
||||||
req.path.clone()
|
req.path.clone()
|
||||||
};
|
};
|
||||||
@@ -1281,9 +1289,7 @@ pub async fn get_gps_summary(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Error querying GPS data: {:?}", e);
|
error!("Error querying GPS data: {:?}", e);
|
||||||
span.set_status(Status::error(format!("Database error: {:?}", e)));
|
span.set_status(Status::error(format!("Database error: {:?}", e)));
|
||||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
Ok(ApiError::internal("Failed to query GPS data").into_response())
|
||||||
"error": "Failed to query GPS data"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1336,7 +1342,7 @@ pub async fn list_exif_summary(
|
|||||||
Ok(lib) => lib.map(|l| l.id),
|
Ok(lib) => lib.map(|l| l.id),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error(msg.clone()));
|
span.set_status(Status::error(msg.clone()));
|
||||||
return Ok(HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })));
|
return Ok(crate::libraries::unknown_library(msg).into_response());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1386,9 +1392,7 @@ pub async fn list_exif_summary(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Error querying EXIF batch: {:?}", e);
|
error!("Error querying EXIF batch: {:?}", e);
|
||||||
span.set_status(Status::error(format!("Database error: {:?}", e)));
|
span.set_status(Status::error(format!("Database error: {:?}", e)));
|
||||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
Ok(ApiError::internal("Failed to query EXIF data").into_response())
|
||||||
"error": "Failed to query EXIF data"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1432,9 +1436,9 @@ pub async fn move_file<FS: FileSystemAccess>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if e == ErrorKind::InvalidData {
|
if e == ErrorKind::InvalidData {
|
||||||
HttpResponse::BadRequest().finish()
|
ApiError::bad_request("invalid_move", "Can't move the file there").into_response()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError().finish()
|
ApiError::internal("internal error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-16
@@ -2,6 +2,7 @@
|
|||||||
//! and shared across libraries — a favorite created in lib1 is visible
|
//! and shared across libraries — a favorite created in lib1 is visible
|
||||||
//! under lib2 if the same rel_path resolves there too.
|
//! under lib2 if the same rel_path resolves there too.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
|
|
||||||
@@ -53,10 +54,9 @@ pub async fn favorites(
|
|||||||
}
|
}
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
span.set_status(Status::error(format!("Error getting favorites: {:?}", e)));
|
span.set_status(Status::error(format!("Error getting favorites: {:?}", e)));
|
||||||
error!("Error getting favorites: {:?}", e);
|
ApiError::internal(format!("Error getting favorites: {:?}", e)).into_response()
|
||||||
HttpResponse::InternalServerError().finish()
|
|
||||||
}
|
}
|
||||||
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 => {
|
Ok(Err(e)) if e.kind == DbErrorKind::AlreadyExists => {
|
||||||
warn!("Favorite: {} exists for user: {}", &body.path, user_id);
|
warn!("Favorite: {} exists for user: {}", &body.path, user_id);
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok().finish()
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
error!("{:?} {}. for user: {}", e, body.path, user_id);
|
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
}
|
||||||
|
Ok(Err(e)) => ApiError::internal(format!(
|
||||||
|
"{:?} adding favorite {}. for user: {}",
|
||||||
|
e, body.path, user_id
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
Ok(Ok(_)) => {
|
Ok(Ok(_)) => {
|
||||||
info!("Adding favorite \"{}\" for userid: {}", body.path, user_id);
|
info!("Adding favorite \"{}\" for userid: {}", body.path, user_id);
|
||||||
HttpResponse::Created()
|
HttpResponse::Created().finish()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Blocking error while inserting favorite: {:?}", e);
|
ApiError::internal(format!("Blocking error while inserting favorite: {:?}", e))
|
||||||
HttpResponse::InternalServerError()
|
.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
bad_user(&claims).into_response()
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +120,14 @@ pub async fn delete_favorite(
|
|||||||
"Removing favorite \"{}\" for userid: {}",
|
"Removing favorite \"{}\" for userid: {}",
|
||||||
body.path, user_id
|
body.path, user_id
|
||||||
);
|
);
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok().finish()
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
bad_user(&claims).into_response()
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
//! legacy thumbnail lookup), upload, EXIF metadata read + GPS / date
|
||||||
//! mutation, and the full exiftool dump used by Apollo's details modal.
|
//! mutation, and the full exiftool dump used by Apollo's details modal.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
@@ -56,7 +57,7 @@ pub async fn get_image(
|
|||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error(msg.clone()));
|
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;
|
return resp;
|
||||||
}
|
}
|
||||||
span.set_status(Status::error("Thumbnail unavailable"));
|
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"));
|
span.set_status(Status::error("Not found"));
|
||||||
HttpResponse::NotFound().finish()
|
file_not_found().into_response()
|
||||||
} else {
|
} else {
|
||||||
span.set_status(Status::error("Not found"));
|
span.set_status(Status::error("Not found"));
|
||||||
error!("Path does not exist in any library: {}", req.path);
|
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);
|
error!("{}", message);
|
||||||
span.set_status(Status::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,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("file not found"));
|
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) {
|
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) {
|
if let Err(e) = exif::write_gps(&full_path, body.latitude, body.longitude) {
|
||||||
let msg = format!("exiftool write failed: {}", e);
|
let msg = format!("exiftool write failed: {}", e);
|
||||||
error!("{}", msg);
|
error!("{}", msg);
|
||||||
span.set_status(Status::error(msg.clone()));
|
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
|
// 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.
|
// next file scan rewrites it.
|
||||||
let msg = format!("EXIF re-read failed after write: {}", e);
|
let msg = format!("EXIF re-read failed after write: {}", e);
|
||||||
error!("{}", msg);
|
error!("{}", msg);
|
||||||
return HttpResponse::InternalServerError().body(msg);
|
return ApiError::internal(msg).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
@@ -742,7 +751,7 @@ pub async fn set_image_gps(
|
|||||||
let msg = format!("EXIF DB update failed: {:?}", e);
|
let msg = format!("EXIF DB update failed: {:?}", e);
|
||||||
error!("{}", msg);
|
error!("{}", msg);
|
||||||
span.set_status(Status::error(msg.clone()));
|
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,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("file not found"));
|
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()));
|
span.set_status(Status::error(msg.clone()));
|
||||||
// 503 — typically "exiftool isn't on PATH" or a transient spawn
|
// 503 — typically "exiftool isn't on PATH" or a transient spawn
|
||||||
// failure. Apollo surfaces a hint in the modal.
|
// 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) => {
|
Err(e) => {
|
||||||
let msg = format!("blocking-pool error: {}", e);
|
let msg = format!("blocking-pool error: {}", e);
|
||||||
error!("{}", msg);
|
error!("{}", msg);
|
||||||
span.set_status(Status::error(msg.clone()));
|
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(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error(msg.clone()));
|
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);
|
error!("{}", msg);
|
||||||
span.set_status(Status::error(msg.clone()));
|
span.set_status(Status::error(msg.clone()));
|
||||||
match e.kind {
|
match e.kind {
|
||||||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
DbErrorKind::NotFound => not_indexed().into_response(),
|
||||||
_ => HttpResponse::InternalServerError().body(msg),
|
_ => ApiError::internal(msg).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1031,7 +1044,7 @@ pub async fn clear_image_date(
|
|||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error(msg.clone()));
|
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);
|
error!("{}", msg);
|
||||||
span.set_status(Status::error(msg.clone()));
|
span.set_status(Status::error(msg.clone()));
|
||||||
match e.kind {
|
match e.kind {
|
||||||
DbErrorKind::NotFound => HttpResponse::NotFound().body(msg),
|
DbErrorKind::NotFound => not_indexed().into_response(),
|
||||||
_ => HttpResponse::InternalServerError().body(msg),
|
_ => ApiError::internal(msg).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1089,7 +1102,7 @@ pub async fn upload_image(
|
|||||||
Ok(None) => app_state.primary_library(),
|
Ok(None) => app_state.primary_library(),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
span.set_status(Status::error(msg.clone()));
|
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_content.is_empty() {
|
||||||
if file_name.is_none() {
|
if file_name.is_none() {
|
||||||
span.set_status(Status::error("No filename provided"));
|
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());
|
let full_path = PathBuf::from(&path).join(file_name.unwrap());
|
||||||
if let Some(full_path) = is_valid_full_path(
|
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)
|
.find(|l| l.id == existing.library_id)
|
||||||
.map(|l| l.name);
|
.map(|l| l.name);
|
||||||
span.set_status(Status::Ok);
|
span.set_status(Status::Ok);
|
||||||
return HttpResponse::Conflict().json(serde_json::json!({
|
// The extra fields predate the error format; clients
|
||||||
"duplicate_of": {
|
// 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,
|
"library_id": existing.library_id,
|
||||||
"rel_path": existing.file_path,
|
"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 {
|
} else {
|
||||||
error!("Invalid path for upload: {:?}", full_path);
|
error!("Invalid path for upload: {:?}", full_path);
|
||||||
span.set_status(Status::error("Invalid path for upload"));
|
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 {
|
} else {
|
||||||
span.set_status(Status::error("No file body read"));
|
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);
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+42
-21
@@ -1,6 +1,7 @@
|
|||||||
//! Video-related endpoints: HLS playlist generation, segment streaming,
|
//! Video-related endpoints: HLS playlist generation, segment streaming,
|
||||||
//! and the short-clip preview pipeline.
|
//! and the short-clip preview pipeline.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
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 resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref());
|
||||||
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
||||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
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
|
// Build the rel_path used to look up the row. Forward-slash normalized
|
||||||
@@ -112,7 +113,7 @@ pub async fn generate_video(
|
|||||||
e
|
e
|
||||||
);
|
);
|
||||||
span.set_status(Status::error(format!("hash compute failed: {}", 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();
|
let (hash, file) = path.into_inner();
|
||||||
if !is_valid_hash(&hash) {
|
if !is_valid_hash(&hash) {
|
||||||
span.set_status(Status::error("invalid 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) {
|
if !is_allowed_hls_filename(&file) {
|
||||||
span.set_status(Status::error("invalid 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];
|
let shard = &hash[..2];
|
||||||
@@ -220,7 +223,7 @@ pub async fn stream_hls_file(
|
|||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
|
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
|
||||||
span.set_status(Status::error("VIDEO_PATH not canonicalisable"));
|
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) {
|
let canonical_file = match std::fs::canonicalize(&file_path) {
|
||||||
@@ -228,7 +231,7 @@ pub async fn stream_hls_file(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
debug!("HLS file not found: {}", file_path.display());
|
debug!("HLS file not found: {}", file_path.display());
|
||||||
span.set_status(Status::error("not found"));
|
span.set_status(Status::error("not found"));
|
||||||
return HttpResponse::NotFound().finish();
|
return stream_not_found().into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !canonical_file.starts_with(&canonical_base) {
|
if !canonical_file.starts_with(&canonical_base) {
|
||||||
@@ -237,7 +240,7 @@ pub async fn stream_hls_file(
|
|||||||
file_path.display()
|
file_path.display()
|
||||||
);
|
);
|
||||||
span.set_status(Status::error("traversal"));
|
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) {
|
match NamedFile::open(&canonical_file) {
|
||||||
@@ -247,7 +250,7 @@ pub async fn stream_hls_file(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
span.set_status(Status::error("not found"));
|
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())
|
resolve_request_path(&app_state, &body.path, body.library.as_deref())
|
||||||
else {
|
else {
|
||||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
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) {
|
if !file_types::is_video_file(&full_path) {
|
||||||
span.set_status(Status::error("not a video"));
|
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);
|
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));
|
.and_then(|t| original_url::verify(&t.0));
|
||||||
let Some(claims) = claims else {
|
let Some(claims) = claims else {
|
||||||
span.set_status(Status::error("bad token"));
|
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
|
let Some(path) = app_state
|
||||||
.libraries
|
.libraries
|
||||||
@@ -382,7 +389,7 @@ pub async fn stream_original_video(
|
|||||||
.filter(|p| p.is_file())
|
.filter(|p| p.is_file())
|
||||||
else {
|
else {
|
||||||
span.set_status(Status::error("not found"));
|
span.set_status(Status::error("not found"));
|
||||||
return HttpResponse::NotFound().finish();
|
return file_not_found().into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
match NamedFile::open(&path) {
|
match NamedFile::open(&path) {
|
||||||
@@ -394,7 +401,7 @@ pub async fn stream_original_video(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
span.set_status(Status::error("not found"));
|
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,
|
Some(path) => path,
|
||||||
None => {
|
None => {
|
||||||
span.set_status(Status::error("Invalid path"));
|
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
|
.error_message
|
||||||
.unwrap_or_else(|| "Unknown error".to_string());
|
.unwrap_or_else(|| "Unknown error".to_string());
|
||||||
span.set_status(Status::error(format!("Generation failed: {}", error_msg)));
|
span.set_status(Status::error(format!("Generation failed: {}", error_msg)));
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({
|
ApiError::internal(format!("Generation failed: {}", error_msg)).into_response()
|
||||||
"error": format!("Generation failed: {}", error_msg)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// pending or unknown status - trigger generation
|
// pending or unknown status - trigger generation
|
||||||
@@ -569,7 +574,7 @@ pub async fn get_video_preview(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
span.set_status(Status::error("Database error"));
|
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
|
// Limit to 200 paths per request
|
||||||
if body.paths.len() > 200 {
|
if body.paths.len() > 200 {
|
||||||
span.set_status(Status::error("Too many paths"));
|
span.set_status(Status::error("Too many paths"));
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("too_many_files", "At most 200 videos per request")
|
||||||
.json(serde_json::json!({"error": "Maximum 200 paths per request"}));
|
.with_details(serde_json::json!({ "max": 200 }))
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let previews = {
|
let previews = {
|
||||||
@@ -665,11 +671,26 @@ pub async fn get_preview_status(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
span.set_status(Status::error("Database error"));
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+79
-68
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||||
use actix_web::{App, HttpRequest, HttpResponse, Responder, web};
|
use actix_web::{App, HttpRequest, HttpResponse, Responder, web};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
@@ -519,7 +520,7 @@ async fn list_entities<D: KnowledgeDao + 'static>(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("list_entities error: {:?}", e);
|
log::error!("list_entities error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,12 +539,12 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
|||||||
let entity = match dao.get_entity_by_id(entity_id) {
|
let entity = match dao.get_entity_by_id(entity_id) {
|
||||||
Ok(Some(e)) => e,
|
Ok(Some(e)) => e,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
|
return ApiError::not_found("entity_not_found", "That entity no longer exists")
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("get_entity error: {:?}", e);
|
log::error!("get_entity error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -552,8 +553,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
|||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("get_facts_for_entity error: {:?}", e);
|
log::error!("get_facts_for_entity error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -644,8 +644,7 @@ async fn get_entity<D: KnowledgeDao + 'static>(
|
|||||||
Ok(links) => links.into_iter().map(PhotoLinkDetail::from).collect(),
|
Ok(links) => links.into_iter().map(PhotoLinkDetail::from).collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("get_links_for_entity error: {:?}", e);
|
log::error!("get_links_for_entity error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -693,10 +692,12 @@ async fn patch_entity<D: KnowledgeDao + 'static>(
|
|||||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||||
match dao.update_entity(entity_id, patch) {
|
match dao.update_entity(entity_id, patch) {
|
||||||
Ok(Some(entity)) => HttpResponse::Ok().json(EntitySummary::from(entity)),
|
Ok(Some(entity)) => HttpResponse::Ok().json(EntitySummary::from(entity)),
|
||||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"})),
|
Ok(None) => {
|
||||||
|
ApiError::not_found("entity_not_found", "That entity no longer exists").into_response()
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("patch_entity error: {:?}", e);
|
log::error!("patch_entity error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,12 +713,12 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
|
|||||||
// Verify entity exists before deleting
|
// Verify entity exists before deleting
|
||||||
match dao.get_entity_by_id(entity_id) {
|
match dao.get_entity_by_id(entity_id) {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "Entity not found"}));
|
return ApiError::not_found("entity_not_found", "That entity no longer exists")
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("delete_entity lookup error: {:?}", e);
|
log::error!("delete_entity lookup error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
}
|
}
|
||||||
@@ -726,7 +727,7 @@ async fn delete_entity<D: KnowledgeDao + 'static>(
|
|||||||
Ok(()) => HttpResponse::NoContent().finish(),
|
Ok(()) => HttpResponse::NoContent().finish(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("delete_entity error: {:?}", e);
|
log::error!("delete_entity error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,8 +738,8 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
|||||||
dao: web::Data<Mutex<D>>,
|
dao: web::Data<Mutex<D>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if body.source_id == body.target_id {
|
if body.source_id == body.target_id {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("merge_into_self", "An entity can't be merged into itself")
|
||||||
.json(serde_json::json!({"error": "source_id and target_id must be different"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||||
@@ -747,13 +748,15 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
|||||||
for id in [body.source_id, body.target_id] {
|
for id in [body.source_id, body.target_id] {
|
||||||
match dao.get_entity_by_id(id) {
|
match dao.get_entity_by_id(id) {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request(
|
||||||
.json(serde_json::json!({"error": format!("Entity {} not found", id)}));
|
"entity_not_found",
|
||||||
|
format!("Entity {} no longer exists", id),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("merge_entities lookup error: {:?}", e);
|
log::error!("merge_entities lookup error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
}
|
}
|
||||||
@@ -768,7 +771,7 @@ async fn merge_entities<D: KnowledgeDao + 'static>(
|
|||||||
}),
|
}),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("merge_entities error: {:?}", e);
|
log::error!("merge_entities error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -790,8 +793,8 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
|||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if body.source_id == body.target_id {
|
if body.source_id == body.target_id {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("merge_into_self", "An entity can't be merged into itself")
|
||||||
.json(serde_json::json!({"error": "source_id and target_id must differ"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let (source, target) = {
|
let (source, target) = {
|
||||||
@@ -800,25 +803,29 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
|||||||
let source = match dao.get_entity_by_id(body.source_id) {
|
let source = match dao.get_entity_by_id(body.source_id) {
|
||||||
Ok(Some(e)) => e,
|
Ok(Some(e)) => e,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request(
|
||||||
.json(serde_json::json!({"error": "source entity not found"}));
|
"entity_not_found",
|
||||||
|
"The entity to merge no longer exists",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("synthesize_merge source lookup: {:?}", e);
|
log::error!("synthesize_merge source lookup: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let target = match dao.get_entity_by_id(body.target_id) {
|
let target = match dao.get_entity_by_id(body.target_id) {
|
||||||
Ok(Some(e)) => e,
|
Ok(Some(e)) => e,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request(
|
||||||
.json(serde_json::json!({"error": "target entity not found"}));
|
"entity_not_found",
|
||||||
|
"The entity to merge into no longer exists",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("synthesize_merge target lookup: {:?}", e);
|
log::error!("synthesize_merge target lookup: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
(source, target)
|
(source, target)
|
||||||
@@ -890,9 +897,11 @@ async fn synthesize_merge<D: KnowledgeDao + 'static>(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("synthesize_merge generate failed: {:?}", e);
|
log::warn!("synthesize_merge generate failed: {:?}", e);
|
||||||
return HttpResponse::ServiceUnavailable().json(serde_json::json!({
|
return ApiError::unavailable(
|
||||||
"error": "LLM unavailable; the merge picker should fall back to skip-synthesis."
|
"llm_unavailable",
|
||||||
}));
|
"The language model is unavailable. Merge without a summary, or try again later.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -967,7 +976,7 @@ async fn list_facts<D: KnowledgeDao + 'static>(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("list_facts error: {:?}", e);
|
log::error!("list_facts error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -980,13 +989,11 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
|||||||
persona_dao: PersonaDaoData,
|
persona_dao: PersonaDaoData,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if body.object_entity_id.is_none() && body.object_value.is_none() {
|
if body.object_entity_id.is_none() && body.object_value.is_none() {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return ApiError::bad_request("invalid_fact", "A fact needs an object entity or a value")
|
||||||
"error": "object_entity_id or object_value is required"
|
.into_response();
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if body.predicate.trim().is_empty() {
|
if body.predicate.trim().is_empty() {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("invalid_fact", "A fact needs a predicate").into_response();
|
||||||
.json(serde_json::json!({"error": "predicate must not be empty"}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persona scoping: facts are written under the active single persona.
|
// Persona scoping: facts are written under the active single persona.
|
||||||
@@ -1006,14 +1013,15 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
|||||||
// Verify subject entity exists.
|
// Verify subject entity exists.
|
||||||
match dao.get_entity_by_id(body.subject_entity_id) {
|
match dao.get_entity_by_id(body.subject_entity_id) {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return ApiError::bad_request(
|
||||||
"error": format!("Subject entity {} not found", body.subject_entity_id)
|
"entity_not_found",
|
||||||
}));
|
"The fact's subject no longer exists",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("create_fact subject lookup error: {:?}", e);
|
log::error!("create_fact subject lookup error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
}
|
}
|
||||||
@@ -1022,14 +1030,15 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
|||||||
if let Some(oid) = body.object_entity_id {
|
if let Some(oid) = body.object_entity_id {
|
||||||
match dao.get_entity_by_id(oid) {
|
match dao.get_entity_by_id(oid) {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
return ApiError::bad_request(
|
||||||
"error": format!("Object entity {} not found", oid)
|
"entity_not_found",
|
||||||
}));
|
"The fact's object no longer exists",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("create_fact object lookup error: {:?}", e);
|
log::error!("create_fact object lookup error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
}
|
}
|
||||||
@@ -1074,7 +1083,7 @@ async fn create_fact<D: KnowledgeDao + 'static>(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("create_fact upsert error: {:?}", e);
|
log::error!("create_fact upsert error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1101,10 +1110,12 @@ async fn patch_fact<D: KnowledgeDao + 'static>(
|
|||||||
// agent corrections.
|
// agent corrections.
|
||||||
match dao.update_fact(fact_id, patch, Some(("manual", "manual"))) {
|
match dao.update_fact(fact_id, patch, Some(("manual", "manual"))) {
|
||||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
|
Ok(None) => {
|
||||||
|
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("patch_fact error: {:?}", e);
|
log::error!("patch_fact error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1131,7 @@ async fn delete_fact<D: KnowledgeDao + 'static>(
|
|||||||
Ok(()) => HttpResponse::NoContent().finish(),
|
Ok(()) => HttpResponse::NoContent().finish(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("delete_fact({}) error: {:?}", fact_id, e);
|
log::warn!("delete_fact({}) error: {:?}", fact_id, e);
|
||||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"}))
|
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1133,8 +1144,8 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
|
|||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let old_id = id.into_inner();
|
let old_id = id.into_inner();
|
||||||
if old_id == body.by_fact_id {
|
if old_id == body.by_fact_id {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("supersede_self", "A fact can't replace itself")
|
||||||
.json(serde_json::json!({"error": "old_id and by_fact_id must differ"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||||
// Manual supersede from the curation UI — same stamping rule as
|
// Manual supersede from the curation UI — same stamping rule as
|
||||||
@@ -1142,11 +1153,11 @@ async fn supersede_fact<D: KnowledgeDao + 'static>(
|
|||||||
match dao.supersede_fact(old_id, body.by_fact_id, Some(("manual", "manual"))) {
|
match dao.supersede_fact(old_id, body.by_fact_id, Some(("manual", "manual"))) {
|
||||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Old or new fact not found"}))
|
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("supersede_fact error: {:?}", e);
|
log::error!("supersede_fact error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1160,10 +1171,12 @@ async fn restore_fact<D: KnowledgeDao + 'static>(
|
|||||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||||
match dao.revert_supersession(fact_id, Some(("manual", "manual"))) {
|
match dao.revert_supersession(fact_id, Some(("manual", "manual"))) {
|
||||||
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
Ok(Some(fact)) => HttpResponse::Ok().json(fact),
|
||||||
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Fact not found"})),
|
Ok(None) => {
|
||||||
|
ApiError::not_found("fact_not_found", "That fact no longer exists").into_response()
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("restore_fact error: {:?}", e);
|
log::error!("restore_fact error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1206,7 @@ async fn get_recent<D: KnowledgeDao + 'static>(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("get_recent error: {:?}", e);
|
log::error!("get_recent error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1217,7 +1230,7 @@ async fn get_predicate_stats<D: KnowledgeDao + 'static>(
|
|||||||
}),
|
}),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("get_predicate_stats error: {:?}", e);
|
log::error!("get_predicate_stats error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1231,8 +1244,7 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
|
|||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let predicate = predicate.into_inner();
|
let predicate = predicate.into_inner();
|
||||||
if predicate.trim().is_empty() {
|
if predicate.trim().is_empty() {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("invalid_fact", "A fact needs a predicate").into_response();
|
||||||
.json(serde_json::json!({"error": "predicate must not be empty"}));
|
|
||||||
}
|
}
|
||||||
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
let persona = resolve_persona_filter(&req, &claims, &persona_dao);
|
||||||
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
let mut dao = dao.lock().expect("Unable to lock KnowledgeDao");
|
||||||
@@ -1240,7 +1252,7 @@ async fn bulk_reject_predicate<D: KnowledgeDao + 'static>(
|
|||||||
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
|
Ok(rejected) => HttpResponse::Ok().json(BulkRejectResponse { rejected }),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("bulk_reject_predicate error: {:?}", e);
|
log::error!("bulk_reject_predicate error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1290,7 @@ async fn get_graph<D: KnowledgeDao + 'static>(
|
|||||||
}),
|
}),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("build_entity_graph error: {:?}", e);
|
log::error!("build_entity_graph error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1302,8 +1314,7 @@ async fn get_consolidation_proposals<D: KnowledgeDao + 'static>(
|
|||||||
Ok(g) => g,
|
Ok(g) => g,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("find_consolidation_proposals: {:?}", e);
|
log::error!("find_consolidation_proposals: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+36
-8
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, Responder, get, patch, web, web::Data};
|
use actix_web::{HttpResponse, Responder, get, patch, web, web::Data};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
@@ -319,6 +320,22 @@ pub fn resolve_library_param<'a>(
|
|||||||
/// Resolve a library request parameter against the AppState's libraries.
|
/// Resolve a library request parameter against the AppState's libraries.
|
||||||
/// Returns `Ok(None)` when the param is absent, meaning "span all libraries".
|
/// Returns `Ok(None)` when the param is absent, meaning "span all libraries".
|
||||||
/// Returns `Err` when a value is provided but does not match any library.
|
/// Returns `Err` when a value is provided but does not match any library.
|
||||||
|
/// The response to a `library` param that names no library.
|
||||||
|
pub fn unknown_library(reason: impl std::fmt::Display) -> ApiError {
|
||||||
|
ApiError::bad_request(
|
||||||
|
"unknown_library",
|
||||||
|
"That library doesn't exist on this server",
|
||||||
|
)
|
||||||
|
.with_details(serde_json::json!({ "reason": reason.to_string() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library_not_found() -> ApiError {
|
||||||
|
ApiError::not_found(
|
||||||
|
"library_not_found",
|
||||||
|
"That library doesn't exist on this server",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_library_param_state<'a>(
|
pub fn resolve_library_param_state<'a>(
|
||||||
state: &'a AppState,
|
state: &'a AppState,
|
||||||
param: Option<&str>,
|
param: Option<&str>,
|
||||||
@@ -546,7 +563,12 @@ pub async fn patch_library(
|
|||||||
let body = body.into_inner();
|
let body = body.into_inner();
|
||||||
|
|
||||||
if body.enabled.is_none() && body.excluded_dirs.is_none() {
|
if body.enabled.is_none() && body.excluded_dirs.is_none() {
|
||||||
return HttpResponse::UnprocessableEntity().body("empty patch body");
|
return ApiError::new(
|
||||||
|
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"nothing_to_change",
|
||||||
|
"Nothing to change",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut conn = crate::database::connect();
|
let mut conn = crate::database::connect();
|
||||||
@@ -566,7 +588,7 @@ pub async fn patch_library(
|
|||||||
"PATCH /libraries/{}: enabled update failed: {:?}",
|
"PATCH /libraries/{}: enabled update failed: {:?}",
|
||||||
lib_id, e
|
lib_id, e
|
||||||
);
|
);
|
||||||
return HttpResponse::InternalServerError().body(format!("{}", e));
|
return ApiError::internal(format!("{}", e)).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -579,7 +601,15 @@ pub async fn patch_library(
|
|||||||
// back as 422 so the operator can fix the typo.
|
// back as 422 so the operator can fix the typo.
|
||||||
let normalised = match normalize_excluded_dirs_input(raw) {
|
let normalised = match normalize_excluded_dirs_input(raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(msg) => return HttpResponse::UnprocessableEntity().body(msg),
|
// The message names the entry at fault and how to fix it.
|
||||||
|
Err(msg) => {
|
||||||
|
return ApiError::new(
|
||||||
|
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"invalid_excluded_dirs",
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let stored: Option<&str> = normalised.as_deref();
|
let stored: Option<&str> = normalised.as_deref();
|
||||||
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
|
match diesel::update(libraries::table.filter(libraries::id.eq(lib_id)))
|
||||||
@@ -592,13 +622,13 @@ pub async fn patch_library(
|
|||||||
"PATCH /libraries/{}: excluded_dirs update failed: {:?}",
|
"PATCH /libraries/{}: excluded_dirs update failed: {:?}",
|
||||||
lib_id, e
|
lib_id, e
|
||||||
);
|
);
|
||||||
return HttpResponse::InternalServerError().body(format!("{}", e));
|
return ApiError::internal(format!("{}", e)).into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return HttpResponse::NotFound().body(format!("library id {} not found", lib_id));
|
return library_not_found().into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh the live view from the canonical DB state. Reloading the
|
// Refresh the live view from the canonical DB state. Reloading the
|
||||||
@@ -623,9 +653,7 @@ pub async fn patch_library(
|
|||||||
);
|
);
|
||||||
HttpResponse::Ok().json(lib)
|
HttpResponse::Ok().json(lib)
|
||||||
}
|
}
|
||||||
None => {
|
None => library_not_found().into_response(),
|
||||||
HttpResponse::NotFound().body(format!("library id {} not found after update", lib_id))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -11,7 +11,6 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_governor::{Governor, GovernorConfigBuilder};
|
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||||
use actix_multipart as mp;
|
|
||||||
use actix_web::{App, HttpServer, middleware, web};
|
use actix_web::{App, HttpServer, middleware, web};
|
||||||
use diesel::sqlite::Sqlite;
|
use diesel::sqlite::Sqlite;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
@@ -443,7 +442,7 @@ fn main() -> std::io::Result<()> {
|
|||||||
.app_data::<Data<image_api::ai::face_client::FaceClient>>(Data::new(
|
.app_data::<Data<image_api::ai::face_client::FaceClient>>(Data::new(
|
||||||
app_data.face_client.clone(),
|
app_data.face_client.clone(),
|
||||||
))
|
))
|
||||||
.app_data(mp::form::MultipartFormConfig::default().total_limit(1024 * 1024 * 1024)) // 1GB upload limit
|
.app_data(image_api::error::multipart_config(1024 * 1024 * 1024)) // 1GB upload limit
|
||||||
// Request-parsing failures and unknown routes answer in the
|
// Request-parsing failures and unknown routes answer in the
|
||||||
// shared error format (#202), not actix's plain text.
|
// shared error format (#202), not actix's plain text.
|
||||||
.app_data(image_api::error::json_config())
|
.app_data(image_api::error::json_config())
|
||||||
|
|||||||
+7
-2
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::web::Data;
|
use actix_web::web::Data;
|
||||||
use actix_web::{HttpResponse, Responder, get, web};
|
use actix_web::{HttpResponse, Responder, get, web};
|
||||||
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc};
|
||||||
@@ -569,7 +570,11 @@ pub async fn list_memories(
|
|||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
warn!("Rejecting /memories request: {}", msg);
|
warn!("Rejecting /memories request: {}", msg);
|
||||||
return HttpResponse::BadRequest().body(msg);
|
return ApiError::bad_request(
|
||||||
|
"invalid_date",
|
||||||
|
"That date isn't valid (expected YYYY-MM-DD)",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -584,7 +589,7 @@ pub async fn list_memories(
|
|||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
warn!("Rejecting /memories request: {}", msg);
|
warn!("Rejecting /memories request: {}", msg);
|
||||||
return HttpResponse::BadRequest().body(msg);
|
return crate::libraries::unknown_library(msg).into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+41
-33
@@ -8,6 +8,7 @@
|
|||||||
//! migration. Customs are created here and may be migrated up from a
|
//! migration. Customs are created here and may be migrated up from a
|
||||||
//! device's local store via `POST /personas/migrate`.
|
//! device's local store via `POST /personas/migrate`.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||||
use actix_web::{App, HttpResponse, Responder, web};
|
use actix_web::{App, HttpResponse, Responder, web};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -148,7 +149,8 @@ fn user_id_from_claims(claims: &Claims) -> Option<i32> {
|
|||||||
|
|
||||||
async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
|
async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
|
||||||
let Some(uid) = user_id_from_claims(&claims) else {
|
let Some(uid) = user_id_from_claims(&claims) else {
|
||||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||||
match dao.list_personas(uid) {
|
match dao.list_personas(uid) {
|
||||||
@@ -158,7 +160,7 @@ async fn list_personas(claims: Claims, dao: PersonaDaoData) -> impl Responder {
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("list_personas error: {:?}", e);
|
log::error!("list_personas error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,15 +171,16 @@ async fn create_persona(
|
|||||||
dao: PersonaDaoData,
|
dao: PersonaDaoData,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let Some(uid) = user_id_from_claims(&claims) else {
|
let Some(uid) = user_id_from_claims(&claims) else {
|
||||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
|
|
||||||
if body.name.trim().is_empty() {
|
if body.name.trim().is_empty() {
|
||||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "name is required"}));
|
return ApiError::bad_request("invalid_persona", "Give the persona a name").into_response();
|
||||||
}
|
}
|
||||||
if body.system_prompt.trim().is_empty() {
|
if body.system_prompt.trim().is_empty() {
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request("invalid_persona", "Give the persona a system prompt")
|
||||||
.json(serde_json::json!({"error": "systemPrompt is required"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||||
@@ -188,23 +191,26 @@ async fn create_persona(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if matches!(pid.as_str(), "default" | "journal" | "factual") {
|
if matches!(pid.as_str(), "default" | "journal" | "factual") {
|
||||||
return HttpResponse::Conflict()
|
return ApiError::conflict(
|
||||||
.json(serde_json::json!({"error": "persona id collides with a built-in"}));
|
"persona_id_taken",
|
||||||
|
"A built-in persona already uses that id",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-check existence so we can return 409 cleanly. The DB UNIQUE
|
// Pre-check existence so we can return 409 cleanly. The DB UNIQUE
|
||||||
// would also catch it, but parsing Diesel's "constraint violation"
|
// would also catch it, but parsing Diesel's "constraint violation"
|
||||||
// out of a generic DbError is uglier than a quick lookup.
|
// out of a generic DbError is uglier than a quick lookup.
|
||||||
if let Ok(Some(_)) = dao.get_persona(uid, &pid) {
|
if let Ok(Some(_)) = dao.get_persona(uid, &pid) {
|
||||||
return HttpResponse::Conflict()
|
return ApiError::conflict("persona_id_taken", "That persona already exists")
|
||||||
.json(serde_json::json!({"error": "persona already exists"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
match dao.create_persona(uid, &pid, &body.name, &body.system_prompt, false, false) {
|
match dao.create_persona(uid, &pid, &body.name, &body.system_prompt, false, false) {
|
||||||
Ok(p) => HttpResponse::Created().json(PersonaView::from(p)),
|
Ok(p) => HttpResponse::Created().json(PersonaView::from(p)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("create_persona error: {:?}", e);
|
log::error!("create_persona error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +222,8 @@ async fn update_persona(
|
|||||||
dao: PersonaDaoData,
|
dao: PersonaDaoData,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let Some(uid) = user_id_from_claims(&claims) else {
|
let Some(uid) = user_id_from_claims(&claims) else {
|
||||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
let pid = path.into_inner();
|
let pid = path.into_inner();
|
||||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||||
@@ -232,19 +239,20 @@ async fn update_persona(
|
|||||||
Ok(Some(p)) if p.is_built_in => {
|
Ok(Some(p)) if p.is_built_in => {
|
||||||
let editing_identity = body.name.is_some() || body.system_prompt.is_some();
|
let editing_identity = body.name.is_some() || body.system_prompt.is_some();
|
||||||
if editing_identity {
|
if editing_identity {
|
||||||
return HttpResponse::Conflict().json(serde_json::json!({
|
return ApiError::conflict(
|
||||||
"error": "Cannot edit name or systemPrompt of a built-in persona"
|
"builtin_persona",
|
||||||
}));
|
"A built-in persona's name and prompt can't be changed",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::NotFound()
|
return ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||||
.json(serde_json::json!({"error": "Persona not found"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("update_persona lookup error: {:?}", e);
|
log::error!("update_persona lookup error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
}
|
}
|
||||||
@@ -258,12 +266,11 @@ async fn update_persona(
|
|||||||
};
|
};
|
||||||
match dao.update_persona(uid, &pid, patch) {
|
match dao.update_persona(uid, &pid, patch) {
|
||||||
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
|
Ok(Some(p)) => HttpResponse::Ok().json(PersonaView::from(p)),
|
||||||
Ok(None) => {
|
Ok(None) => ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||||
HttpResponse::NotFound().json(serde_json::json!({"error": "Persona not found"}))
|
.into_response(),
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("update_persona error: {:?}", e);
|
log::error!("update_persona error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,24 +281,24 @@ async fn delete_persona(
|
|||||||
dao: PersonaDaoData,
|
dao: PersonaDaoData,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let Some(uid) = user_id_from_claims(&claims) else {
|
let Some(uid) = user_id_from_claims(&claims) else {
|
||||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
let pid = path.into_inner();
|
let pid = path.into_inner();
|
||||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||||
|
|
||||||
match dao.get_persona(uid, &pid) {
|
match dao.get_persona(uid, &pid) {
|
||||||
Ok(Some(p)) if p.is_built_in => {
|
Ok(Some(p)) if p.is_built_in => {
|
||||||
return HttpResponse::Conflict()
|
return ApiError::conflict("builtin_persona", "Built-in personas can't be deleted")
|
||||||
.json(serde_json::json!({"error": "Cannot delete built-in persona"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
return HttpResponse::NotFound()
|
return ApiError::not_found("persona_not_found", "That persona no longer exists")
|
||||||
.json(serde_json::json!({"error": "Persona not found"}));
|
.into_response();
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("delete_persona lookup error: {:?}", e);
|
log::error!("delete_persona lookup error: {:?}", e);
|
||||||
return HttpResponse::InternalServerError()
|
return ApiError::internal("Database error").into_response();
|
||||||
.json(serde_json::json!({"error": "Database error"}));
|
|
||||||
}
|
}
|
||||||
Ok(Some(_)) => {}
|
Ok(Some(_)) => {}
|
||||||
}
|
}
|
||||||
@@ -300,7 +307,7 @@ async fn delete_persona(
|
|||||||
Ok(_) => HttpResponse::NoContent().finish(),
|
Ok(_) => HttpResponse::NoContent().finish(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("delete_persona error: {:?}", e);
|
log::error!("delete_persona error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,7 +318,8 @@ async fn migrate_personas(
|
|||||||
dao: PersonaDaoData,
|
dao: PersonaDaoData,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let Some(uid) = user_id_from_claims(&claims) else {
|
let Some(uid) = user_id_from_claims(&claims) else {
|
||||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid claims"}));
|
return ApiError::unauthorized("unauthorized", "Your session has expired. Sign in again.")
|
||||||
|
.into_response();
|
||||||
};
|
};
|
||||||
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
let mut dao = dao.lock().expect("Unable to lock PersonaDao");
|
||||||
|
|
||||||
@@ -336,7 +344,7 @@ async fn migrate_personas(
|
|||||||
Ok(inserted) => HttpResponse::Ok().json(MigrateResponse { inserted }),
|
Ok(inserted) => HttpResponse::Ok().json(MigrateResponse { inserted }),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("migrate_personas error: {:?}", e);
|
log::error!("migrate_personas error: {:?}", e);
|
||||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Database error"}))
|
ApiError::internal("Database error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+18
-25
@@ -18,11 +18,11 @@
|
|||||||
//! single row, so they're idempotent. The legacy `tz_offset_minutes`
|
//! single row, so they're idempotent. The legacy `tz_offset_minutes`
|
||||||
//! column is preserved verbatim on every write.
|
//! column is preserved verbatim on every write.
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use actix_web::{HttpResponse, web};
|
use actix_web::{HttpResponse, web};
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use serde::de::IntoDeserializer;
|
use serde::de::IntoDeserializer;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -264,16 +264,12 @@ pub async fn get_prefs(_claims: Claims, app_state: web::Data<AppState>) -> HttpR
|
|||||||
let mut dao = match app_state.user_ai_prefs_dao.lock() {
|
let mut dao = match app_state.user_ai_prefs_dao.lock() {
|
||||||
Ok(dao) => dao,
|
Ok(dao) => dao,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return HttpResponse::InternalServerError().json(json!({
|
return ApiError::internal("prefs store unavailable").into_response();
|
||||||
"error": "prefs store unavailable"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match dao.get_prefs() {
|
match dao.get_prefs() {
|
||||||
Ok(row) => HttpResponse::Ok().json(row_to_response(row.as_ref())),
|
Ok(row) => HttpResponse::Ok().json(row_to_response(row.as_ref())),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(json!({
|
Err(e) => ApiError::internal(format!("failed to read prefs: {e}")).into_response(),
|
||||||
"error": format!("failed to read prefs: {e}")
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,39 +285,38 @@ pub async fn put_prefs(
|
|||||||
if let TriState::Val(name) = &patch.display_tz
|
if let TriState::Val(name) = &patch.display_tz
|
||||||
&& !is_valid_display_tz(name)
|
&& !is_valid_display_tz(name)
|
||||||
{
|
{
|
||||||
return HttpResponse::BadRequest().json(json!({
|
return ApiError::bad_request("invalid_time_zone", format!("\"{name}\" isn't a time zone"))
|
||||||
"error": format!("invalid display_tz: {name}")
|
.into_response();
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if let TriState::Val(name) = &patch.default_backend
|
if let TriState::Val(name) = &patch.default_backend
|
||||||
&& !is_valid_backend(name)
|
&& !is_valid_backend(name)
|
||||||
{
|
{
|
||||||
return HttpResponse::BadRequest().json(json!({
|
return ApiError::bad_request(
|
||||||
"error": format!("invalid default_backend: {name} (expected local|hybrid)")
|
"invalid_backend",
|
||||||
}));
|
format!("\"{name}\" isn't a backend (expected local or hybrid)"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
if let TriState::Val(name) = &patch.reel_span
|
if let TriState::Val(name) = &patch.reel_span
|
||||||
&& MemoriesSpan::from_str(name).is_err()
|
&& MemoriesSpan::from_str(name).is_err()
|
||||||
{
|
{
|
||||||
return HttpResponse::BadRequest().json(json!({
|
return ApiError::bad_request(
|
||||||
"error": format!("invalid reel_span: {name} (expected day|week|month)")
|
"invalid_span",
|
||||||
}));
|
format!("\"{name}\" isn't a reel span (expected day, week or month)"),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut dao = match app_state.user_ai_prefs_dao.lock() {
|
let mut dao = match app_state.user_ai_prefs_dao.lock() {
|
||||||
Ok(dao) => dao,
|
Ok(dao) => dao,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
return HttpResponse::InternalServerError().json(json!({
|
return ApiError::internal("prefs store unavailable").into_response();
|
||||||
"error": "prefs store unavailable"
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let existing = match dao.get_prefs() {
|
let existing = match dao.get_prefs() {
|
||||||
Ok(row) => row,
|
Ok(row) => row,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return HttpResponse::InternalServerError().json(json!({
|
return ApiError::internal(format!("failed to read prefs: {e}")).into_response();
|
||||||
"error": format!("failed to read prefs: {e}")
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -363,9 +358,7 @@ pub async fn put_prefs(
|
|||||||
};
|
};
|
||||||
match dao.upsert_prefs(&upsert) {
|
match dao.upsert_prefs(&upsert) {
|
||||||
Ok(()) => HttpResponse::Ok().json(resp),
|
Ok(()) => HttpResponse::Ok().json(resp),
|
||||||
Err(e) => HttpResponse::InternalServerError().json(json!({
|
Err(e) => ApiError::internal(format!("failed to write prefs: {e}")).into_response(),
|
||||||
"error": format!("failed to write prefs: {e}")
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-19
@@ -16,6 +16,7 @@ pub mod render;
|
|||||||
pub mod script;
|
pub mod script;
|
||||||
pub mod selector;
|
pub mod selector;
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
@@ -449,9 +450,11 @@ pub async fn create_reel_handler(
|
|||||||
insight_dao: web::Data<Mutex<Box<dyn InsightDao>>>,
|
insight_dao: web::Data<Mutex<Box<dyn InsightDao>>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if app_state.llamacpp.is_none() {
|
if app_state.llamacpp.is_none() {
|
||||||
return HttpResponse::ServiceUnavailable().json(json!({
|
return ApiError::unavailable(
|
||||||
"error": "Reel narration needs the LLM/TTS backend (set llamacpp.url in llm.toml)"
|
"reels_unavailable",
|
||||||
}));
|
"Reels need the language model and voice backend, which isn't set up on this server",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner defaults: explicit request param → stored pref → hard default.
|
// Owner defaults: explicit request param → stored pref → hard default.
|
||||||
@@ -482,12 +485,15 @@ pub async fn create_reel_handler(
|
|||||||
// check. Insight enrichment + scripting happen in the background job.
|
// check. Insight enrichment + scripting happen in the background job.
|
||||||
let (planned, meta) = match selector::resolve(&app_state, &exif_dao, &selector) {
|
let (planned, meta) = match selector::resolve(&app_state, &exif_dao, &selector) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(msg) => return HttpResponse::BadRequest().body(msg),
|
Err(msg) => return ApiError::bad_request("invalid_reel_request", msg).into_response(),
|
||||||
};
|
};
|
||||||
if planned.is_empty() {
|
if planned.is_empty() {
|
||||||
return HttpResponse::UnprocessableEntity().json(json!({
|
return ApiError::new(
|
||||||
"error": "No photo memories found for this span"
|
actix_web::http::StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
}));
|
"no_memories",
|
||||||
|
"No photos found for this span",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten every media item across beats (in order) into the cache key — the
|
// Flatten every media item across beats (in order) into the cache key — the
|
||||||
@@ -609,7 +615,7 @@ pub async fn create_reel_handler(
|
|||||||
pub async fn reel_status_handler(_claims: Claims, path: web::Path<String>) -> impl Responder {
|
pub async fn reel_status_handler(_claims: Claims, path: web::Path<String>) -> impl Responder {
|
||||||
let id_str = path.into_inner();
|
let id_str = path.into_inner();
|
||||||
let Ok(id) = Uuid::parse_str(&id_str) else {
|
let Ok(id) = Uuid::parse_str(&id_str) else {
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
return ApiError::bad_request("invalid_job_id", "Invalid reel job").into_response();
|
||||||
};
|
};
|
||||||
let resp = with_job(id, |job| ReelStatusResponse {
|
let resp = with_job(id, |job| ReelStatusResponse {
|
||||||
job_id: id_str.clone(),
|
job_id: id_str.clone(),
|
||||||
@@ -622,7 +628,7 @@ pub async fn reel_status_handler(_claims: Claims, path: web::Path<String>) -> im
|
|||||||
});
|
});
|
||||||
match resp {
|
match resp {
|
||||||
Some(r) => HttpResponse::Ok().json(r),
|
Some(r) => HttpResponse::Ok().json(r),
|
||||||
None => HttpResponse::NotFound().json(json!({ "error": "job not found or expired" })),
|
None => ApiError::not_found("job_not_found", "That reel job has expired").into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,17 +642,17 @@ pub async fn reel_video_handler(
|
|||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let id_str = path.into_inner();
|
let id_str = path.into_inner();
|
||||||
let Ok(id) = Uuid::parse_str(&id_str) else {
|
let Ok(id) = Uuid::parse_str(&id_str) else {
|
||||||
return HttpResponse::BadRequest().json(json!({ "error": "invalid job id" }));
|
return ApiError::bad_request("invalid_job_id", "Invalid reel job").into_response();
|
||||||
};
|
};
|
||||||
let output = with_job(id, |job| job.output_path.clone()).flatten();
|
let output = with_job(id, |job| job.output_path.clone()).flatten();
|
||||||
let Some(path) = output else {
|
let Some(path) = output else {
|
||||||
return HttpResponse::NotFound().json(json!({ "error": "reel not ready" }));
|
return ApiError::not_found("reel_not_ready", "The reel isn't ready yet").into_response();
|
||||||
};
|
};
|
||||||
match NamedFile::open(&path) {
|
match NamedFile::open(&path) {
|
||||||
Ok(file) => file.into_response(&request),
|
Ok(file) => file.into_response(&request),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("opening reel mp4 {path:?} failed: {e:?}");
|
log::error!("opening reel mp4 {path:?} failed: {e:?}");
|
||||||
HttpResponse::NotFound().json(json!({ "error": "reel file missing" }))
|
reel_missing().into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -694,32 +700,31 @@ pub async fn precomputed_reel_handler(
|
|||||||
.exists_fresh(span, &library_key, RENDER_VERSION as i32, min_generated_at)
|
.exists_fresh(span, &library_key, RENDER_VERSION as i32, min_generated_at)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
return HttpResponse::NotFound().json(json!({ "error": "no precomputed reel found" }));
|
return no_precomputed_reel("none").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the latest row for full validity checks.
|
// Fetch the latest row for full validity checks.
|
||||||
let reel = match dao.latest_for(span, &library_key) {
|
let reel = match dao.latest_for(span, &library_key) {
|
||||||
Ok(Some(r)) => r,
|
Ok(Some(r)) => r,
|
||||||
_ => {
|
_ => {
|
||||||
return HttpResponse::NotFound().json(json!({ "error": "no precomputed reel found" }));
|
return no_precomputed_reel("none").into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validity gate 1: render version must match.
|
// Validity gate 1: render version must match.
|
||||||
if reel.render_version != RENDER_VERSION as i32 {
|
if reel.render_version != RENDER_VERSION as i32 {
|
||||||
return HttpResponse::NotFound()
|
return no_precomputed_reel("stale").into_response();
|
||||||
.json(json!({ "error": "precomputed reel is stale (render version mismatch)" }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validity gate 2: output_path must exist.
|
// Validity gate 2: output_path must exist.
|
||||||
let output = std::path::Path::new(&reel.output_path);
|
let output = std::path::Path::new(&reel.output_path);
|
||||||
if !output.exists() {
|
if !output.exists() {
|
||||||
return HttpResponse::NotFound().json(json!({ "error": "precomputed reel file missing" }));
|
return no_precomputed_reel("file_missing").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validity gate 3: age <= max_age (re-checked via min_generated_at).
|
// Validity gate 3: age <= max_age (re-checked via min_generated_at).
|
||||||
if reel.generated_at < min_generated_at {
|
if reel.generated_at < min_generated_at {
|
||||||
return HttpResponse::NotFound().json(json!({ "error": "precomputed reel has expired" }));
|
return no_precomputed_reel("expired").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::Ok().json(PrecomputedReelResponse {
|
HttpResponse::Ok().json(PrecomputedReelResponse {
|
||||||
@@ -742,7 +747,7 @@ pub async fn precomputed_video_handler(
|
|||||||
Ok(file) => file.into_response(&request),
|
Ok(file) => file.into_response(&request),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("opening precomputed reel {key} failed: {e:?}");
|
log::error!("opening precomputed reel {key} failed: {e:?}");
|
||||||
HttpResponse::NotFound().json(json!({ "error": "precomputed reel file missing" }))
|
reel_missing().into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1368,6 +1373,18 @@ fn sweep_reel_cache(app_state: &AppState) -> usize {
|
|||||||
removed
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// No usable precomputed reel; `reason` (none / stale / file_missing /
|
||||||
|
/// expired) is for debugging, the client just generates one.
|
||||||
|
fn no_precomputed_reel(reason: &str) -> ApiError {
|
||||||
|
ApiError::not_found("no_precomputed_reel", "No ready-made reel for this span")
|
||||||
|
.with_details(json!({ "reason": reason }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The job finished but its video file is gone (cleaned up or moved).
|
||||||
|
fn reel_missing() -> ApiError {
|
||||||
|
ApiError::not_found("reel_missing", "The reel's video is gone. Make it again.")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+20
-18
@@ -27,6 +27,7 @@ use crate::clip_search::{
|
|||||||
};
|
};
|
||||||
use crate::data::{Claims, FilterMode, MediaType};
|
use crate::data::{Claims, FilterMode, MediaType};
|
||||||
use crate::database::ExifDao;
|
use crate::database::ExifDao;
|
||||||
|
use crate::error::ApiError;
|
||||||
use crate::file_types::{is_image_file, is_video_file};
|
use crate::file_types::{is_image_file, is_video_file};
|
||||||
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
|
use crate::geo::{forward_geocode, gps_bounding_box, haversine_distance};
|
||||||
use crate::tags::TagDao;
|
use crate::tags::TagDao;
|
||||||
@@ -264,14 +265,7 @@ struct UnifiedResponse {
|
|||||||
results: Vec<SearchHit>,
|
results: Vec<SearchHit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
// Errors use the shared format (crate::error::ApiError, #202).
|
||||||
struct ErrorBody {
|
|
||||||
error: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bad_request(msg: impl Into<String>) -> HttpResponse {
|
|
||||||
HttpResponse::BadRequest().json(ErrorBody { error: msg.into() })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Combine the model's semantic term with any tag words that didn't match the
|
/// Combine the model's semantic term with any tag words that didn't match the
|
||||||
/// vocab, so a hallucinated/non-vocab tag becomes a soft semantic signal
|
/// vocab, so a hallucinated/non-vocab tag becomes a soft semantic signal
|
||||||
@@ -306,7 +300,7 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
);
|
);
|
||||||
let nl = query.q.trim().to_string();
|
let nl = query.q.trim().to_string();
|
||||||
if nl.is_empty() {
|
if nl.is_empty() {
|
||||||
return bad_request("query parameter `q` is required");
|
return crate::clip_search::empty_query().into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = query.limit.clamp(1, 200);
|
let limit = query.limit.clamp(1, 200);
|
||||||
@@ -315,7 +309,7 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
|
|
||||||
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
let library_ids = match parse_library_scope(query.library_ids.as_deref(), query.library) {
|
||||||
Ok(ids) => ids,
|
Ok(ids) => ids,
|
||||||
Err(msg) => return bad_request(msg),
|
Err(msg) => return crate::libraries::unknown_library(msg).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
|
// ── 1. Translate the NL query, grounded on the real tag vocabulary ──
|
||||||
@@ -333,7 +327,13 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
};
|
};
|
||||||
let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) {
|
let (sq, echo, explicit_tags, explicit_gps) = match merge_explicit(sq, &query) {
|
||||||
Ok(merged) => merged,
|
Ok(merged) => merged,
|
||||||
Err(msg) => return bad_request(msg),
|
Err(_) => {
|
||||||
|
return ApiError::bad_request(
|
||||||
|
"incomplete_gps_filter",
|
||||||
|
"A location filter needs a latitude, longitude and radius",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let folder = folder_prefix(query.path.as_deref());
|
let folder = folder_prefix(query.path.as_deref());
|
||||||
|
|
||||||
@@ -597,7 +597,8 @@ pub async fn unified_search<TagD: TagDao>(
|
|||||||
// Filters-only: no semantic term. Require at least one filter,
|
// Filters-only: no semantic term. Require at least one filter,
|
||||||
// then return the candidate set newest-first.
|
// then return the candidate set newest-first.
|
||||||
if !has_struct {
|
if !has_struct {
|
||||||
return bad_request("query had no searchable terms");
|
return ApiError::bad_request("nothing_to_search", "Add search words or a filter")
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
candidate.sort_by(|a, b| b.date_taken.cmp(&a.date_taken));
|
candidate.sort_by(|a, b| b.date_taken.cmp(&a.date_taken));
|
||||||
let total_matching = candidate.len();
|
let total_matching = candidate.len();
|
||||||
@@ -684,9 +685,11 @@ async fn translate<TagD: TagDao>(
|
|||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("unified_search: resolve_backend failed: {e:?}");
|
log::warn!("unified_search: resolve_backend failed: {e:?}");
|
||||||
return Err(HttpResponse::ServiceUnavailable().json(ErrorBody {
|
return Err(ApiError::unavailable(
|
||||||
error: "LLM backend unavailable".into(),
|
"llm_unavailable",
|
||||||
}));
|
"The language model is unavailable. Try again shortly.",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
log::info!("unified_search: translating with model={}", backend.model());
|
log::info!("unified_search: translating with model={}", backend.model());
|
||||||
@@ -700,9 +703,8 @@ async fn translate<TagD: TagDao>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
log::warn!("unified_search: translate_nl_query failed: {e:?}");
|
||||||
HttpResponse::BadGateway().json(ErrorBody {
|
ApiError::bad_gateway("query_not_understood", "Couldn't understand that search")
|
||||||
error: "could not interpret the query".into(),
|
.into_response()
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-11
@@ -3,6 +3,7 @@
|
|||||||
//! Both take the path of the original *or* of any version, and answer about
|
//! Both take the path of the original *or* of any version, and answer about
|
||||||
//! the whole set. The display choice is global (not per user).
|
//! the whole set. The display choice is global (not per user).
|
||||||
|
|
||||||
|
use crate::error::ApiError;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use actix_web::{HttpResponse, Responder, get, put, web};
|
use actix_web::{HttpResponse, Responder, get, put, web};
|
||||||
@@ -84,14 +85,14 @@ fn resolve(
|
|||||||
) -> Result<Resolved, HttpResponse> {
|
) -> Result<Resolved, HttpResponse> {
|
||||||
let library_id = match libraries::resolve_library_param_state(app_state, library) {
|
let library_id = match libraries::resolve_library_param_state(app_state, library) {
|
||||||
Ok(lib) => lib.map(|l| l.id),
|
Ok(lib) => lib.map(|l| l.id),
|
||||||
Err(msg) => return Err(HttpResponse::BadRequest().body(msg)),
|
Err(msg) => return Err(libraries::unknown_library(msg).into_response()),
|
||||||
};
|
};
|
||||||
let path = normalize_path(path);
|
let path = normalize_path(path);
|
||||||
let mut rows = exif_dao
|
let mut rows = exif_dao
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Unable to lock ExifDao")
|
.expect("Unable to lock ExifDao")
|
||||||
.get_exif_batch(library_id, std::slice::from_ref(&path))
|
.get_exif_batch(library_id, std::slice::from_ref(&path))
|
||||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
.map_err(|_| ApiError::internal("internal error").into_response())?;
|
||||||
// Without `library`, a path can name rows in several libraries. Take the
|
// Without `library`, a path can name rows in several libraries. Take the
|
||||||
// earliest hashed one; it then resolves to its original below, so a
|
// earliest hashed one; it then resolves to its original below, so a
|
||||||
// version in one library and its original in another agree.
|
// version in one library and its original in another agree.
|
||||||
@@ -100,14 +101,18 @@ fn resolve(
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
|
.find_map(|r| r.content_hash.clone().map(|h| (r, h)))
|
||||||
else {
|
else {
|
||||||
return Err(HttpResponse::NotFound().body("no indexed file at that path"));
|
return Err(ApiError::not_found(
|
||||||
|
"photo_not_indexed",
|
||||||
|
"This photo isn't indexed yet. Try again in a minute.",
|
||||||
|
)
|
||||||
|
.into_response());
|
||||||
};
|
};
|
||||||
let link = app_state
|
let link = app_state
|
||||||
.version_dao
|
.version_dao
|
||||||
.lock()
|
.lock()
|
||||||
.expect("Unable to lock VersionDao")
|
.expect("Unable to lock VersionDao")
|
||||||
.get_link(&hash)
|
.get_link(&hash)
|
||||||
.map_err(|_| HttpResponse::InternalServerError().finish())?;
|
.map_err(|_| ApiError::internal("internal error").into_response())?;
|
||||||
Ok(Resolved {
|
Ok(Resolved {
|
||||||
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
|
original_hash: link.map(|l| l.original_hash).unwrap_or(hash),
|
||||||
library_id: row.library_id,
|
library_id: row.library_id,
|
||||||
@@ -196,7 +201,7 @@ pub async fn get_versions(
|
|||||||
Ok(body) => HttpResponse::Ok().json(body),
|
Ok(body) => HttpResponse::Ok().json(body),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("get_versions failed: {:?}", e);
|
warn!("get_versions failed: {:?}", e);
|
||||||
HttpResponse::InternalServerError().finish()
|
ApiError::internal("internal error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,11 +238,15 @@ pub async fn put_display(
|
|||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("put_display lookup failed: {:?}", e);
|
warn!("put_display lookup failed: {:?}", e);
|
||||||
return HttpResponse::InternalServerError().finish();
|
return ApiError::internal("internal error").into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !current.versions.iter().any(|v| v.hash == hash) {
|
if !current.versions.iter().any(|v| v.hash == hash) {
|
||||||
return HttpResponse::BadRequest().body("display_hash is not a version of this photo");
|
return ApiError::bad_request(
|
||||||
|
"not_a_version",
|
||||||
|
"That file isn't a version of this photo",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
// The display file is fetched through the original's library.
|
// The display file is fetched through the original's library.
|
||||||
let original_lib = current.original.as_ref().map(|o| o.library_id);
|
let original_lib = current.original.as_ref().map(|o| o.library_id);
|
||||||
@@ -246,19 +255,22 @@ pub async fn put_display(
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id))
|
.any(|v| v.hash == hash && original_lib.is_none_or(|l| l == v.library_id))
|
||||||
{
|
{
|
||||||
return HttpResponse::BadRequest()
|
return ApiError::bad_request(
|
||||||
.body("display_hash has no file in the original's library");
|
"version_in_other_library",
|
||||||
|
"That version isn't in the same library as the original",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
|
if let Err(e) = dao.set_display(&resolved.original_hash, wanted) {
|
||||||
warn!("put_display failed: {:?}", e);
|
warn!("put_display failed: {:?}", e);
|
||||||
return HttpResponse::InternalServerError().finish();
|
return ApiError::internal("internal error").into_response();
|
||||||
}
|
}
|
||||||
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
match build_response(dao.as_mut(), &resolved.original_hash, resolved.library_id) {
|
||||||
Ok(body) => HttpResponse::Ok().json(body),
|
Ok(body) => HttpResponse::Ok().json(body),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("put_display response failed: {:?}", e);
|
warn!("put_display response failed: {:?}", e);
|
||||||
HttpResponse::InternalServerError().finish()
|
ApiError::internal("internal error").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user