use std::net::SocketAddr; use axum::{ Json, extract::{ConnectInfo, Extension, Path, Query, State}, http::StatusCode, }; use chrono::Utc; use pinakes_core::{ model::{MediaId, Pagination}, sharing::{ Share, ShareActivity, ShareActivityAction, ShareId, ShareMutatePermissions, ShareNotification, ShareNotificationType, SharePermissions, ShareRecipient, ShareTarget, ShareViewPermissions, generate_share_token, hash_share_password, verify_share_password, }, users::UserId, }; use uuid::Uuid; use crate::{ auth::resolve_user_id, dto::{ AccessSharedRequest, BatchDeleteSharesRequest, CreateShareRequest, MediaResponse, PaginationParams, ShareActivityResponse, ShareNotificationResponse, ShareResponse, SharedContentResponse, UpdateShareRequest, }, error::{ApiError, ApiResult}, state::AppState, }; /// Create a new share /// POST /api/shares pub async fn create_share( State(state): State, Extension(username): Extension, Json(req): Json, ) -> ApiResult> { let config = state.config.read().await; 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() { 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?; // Parse target let target_id: Uuid = req .target_id .parse() .map_err(|_| ApiError::bad_request("Invalid target_id"))?; let target = match req.target_type.as_str() { "media" => { ShareTarget::Media { media_id: MediaId(target_id), } }, "collection" => { ShareTarget::Collection { collection_id: target_id, } }, "tag" => ShareTarget::Tag { tag_id: target_id }, "saved_search" => { ShareTarget::SavedSearch { search_id: target_id, } }, _ => return Err(ApiError::bad_request("Invalid target_type")), }; // Parse recipient let recipient = match req.recipient_type.as_str() { "public_link" => { let token = generate_share_token(); let password_hash = req .password .as_ref() .map(|p| hash_share_password(p)) .transpose() .map_err(ApiError)?; ShareRecipient::PublicLink { token, password_hash, } }, "user" => { let recipient_user_id = req.recipient_user_id.ok_or_else(|| { ApiError::bad_request("recipient_user_id required for user share") })?; ShareRecipient::User { user_id: UserId(recipient_user_id), } }, "group" => { let group_id = req.recipient_group_id.ok_or_else(|| { ApiError::bad_request("recipient_group_id required for group share") })?; ShareRecipient::Group { group_id } }, _ => return Err(ApiError::bad_request("Invalid recipient_type")), }; // Parse permissions let permissions = if let Some(perms) = req.permissions { SharePermissions { 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() }; // Calculate expiration let expires_at = req .expires_in_hours .map(|hours| Utc::now() + chrono::Duration::hours(hours as i64)); let share = Share { id: ShareId(Uuid::now_v7()), target, owner_id, recipient, permissions, note: req.note, expires_at, access_count: 0, last_accessed: None, inherit_to_children: req.inherit_to_children.unwrap_or(true), parent_share_id: None, created_at: Utc::now(), updated_at: Utc::now(), }; 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 { let notification = ShareNotification { id: Uuid::now_v7(), user_id: *user_id, share_id: created.id, notification_type: ShareNotificationType::NewShare, is_read: false, created_at: Utc::now(), }; if let Err(e) = state.storage.create_share_notification(¬ification).await { tracing::warn!(error = %e, "failed to send share notification"); } } Ok(Json(created.into())) } /// List outgoing shares (shares I created) /// GET /api/shares/outgoing pub async fn list_outgoing( State(state): State, Extension(username): Extension, Query(params): Query, ) -> ApiResult>> { let user_id = resolve_user_id(&state.storage, &username).await?; let pagination = Pagination { offset: params.offset.unwrap_or(0), limit: params.limit.unwrap_or(50).min(1000), sort: params.sort, }; let shares = state .storage .list_shares_by_owner(user_id, &pagination) .await .map_err(|e| ApiError::internal(format!("Failed to list shares: {e}")))?; Ok(Json(shares.into_iter().map(Into::into).collect())) } /// List incoming shares (shares shared with me) /// GET /api/shares/incoming pub async fn list_incoming( State(state): State, Extension(username): Extension, Query(params): Query, ) -> ApiResult>> { let user_id = resolve_user_id(&state.storage, &username).await?; let pagination = Pagination { offset: params.offset.unwrap_or(0), limit: params.limit.unwrap_or(50).min(1000), sort: params.sort, }; let shares = state .storage .list_shares_for_user(user_id, &pagination) .await .map_err(|e| ApiError::internal(format!("Failed to list shares: {e}")))?; Ok(Json(shares.into_iter().map(Into::into).collect())) } /// Get share details /// GET /api/shares/{id} pub async fn get_share( State(state): State, Extension(username): Extension, Path(id): Path, ) -> ApiResult> { let user_id = resolve_user_id(&state.storage, &username).await?; let share = state .storage .get_share(ShareId(id)) .await .map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?; // Check authorization let is_owner = share.owner_id == user_id; let is_recipient = match &share.recipient { ShareRecipient::User { user_id: recipient_id, } => *recipient_id == user_id, _ => false, }; if !is_owner && !is_recipient { return Err(ApiError::forbidden("Not authorized to view this share")); } Ok(Json(share.into())) } /// Update a share /// PATCH /api/shares/{id} pub async fn update_share( State(state): State, Extension(username): Extension, Path(id): Path, Json(req): Json, ) -> ApiResult> { let user_id = resolve_user_id(&state.storage, &username).await?; let mut share = state .storage .get_share(ShareId(id)) .await .map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?; // Only owner can update if share.owner_id != user_id { return Err(ApiError::forbidden("Only the owner can update this share")); } // Update fields if let Some(perms) = req.permissions { share.permissions = SharePermissions { 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), }, }; } if let Some(note) = req.note { share.note = Some(note); } if let Some(expires_at) = req.expires_at { share.expires_at = Some(expires_at); } if let Some(inherit) = req.inherit_to_children { share.inherit_to_children = inherit; } share.updated_at = Utc::now(); 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 { let notification = ShareNotification { id: Uuid::now_v7(), user_id: *user_id, share_id: updated.id, notification_type: ShareNotificationType::ShareUpdated, is_read: false, created_at: Utc::now(), }; if let Err(e) = state.storage.create_share_notification(¬ification).await { tracing::warn!(error = %e, "failed to send share update notification"); } } Ok(Json(updated.into())) } /// Delete (revoke) a share /// DELETE /api/shares/{id} pub async fn delete_share( State(state): State, Extension(username): Extension, Path(id): Path, ) -> ApiResult { let user_id = resolve_user_id(&state.storage, &username).await?; let share = state .storage .get_share(ShareId(id)) .await .map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?; // Only owner can delete if share.owner_id != user_id { return Err(ApiError::forbidden("Only the owner can revoke this share")); } // Notify recipient before deletion if let ShareRecipient::User { user_id } = &share.recipient { let notification = ShareNotification { id: Uuid::now_v7(), user_id: *user_id, share_id: share.id, notification_type: ShareNotificationType::ShareRevoked, is_read: false, created_at: Utc::now(), }; if let Err(e) = state.storage.create_share_notification(¬ification).await { tracing::warn!(error = %e, "failed to send share revocation notification"); } } state .storage .delete_share(ShareId(id)) .await .map_err(|e| ApiError::internal(format!("Failed to delete share: {e}")))?; Ok(StatusCode::NO_CONTENT) } /// Batch delete shares /// POST /api/shares/batch/delete pub async fn batch_delete( State(state): State, Extension(username): Extension, Json(req): Json, ) -> ApiResult> { let user_id = resolve_user_id(&state.storage, &username).await?; let share_ids: Vec = req.share_ids.into_iter().map(ShareId).collect(); // Verify ownership of all shares for share_id in &share_ids { let share = state .storage .get_share(*share_id) .await .map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?; if share.owner_id != user_id { return Err(ApiError::forbidden(format!( "Not authorized to delete share {}", share_id.0 ))); } } let deleted = state .storage .batch_delete_shares(&share_ids) .await .map_err(|e| ApiError::internal(format!("Failed to batch delete: {e}")))?; Ok(Json(serde_json::json!({ "deleted": deleted }))) } /// Access a public shared resource /// GET /api/shared/{token} pub async fn access_shared( State(state): State, Path(token): Path, Query(params): Query, ConnectInfo(addr): ConnectInfo, ) -> ApiResult> { let share = state .storage .get_share_by_token(&token) .await .map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?; // Check expiration if let Some(expires_at) = share.expires_at && Utc::now() > expires_at { return Err(ApiError::not_found("Share has expired")); } // Check password if required if let ShareRecipient::PublicLink { password_hash: Some(hash), .. } = &share.recipient { let provided_password = params .password .as_ref() .ok_or_else(|| ApiError::unauthorized("Password required"))?; if !verify_share_password(provided_password, hash) { // Log failed attempt let activity = ShareActivity { id: Uuid::now_v7(), share_id: share.id, actor_id: None, actor_ip: Some(addr.ip().to_string()), action: ShareActivityAction::PasswordFailed, details: None, timestamp: Utc::now(), }; if let Err(e) = state.storage.record_share_activity(&activity).await { tracing::warn!(error = %e, "failed to record share activity"); } return Err(ApiError::unauthorized("Invalid password")); } } // Record access state .storage .record_share_access(share.id) .await .map_err(|e| ApiError::internal(format!("Failed to record access: {e}")))?; // Log the access let activity = ShareActivity { id: Uuid::now_v7(), share_id: share.id, actor_id: None, actor_ip: Some(addr.ip().to_string()), action: ShareActivityAction::Accessed, details: None, timestamp: Utc::now(), }; let _ = state.storage.record_share_activity(&activity).await; // Return the shared content let roots = state.config.read().await.directories.roots.clone(); 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}")))?; Ok(Json(SharedContentResponse::Single(MediaResponse::new( item, &roots, )))) }, 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}")) })?; let items: Vec = members .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, 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 = results .items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .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 = results .items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); Ok(Json(SharedContentResponse::Multiple { items })) }, } } /// Get share activity log /// GET /api/shares/{id}/activity pub async fn get_activity( State(state): State, Extension(username): Extension, Path(id): Path, Query(params): Query, ) -> ApiResult>> { let user_id = resolve_user_id(&state.storage, &username).await?; let share = state .storage .get_share(ShareId(id)) .await .map_err(|e| ApiError::not_found(format!("Share not found: {e}")))?; // Only owner can view activity if share.owner_id != user_id { return Err(ApiError::forbidden( "Only the owner can view share activity", )); } let pagination = Pagination { offset: params.offset.unwrap_or(0), limit: params.limit.unwrap_or(50).min(1000), sort: params.sort, }; let activity = state .storage .get_share_activity(ShareId(id), &pagination) .await .map_err(|e| ApiError::internal(format!("Failed to get activity: {e}")))?; Ok(Json(activity.into_iter().map(Into::into).collect())) } /// Get unread share notifications /// GET /api/notifications/shares pub async fn get_notifications( State(state): State, Extension(username): Extension, ) -> ApiResult>> { let user_id = resolve_user_id(&state.storage, &username).await?; let notifications = state .storage .get_unread_notifications(user_id) .await .map_err(|e| { ApiError::internal(format!("Failed to get notifications: {e}")) })?; Ok(Json(notifications.into_iter().map(Into::into).collect())) } /// Mark a notification as read /// POST /api/notifications/shares/{id}/read pub async fn mark_notification_read( State(state): State, Extension(username): Extension, Path(id): Path, ) -> ApiResult { let user_id = resolve_user_id(&state.storage, &username).await?; state .storage .mark_notification_read(id, user_id) .await .map_err(|e| ApiError::internal(format!("Failed to mark as read: {e}")))?; Ok(StatusCode::OK) } /// Mark all notifications as read /// POST /api/notifications/shares/read-all pub async fn mark_all_read( State(state): State, Extension(username): Extension, ) -> ApiResult { let user_id = resolve_user_id(&state.storage, &username).await?; state .storage .mark_all_notifications_read(user_id) .await .map_err(|e| { ApiError::internal(format!("Failed to mark all as read: {e}")) })?; Ok(StatusCode::OK) }