feat(places): backend for the photo map (geo clusters API)
Phase 1 server side, gated on OXICLOUD_ENABLE_PLACES (off by default): - migration: partial index on storage.file_metadata(longitude, latitude). - FileBlobReadRepository::list_geo_clusters — plain-SQL grid aggregation (no PostGIS) scoped to the caller's own non-trashed photos, returning a centroid, count and a representative file id per non-empty cell. - PlacesService (caller_id-scoped; user-scoped data needs no authz check, mirroring RecentService) with a zoom→cell-size mapping. - GET /api/photos/geo?bbox=w,s,e,n&zoom=N returning GeoCluster[]. The route is mounted only when the Places service is present, and is registered in the OpenAPI path list. The map frontend (PMTiles serving + MapLibre module) is deferred pending the basemap-sourcing and vendoring decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -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;
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ pub mod favorites_dto;
|
|||||||
pub mod file_dto;
|
pub mod file_dto;
|
||||||
pub mod folder_dto;
|
pub mod folder_dto;
|
||||||
pub mod folder_listing_dto;
|
pub mod folder_listing_dto;
|
||||||
|
pub mod geo_dto;
|
||||||
pub mod grant_dto;
|
pub mod grant_dto;
|
||||||
pub mod i18n_dto;
|
pub mod i18n_dto;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ pub mod magic_link_invite_service;
|
|||||||
pub mod music_service;
|
pub mod music_service;
|
||||||
pub mod nextcloud_file_id_service;
|
pub mod nextcloud_file_id_service;
|
||||||
pub mod nextcloud_login_flow_service;
|
pub mod nextcloud_login_flow_service;
|
||||||
|
pub mod places_service;
|
||||||
pub mod recent_service;
|
pub mod recent_service;
|
||||||
pub mod recipient_notification_service;
|
pub mod recipient_notification_service;
|
||||||
pub mod search_service;
|
pub mod search_service;
|
||||||
|
|||||||
@@ -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<FileBlobReadRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlacesService {
|
||||||
|
pub fn new(file_read: Arc<FileBlobReadRepository>) -> 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<Vec<GeoCluster>, DomainError> {
|
||||||
|
let cell = Self::cell_for_zoom(zoom);
|
||||||
|
self.file_read
|
||||||
|
.list_geo_clusters(caller_id, bounds, cell)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -879,6 +879,8 @@ pub struct FeaturesConfig {
|
|||||||
pub enable_trash: bool,
|
pub enable_trash: bool,
|
||||||
pub enable_search: bool,
|
pub enable_search: bool,
|
||||||
pub enable_music: 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
|
/// Expose other OxiCloud users as a read-only "system" address book
|
||||||
/// at GET /api/address-books. Set to false to hide the user directory.
|
/// at GET /api/address-books. Set to false to hide the user directory.
|
||||||
pub expose_system_users: bool,
|
pub expose_system_users: bool,
|
||||||
@@ -893,6 +895,7 @@ impl Default for FeaturesConfig {
|
|||||||
enable_trash: true, // Enable trash feature
|
enable_trash: true, // Enable trash feature
|
||||||
enable_search: true, // Enable search feature
|
enable_search: true, // Enable search feature
|
||||||
enable_music: true, // Enable music 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
|
expose_system_users: true, // Expose OxiCloud users as address book by default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1378,6 +1381,12 @@ impl AppConfig {
|
|||||||
config.features.enable_music = val;
|
config.features.enable_music = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Ok(enable_places) = env::var("OXICLOUD_ENABLE_PLACES").map(|v| v.parse::<bool>())
|
||||||
|
&& let Ok(val) = enable_places
|
||||||
|
{
|
||||||
|
config.features.enable_places = val;
|
||||||
|
}
|
||||||
|
|
||||||
// Content search (embedded Tantivy index)
|
// Content search (embedded Tantivy index)
|
||||||
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
|
if let Ok(v) = env::var("OXICLOUD_ENABLE_CONTENT_SEARCH").map(|v| v.parse::<bool>())
|
||||||
&& let Ok(val) = v
|
&& let Ok(val) = v
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use crate::application::services::folder_service::FolderService;
|
|||||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||||
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
|
||||||
use crate::application::services::nextcloud_login_flow_service::NextcloudLoginFlowService;
|
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::recent_service::RecentService;
|
||||||
use crate::application::services::search_service::SearchService;
|
use crate::application::services::search_service::SearchService;
|
||||||
use crate::application::services::share_browse_service::ShareBrowseService;
|
use crate::application::services::share_browse_service::ShareBrowseService;
|
||||||
@@ -798,6 +799,17 @@ impl AppServiceFactory {
|
|||||||
service
|
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<FileBlobReadRepository>,
|
||||||
|
) -> Arc<PlacesService> {
|
||||||
|
let service = Arc::new(PlacesService::new(file_read.clone()));
|
||||||
|
tracing::info!("Places service initialized");
|
||||||
|
service
|
||||||
|
}
|
||||||
|
|
||||||
/// Preloads translations for every locale in the registry. Build
|
/// Preloads translations for every locale in the registry. Build
|
||||||
/// the registry at startup via `LocaleRegistry::discover` and pass
|
/// the registry at startup via `LocaleRegistry::discover` and pass
|
||||||
/// the resulting list here.
|
/// the resulting list here.
|
||||||
@@ -1005,6 +1017,7 @@ impl AppServiceFactory {
|
|||||||
// 6. Database-dependent services (PgPool always available in blob model)
|
// 6. Database-dependent services (PgPool always available in blob model)
|
||||||
let favorites_service: Option<Arc<FavoritesService>>;
|
let favorites_service: Option<Arc<FavoritesService>>;
|
||||||
let recent_service: Option<Arc<RecentService>>;
|
let recent_service: Option<Arc<RecentService>>;
|
||||||
|
let places_service: Option<Arc<PlacesService>>;
|
||||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||||
@@ -1027,6 +1040,12 @@ impl AppServiceFactory {
|
|||||||
recent_service = Some(recent.clone());
|
recent_service = Some(recent.clone());
|
||||||
apps.recent_service = Some(recent);
|
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());
|
storage_usage_service = Some(storage_usage.clone());
|
||||||
|
|
||||||
self.start_tree_etag_flush_job(&maintenance_pool);
|
self.start_tree_etag_flush_job(&maintenance_pool);
|
||||||
@@ -1253,6 +1272,7 @@ impl AppServiceFactory {
|
|||||||
share_browse_service,
|
share_browse_service,
|
||||||
favorites_service,
|
favorites_service,
|
||||||
recent_service,
|
recent_service,
|
||||||
|
places_service,
|
||||||
storage_usage_service,
|
storage_usage_service,
|
||||||
calendar_service: None,
|
calendar_service: None,
|
||||||
contact_service: None,
|
contact_service: None,
|
||||||
@@ -1699,6 +1719,7 @@ pub struct AppState {
|
|||||||
pub share_browse_service: Option<Arc<ShareBrowseService>>,
|
pub share_browse_service: Option<Arc<ShareBrowseService>>,
|
||||||
pub favorites_service: Option<Arc<FavoritesService>>,
|
pub favorites_service: Option<Arc<FavoritesService>>,
|
||||||
pub recent_service: Option<Arc<RecentService>>,
|
pub recent_service: Option<Arc<RecentService>>,
|
||||||
|
pub places_service: Option<Arc<PlacesService>>,
|
||||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||||
pub calendar_service: Option<Arc<CalendarService>>,
|
pub calendar_service: Option<Arc<CalendarService>>,
|
||||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use std::pin::Pin;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::application::dtos::geo_dto::{GeoBounds, GeoCluster};
|
||||||
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
use crate::application::dtos::search_dto::SearchCriteriaDto;
|
||||||
use crate::application::ports::storage_ports::FileReadPort;
|
use crate::application::ports::storage_ports::FileReadPort;
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
@@ -416,6 +417,56 @@ impl FileBlobReadRepository {
|
|||||||
|
|
||||||
Ok((files, sort_dates, dims))
|
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<Vec<GeoCluster>, 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 {
|
impl FileReadPort for FileBlobReadRepository {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use std::sync::Arc;
|
|||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::application::dtos::file_dto::FileDto;
|
use crate::application::dtos::file_dto::FileDto;
|
||||||
|
use crate::application::dtos::geo_dto::GeoBounds;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::interfaces::middleware::auth::AuthUser;
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u8>, 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<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Query(params): Query<GeoQueryParams>,
|
||||||
|
) -> 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<f64> = params
|
||||||
|
.bbox
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| s.trim().parse::<f64>().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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
|||||||
handlers::recent_handler::clear_recent_items,
|
handlers::recent_handler::clear_recent_items,
|
||||||
// Photos handler (free function)
|
// Photos handler (free function)
|
||||||
handlers::photos_handler::list_photos,
|
handlers::photos_handler::list_photos,
|
||||||
|
handlers::photos_handler::list_photos_geo,
|
||||||
// Batch handlers (free functions)
|
// Batch handlers (free functions)
|
||||||
handlers::batch_handler::move_files_batch,
|
handlers::batch_handler::move_files_batch,
|
||||||
handlers::batch_handler::copy_files_batch,
|
handlers::batch_handler::copy_files_batch,
|
||||||
|
|||||||
@@ -431,9 +431,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
{
|
{
|
||||||
use crate::interfaces::api::handlers::photos_handler;
|
use crate::interfaces::api::handlers::photos_handler;
|
||||||
|
|
||||||
let photos_router = Router::new()
|
let mut photos_router = Router::new().route("/", get(photos_handler::list_photos));
|
||||||
.route("/", get(photos_handler::list_photos))
|
if app_state.places_service.is_some() {
|
||||||
.with_state(app_state.clone());
|
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);
|
router = router.nest("/photos", photos_router);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user