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:
Claude
2026-06-19 10:57:01 +00:00
parent 3b30a11161
commit f4b431bb03
11 changed files with 244 additions and 3 deletions
@@ -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;
+26
View File
@@ -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,
}
+1
View File
@@ -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;
+1
View File
@@ -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
}
}
+9
View File
@@ -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
+21
View File
@@ -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()
}
}
}
+1
View File
@@ -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,
+5 -3
View File
@@ -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);
}