diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index 127a488..3847ba4 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -17,10 +17,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.util.Arrays; import java.util.List; @@ -43,6 +46,24 @@ public class SecurityConfig { */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // One-shot migration carve-out: allow POST /api/debug/reprocess-fit-elevation + // ONLY when the request comes from a loopback address. The intended workflow + // is "open a shell on the prod container, curl localhost:8080" — anyone outside + // the host network is rejected by the trailing denyAll() on /api/debug/**. + // Remove this matcher (and the corresponding permitAll line below) once the + // FIT elevation backfill is complete. + final RequestMatcher loopbackFitElevationMatcher = request -> { + if (!"POST".equalsIgnoreCase(request.getMethod()) + || !"/api/debug/reprocess-fit-elevation".equals(request.getRequestURI())) { + return false; + } + try { + return InetAddress.getByName(request.getRemoteAddr()).isLoopbackAddress(); + } catch (UnknownHostException e) { + return false; + } + }; + http .csrf(csrf -> csrf.disable()) // Disable CSRF for REST API .cors(cors -> cors.configurationSource(corsConfigurationSource())) @@ -99,7 +120,10 @@ public class SecurityConfig { // Public endpoints - User's public activities .requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll() - // Debug endpoints (dev only) + // Debug endpoints — denied by default. Specific endpoints can be + // carved out above this line for one-off backfills / migrations. + // Re-lock by removing the carve-outs once the migration is done. + .requestMatchers(loopbackFitElevationMatcher).permitAll() .requestMatchers("/api/debug/**").denyAll() // Public endpoints - Likes and Comments (GET only) diff --git a/src/main/java/net/javahippie/fitpub/controller/DebugController.java b/src/main/java/net/javahippie/fitpub/controller/DebugController.java index 5d66b3f..657c4a5 100644 --- a/src/main/java/net/javahippie/fitpub/controller/DebugController.java +++ b/src/main/java/net/javahippie/fitpub/controller/DebugController.java @@ -164,4 +164,63 @@ public class DebugController { return org.springframework.http.ResponseEntity.ok(Map.of("status", "started")); } + + private final java.util.concurrent.atomic.AtomicBoolean fitElevationReprocessRunning = + new java.util.concurrent.atomic.AtomicBoolean(false); + + /** + * Backfill the per-track-point elevation profile for FIT activities that + * currently have none. Re-parses each FIT activity's stored raw bytes through + * the current FitParser (which reads {@code enhanced_altitude} from modern + * Garmin devices in addition to the legacy {@code altitude} field) and + * replaces the activity's track points JSON. + * + *

The job is idempotent: activities that already have at least one non-null + * elevation value on a track point are skipped, so re-running the backfill is + * safe and only touches the still-broken activities. + * + *

Runs on a single background thread, with progress logged every 100 rows. + * A second concurrent invocation returns {@code "already running"} immediately. + */ + @org.springframework.web.bind.annotation.PostMapping("/reprocess-fit-elevation") + public org.springframework.http.ResponseEntity> reprocessFitElevation() { + if (!fitElevationReprocessRunning.compareAndSet(false, true)) { + return org.springframework.http.ResponseEntity.ok(Map.of("status", "already running")); + } + + new Thread(() -> { + try { + var ids = activityRepository.findAllIds(); + log.info("Starting FIT elevation reprocessing for {} activities", ids.size()); + + int updated = 0, skipped = 0, failed = 0; + for (int i = 0; i < ids.size(); i++) { + try { + var activity = activityRepository.findById(ids.get(i)).orElse(null); + if (activity == null || !"FIT".equals(activity.getSourceFileFormat())) { + skipped++; + continue; + } + if (activityFileService.reprocessFitElevation(activity)) { + updated++; + } else { + skipped++; + } + } catch (Exception e) { + failed++; + log.warn("Failed to reprocess FIT elevation for {}: {}", ids.get(i), e.getMessage()); + } + if ((i + 1) % 100 == 0) { + log.info("FIT elevation reprocess progress: {} / {} (updated={}, skipped={}, failed={})", + i + 1, ids.size(), updated, skipped, failed); + } + } + log.info("FIT elevation reprocessing complete: updated={}, skipped={}, failed={}", updated, skipped, failed); + } finally { + fitElevationReprocessRunning.set(false); + } + }, "fit-elevation-reprocess").start(); + + return org.springframework.http.ResponseEntity.ok(Map.of("status", "started")); + } } diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java index ac5e4b3..dcd60f7 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityFileService.java @@ -232,6 +232,102 @@ public class ActivityFileService { } } + /** + * Reprocess elevation for a FIT activity that currently has no per-point + * elevation profile. Re-parses the stored raw FIT bytes with the current parser + * (which reads {@code enhanced_altitude} in addition to the legacy {@code altitude} + * field) and replaces the activity's track-points JSON. Also fills in + * {@code elevationGain}/{@code elevationLoss} if they were missing. + * + *

Returns {@code false} (and does not touch the activity) if the activity: + *

+ * + * @param activity the activity to reprocess + * @return true if the track points JSON was updated + */ + @Transactional + public boolean reprocessFitElevation(Activity activity) { + if (!"FIT".equals(activity.getSourceFileFormat())) { + return false; + } + + byte[] rawFile = activity.getRawActivityFile(); + if (rawFile == null || rawFile.length == 0) { + log.debug("No raw file stored for FIT activity {}, skipping", activity.getId()); + return false; + } + + // Skip activities that already have per-point elevation. This makes the + // backfill idempotent — running it twice in a row is safe and the second + // run is a no-op for already-fixed activities. + if (trackJsonAlreadyHasElevation(activity.getTrackPointsJson())) { + return false; + } + + try { + ParsedActivityData parsedData = fitParser.parse(rawFile); + + // Replace the entire track points blob. The new JSON will contain + // elevation values from enhanced_altitude (or legacy altitude as + // fallback) thanks to the FitParser fix. + String newJson = convertTrackPointsToJson(parsedData.getTrackPoints()); + activity.setTrackPointsJson(newJson); + + // Backfill session totals if they were missing. We don't overwrite + // existing values — those came from session.getTotalAscent() at upload + // time and the device's own calculation is likely more accurate than + // anything we'd recompute from the track points. + if (activity.getElevationGain() == null && parsedData.getElevationGain() != null) { + activity.setElevationGain(parsedData.getElevationGain()); + } + if (activity.getElevationLoss() == null && parsedData.getElevationLoss() != null) { + activity.setElevationLoss(parsedData.getElevationLoss()); + } + + activityRepository.save(activity); + log.info("Reprocessed FIT elevation profile for activity {} ({} track points)", + activity.getId(), parsedData.getTrackPoints().size()); + return true; + } catch (Exception e) { + log.warn("Failed to reprocess FIT elevation for activity {}: {}", activity.getId(), e.getMessage()); + return false; + } + } + + /** + * Returns true if the given track points JSON already contains at least one + * non-null {@code elevation} value. Used by the FIT elevation backfill to + * skip activities that don't need to be touched. + */ + private boolean trackJsonAlreadyHasElevation(String trackPointsJson) { + if (trackPointsJson == null || trackPointsJson.isEmpty()) { + return false; + } + try { + com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(trackPointsJson); + if (!root.isArray()) { + return false; + } + for (com.fasterxml.jackson.databind.JsonNode point : root) { + com.fasterxml.jackson.databind.JsonNode elevation = point.get("elevation"); + if (elevation != null && !elevation.isNull()) { + return true; + } + } + return false; + } catch (Exception e) { + // Malformed JSON shouldn't happen for stored activities, but if it does + // err on the side of "no elevation" so the backfill picks it up. + log.warn("Could not parse track points JSON to check for elevation: {}", e.getMessage()); + return false; + } + } + /** * Detects file format from content and filename. * Priority: magic bytes > XML header > file extension diff --git a/src/main/java/net/javahippie/fitpub/util/FitParser.java b/src/main/java/net/javahippie/fitpub/util/FitParser.java index c757b60..b88d073 100644 --- a/src/main/java/net/javahippie/fitpub/util/FitParser.java +++ b/src/main/java/net/javahippie/fitpub/util/FitParser.java @@ -156,9 +156,18 @@ public class FitParser { point.setTimestamp(convertDateTime(record.getTimestamp())); } - // Extract elevation - if (record.getAltitude() != null) { - point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP)); + // Extract elevation. Prefer enhanced_altitude (4-byte high-resolution barometric + // value used by modern Garmin devices) and fall back to the legacy altitude field + // if it's not present. Older devices write only the legacy field; newer Edge / + // Fenix / Forerunner units write only the enhanced one. Reading just one of them + // produces empty elevation profiles for everything recorded on the other class + // of device. + Float elevationValue = record.getEnhancedAltitude(); + if (elevationValue == null) { + elevationValue = record.getAltitude(); + } + if (elevationValue != null) { + point.setElevation(BigDecimal.valueOf(elevationValue).setScale(2, RoundingMode.HALF_UP)); } // Extract heart rate @@ -176,9 +185,15 @@ public class FitParser { point.setPower(record.getPower()); } - // Extract speed (convert m/s to km/h) - if (record.getSpeed() != null) { - point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH) + // Extract speed (convert m/s to km/h). Same enhanced-vs-legacy split as altitude: + // modern Garmin devices write enhanced_speed (higher resolution) and may leave the + // legacy speed field null. Read both, preferring enhanced. + Float speedValue = record.getEnhancedSpeed(); + if (speedValue == null) { + speedValue = record.getSpeed(); + } + if (speedValue != null) { + point.setSpeed(BigDecimal.valueOf(speedValue * MPS_TO_KPH) .setScale(2, RoundingMode.HALF_UP)); }