Use speed_enhanced, speed or elevation_enhanced and elevation
This commit is contained in:
parent
cfea7abff3
commit
df3fdad43c
4 changed files with 201 additions and 7 deletions
|
|
@ -17,10 +17,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
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.CorsConfiguration;
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.net.InetAddress;
|
||||||
|
import java.net.UnknownHostException;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
|
@ -43,6 +46,24 @@ public class SecurityConfig {
|
||||||
*/
|
*/
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
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
|
http
|
||||||
.csrf(csrf -> csrf.disable()) // Disable CSRF for REST API
|
.csrf(csrf -> csrf.disable()) // Disable CSRF for REST API
|
||||||
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
|
@ -99,7 +120,10 @@ public class SecurityConfig {
|
||||||
// Public endpoints - User's public activities
|
// Public endpoints - User's public activities
|
||||||
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
|
.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()
|
.requestMatchers("/api/debug/**").denyAll()
|
||||||
|
|
||||||
// Public endpoints - Likes and Comments (GET only)
|
// Public endpoints - Likes and Comments (GET only)
|
||||||
|
|
|
||||||
|
|
@ -164,4 +164,63 @@ public class DebugController {
|
||||||
|
|
||||||
return org.springframework.http.ResponseEntity.ok(Map.of("status", "started"));
|
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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<Map<String, String>> 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
*
|
||||||
|
* <p>Returns {@code false} (and does not touch the activity) if the activity:
|
||||||
|
* <ul>
|
||||||
|
* <li>is not a FIT file,</li>
|
||||||
|
* <li>has no raw file stored (e.g. uploaded before raw file persistence was added),</li>
|
||||||
|
* <li>already has a non-null elevation value on at least one track point,</li>
|
||||||
|
* <li>or fails to re-parse for any reason.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @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.
|
* Detects file format from content and filename.
|
||||||
* Priority: magic bytes > XML header > file extension
|
* Priority: magic bytes > XML header > file extension
|
||||||
|
|
|
||||||
|
|
@ -156,9 +156,18 @@ public class FitParser {
|
||||||
point.setTimestamp(convertDateTime(record.getTimestamp()));
|
point.setTimestamp(convertDateTime(record.getTimestamp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract elevation
|
// Extract elevation. Prefer enhanced_altitude (4-byte high-resolution barometric
|
||||||
if (record.getAltitude() != null) {
|
// value used by modern Garmin devices) and fall back to the legacy altitude field
|
||||||
point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP));
|
// 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
|
// Extract heart rate
|
||||||
|
|
@ -176,9 +185,15 @@ public class FitParser {
|
||||||
point.setPower(record.getPower());
|
point.setPower(record.getPower());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract speed (convert m/s to km/h)
|
// Extract speed (convert m/s to km/h). Same enhanced-vs-legacy split as altitude:
|
||||||
if (record.getSpeed() != null) {
|
// modern Garmin devices write enhanced_speed (higher resolution) and may leave the
|
||||||
point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH)
|
// 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));
|
.setScale(2, RoundingMode.HALF_UP));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue