pinakes-server: fix api key timing, notification scoping, and validate progress inputs
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ieb342b4b48034de0a2184cdf89d068316a6a6964
This commit is contained in:
parent
52f0b5defc
commit
2b2c1830a1
7 changed files with 334 additions and 179 deletions
|
|
@ -9,23 +9,28 @@ use pinakes_core::config::UserRole;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// Constant-time string comparison to prevent timing attacks on API keys.
|
/// Constant-time string comparison to prevent timing attacks on API keys.
|
||||||
|
///
|
||||||
|
/// Always iterates to `max(len_a, len_b)` so that neither a length difference
|
||||||
|
/// nor a byte mismatch causes an early return.
|
||||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||||
if a.len() != b.len() {
|
let a = a.as_bytes();
|
||||||
return false;
|
let b = b.as_bytes();
|
||||||
|
let len = a.len().max(b.len());
|
||||||
|
let mut result = a.len() ^ b.len(); // non-zero if lengths differ
|
||||||
|
for i in 0..len {
|
||||||
|
let ab = a.get(i).copied().unwrap_or(0);
|
||||||
|
let bb = b.get(i).copied().unwrap_or(0);
|
||||||
|
result |= usize::from(ab ^ bb);
|
||||||
}
|
}
|
||||||
a.as_bytes()
|
result == 0
|
||||||
.iter()
|
|
||||||
.zip(b.as_bytes())
|
|
||||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
|
||||||
== 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Axum middleware that checks for a valid Bearer token.
|
/// Axum middleware that checks for a valid Bearer token.
|
||||||
///
|
///
|
||||||
/// If `accounts.enabled == true`: look up bearer token in database session
|
/// If `accounts.enabled == true`: look up bearer token in database session
|
||||||
/// store. If `accounts.enabled == false`: use existing api_key logic (unchanged
|
/// store. If `accounts.enabled == false`: use existing `api_key` logic
|
||||||
/// behavior). Skips authentication for the `/health` and `/auth/login` path
|
/// (unchanged behavior). Skips authentication for the `/health` and
|
||||||
/// suffixes.
|
/// `/auth/login` path suffixes.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
mut request: Request,
|
mut request: Request,
|
||||||
|
|
@ -58,7 +63,7 @@ pub async fn require_auth(
|
||||||
.get("authorization")
|
.get("authorization")
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|s| s.strip_prefix("Bearer "))
|
.and_then(|s| s.strip_prefix("Bearer "))
|
||||||
.map(|s| s.to_string());
|
.map(std::string::ToString::to_string);
|
||||||
|
|
||||||
let Some(token) = token else {
|
let Some(token) = token else {
|
||||||
tracing::debug!(path = %path, "rejected: missing Authorization header");
|
tracing::debug!(path = %path, "rejected: missing Authorization header");
|
||||||
|
|
@ -83,7 +88,7 @@ pub async fn require_auth(
|
||||||
// Check session expiry
|
// Check session expiry
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
if session.expires_at < now {
|
if session.expires_at < now {
|
||||||
let username = session.username.clone();
|
let username = session.username;
|
||||||
// Delete expired session in a bounded background task
|
// Delete expired session in a bounded background task
|
||||||
if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() {
|
if let Ok(permit) = state.session_semaphore.clone().try_acquire_owned() {
|
||||||
let storage = state.storage.clone();
|
let storage = state.storage.clone();
|
||||||
|
|
@ -124,7 +129,7 @@ pub async fn require_auth(
|
||||||
|
|
||||||
// Inject role and username into request extensions
|
// Inject role and username into request extensions
|
||||||
request.extensions_mut().insert(role);
|
request.extensions_mut().insert(role);
|
||||||
request.extensions_mut().insert(session.username.clone());
|
request.extensions_mut().insert(session.username);
|
||||||
} else {
|
} else {
|
||||||
// Legacy API key auth
|
// Legacy API key auth
|
||||||
let api_key = std::env::var("PINAKES_API_KEY")
|
let api_key = std::env::var("PINAKES_API_KEY")
|
||||||
|
|
@ -202,7 +207,7 @@ pub async fn require_admin(request: Request, next: Next) -> Response {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the authenticated username (from request extensions) to a UserId.
|
/// Resolve the authenticated username (from request extensions) to a `UserId`.
|
||||||
///
|
///
|
||||||
/// Returns an error if the user cannot be found.
|
/// Returns an error if the user cannot be found.
|
||||||
pub async fn resolve_user_id(
|
pub async fn resolve_user_id(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,19 @@ use pinakes_core::{
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{auth::resolve_user_id, dto::*, error::ApiError, state::AppState};
|
use crate::{
|
||||||
|
auth::resolve_user_id,
|
||||||
|
dto::{
|
||||||
|
MediaResponse,
|
||||||
|
MostViewedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
RecordUsageEventRequest,
|
||||||
|
WatchProgressRequest,
|
||||||
|
WatchProgressResponse,
|
||||||
|
},
|
||||||
|
error::ApiError,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_LIMIT: u64 = 100;
|
const MAX_LIMIT: u64 = 100;
|
||||||
|
|
||||||
|
|
@ -87,6 +99,11 @@ pub async fn update_watch_progress(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(req): Json<WatchProgressRequest>,
|
Json(req): Json<WatchProgressRequest>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
if !req.progress_secs.is_finite() || req.progress_secs < 0.0 {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"progress_secs must be a non-negative finite number",
|
||||||
|
));
|
||||||
|
}
|
||||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
state
|
state
|
||||||
.storage
|
.storage
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,11 @@ pub struct SearchBooksQuery {
|
||||||
pub limit: u64,
|
pub limit: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_offset() -> u64 {
|
const fn default_offset() -> u64 {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_limit() -> u64 {
|
const fn default_limit() -> u64 {
|
||||||
50
|
50
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +178,7 @@ pub async fn list_books(
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let pagination = Pagination {
|
let pagination = Pagination {
|
||||||
offset: query.offset,
|
offset: query.offset,
|
||||||
limit: query.limit,
|
limit: query.limit.min(1000),
|
||||||
sort: None,
|
sort: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -290,6 +290,9 @@ pub async fn update_reading_progress(
|
||||||
Path(media_id): Path<Uuid>,
|
Path(media_id): Path<Uuid>,
|
||||||
Json(req): Json<UpdateProgressRequest>,
|
Json(req): Json<UpdateProgressRequest>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
if req.current_page < 0 {
|
||||||
|
return Err(ApiError::bad_request("current_page must be non-negative"));
|
||||||
|
}
|
||||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
let media_id = MediaId(media_id);
|
let media_id = MediaId(media_id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,41 @@ use pinakes_core::{
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{dto::*, error::ApiError, state::AppState};
|
use crate::{
|
||||||
|
dto::{
|
||||||
|
BatchCollectionRequest,
|
||||||
|
BatchDeleteRequest,
|
||||||
|
BatchImportItemResult,
|
||||||
|
BatchImportRequest,
|
||||||
|
BatchImportResponse,
|
||||||
|
BatchMoveRequest,
|
||||||
|
BatchOperationResponse,
|
||||||
|
BatchTagRequest,
|
||||||
|
BatchUpdateRequest,
|
||||||
|
DirectoryImportRequest,
|
||||||
|
DirectoryPreviewFile,
|
||||||
|
DirectoryPreviewResponse,
|
||||||
|
EmptyTrashResponse,
|
||||||
|
ImportRequest,
|
||||||
|
ImportResponse,
|
||||||
|
ImportWithOptionsRequest,
|
||||||
|
MediaCountResponse,
|
||||||
|
MediaResponse,
|
||||||
|
MoveMediaRequest,
|
||||||
|
PaginationParams,
|
||||||
|
RenameMediaRequest,
|
||||||
|
SetCustomFieldRequest,
|
||||||
|
TrashInfoResponse,
|
||||||
|
TrashResponse,
|
||||||
|
UpdateMediaRequest,
|
||||||
|
},
|
||||||
|
error::ApiError,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
|
||||||
/// Apply tags and add to collection after a successful import.
|
/// Apply tags and add to collection after a successful import.
|
||||||
/// Shared logic used by import_with_options, batch_import, and
|
/// Shared logic used by `import_with_options`, `batch_import`, and
|
||||||
/// import_directory_endpoint.
|
/// `import_directory_endpoint`.
|
||||||
async fn apply_import_post_processing(
|
async fn apply_import_post_processing(
|
||||||
storage: &DynStorageBackend,
|
storage: &DynStorageBackend,
|
||||||
media_id: MediaId,
|
media_id: MediaId,
|
||||||
|
|
@ -59,6 +89,17 @@ pub async fn import_media(
|
||||||
) -> Result<Json<ImportResponse>, ApiError> {
|
) -> Result<Json<ImportResponse>, ApiError> {
|
||||||
let result =
|
let result =
|
||||||
pinakes_core::import::import_file(&state.storage, &req.path).await?;
|
pinakes_core::import::import_file(&state.storage, &req.path).await?;
|
||||||
|
|
||||||
|
if let Some(ref dispatcher) = state.webhook_dispatcher {
|
||||||
|
let id = result.media_id.0.to_string();
|
||||||
|
dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::MediaCreated {
|
||||||
|
media_id: id.clone(),
|
||||||
|
});
|
||||||
|
dispatcher.dispatch(
|
||||||
|
pinakes_core::webhooks::WebhookEvent::ImportCompleted { media_id: id },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(ImportResponse {
|
Ok(Json(ImportResponse {
|
||||||
media_id: result.media_id.0.to_string(),
|
media_id: result.media_id.0.to_string(),
|
||||||
was_duplicate: result.was_duplicate,
|
was_duplicate: result.was_duplicate,
|
||||||
|
|
@ -150,6 +191,12 @@ pub async fn update_media(
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
if let Some(ref dispatcher) = state.webhook_dispatcher {
|
||||||
|
dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::MediaUpdated {
|
||||||
|
media_id: item.id.0.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(MediaResponse::from(item)))
|
Ok(Json(MediaResponse::from(item)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -473,7 +520,7 @@ pub async fn preview_directory(
|
||||||
})?;
|
})?;
|
||||||
let recursive = req
|
let recursive = req
|
||||||
.get("recursive")
|
.get("recursive")
|
||||||
.and_then(|v| v.as_bool())
|
.and_then(serde_json::Value::as_bool)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let dir = std::path::PathBuf::from(path_str);
|
let dir = std::path::PathBuf::from(path_str);
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
|
|
@ -515,8 +562,7 @@ pub async fn preview_directory(
|
||||||
// Skip hidden files/dirs
|
// Skip hidden files/dirs
|
||||||
if path
|
if path
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().starts_with('.'))
|
.is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -528,7 +574,7 @@ pub async fn preview_directory(
|
||||||
&& let Some(mt) =
|
&& let Some(mt) =
|
||||||
pinakes_core::media_type::MediaType::from_path(&path)
|
pinakes_core::media_type::MediaType::from_path(&path)
|
||||||
{
|
{
|
||||||
let size = entry.metadata().ok().map(|m| m.len()).unwrap_or(0);
|
let size = entry.metadata().ok().map_or(0, |m| m.len());
|
||||||
let file_name = path
|
let file_name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
|
@ -579,8 +625,7 @@ pub async fn set_custom_field(
|
||||||
if req.value.len() > MAX_LONG_TEXT {
|
if req.value.len() > MAX_LONG_TEXT {
|
||||||
return Err(ApiError(
|
return Err(ApiError(
|
||||||
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
pinakes_core::error::PinakesError::InvalidOperation(format!(
|
||||||
"field value exceeds {} characters",
|
"field value exceeds {MAX_LONG_TEXT} characters"
|
||||||
MAX_LONG_TEXT
|
|
||||||
)),
|
)),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -747,7 +792,7 @@ pub async fn batch_add_to_collection(
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => processed += 1,
|
Ok(()) => processed += 1,
|
||||||
Err(e) => errors.push(format!("{media_id}: {e}")),
|
Err(e) => errors.push(format!("{media_id}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1113,10 +1158,7 @@ pub async fn permanent_delete_media(
|
||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let media_id = MediaId(id);
|
let media_id = MediaId(id);
|
||||||
let permanent = params
|
let permanent = params.get("permanent").is_some_and(|v| v == "true");
|
||||||
.get("permanent")
|
|
||||||
.map(|v| v == "true")
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if permanent {
|
if permanent {
|
||||||
// Get item info before delete
|
// Get item info before delete
|
||||||
|
|
@ -1161,6 +1203,12 @@ pub async fn permanent_delete_media(
|
||||||
tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail");
|
tracing::warn!(path = %thumb_path.display(), error = %e, "failed to remove thumbnail");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref dispatcher) = state.webhook_dispatcher {
|
||||||
|
dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::MediaDeleted {
|
||||||
|
media_id: id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
serde_json::json!({"deleted": true, "permanent": true}),
|
serde_json::json!({"deleted": true, "permanent": true}),
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,13 @@ use pinakes_core::{
|
||||||
ShareActivity,
|
ShareActivity,
|
||||||
ShareActivityAction,
|
ShareActivityAction,
|
||||||
ShareId,
|
ShareId,
|
||||||
|
ShareMutatePermissions,
|
||||||
ShareNotification,
|
ShareNotification,
|
||||||
ShareNotificationType,
|
ShareNotificationType,
|
||||||
SharePermissions,
|
SharePermissions,
|
||||||
ShareRecipient,
|
ShareRecipient,
|
||||||
ShareTarget,
|
ShareTarget,
|
||||||
|
ShareViewPermissions,
|
||||||
generate_share_token,
|
generate_share_token,
|
||||||
hash_share_password,
|
hash_share_password,
|
||||||
verify_share_password,
|
verify_share_password,
|
||||||
|
|
@ -37,6 +39,7 @@ use crate::{
|
||||||
ShareActivityResponse,
|
ShareActivityResponse,
|
||||||
ShareNotificationResponse,
|
ShareNotificationResponse,
|
||||||
ShareResponse,
|
ShareResponse,
|
||||||
|
SharedContentResponse,
|
||||||
UpdateShareRequest,
|
UpdateShareRequest,
|
||||||
},
|
},
|
||||||
error::{ApiError, ApiResult},
|
error::{ApiError, ApiResult},
|
||||||
|
|
@ -51,14 +54,25 @@ pub async fn create_share(
|
||||||
Json(req): Json<CreateShareRequest>,
|
Json(req): Json<CreateShareRequest>,
|
||||||
) -> ApiResult<Json<ShareResponse>> {
|
) -> ApiResult<Json<ShareResponse>> {
|
||||||
let config = state.config.read().await;
|
let config = state.config.read().await;
|
||||||
if !config.sharing.enabled {
|
if !config.sharing.enabled() {
|
||||||
return Err(ApiError::bad_request("Sharing is not enabled"));
|
return Err(ApiError::bad_request("Sharing is not enabled"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate public links are allowed
|
// Validate public links are allowed
|
||||||
if req.recipient_type == "public_link" && !config.sharing.allow_public_links {
|
if req.recipient_type == "public_link" && !config.sharing.allow_public_links()
|
||||||
|
{
|
||||||
return Err(ApiError::bad_request("Public links are not allowed"));
|
return Err(ApiError::bad_request("Public links are not allowed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enforce password requirement for public links if configured
|
||||||
|
if req.recipient_type == "public_link"
|
||||||
|
&& config.sharing.require_public_link_password
|
||||||
|
&& req.password.is_none()
|
||||||
|
{
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"Public links require a password per server policy",
|
||||||
|
));
|
||||||
|
}
|
||||||
drop(config);
|
drop(config);
|
||||||
|
|
||||||
let owner_id = resolve_user_id(&state.storage, &username).await?;
|
let owner_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
|
|
@ -124,12 +138,16 @@ pub async fn create_share(
|
||||||
// Parse permissions
|
// Parse permissions
|
||||||
let permissions = if let Some(perms) = req.permissions {
|
let permissions = if let Some(perms) = req.permissions {
|
||||||
SharePermissions {
|
SharePermissions {
|
||||||
|
view: ShareViewPermissions {
|
||||||
can_view: perms.can_view.unwrap_or(true),
|
can_view: perms.can_view.unwrap_or(true),
|
||||||
can_download: perms.can_download.unwrap_or(false),
|
can_download: perms.can_download.unwrap_or(false),
|
||||||
|
can_reshare: perms.can_reshare.unwrap_or(false),
|
||||||
|
},
|
||||||
|
mutate: ShareMutatePermissions {
|
||||||
can_edit: perms.can_edit.unwrap_or(false),
|
can_edit: perms.can_edit.unwrap_or(false),
|
||||||
can_delete: perms.can_delete.unwrap_or(false),
|
can_delete: perms.can_delete.unwrap_or(false),
|
||||||
can_reshare: perms.can_reshare.unwrap_or(false),
|
|
||||||
can_add: perms.can_add.unwrap_or(false),
|
can_add: perms.can_add.unwrap_or(false),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SharePermissions::view_only()
|
SharePermissions::view_only()
|
||||||
|
|
@ -156,8 +174,9 @@ pub async fn create_share(
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let created = state.storage.create_share(&share).await.map_err(|e| {
|
let created =
|
||||||
ApiError::internal(format!("Failed to create share: {}", e))
|
state.storage.create_share(&share).await.map_err(|e| {
|
||||||
|
ApiError::internal(format!("Failed to create share: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Send notification to recipient if it's a user share
|
// Send notification to recipient if it's a user share
|
||||||
|
|
@ -198,7 +217,7 @@ pub async fn list_outgoing(
|
||||||
.storage
|
.storage
|
||||||
.list_shares_by_owner(user_id, &pagination)
|
.list_shares_by_owner(user_id, &pagination)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to list shares: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Failed to list shares: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(shares.into_iter().map(Into::into).collect()))
|
Ok(Json(shares.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
@ -221,7 +240,7 @@ pub async fn list_incoming(
|
||||||
.storage
|
.storage
|
||||||
.list_shares_for_user(user_id, &pagination)
|
.list_shares_for_user(user_id, &pagination)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to list shares: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Failed to list shares: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(shares.into_iter().map(Into::into).collect()))
|
Ok(Json(shares.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
@ -238,7 +257,7 @@ pub async fn get_share(
|
||||||
.storage
|
.storage
|
||||||
.get_share(ShareId(id))
|
.get_share(ShareId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
||||||
|
|
||||||
// Check authorization
|
// Check authorization
|
||||||
let is_owner = share.owner_id == user_id;
|
let is_owner = share.owner_id == user_id;
|
||||||
|
|
@ -269,7 +288,7 @@ pub async fn update_share(
|
||||||
.storage
|
.storage
|
||||||
.get_share(ShareId(id))
|
.get_share(ShareId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
||||||
|
|
||||||
// Only owner can update
|
// Only owner can update
|
||||||
if share.owner_id != user_id {
|
if share.owner_id != user_id {
|
||||||
|
|
@ -279,14 +298,22 @@ pub async fn update_share(
|
||||||
// Update fields
|
// Update fields
|
||||||
if let Some(perms) = req.permissions {
|
if let Some(perms) = req.permissions {
|
||||||
share.permissions = SharePermissions {
|
share.permissions = SharePermissions {
|
||||||
can_view: perms.can_view.unwrap_or(share.permissions.can_view),
|
view: ShareViewPermissions {
|
||||||
|
can_view: perms.can_view.unwrap_or(share.permissions.view.can_view),
|
||||||
can_download: perms
|
can_download: perms
|
||||||
.can_download
|
.can_download
|
||||||
.unwrap_or(share.permissions.can_download),
|
.unwrap_or(share.permissions.view.can_download),
|
||||||
can_edit: perms.can_edit.unwrap_or(share.permissions.can_edit),
|
can_reshare: perms
|
||||||
can_delete: perms.can_delete.unwrap_or(share.permissions.can_delete),
|
.can_reshare
|
||||||
can_reshare: perms.can_reshare.unwrap_or(share.permissions.can_reshare),
|
.unwrap_or(share.permissions.view.can_reshare),
|
||||||
can_add: perms.can_add.unwrap_or(share.permissions.can_add),
|
},
|
||||||
|
mutate: ShareMutatePermissions {
|
||||||
|
can_edit: perms.can_edit.unwrap_or(share.permissions.mutate.can_edit),
|
||||||
|
can_delete: perms
|
||||||
|
.can_delete
|
||||||
|
.unwrap_or(share.permissions.mutate.can_delete),
|
||||||
|
can_add: perms.can_add.unwrap_or(share.permissions.mutate.can_add),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,8 +331,9 @@ pub async fn update_share(
|
||||||
|
|
||||||
share.updated_at = Utc::now();
|
share.updated_at = Utc::now();
|
||||||
|
|
||||||
let updated = state.storage.update_share(&share).await.map_err(|e| {
|
let updated =
|
||||||
ApiError::internal(format!("Failed to update share: {}", e))
|
state.storage.update_share(&share).await.map_err(|e| {
|
||||||
|
ApiError::internal(format!("Failed to update share: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Notify recipient of update
|
// Notify recipient of update
|
||||||
|
|
@ -339,7 +367,7 @@ pub async fn delete_share(
|
||||||
.storage
|
.storage
|
||||||
.get_share(ShareId(id))
|
.get_share(ShareId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
||||||
|
|
||||||
// Only owner can delete
|
// Only owner can delete
|
||||||
if share.owner_id != user_id {
|
if share.owner_id != user_id {
|
||||||
|
|
@ -362,9 +390,11 @@ pub async fn delete_share(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.storage.delete_share(ShareId(id)).await.map_err(|e| {
|
state
|
||||||
ApiError::internal(format!("Failed to delete share: {}", e))
|
.storage
|
||||||
})?;
|
.delete_share(ShareId(id))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to delete share: {e}")))?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +416,7 @@ pub async fn batch_delete(
|
||||||
.storage
|
.storage
|
||||||
.get_share(*share_id)
|
.get_share(*share_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
||||||
|
|
||||||
if share.owner_id != user_id {
|
if share.owner_id != user_id {
|
||||||
return Err(ApiError::forbidden(format!(
|
return Err(ApiError::forbidden(format!(
|
||||||
|
|
@ -400,9 +430,7 @@ pub async fn batch_delete(
|
||||||
.storage
|
.storage
|
||||||
.batch_delete_shares(&share_ids)
|
.batch_delete_shares(&share_ids)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to batch delete: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to batch delete: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "deleted": deleted })))
|
Ok(Json(serde_json::json!({ "deleted": deleted })))
|
||||||
}
|
}
|
||||||
|
|
@ -414,12 +442,12 @@ pub async fn access_shared(
|
||||||
Path(token): Path<String>,
|
Path(token): Path<String>,
|
||||||
Query(params): Query<AccessSharedRequest>,
|
Query(params): Query<AccessSharedRequest>,
|
||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
) -> ApiResult<Json<MediaResponse>> {
|
) -> ApiResult<Json<SharedContentResponse>> {
|
||||||
let share = state
|
let share = state
|
||||||
.storage
|
.storage
|
||||||
.get_share_by_token(&token)
|
.get_share_by_token(&token)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
||||||
|
|
||||||
// Check expiration
|
// Check expiration
|
||||||
if let Some(expires_at) = share.expires_at
|
if let Some(expires_at) = share.expires_at
|
||||||
|
|
@ -463,9 +491,7 @@ pub async fn access_shared(
|
||||||
.storage
|
.storage
|
||||||
.record_share_access(share.id)
|
.record_share_access(share.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to record access: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to record access: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Log the access
|
// Log the access
|
||||||
let activity = ShareActivity {
|
let activity = ShareActivity {
|
||||||
|
|
@ -482,17 +508,87 @@ pub async fn access_shared(
|
||||||
// Return the shared content
|
// Return the shared content
|
||||||
match &share.target {
|
match &share.target {
|
||||||
ShareTarget::Media { media_id } => {
|
ShareTarget::Media { media_id } => {
|
||||||
let item =
|
let item = state
|
||||||
state.storage.get_media(*media_id).await.map_err(|e| {
|
.storage
|
||||||
ApiError::not_found(format!("Media not found: {}", e))
|
.get_media(*media_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Json(SharedContentResponse::Single(MediaResponse::from(
|
||||||
|
item,
|
||||||
|
))))
|
||||||
|
},
|
||||||
|
ShareTarget::Collection { collection_id } => {
|
||||||
|
let members = state
|
||||||
|
.storage
|
||||||
|
.get_collection_members(*collection_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::not_found(format!("Collection not found: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(item.into()))
|
let items: Vec<MediaResponse> =
|
||||||
|
members.into_iter().map(MediaResponse::from).collect();
|
||||||
|
|
||||||
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
||||||
},
|
},
|
||||||
_ => {
|
ShareTarget::Tag { tag_id } => {
|
||||||
Err(ApiError::bad_request(
|
let tag = state
|
||||||
"Collection/tag sharing not yet fully implemented",
|
.storage
|
||||||
))
|
.get_tag(*tag_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::not_found(format!("Tag not found: {e}")))?;
|
||||||
|
|
||||||
|
let request = pinakes_core::search::SearchRequest {
|
||||||
|
query: pinakes_core::search::SearchQuery::TagFilter(
|
||||||
|
tag.name.clone(),
|
||||||
|
),
|
||||||
|
sort: pinakes_core::search::SortOrder::default(),
|
||||||
|
pagination: Pagination::new(0, 100, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = state
|
||||||
|
.storage
|
||||||
|
.search(&request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
||||||
|
|
||||||
|
let items: Vec<MediaResponse> =
|
||||||
|
results.items.into_iter().map(MediaResponse::from).collect();
|
||||||
|
|
||||||
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
||||||
|
},
|
||||||
|
ShareTarget::SavedSearch { search_id } => {
|
||||||
|
let saved =
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.get_saved_search(*search_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::not_found(format!("Saved search not found: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let parsed_query = pinakes_core::search::parse_search_query(&saved.query)
|
||||||
|
.map_err(|e| {
|
||||||
|
ApiError::internal(format!("Failed to parse search query: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let request = pinakes_core::search::SearchRequest {
|
||||||
|
query: parsed_query,
|
||||||
|
sort: pinakes_core::search::SortOrder::default(),
|
||||||
|
pagination: Pagination::new(0, 100, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let results = state
|
||||||
|
.storage
|
||||||
|
.search(&request)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Search failed: {e}")))?;
|
||||||
|
|
||||||
|
let items: Vec<MediaResponse> =
|
||||||
|
results.items.into_iter().map(MediaResponse::from).collect();
|
||||||
|
|
||||||
|
Ok(Json(SharedContentResponse::Multiple { items }))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -510,7 +606,7 @@ pub async fn get_activity(
|
||||||
.storage
|
.storage
|
||||||
.get_share(ShareId(id))
|
.get_share(ShareId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Share not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?;
|
||||||
|
|
||||||
// Only owner can view activity
|
// Only owner can view activity
|
||||||
if share.owner_id != user_id {
|
if share.owner_id != user_id {
|
||||||
|
|
@ -529,9 +625,7 @@ pub async fn get_activity(
|
||||||
.storage
|
.storage
|
||||||
.get_share_activity(ShareId(id), &pagination)
|
.get_share_activity(ShareId(id), &pagination)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to get activity: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to get activity: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(activity.into_iter().map(Into::into).collect()))
|
Ok(Json(activity.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
@ -548,7 +642,7 @@ pub async fn get_notifications(
|
||||||
.get_unread_notifications(user_id)
|
.get_unread_notifications(user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to get notifications: {}", e))
|
ApiError::internal(format!("Failed to get notifications: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(notifications.into_iter().map(Into::into).collect()))
|
Ok(Json(notifications.into_iter().map(Into::into).collect()))
|
||||||
|
|
@ -558,16 +652,15 @@ pub async fn get_notifications(
|
||||||
/// POST /api/notifications/shares/{id}/read
|
/// POST /api/notifications/shares/{id}/read
|
||||||
pub async fn mark_notification_read(
|
pub async fn mark_notification_read(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(_username): Extension<String>,
|
Extension(username): Extension<String>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
state
|
state
|
||||||
.storage
|
.storage
|
||||||
.mark_notification_read(id)
|
.mark_notification_read(id, user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to mark as read: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to mark as read: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
@ -584,7 +677,7 @@ pub async fn mark_all_read(
|
||||||
.mark_all_notifications_read(user_id)
|
.mark_all_notifications_read(user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to mark all as read: {}", e))
|
ApiError::internal(format!("Failed to mark all as read: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ use pinakes_core::{
|
||||||
update_device_cursor,
|
update_device_cursor,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -99,7 +100,7 @@ pub async fn register_device(
|
||||||
.register_device(&device, &token_hash)
|
.register_device(&device, &token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to register device: {}", e))
|
ApiError::internal(format!("Failed to register device: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeviceRegistrationResponse {
|
Ok(Json(DeviceRegistrationResponse {
|
||||||
|
|
@ -115,14 +116,11 @@ pub async fn list_devices(
|
||||||
Extension(username): Extension<String>,
|
Extension(username): Extension<String>,
|
||||||
) -> ApiResult<Json<Vec<DeviceResponse>>> {
|
) -> ApiResult<Json<Vec<DeviceResponse>>> {
|
||||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||||
let devices =
|
let devices = state
|
||||||
state
|
|
||||||
.storage
|
.storage
|
||||||
.list_user_devices(user_id)
|
.list_user_devices(user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to list devices: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to list devices: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(devices.into_iter().map(Into::into).collect()))
|
Ok(Json(devices.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +137,7 @@ pub async fn get_device(
|
||||||
.storage
|
.storage
|
||||||
.get_device(DeviceId(id))
|
.get_device(DeviceId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?;
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
if device.user_id != user_id {
|
if device.user_id != user_id {
|
||||||
|
|
@ -162,7 +160,7 @@ pub async fn update_device(
|
||||||
.storage
|
.storage
|
||||||
.get_device(DeviceId(id))
|
.get_device(DeviceId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?;
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
if device.user_id != user_id {
|
if device.user_id != user_id {
|
||||||
|
|
@ -176,9 +174,11 @@ pub async fn update_device(
|
||||||
device.enabled = enabled;
|
device.enabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.storage.update_device(&device).await.map_err(|e| {
|
state
|
||||||
ApiError::internal(format!("Failed to update device: {}", e))
|
.storage
|
||||||
})?;
|
.update_device(&device)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to update device: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(device.into()))
|
Ok(Json(device.into()))
|
||||||
}
|
}
|
||||||
|
|
@ -195,7 +195,7 @@ pub async fn delete_device(
|
||||||
.storage
|
.storage
|
||||||
.get_device(DeviceId(id))
|
.get_device(DeviceId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?;
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
if device.user_id != user_id {
|
if device.user_id != user_id {
|
||||||
|
|
@ -206,9 +206,7 @@ pub async fn delete_device(
|
||||||
.storage
|
.storage
|
||||||
.delete_device(DeviceId(id))
|
.delete_device(DeviceId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to delete device: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to delete device: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +223,7 @@ pub async fn regenerate_token(
|
||||||
.storage
|
.storage
|
||||||
.get_device(DeviceId(id))
|
.get_device(DeviceId(id))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Device not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Device not found: {e}")))?;
|
||||||
|
|
||||||
// Verify ownership
|
// Verify ownership
|
||||||
if device.user_id != user_id {
|
if device.user_id != user_id {
|
||||||
|
|
@ -244,7 +242,7 @@ pub async fn regenerate_token(
|
||||||
.register_device(&device, &token_hash)
|
.register_device(&device, &token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to regenerate token: {}", e))
|
ApiError::internal(format!("Failed to regenerate token: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(DeviceRegistrationResponse {
|
Ok(Json(DeviceRegistrationResponse {
|
||||||
|
|
@ -272,7 +270,7 @@ pub async fn get_changes(
|
||||||
.storage
|
.storage
|
||||||
.get_changes_since(cursor, limit + 1)
|
.get_changes_since(cursor, limit + 1)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to get changes: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Failed to get changes: {e}")))?;
|
||||||
|
|
||||||
let has_more = changes.len() > limit as usize;
|
let has_more = changes.len() > limit as usize;
|
||||||
let changes: Vec<SyncChangeResponse> = changes
|
let changes: Vec<SyncChangeResponse> = changes
|
||||||
|
|
@ -281,7 +279,7 @@ pub async fn get_changes(
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let new_cursor = changes.last().map(|c| c.sequence).unwrap_or(cursor);
|
let new_cursor = changes.last().map_or(cursor, |c| c.sequence);
|
||||||
|
|
||||||
Ok(Json(ChangesResponse {
|
Ok(Json(ChangesResponse {
|
||||||
changes,
|
changes,
|
||||||
|
|
@ -357,12 +355,8 @@ pub async fn report_changes(
|
||||||
|
|
||||||
// No conflict, check if upload is needed
|
// No conflict, check if upload is needed
|
||||||
match change.change_type.as_str() {
|
match change.change_type.as_str() {
|
||||||
"created" | "modified" => {
|
"created" | "modified" if change.content_hash.is_some() => {
|
||||||
if change.content_hash.is_some() {
|
|
||||||
upload_required.push(change.path);
|
upload_required.push(change.path);
|
||||||
} else {
|
|
||||||
accepted.push(change.path);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"deleted" => {
|
"deleted" => {
|
||||||
// Record deletion
|
// Record deletion
|
||||||
|
|
@ -415,15 +409,13 @@ pub async fn acknowledge_changes(
|
||||||
.storage
|
.storage
|
||||||
.get_device_by_token(&token_hash)
|
.get_device_by_token(&token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))?
|
.map_err(|e| ApiError::internal(format!("Failed to get device: {e}")))?
|
||||||
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
||||||
|
|
||||||
// Update device cursor
|
// Update device cursor
|
||||||
update_device_cursor(&state.storage, device.id, req.cursor)
|
update_device_cursor(&state.storage, device.id, req.cursor)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to update cursor: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to update cursor: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
@ -445,16 +437,14 @@ pub async fn list_conflicts(
|
||||||
.storage
|
.storage
|
||||||
.get_device_by_token(&token_hash)
|
.get_device_by_token(&token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))?
|
.map_err(|e| ApiError::internal(format!("Failed to get device: {e}")))?
|
||||||
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
||||||
|
|
||||||
let conflicts = state
|
let conflicts = state
|
||||||
.storage
|
.storage
|
||||||
.get_unresolved_conflicts(device.id)
|
.get_unresolved_conflicts(device.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to get conflicts: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to get conflicts: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(conflicts.into_iter().map(Into::into).collect()))
|
Ok(Json(conflicts.into_iter().map(Into::into).collect()))
|
||||||
}
|
}
|
||||||
|
|
@ -479,7 +469,7 @@ pub async fn resolve_conflict(
|
||||||
.resolve_conflict(id, resolution)
|
.resolve_conflict(id, resolution)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to resolve conflict: {}", e))
|
ApiError::internal(format!("Failed to resolve conflict: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
|
|
@ -510,7 +500,7 @@ pub async fn create_upload(
|
||||||
.storage
|
.storage
|
||||||
.get_device_by_token(&token_hash)
|
.get_device_by_token(&token_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to get device: {}", e)))?
|
.map_err(|e| ApiError::internal(format!("Failed to get device: {e}")))?
|
||||||
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
.ok_or_else(|| ApiError::unauthorized("Invalid device token"))?;
|
||||||
|
|
||||||
let chunk_size = req.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
|
let chunk_size = req.chunk_size.unwrap_or(DEFAULT_CHUNK_SIZE);
|
||||||
|
|
@ -536,13 +526,13 @@ pub async fn create_upload(
|
||||||
.create_upload_session(&session)
|
.create_upload_session(&session)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to create upload session: {}", e))
|
ApiError::internal(format!("Failed to create upload session: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Create temp file for chunked upload if manager is available
|
// Create temp file for chunked upload if manager is available
|
||||||
if let Some(ref manager) = state.chunked_upload_manager {
|
if let Some(ref manager) = state.chunked_upload_manager {
|
||||||
manager.create_temp_file(&session).await.map_err(|e| {
|
manager.create_temp_file(&session).await.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to create temp file: {}", e))
|
ApiError::internal(format!("Failed to create temp file: {e}"))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,7 +553,7 @@ pub async fn upload_chunk(
|
||||||
.get_upload_session(session_id)
|
.get_upload_session(session_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::not_found(format!("Upload session not found: {}", e))
|
ApiError::not_found(format!("Upload session not found: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if session.status == UploadStatus::Expired {
|
if session.status == UploadStatus::Expired {
|
||||||
|
|
@ -583,16 +573,14 @@ pub async fn upload_chunk(
|
||||||
let chunk = manager
|
let chunk = manager
|
||||||
.write_chunk(&session, chunk_index, body.as_ref())
|
.write_chunk(&session, chunk_index, body.as_ref())
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to write chunk: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Failed to write chunk: {e}")))?;
|
||||||
|
|
||||||
// Record chunk metadata in database
|
// Record chunk metadata in database
|
||||||
state
|
state
|
||||||
.storage
|
.storage
|
||||||
.record_chunk(session_id, &chunk)
|
.record_chunk(session_id, &chunk)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to record chunk: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to record chunk: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(Json(ChunkUploadedResponse {
|
Ok(Json(ChunkUploadedResponse {
|
||||||
chunk_index,
|
chunk_index,
|
||||||
|
|
@ -607,7 +595,7 @@ pub async fn get_upload_status(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> ApiResult<Json<UploadSessionResponse>> {
|
) -> ApiResult<Json<UploadSessionResponse>> {
|
||||||
let session = state.storage.get_upload_session(id).await.map_err(|e| {
|
let session = state.storage.get_upload_session(id).await.map_err(|e| {
|
||||||
ApiError::not_found(format!("Upload session not found: {}", e))
|
ApiError::not_found(format!("Upload session not found: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(session.into()))
|
Ok(Json(session.into()))
|
||||||
|
|
@ -621,14 +609,15 @@ pub async fn complete_upload(
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
let mut session =
|
let mut session =
|
||||||
state.storage.get_upload_session(id).await.map_err(|e| {
|
state.storage.get_upload_session(id).await.map_err(|e| {
|
||||||
ApiError::not_found(format!("Upload session not found: {}", e))
|
ApiError::not_found(format!("Upload session not found: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Verify all chunks received
|
// Verify all chunks received
|
||||||
let chunks =
|
let chunks = state
|
||||||
state.storage.get_upload_chunks(id).await.map_err(|e| {
|
.storage
|
||||||
ApiError::internal(format!("Failed to get chunks: {}", e))
|
.get_upload_chunks(id)
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to get chunks: {e}")))?;
|
||||||
|
|
||||||
if chunks.len() != session.chunk_count as usize {
|
if chunks.len() != session.chunk_count as usize {
|
||||||
return Err(ApiError::bad_request(format!(
|
return Err(ApiError::bad_request(format!(
|
||||||
|
|
@ -645,7 +634,7 @@ pub async fn complete_upload(
|
||||||
|
|
||||||
// Verify and finalize the temp file
|
// Verify and finalize the temp file
|
||||||
let temp_path = manager.finalize(&session, &chunks).await.map_err(|e| {
|
let temp_path = manager.finalize(&session, &chunks).await.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to finalize upload: {}", e))
|
ApiError::internal(format!("Failed to finalize upload: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Validate and resolve target path securely
|
// Validate and resolve target path securely
|
||||||
|
|
@ -676,13 +665,13 @@ pub async fn complete_upload(
|
||||||
|
|
||||||
// Canonicalize root to resolve symlinks and get absolute path
|
// Canonicalize root to resolve symlinks and get absolute path
|
||||||
let root_canon = tokio::fs::canonicalize(&root).await.map_err(|e| {
|
let root_canon = tokio::fs::canonicalize(&root).await.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to canonicalize root: {}", e))
|
ApiError::internal(format!("Failed to canonicalize root: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Ensure parent directory exists before canonicalizing candidate
|
// Ensure parent directory exists before canonicalizing candidate
|
||||||
if let Some(parent) = candidate.parent() {
|
if let Some(parent) = candidate.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
tokio::fs::create_dir_all(parent).await.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to create directory: {}", e))
|
ApiError::internal(format!("Failed to create directory: {e}"))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -694,7 +683,7 @@ pub async fn complete_upload(
|
||||||
canon
|
canon
|
||||||
} else if let Some(parent) = candidate.parent() {
|
} else if let Some(parent) = candidate.parent() {
|
||||||
let parent_canon = tokio::fs::canonicalize(parent).await.map_err(|e| {
|
let parent_canon = tokio::fs::canonicalize(parent).await.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to canonicalize parent: {}", e))
|
ApiError::internal(format!("Failed to canonicalize parent: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(filename) = candidate.file_name() {
|
if let Some(filename) = candidate.file_name() {
|
||||||
|
|
@ -721,13 +710,11 @@ pub async fn complete_upload(
|
||||||
// Fallback: copy then remove
|
// Fallback: copy then remove
|
||||||
tokio::fs::copy(&temp_path, &final_path)
|
tokio::fs::copy(&temp_path, &final_path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to copy file: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to copy file: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let _ = tokio::fs::remove_file(&temp_path).await;
|
let _ = tokio::fs::remove_file(&temp_path).await;
|
||||||
} else {
|
} else {
|
||||||
return Err(ApiError::internal(format!("Failed to move file: {}", e)));
|
return Err(ApiError::internal(format!("Failed to move file: {e}")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -744,7 +731,7 @@ pub async fn complete_upload(
|
||||||
.update_upload_session(&session)
|
.update_upload_session(&session)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to update session: {}", e))
|
ApiError::internal(format!("Failed to update session: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Record the sync change
|
// Record the sync change
|
||||||
|
|
@ -765,9 +752,7 @@ pub async fn complete_upload(
|
||||||
.storage
|
.storage
|
||||||
.record_sync_change(&entry)
|
.record_sync_change(&entry)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| ApiError::internal(format!("Failed to record change: {e}")))?;
|
||||||
ApiError::internal(format!("Failed to record change: {}", e))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(StatusCode::OK)
|
Ok(StatusCode::OK)
|
||||||
}
|
}
|
||||||
|
|
@ -780,7 +765,7 @@ pub async fn cancel_upload(
|
||||||
) -> ApiResult<StatusCode> {
|
) -> ApiResult<StatusCode> {
|
||||||
let mut session =
|
let mut session =
|
||||||
state.storage.get_upload_session(id).await.map_err(|e| {
|
state.storage.get_upload_session(id).await.map_err(|e| {
|
||||||
ApiError::not_found(format!("Upload session not found: {}", e))
|
ApiError::not_found(format!("Upload session not found: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Clean up temp file if manager is available
|
// Clean up temp file if manager is available
|
||||||
|
|
@ -796,7 +781,7 @@ pub async fn cancel_upload(
|
||||||
.update_upload_session(&session)
|
.update_upload_session(&session)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::internal(format!("Failed to cancel session: {}", e))
|
ApiError::internal(format!("Failed to cancel session: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
|
@ -813,16 +798,17 @@ pub async fn download_file(
|
||||||
.storage
|
.storage
|
||||||
.get_media_by_path(FilePath::new(&path))
|
.get_media_by_path(FilePath::new(&path))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to get media: {}", e)))?
|
.map_err(|e| ApiError::internal(format!("Failed to get media: {e}")))?
|
||||||
.ok_or_else(|| ApiError::not_found("File not found"))?;
|
.ok_or_else(|| ApiError::not_found("File not found"))?;
|
||||||
|
|
||||||
let file = tokio::fs::File::open(&item.path)
|
let file = tokio::fs::File::open(&item.path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("File not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("File not found: {e}")))?;
|
||||||
|
|
||||||
let metadata = file.metadata().await.map_err(|e| {
|
let metadata = file
|
||||||
ApiError::internal(format!("Failed to get metadata: {}", e))
|
.metadata()
|
||||||
})?;
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to get metadata: {e}")))?;
|
||||||
|
|
||||||
let file_size = metadata.len();
|
let file_size = metadata.len();
|
||||||
|
|
||||||
|
|
@ -835,11 +821,15 @@ pub async fn download_file(
|
||||||
let (start, end) = range;
|
let (start, end) = range;
|
||||||
let length = end - start + 1;
|
let length = end - start + 1;
|
||||||
|
|
||||||
let file = tokio::fs::File::open(&item.path).await.map_err(|e| {
|
let mut file = tokio::fs::File::open(&item.path)
|
||||||
ApiError::internal(format!("Failed to reopen file: {}", e))
|
.await
|
||||||
})?;
|
.map_err(|e| ApiError::internal(format!("Failed to reopen file: {e}")))?;
|
||||||
|
file
|
||||||
|
.seek(std::io::SeekFrom::Start(start))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApiError::internal(format!("Failed to seek file: {e}")))?;
|
||||||
|
|
||||||
let stream = ReaderStream::new(file);
|
let stream = ReaderStream::new(file.take(length));
|
||||||
let body = Body::from_stream(stream);
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
|
|
@ -850,7 +840,7 @@ pub async fn download_file(
|
||||||
(header::CONTENT_LENGTH, length.to_string()),
|
(header::CONTENT_LENGTH, length.to_string()),
|
||||||
(
|
(
|
||||||
header::CONTENT_RANGE,
|
header::CONTENT_RANGE,
|
||||||
format!("bytes {}-{}/{}", start, end, file_size),
|
format!("bytes {start}-{end}/{file_size}"),
|
||||||
),
|
),
|
||||||
(header::ACCEPT_RANGES, "bytes".to_string()),
|
(header::ACCEPT_RANGES, "bytes".to_string()),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -52,22 +52,21 @@ pub async fn upload_file(
|
||||||
.next_field()
|
.next_field()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
ApiError::bad_request(format!("Failed to read multipart field: {}", e))
|
ApiError::bad_request(format!("Failed to read multipart field: {e}"))
|
||||||
})?
|
})?
|
||||||
.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
.ok_or_else(|| ApiError::bad_request("No file provided"))?;
|
||||||
|
|
||||||
let original_filename = field
|
let original_filename = field
|
||||||
.file_name()
|
.file_name()
|
||||||
.map(|s| s.to_string())
|
.map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
|
||||||
.unwrap_or_else(|| "unknown".to_string());
|
|
||||||
|
|
||||||
let content_type = field
|
let content_type = field.content_type().map_or_else(
|
||||||
.content_type()
|
|| "application/octet-stream".to_string(),
|
||||||
.map(|s| s.to_string())
|
std::string::ToString::to_string,
|
||||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
);
|
||||||
|
|
||||||
let data = field.bytes().await.map_err(|e| {
|
let data = field.bytes().await.map_err(|e| {
|
||||||
ApiError::bad_request(format!("Failed to read file data: {}", e))
|
ApiError::bad_request(format!("Failed to read file data: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Process the upload
|
// Process the upload
|
||||||
|
|
@ -79,7 +78,7 @@ pub async fn upload_file(
|
||||||
Some(&content_type),
|
Some(&content_type),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Upload failed: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Upload failed: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(result.into()))
|
Ok(Json(result.into()))
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +94,7 @@ pub async fn download_file(
|
||||||
.storage
|
.storage
|
||||||
.get_media(media_id)
|
.get_media(media_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Media not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Media not found: {e}")))?;
|
||||||
|
|
||||||
let managed_storage = state
|
let managed_storage = state
|
||||||
.managed_storage
|
.managed_storage
|
||||||
|
|
@ -107,7 +106,7 @@ pub async fn download_file(
|
||||||
// For external files, stream from their original path
|
// For external files, stream from their original path
|
||||||
let file = tokio::fs::File::open(&item.path)
|
let file = tokio::fs::File::open(&item.path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("File not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("File not found: {e}")))?;
|
||||||
|
|
||||||
let stream = ReaderStream::new(file);
|
let stream = ReaderStream::new(file);
|
||||||
let body = axum::body::Body::from_stream(stream);
|
let body = axum::body::Body::from_stream(stream);
|
||||||
|
|
@ -132,7 +131,7 @@ pub async fn download_file(
|
||||||
let file = managed_storage
|
let file = managed_storage
|
||||||
.open(&item.content_hash)
|
.open(&item.content_hash)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::not_found(format!("Blob not found: {}", e)))?;
|
.map_err(|e| ApiError::not_found(format!("Blob not found: {e}")))?;
|
||||||
|
|
||||||
let stream = ReaderStream::new(file);
|
let stream = ReaderStream::new(file);
|
||||||
let body = axum::body::Body::from_stream(stream);
|
let body = axum::body::Body::from_stream(stream);
|
||||||
|
|
@ -171,7 +170,7 @@ pub async fn move_to_managed(
|
||||||
media_id,
|
media_id,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Migration failed: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Migration failed: {e}")))?;
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +184,7 @@ pub async fn managed_stats(
|
||||||
.storage
|
.storage
|
||||||
.managed_storage_stats()
|
.managed_storage_stats()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiError::internal(format!("Failed to get stats: {}", e)))?;
|
.map_err(|e| ApiError::internal(format!("Failed to get stats: {e}")))?;
|
||||||
|
|
||||||
Ok(Json(stats.into()))
|
Ok(Json(stats.into()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue