diff --git a/migrations/20260801000003_places_geo_index.sql b/migrations/20260801000003_places_geo_index.sql new file mode 100644 index 00000000..34ea70fb --- /dev/null +++ b/migrations/20260801000003_places_geo_index.sql @@ -0,0 +1,10 @@ +-- ════════════════════════════════════════════════════════════════════════ +-- Places (photo map): partial index for fast bounding-box scans over the +-- caller's geotagged photos. Plain B-tree on (longitude, latitude); no +-- PostGIS required. The partial predicate keeps the index small — only rows +-- that actually carry GPS coordinates are indexed. +-- ════════════════════════════════════════════════════════════════════════ + +CREATE INDEX IF NOT EXISTS idx_file_metadata_geo + ON storage.file_metadata (longitude, latitude) + WHERE latitude IS NOT NULL AND longitude IS NOT NULL; diff --git a/src/application/dtos/geo_dto.rs b/src/application/dtos/geo_dto.rs new file mode 100644 index 00000000..de3edd14 --- /dev/null +++ b/src/application/dtos/geo_dto.rs @@ -0,0 +1,26 @@ +//! DTOs for the "Places" (photo map) feature. + +use serde::Serialize; +use utoipa::ToSchema; + +/// A geographic bounding box in decimal degrees. +#[derive(Debug, Clone, Copy)] +pub struct GeoBounds { + pub west: f64, + pub south: f64, + pub east: f64, + pub north: f64, +} + +/// A clustered group of geotagged photos within one aggregation cell. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct GeoCluster { + /// Cluster centroid longitude. + pub lng: f64, + /// Cluster centroid latitude. + pub lat: f64, + /// Number of photos in the cluster. + pub count: i64, + /// A representative photo id, for the cluster thumbnail. + pub sample_file_id: String, +} diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index b19c940d..d86edb67 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -10,6 +10,7 @@ pub mod favorites_dto; pub mod file_dto; pub mod folder_dto; pub mod folder_listing_dto; +pub mod geo_dto; pub mod grant_dto; pub mod i18n_dto; pub mod pagination; diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 7006ef9f..299b723d 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -20,6 +20,7 @@ pub mod magic_link_invite_service; pub mod music_service; pub mod nextcloud_file_id_service; pub mod nextcloud_login_flow_service; +pub mod places_service; pub mod recent_service; pub mod recipient_notification_service; pub mod search_service; diff --git a/src/application/services/places_service.rs b/src/application/services/places_service.rs new file mode 100644 index 00000000..6f0f42ad --- /dev/null +++ b/src/application/services/places_service.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use uuid::Uuid; + +use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster}; +use crate::common::errors::DomainError; +use crate::infrastructure::repositories::pg::FileBlobReadRepository; + +/// "Places" use case: the caller's geotagged photos aggregated into map +/// clusters. +/// +/// Strictly user-scoped — the repository filters `WHERE fi.user_id = $1`, so, +/// like [`RecentService`](super::recent_service::RecentService) and the photos +/// timeline, it needs no `AuthorizationEngine` check: the `caller_id` +/// parameter *is* the access scope. +pub struct PlacesService { + file_read: Arc, +} + +impl PlacesService { + pub fn new(file_read: Arc) -> Self { + Self { file_read } + } + + /// Aggregation cell side, in degrees, for a slippy-map zoom level. The + /// world (360°) is split into `2^zoom` tiles; we use ~4 cells per tile so + /// clusters refine as the user zooms in. Clamped to a sane range. + fn cell_for_zoom(zoom: u8) -> f64 { + let z = i32::from(zoom.min(20)); + 360.0 / (2_f64.powi(z) * 4.0) + } + + /// Clustered geotagged photos for `caller_id` within `bounds`. + pub async fn clusters( + &self, + caller_id: Uuid, + bounds: GeoBounds, + zoom: u8, + ) -> Result, DomainError> { + let cell = Self::cell_for_zoom(zoom); + self.file_read + .list_geo_clusters(caller_id, bounds, cell) + .await + } +} diff --git a/src/common/config.rs b/src/common/config.rs index a6f42c28..0a1147c9 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -879,6 +879,8 @@ pub struct FeaturesConfig { pub enable_trash: bool, pub enable_search: bool, pub enable_music: bool, + /// Lists the user's geotagged photos on a map (GET /api/photos/geo). + pub enable_places: bool, /// Expose other OxiCloud users as a read-only "system" address book /// at GET /api/address-books. Set to false to hide the user directory. pub expose_system_users: bool, @@ -893,6 +895,7 @@ impl Default for FeaturesConfig { enable_trash: true, // Enable trash feature enable_search: true, // Enable search feature enable_music: true, // Enable music feature + enable_places: false, // Photo map; off until the map UI ships expose_system_users: true, // Expose OxiCloud users as address book by default } } @@ -1378,6 +1381,12 @@ impl AppConfig { config.features.enable_music = val; } + if let Ok(enable_places) = env::var("OXICLOUD_ENABLE_PLACES").map(|v| v.parse::()) + && let Ok(val) = enable_places + { + config.features.enable_places = val; + } + // Content search (embedded Tantivy index) if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::()) && let Ok(val) = v diff --git a/src/common/di.rs b/src/common/di.rs index 62a3e133..37cf04fe 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -17,6 +17,7 @@ use crate::application::services::folder_service::FolderService; use crate::application::services::i18n_application_service::I18nApplicationService; use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService; use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService; +use crate::application::services::places_service::PlacesService; use crate::application::services::recent_service::RecentService; use crate::application::services::search_service::SearchService; use crate::application::services::share_browse_service::ShareBrowseService; @@ -798,6 +799,17 @@ impl AppServiceFactory { service } + /// Creates the Places (photo map) service. Reuses the existing file-read + /// repository — the data is the caller's own geotagged photos. + pub fn create_places_service( + &self, + file_read: &Arc, + ) -> Arc { + let service = Arc::new(PlacesService::new(file_read.clone())); + tracing::info!("Places service initialized"); + service + } + /// Preloads translations for every locale in the registry. Build /// the registry at startup via `LocaleRegistry::discover` and pass /// the resulting list here. @@ -1005,6 +1017,7 @@ impl AppServiceFactory { // 6. Database-dependent services (PgPool always available in blob model) let favorites_service: Option>; let recent_service: Option>; + let places_service: Option>; let storage_usage_service: Option>; let mut auth_services: Option = None; let mut nextcloud_services: Option = None; @@ -1027,6 +1040,12 @@ impl AppServiceFactory { recent_service = Some(recent.clone()); apps.recent_service = Some(recent); + places_service = if core.config.features.enable_places { + Some(self.create_places_service(&repos.file_read_repository)) + } else { + None + }; + storage_usage_service = Some(storage_usage.clone()); self.start_tree_etag_flush_job(&maintenance_pool); @@ -1253,6 +1272,7 @@ impl AppServiceFactory { share_browse_service, favorites_service, recent_service, + places_service, storage_usage_service, calendar_service: None, contact_service: None, @@ -1699,6 +1719,7 @@ pub struct AppState { pub share_browse_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, + pub places_service: Option>, pub storage_usage_service: Option>, pub calendar_service: Option>, pub contact_service: Option>, diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 524f36d8..cbcdcce2 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -32,6 +32,7 @@ use std::pin::Pin; use std::sync::Arc; use std::time::Duration; +use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster}; use crate::application::dtos::search_dto::SearchCriteriaDto; use crate::application::ports::storage_ports::FileReadPort; use crate::common::errors::DomainError; @@ -416,6 +417,56 @@ impl FileBlobReadRepository { Ok((files, sort_dates, dims)) } + + /// Aggregate the caller's geotagged photos into grid cells of side `cell` + /// (degrees) within `bounds`. Plain SQL (no PostGIS), scoped to `user_id`. + /// Returns one cluster per non-empty cell with its centroid, photo count + /// and a representative photo id (for the cluster thumbnail). + pub async fn list_geo_clusters( + &self, + user_id: Uuid, + bounds: GeoBounds, + cell: f64, + ) -> Result, DomainError> { + let rows: Vec<(i64, f64, f64, String)> = sqlx::query_as( + r#" + SELECT count(*) AS n, + avg(fm.longitude) AS clng, + avg(fm.latitude) AS clat, + min(fm.file_id::text) AS sample_id + FROM storage.file_metadata fm + JOIN storage.files fi ON fi.id = fm.file_id + WHERE fi.user_id = $1 + AND NOT fi.is_trashed + AND fm.latitude IS NOT NULL + AND fm.longitude IS NOT NULL + AND fm.longitude BETWEEN $2 AND $3 + AND fm.latitude BETWEEN $4 AND $5 + GROUP BY round(fm.longitude / $6), round(fm.latitude / $6) + "#, + ) + .bind(user_id) + .bind(bounds.west) + .bind(bounds.east) + .bind(bounds.south) + .bind(bounds.north) + .bind(cell) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| { + DomainError::internal_error("FileBlobRead", format!("list_geo_clusters: {e}")) + })?; + + Ok(rows + .into_iter() + .map(|(n, clng, clat, sample_id)| GeoCluster { + lng: clng, + lat: clat, + count: n, + sample_file_id: sample_id, + }) + .collect()) + } } impl FileReadPort for FileBlobReadRepository { diff --git a/src/interfaces/api/handlers/photos_handler.rs b/src/interfaces/api/handlers/photos_handler.rs index d1110f43..c6243e82 100644 --- a/src/interfaces/api/handlers/photos_handler.rs +++ b/src/interfaces/api/handlers/photos_handler.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use tracing::{error, info}; use crate::application::dtos::file_dto::FileDto; +use crate::application::dtos::geo_dto::GeoBounds; use crate::common::di::AppState; use crate::interfaces::middleware::auth::AuthUser; @@ -110,3 +111,76 @@ pub async fn list_photos( } } } + +/// Query parameters for the photos map (clustered) endpoint. +#[derive(Deserialize)] +pub struct GeoQueryParams { + /// Bounding box as `west,south,east,north` (decimal degrees). + pub bbox: String, + /// Slippy-map zoom level (0–20); controls cluster granularity. + pub zoom: Option, +} + +/// Lists the caller's geotagged photos aggregated into map clusters within a +/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted +/// when the Places service is present). +#[utoipa::path( + get, + path = "/api/photos/geo", + params( + ("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"), + ("zoom" = Option, Query, description = "Map zoom level (0-20), controls cluster size") + ), + responses( + (status = 200, description = "Geotagged photos aggregated into map clusters"), + (status = 400, description = "Invalid bounding box"), + (status = 401, description = "Unauthorized") + ), + security(("bearerAuth" = [])), + tag = "photos" +)] +pub async fn list_photos_geo( + State(state): State>, + auth_user: AuthUser, + Query(params): Query, +) -> impl IntoResponse { + let Some(places) = state.places_service.as_ref() else { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Places feature is disabled" })), + ) + .into_response(); + }; + + let coords: Vec = params + .bbox + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if coords.len() != 4 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })), + ) + .into_response(); + } + let bounds = GeoBounds { + west: coords[0], + south: coords[1], + east: coords[2], + north: coords[3], + }; + let zoom = params.zoom.unwrap_or(3); + + match places.clusters(auth_user.id, bounds, zoom).await { + Ok(clusters) => Json(clusters).into_response(), + Err(err) => { + error!("Error listing photo geo clusters: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": format!("{}", err) })), + ) + .into_response() + } + } +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 2c07d61f..c906f75f 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::recent_handler::clear_recent_items, // Photos handler (free function) handlers::photos_handler::list_photos, + handlers::photos_handler::list_photos_geo, // Batch handlers (free functions) handlers::batch_handler::move_files_batch, handlers::batch_handler::copy_files_batch, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 08f08896..af2ff022 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -431,9 +431,11 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { { use crate::interfaces::api::handlers::photos_handler; - let photos_router = Router::new() - .route("/", get(photos_handler::list_photos)) - .with_state(app_state.clone()); + let mut photos_router = Router::new().route("/", get(photos_handler::list_photos)); + if app_state.places_service.is_some() { + photos_router = photos_router.route("/geo", get(photos_handler::list_photos_geo)); + } + let photos_router = photos_router.with_state(app_state.clone()); router = router.nest("/photos", photos_router); }