From 91c91ffd1a23c6dbd24b2617abebd3dfd85ed501 Mon Sep 17 00:00:00 2001 From: Daniel Graf Date: Sat, 13 Sep 2025 06:45:27 +0200 Subject: [PATCH] added import and export api endpoints for gpx (#259) --- README.md | 64 +++++------ .../controller/ExportDataController.java | 53 +-------- .../controller/api/GpxApiController.java | 105 ++++++++++++++++++ .../reitti/service/GpxExportService.java | 66 +++++++++++ 4 files changed, 209 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java create mode 100644 src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java diff --git a/README.md b/README.md index 5dc60c08..fa3421d1 100644 --- a/README.md +++ b/README.md @@ -197,39 +197,39 @@ The included `docker-compose.yml` provides a complete setup with: ### Environment Variables -| Variable | Description | Default | Example | -|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|-----------------------------------------| -| `POSTGIS_HOST` | PostgreSQL database host | postgis | postgis | -| `POSTGIS_PORT` | PostgreSQL database port | 5432 | 5432 | -| `POSTGIS_DB` | PostgreSQL database name | reittidb | reittidb | -| `POSTGIS_USER` | Database username | reitti | reitti | -| `POSTGIS_PASSWORD` | Database password | reitti | reitti | -| `RABBITMQ_HOST` | RabbitMQ host | rabbitmq | rabbitmq | -| `RABBITMQ_PORT` | RabbitMQ port | 5672 | 5672 | -| `RABBITMQ_USER` | RabbitMQ username | reitti | reitti | -| `RABBITMQ_PASSWORD` | RabbitMQ password | reitti | reitti | -| `REDIS_HOST` | Redis host | redis | redis | -| `REDIS_PORT` | Redis port | 6379 | 6379 | -| `REDIS_USERNAME` | Redis username (optional) | | username | -| `REDIS_PASSWORD` | Redis password (optional) | | password | -| `ADVERTISE_URI` | Routable URL of the instance. Used for federation of multiple instances. (optional) | | https://reitti.lab | -| `DISABLE_LOCAL_LOGIN` | Whether to disable the local login form (username/password) This only works, if OIDC login is configured. | false | true | -| `OIDC_ENABLED` | Whether to enable OIDC sign-ins | false | true | -| `OIDC_CLIENT_ID` | Your OpenID Connect Client ID (from your provider) | | google | -| `OIDC_CLIENT_SECRET` | Your OpenID Connect Client secret (from your provider) | | F0oxfg8b2rp5X97YPS92C2ERxof1oike | -| `OIDC_ISSUER_URI` | Your OpenID Connect Provider Discovery URI (don't include the /.well-known/openid-configuration part of the URI) | | https://github.com/login/oauth | -| `OIDC_SCOPE` | Your OpenID Connect scopes for your user (optional) | openid,profile | openid,profile | -| `OIDC_SIGN_UP_ENABLED` | Whether new users should be signed up automatically if they first login via the OIDC Provider. (optional) | true | false | -| `PHOTON_BASE_URL` | Base URL for Photon geocoding service | | | -| `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | 15 | -| `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | true | +| Variable | Description | Default | Example | +|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------|-------------------------------------------| +| `POSTGIS_HOST` | PostgreSQL database host | postgis | postgis | +| `POSTGIS_PORT` | PostgreSQL database port | 5432 | 5432 | +| `POSTGIS_DB` | PostgreSQL database name | reittidb | reittidb | +| `POSTGIS_USER` | Database username | reitti | reitti | +| `POSTGIS_PASSWORD` | Database password | reitti | reitti | +| `RABBITMQ_HOST` | RabbitMQ host | rabbitmq | rabbitmq | +| `RABBITMQ_PORT` | RabbitMQ port | 5672 | 5672 | +| `RABBITMQ_USER` | RabbitMQ username | reitti | reitti | +| `RABBITMQ_PASSWORD` | RabbitMQ password | reitti | reitti | +| `REDIS_HOST` | Redis host | redis | redis | +| `REDIS_PORT` | Redis port | 6379 | 6379 | +| `REDIS_USERNAME` | Redis username (optional) | | username | +| `REDIS_PASSWORD` | Redis password (optional) | | password | +| `ADVERTISE_URI` | Routable URL of the instance. Used for federation of multiple instances. (optional) | | https://reitti.lab | +| `DISABLE_LOCAL_LOGIN` | Whether to disable the local login form (username/password) This only works, if OIDC login is configured. | false | true | +| `OIDC_ENABLED` | Whether to enable OIDC sign-ins | false | true | +| `OIDC_CLIENT_ID` | Your OpenID Connect Client ID (from your provider) | | google | +| `OIDC_CLIENT_SECRET` | Your OpenID Connect Client secret (from your provider) | | F0oxfg8b2rp5X97YPS92C2ERxof1oike | +| `OIDC_ISSUER_URI` | Your OpenID Connect Provider Discovery URI (don't include the /.well-known/openid-configuration part of the URI) | | https://github.com/login/oauth | +| `OIDC_SCOPE` | Your OpenID Connect scopes for your user (optional) | openid,profile | openid,profile | +| `OIDC_SIGN_UP_ENABLED` | Whether new users should be signed up automatically if they first login via the OIDC Provider. (optional) | true | false | +| `PHOTON_BASE_URL` | Base URL for Photon geocoding service | | | +| `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 | 15 | +| `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false | true | | `CUSTOM_TILES_SERVICE` | Custom tile service URL template | | https://tiles.example.com/{z}/{x}/{y}.png | -| `CUSTOM_TILES_ATTRIBUTION` | Custom attribution text for the tile service | | | -| `SERVER_PORT` | Application server port | 8080 | 8080 | -| `APP_UID` | User ID to run the application as | 1000 | 1000 | -| `APP_GID` | Group ID to run the application as | 1000 | 1000 | -| `JAVA_OPTS` | JVM options | | | -| `LOGGING_LEVEL` | Used for adjust the verbosity of the logs | INFO | DEBUG | +| `CUSTOM_TILES_ATTRIBUTION` | Custom attribution text for the tile service | | | +| `SERVER_PORT` | Application server port | 8080 | 8080 | +| `APP_UID` | User ID to run the application as | 1000 | 1000 | +| `APP_GID` | Group ID to run the application as | 1000 | 1000 | +| `JAVA_OPTS` | JVM options | | | +| `LOGGING_LEVEL` | Used to adjust the verbosity of the logs | INFO | DEBUG | ### Tags diff --git a/src/main/java/com/dedicatedcode/reitti/controller/ExportDataController.java b/src/main/java/com/dedicatedcode/reitti/controller/ExportDataController.java index 8f01cf59..c18777f7 100644 --- a/src/main/java/com/dedicatedcode/reitti/controller/ExportDataController.java +++ b/src/main/java/com/dedicatedcode/reitti/controller/ExportDataController.java @@ -3,6 +3,7 @@ package com.dedicatedcode.reitti.controller; import com.dedicatedcode.reitti.model.geo.RawLocationPoint; import com.dedicatedcode.reitti.model.security.User; import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import com.dedicatedcode.reitti.service.GpxExportService; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -28,9 +29,12 @@ import java.util.List; public class ExportDataController { private final RawLocationPointJdbcService rawLocationPointJdbcService; + private final GpxExportService gpxExportService; - public ExportDataController(RawLocationPointJdbcService rawLocationPointJdbcService) { + public ExportDataController(RawLocationPointJdbcService rawLocationPointJdbcService, + GpxExportService gpxExportService) { this.rawLocationPointJdbcService = rawLocationPointJdbcService; + this.gpxExportService = gpxExportService; } @GetMapping("/data-content") @@ -67,7 +71,7 @@ public class ExportDataController { StreamingResponseBody stream = outputStream -> { try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { - generateGpxContentStreaming(user, start, end, writer); + gpxExportService.generateGpxContentStreaming(user, start, end, writer); } catch (Exception e) { throw new RuntimeException("Error generating GPX file", e); } @@ -90,49 +94,4 @@ public class ExportDataController { } } - // Writing XML as strings is necessary for streaming functionality to avoid loading entire DOM into memory - private void generateGpxContentStreaming(User user, LocalDate startDate, LocalDate endDate, Writer writer) throws IOException { - // Write GPX header - writer.write("\n"); - writer.write("\n"); - writer.write(" \n"); - writer.write(" Location Data Export\n"); - writer.write(" Exported location data from " + startDate + " to " + endDate + "\n"); - writer.write(" \n"); - writer.write(" \n"); - writer.write(" Location Track\n"); - writer.write(" \n"); - - // Stream location points in batches to avoid loading all into memory - LocalDate currentDate = startDate; - - while (!currentDate.isAfter(endDate)) { - LocalDate nextDate = currentDate.plusDays(1); - - List points = rawLocationPointJdbcService.findByUserAndDateRange( - user, currentDate.atStartOfDay(), nextDate.atStartOfDay()); - - for (RawLocationPoint point : points) { - writer.write(" \n"); - writer.write(" \n"); - - if (point.getAccuracyMeters() != null) { - writer.write(" \n"); - writer.write(" " + point.getAccuracyMeters() + "\n"); - writer.write(" \n"); - } - - writer.write(" \n"); - } - - writer.flush(); // Flush periodically - currentDate = nextDate; - } - - // Write GPX footer - writer.write(" \n"); - writer.write(" \n"); - writer.write(""); - writer.flush(); - } } diff --git a/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java b/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java new file mode 100644 index 00000000..a704ffa7 --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/controller/api/GpxApiController.java @@ -0,0 +1,105 @@ +package com.dedicatedcode.reitti.controller.api; + +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.service.GpxExportService; +import com.dedicatedcode.reitti.service.importer.GpxImporter; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/gpx") +public class GpxApiController { + + private final GpxExportService gpxExportService; + private final GpxImporter gpxImporter; + + public GpxApiController(GpxExportService gpxExportService, GpxImporter gpxImporter) { + this.gpxExportService = gpxExportService; + this.gpxImporter = gpxImporter; + } + + @GetMapping("/export") + public ResponseEntity exportGpx(@AuthenticationPrincipal User user, + @RequestParam LocalDate start, + @RequestParam LocalDate end) { + try { + StreamingResponseBody stream = outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + gpxExportService.generateGpxContentStreaming(user, start, end, writer); + } catch (Exception e) { + throw new RuntimeException("Error generating GPX file", e); + } + }; + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_XML) + .body(stream); + + } catch (Exception e) { + return ResponseEntity.badRequest() + .body(outputStream -> { + try (Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) { + writer.write("Error generating GPX file: " + e.getMessage()); + } catch (IOException ioException) { + throw new RuntimeException(ioException); + } + }); + } + } + @PostMapping("/import") + public ResponseEntity> importGpx(@AuthenticationPrincipal User user, + @RequestParam("file") MultipartFile file) { + Map response = new HashMap<>(); + + try { + if (file.isEmpty() || file.getOriginalFilename() == null) { + response.put("success", false); + response.put("error", "File is empty"); + return ResponseEntity.badRequest().body(response); + } + + if (!file.getOriginalFilename().endsWith(".gpx")) { + response.put("success", false); + response.put("error", "Only GPX files are supported"); + return ResponseEntity.badRequest().body(response); + } + + try (InputStream inputStream = file.getInputStream()) { + Map result = gpxImporter.importGpx(inputStream, user); + + if ((Boolean) result.get("success")) { + response.put("success", true); + response.put("pointsScheduled", result.get("pointsReceived")); + response.put("message", "Successfully imported GPX file with " + result.get("pointsReceived") + " location points"); + } else { + response.put("success", false); + response.put("error", result.get("error")); + } + + return ResponseEntity.ok(response); + } + + } catch (IOException e) { + response.put("success", false); + response.put("error", "Error processing file: " + e.getMessage()); + return ResponseEntity.status(500).body(response); + } catch (Exception e) { + response.put("success", false); + response.put("error", "Unexpected error: " + e.getMessage()); + return ResponseEntity.status(500).body(response); + } + } +} diff --git a/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java b/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java new file mode 100644 index 00000000..5a4703aa --- /dev/null +++ b/src/main/java/com/dedicatedcode/reitti/service/GpxExportService.java @@ -0,0 +1,66 @@ +package com.dedicatedcode.reitti.service; + +import com.dedicatedcode.reitti.model.geo.RawLocationPoint; +import com.dedicatedcode.reitti.model.security.User; +import com.dedicatedcode.reitti.repository.RawLocationPointJdbcService; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.Writer; +import java.time.LocalDate; +import java.util.List; + +@Service +public class GpxExportService { + + private final RawLocationPointJdbcService rawLocationPointJdbcService; + + public GpxExportService(RawLocationPointJdbcService rawLocationPointJdbcService) { + this.rawLocationPointJdbcService = rawLocationPointJdbcService; + } + + public void generateGpxContentStreaming(User user, LocalDate startDate, LocalDate endDate, Writer writer) throws IOException { + // Write GPX header + writer.write("\n"); + writer.write("\n"); + writer.write(" \n"); + writer.write(" Location Data Export\n"); + writer.write(" Exported location data from " + startDate + " to " + endDate + "\n"); + writer.write(" \n"); + writer.write(" \n"); + writer.write(" Location Track\n"); + writer.write(" \n"); + + // Stream location points in batches to avoid loading all into memory + LocalDate currentDate = startDate; + + while (!currentDate.isAfter(endDate)) { + LocalDate nextDate = currentDate.plusDays(1); + + List points = rawLocationPointJdbcService.findByUserAndDateRange( + user, currentDate.atStartOfDay(), nextDate.atStartOfDay()); + + for (RawLocationPoint point : points) { + writer.write(" \n"); + writer.write(" \n"); + + if (point.getAccuracyMeters() != null) { + writer.write(" \n"); + writer.write(" " + point.getAccuracyMeters() + "\n"); + writer.write(" \n"); + } + + writer.write(" \n"); + } + + writer.flush(); // Flush periodically + currentDate = nextDate; + } + + // Write GPX footer + writer.write(" \n"); + writer.write(" \n"); + writer.write(""); + writer.flush(); + } +}