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 folder_dto;
|
||||
pub mod folder_listing_dto;
|
||||
pub mod geo_dto;
|
||||
pub mod grant_dto;
|
||||
pub mod i18n_dto;
|
||||
pub mod pagination;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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_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::<bool>())
|
||||
&& 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::<bool>())
|
||||
&& 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::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<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
|
||||
/// 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<Arc<FavoritesService>>;
|
||||
let recent_service: Option<Arc<RecentService>>;
|
||||
let places_service: Option<Arc<PlacesService>>;
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = 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<Arc<ShareBrowseService>>,
|
||||
pub favorites_service: Option<Arc<FavoritesService>>,
|
||||
pub recent_service: Option<Arc<RecentService>>,
|
||||
pub places_service: Option<Arc<PlacesService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub contact_service: Option<Arc<ContactStorageAdapter>>,
|
||||
|
||||
@@ -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<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 {
|
||||
|
||||
@@ -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<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,
|
||||
// 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,
|
||||
|
||||
@@ -431,9 +431,11 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user