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:
raf 2026-03-08 00:42:17 +03:00
commit 2b2c1830a1
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 334 additions and 179 deletions

View file

@ -13,11 +13,13 @@ use pinakes_core::{
ShareActivity,
ShareActivityAction,
ShareId,
ShareMutatePermissions,
ShareNotification,
ShareNotificationType,
SharePermissions,
ShareRecipient,
ShareTarget,
ShareViewPermissions,
generate_share_token,
hash_share_password,
verify_share_password,
@ -37,6 +39,7 @@ use crate::{
ShareActivityResponse,
ShareNotificationResponse,
ShareResponse,
SharedContentResponse,
UpdateShareRequest,
},
error::{ApiError, ApiResult},
@ -51,14 +54,25 @@ pub async fn create_share(
Json(req): Json<CreateShareRequest>,
) -> ApiResult<Json<ShareResponse>> {
let config = state.config.read().await;
if !config.sharing.enabled {
if !config.sharing.enabled() {
return Err(ApiError::bad_request("Sharing is not enabled"));
}
// 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"));
}
// 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);
let owner_id = resolve_user_id(&state.storage, &username).await?;
@ -124,12 +138,16 @@ pub async fn create_share(
// Parse permissions
let permissions = if let Some(perms) = req.permissions {
SharePermissions {
can_view: perms.can_view.unwrap_or(true),
can_download: perms.can_download.unwrap_or(false),
can_edit: perms.can_edit.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),
view: ShareViewPermissions {
can_view: perms.can_view.unwrap_or(true),
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_delete: perms.can_delete.unwrap_or(false),
can_add: perms.can_add.unwrap_or(false),
},
}
} else {
SharePermissions::view_only()
@ -156,9 +174,10 @@ pub async fn create_share(
updated_at: Utc::now(),
};
let created = state.storage.create_share(&share).await.map_err(|e| {
ApiError::internal(format!("Failed to create share: {}", e))
})?;
let created =
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
if let ShareRecipient::User { user_id } = &created.recipient {
@ -198,7 +217,7 @@ pub async fn list_outgoing(
.storage
.list_shares_by_owner(user_id, &pagination)
.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()))
}
@ -221,7 +240,7 @@ pub async fn list_incoming(
.storage
.list_shares_for_user(user_id, &pagination)
.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()))
}
@ -238,7 +257,7 @@ pub async fn get_share(
.storage
.get_share(ShareId(id))
.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
let is_owner = share.owner_id == user_id;
@ -269,7 +288,7 @@ pub async fn update_share(
.storage
.get_share(ShareId(id))
.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
if share.owner_id != user_id {
@ -279,14 +298,22 @@ pub async fn update_share(
// Update fields
if let Some(perms) = req.permissions {
share.permissions = SharePermissions {
can_view: perms.can_view.unwrap_or(share.permissions.can_view),
can_download: perms
.can_download
.unwrap_or(share.permissions.can_download),
can_edit: perms.can_edit.unwrap_or(share.permissions.can_edit),
can_delete: perms.can_delete.unwrap_or(share.permissions.can_delete),
can_reshare: perms.can_reshare.unwrap_or(share.permissions.can_reshare),
can_add: perms.can_add.unwrap_or(share.permissions.can_add),
view: ShareViewPermissions {
can_view: perms.can_view.unwrap_or(share.permissions.view.can_view),
can_download: perms
.can_download
.unwrap_or(share.permissions.view.can_download),
can_reshare: perms
.can_reshare
.unwrap_or(share.permissions.view.can_reshare),
},
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,9 +331,10 @@ pub async fn update_share(
share.updated_at = Utc::now();
let updated = state.storage.update_share(&share).await.map_err(|e| {
ApiError::internal(format!("Failed to update share: {}", e))
})?;
let updated =
state.storage.update_share(&share).await.map_err(|e| {
ApiError::internal(format!("Failed to update share: {e}"))
})?;
// Notify recipient of update
if let ShareRecipient::User { user_id } = &updated.recipient {
@ -339,7 +367,7 @@ pub async fn delete_share(
.storage
.get_share(ShareId(id))
.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
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| {
ApiError::internal(format!("Failed to delete share: {}", e))
})?;
state
.storage
.delete_share(ShareId(id))
.await
.map_err(|e| ApiError::internal(format!("Failed to delete share: {e}")))?;
Ok(StatusCode::NO_CONTENT)
}
@ -386,7 +416,7 @@ pub async fn batch_delete(
.storage
.get_share(*share_id)
.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 {
return Err(ApiError::forbidden(format!(
@ -400,9 +430,7 @@ pub async fn batch_delete(
.storage
.batch_delete_shares(&share_ids)
.await
.map_err(|e| {
ApiError::internal(format!("Failed to batch delete: {}", e))
})?;
.map_err(|e| ApiError::internal(format!("Failed to batch delete: {e}")))?;
Ok(Json(serde_json::json!({ "deleted": deleted })))
}
@ -414,12 +442,12 @@ pub async fn access_shared(
Path(token): Path<String>,
Query(params): Query<AccessSharedRequest>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> ApiResult<Json<MediaResponse>> {
) -> ApiResult<Json<SharedContentResponse>> {
let share = state
.storage
.get_share_by_token(&token)
.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
if let Some(expires_at) = share.expires_at
@ -463,9 +491,7 @@ pub async fn access_shared(
.storage
.record_share_access(share.id)
.await
.map_err(|e| {
ApiError::internal(format!("Failed to record access: {}", e))
})?;
.map_err(|e| ApiError::internal(format!("Failed to record access: {e}")))?;
// Log the access
let activity = ShareActivity {
@ -482,17 +508,87 @@ pub async fn access_shared(
// Return the shared content
match &share.target {
ShareTarget::Media { media_id } => {
let item =
state.storage.get_media(*media_id).await.map_err(|e| {
ApiError::not_found(format!("Media not found: {}", e))
let item = state
.storage
.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 }))
},
_ => {
Err(ApiError::bad_request(
"Collection/tag sharing not yet fully implemented",
))
ShareTarget::Tag { tag_id } => {
let tag = state
.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
.get_share(ShareId(id))
.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
if share.owner_id != user_id {
@ -529,9 +625,7 @@ pub async fn get_activity(
.storage
.get_share_activity(ShareId(id), &pagination)
.await
.map_err(|e| {
ApiError::internal(format!("Failed to get activity: {}", e))
})?;
.map_err(|e| ApiError::internal(format!("Failed to get activity: {e}")))?;
Ok(Json(activity.into_iter().map(Into::into).collect()))
}
@ -548,7 +642,7 @@ pub async fn get_notifications(
.get_unread_notifications(user_id)
.await
.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()))
@ -558,16 +652,15 @@ pub async fn get_notifications(
/// POST /api/notifications/shares/{id}/read
pub async fn mark_notification_read(
State(state): State<AppState>,
Extension(_username): Extension<String>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let user_id = resolve_user_id(&state.storage, &username).await?;
state
.storage
.mark_notification_read(id)
.mark_notification_read(id, user_id)
.await
.map_err(|e| {
ApiError::internal(format!("Failed to mark as read: {}", e))
})?;
.map_err(|e| ApiError::internal(format!("Failed to mark as read: {e}")))?;
Ok(StatusCode::OK)
}
@ -584,7 +677,7 @@ pub async fn mark_all_read(
.mark_all_notifications_read(user_id)
.await
.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)