diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index b7afd92..96a77d7 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -77,6 +77,9 @@ public class SecurityConfig { // Public endpoints - Activity track data (for public activities) .requestMatchers(HttpMethod.GET, "/api/activities/*/track").permitAll() + // Public endpoints - Activity images (for federation) + .requestMatchers(HttpMethod.GET, "/api/activities/*/image").permitAll() + // Public endpoints - User's public activities .requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll() diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java index 48da28a..f1c62aa 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -9,6 +9,7 @@ import org.operaton.fitpub.model.dto.ActivityUploadRequest; import org.operaton.fitpub.model.entity.Activity; import org.operaton.fitpub.model.entity.User; import org.operaton.fitpub.repository.UserRepository; +import org.operaton.fitpub.service.ActivityImageService; import org.operaton.fitpub.service.FederationService; import org.operaton.fitpub.service.FitFileService; import org.springframework.beans.factory.annotation.Value; @@ -40,6 +41,7 @@ public class ActivityController { private final FitFileService fitFileService; private final UserRepository userRepository; private final FederationService federationService; + private final ActivityImageService activityImageService; @Value("${fitpub.base-url}") private String baseUrl; @@ -106,13 +108,20 @@ public class ActivityController { noteObject.put("to", List.of(actorUri + "/followers")); } - // Add summary with key metrics - String summary = formatActivitySummary(activity); - noteObject.put("summary", summary); - // Add URL to the activity page noteObject.put("url", baseUrl + "/activities/" + activity.getId()); + // Generate and attach activity image + String imageUrl = activityImageService.generateActivityImage(activity); + if (imageUrl != null) { + Map imageAttachment = new HashMap<>(); + imageAttachment.put("type", "Image"); + imageAttachment.put("mediaType", "image/png"); + imageAttachment.put("url", imageUrl); + imageAttachment.put("name", "Activity map showing " + activity.getActivityType() + " route"); + noteObject.put("attachment", List.of(imageAttachment)); + } + federationService.sendCreateActivity( activityUri, noteObject, @@ -170,30 +179,6 @@ public class ActivityController { return content.toString(); } - /** - * Format activity summary for ActivityPub. - */ - private String formatActivitySummary(Activity activity) { - StringBuilder summary = new StringBuilder(); - summary.append(activity.getActivityType()); - - if (activity.getTotalDistance() != null) { - summary.append(" • ").append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0)); - } - - if (activity.getTotalDurationSeconds() != null) { - long hours = activity.getTotalDurationSeconds() / 3600; - long minutes = (activity.getTotalDurationSeconds() % 3600) / 60; - if (hours > 0) { - summary.append(" • ").append(hours).append("h ").append(minutes).append("m"); - } else { - summary.append(" • ").append(minutes).append("m"); - } - } - - return summary.toString(); - } - /** * Simple HTML escaping. */ @@ -472,4 +457,32 @@ public class ActivityController { return ResponseEntity.ok(geoJson); } + + /** + * Serves the generated activity image. + * + * @param id the activity ID + * @return the activity image + */ + @GetMapping("/{id}/image") + public ResponseEntity getActivityImage(@PathVariable UUID id) { + try { + java.io.File imageFile = activityImageService.getActivityImageFile(id); + + if (!imageFile.exists()) { + return ResponseEntity.notFound().build(); + } + + org.springframework.core.io.Resource resource = + new org.springframework.core.io.FileSystemResource(imageFile); + + return ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.IMAGE_PNG) + .header(org.springframework.http.HttpHeaders.CACHE_CONTROL, "public, max-age=31536000") + .body(resource); + } catch (Exception e) { + log.error("Error serving activity image for {}", id, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } } diff --git a/src/main/java/org/operaton/fitpub/service/ActivityImageService.java b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java new file mode 100644 index 0000000..e15c5be --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/ActivityImageService.java @@ -0,0 +1,321 @@ +package org.operaton.fitpub.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.operaton.fitpub.model.entity.Activity; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.geom.Path2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Service for generating activity preview images for ActivityPub federation. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class ActivityImageService { + + @Value("${fitpub.storage.images.path:${java.io.tmpdir}/fitpub/images}") + private String imagesPath; + + @Value("${fitpub.base-url}") + private String baseUrl; + + /** + * Generate a preview image for an activity showing the track outline and metadata. + * + * @param activity the activity to generate an image for + * @return the URL of the generated image + */ + public String generateActivityImage(Activity activity) { + try { + // Image dimensions + int width = 1200; + int height = 630; // Open Graph standard size + + // Create image + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = image.createGraphics(); + + // Enable antialiasing + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + + // Background + g2d.setColor(new Color(30, 30, 30)); // Dark background + g2d.fillRect(0, 0, width, height); + + // Draw track if available + if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) { + drawTrack(g2d, activity, width, height); + } else if (activity.getSimplifiedTrack() != null) { + drawSimplifiedTrack(g2d, activity, width, height); + } + + // Draw metadata overlay + drawMetadata(g2d, activity, width, height); + + g2d.dispose(); + + // Save image + File imagesDir = new File(imagesPath); + if (!imagesDir.exists()) { + imagesDir.mkdirs(); + } + + String filename = activity.getId() + ".png"; + File imageFile = new File(imagesDir, filename); + ImageIO.write(image, "png", imageFile); + + log.info("Generated activity image: {}", imageFile.getAbsolutePath()); + + // Return URL to the image + return baseUrl + "/api/activities/" + activity.getId() + "/image"; + + } catch (Exception e) { + log.error("Failed to generate activity image for {}", activity.getId(), e); + return null; + } + } + + /** + * Draw the track outline from high-resolution track points. + */ + private void drawTrack(Graphics2D g2d, Activity activity, int width, int height) { + List> trackPoints = parseTrackPoints(activity.getTrackPointsJson()); + if (trackPoints == null || trackPoints.isEmpty()) { + return; + } + + // Find bounds + double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE; + double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE; + + for (Map point : trackPoints) { + Double lat = getDouble(point, "latitude"); + Double lon = getDouble(point, "longitude"); + if (lat != null && lon != null) { + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + minLon = Math.min(minLon, lon); + maxLon = Math.max(maxLon, lon); + } + } + + // Add padding + double latRange = maxLat - minLat; + double lonRange = maxLon - minLon; + double padding = 0.1; // 10% padding + minLat -= latRange * padding; + maxLat += latRange * padding; + minLon -= lonRange * padding; + maxLon += lonRange * padding; + + // Calculate scale (use left 60% of image for track, right 40% for metadata) + int trackWidth = (int) (width * 0.6); + int trackHeight = height; + double scaleX = trackWidth / (maxLon - minLon); + double scaleY = trackHeight / (maxLat - minLat); + double scale = Math.min(scaleX, scaleY); + + // Create path + Path2D.Double path = new Path2D.Double(); + boolean first = true; + + for (Map point : trackPoints) { + Double lat = getDouble(point, "latitude"); + Double lon = getDouble(point, "longitude"); + if (lat != null && lon != null) { + double x = (lon - minLon) * scale; + double y = trackHeight - (lat - minLat) * scale; // Flip Y axis + + if (first) { + path.moveTo(x, y); + first = false; + } else { + path.lineTo(x, y); + } + } + } + + // Draw track + g2d.setColor(new Color(0, 180, 216)); // Bright blue + g2d.setStroke(new BasicStroke(4.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g2d.draw(path); + + // Draw start and end markers + if (!trackPoints.isEmpty()) { + Map firstPoint = trackPoints.get(0); + Map lastPoint = trackPoints.get(trackPoints.size() - 1); + + drawMarker(g2d, firstPoint, minLat, minLon, scale, trackHeight, new Color(0, 255, 0)); // Green start + drawMarker(g2d, lastPoint, minLat, minLon, scale, trackHeight, new Color(255, 0, 0)); // Red end + } + } + + /** + * Draw the track from simplified track (LineString). + */ + private void drawSimplifiedTrack(Graphics2D g2d, Activity activity, int width, int height) { + // Similar logic but using simplified track coordinates + // This is a fallback if high-res track points aren't available + log.debug("Using simplified track for activity {}", activity.getId()); + // TODO: Implement if needed + } + + /** + * Draw a circular marker at a track point. + */ + private void drawMarker(Graphics2D g2d, Map point, double minLat, double minLon, + double scale, int trackHeight, Color color) { + Double lat = getDouble(point, "latitude"); + Double lon = getDouble(point, "longitude"); + if (lat != null && lon != null) { + double x = (lon - minLon) * scale; + double y = trackHeight - (lat - minLat) * scale; + + g2d.setColor(color); + int markerSize = 12; + g2d.fillOval((int) x - markerSize / 2, (int) y - markerSize / 2, markerSize, markerSize); + + // White outline + g2d.setColor(Color.WHITE); + g2d.setStroke(new BasicStroke(2.0f)); + g2d.drawOval((int) x - markerSize / 2, (int) y - markerSize / 2, markerSize, markerSize); + } + } + + /** + * Draw metadata overlay on the right side of the image. + */ + private void drawMetadata(Graphics2D g2d, Activity activity, int width, int height) { + int metadataX = (int) (width * 0.62); // Start at 62% to leave some margin + int y = 60; + int lineHeight = 50; + + // Title + g2d.setColor(Color.WHITE); + g2d.setFont(new Font("Arial", Font.BOLD, 32)); + String title = activity.getTitle() != null ? activity.getTitle() : "Activity"; + if (title.length() > 20) { + title = title.substring(0, 20) + "..."; + } + g2d.drawString(title, metadataX, y); + y += lineHeight + 10; + + // Activity type + g2d.setFont(new Font("Arial", Font.PLAIN, 24)); + g2d.setColor(new Color(200, 200, 200)); + g2d.drawString(activity.getActivityType().toString(), metadataX, y); + y += lineHeight; + + // Distance + if (activity.getTotalDistance() != null) { + g2d.setFont(new Font("Arial", Font.BOLD, 28)); + g2d.setColor(Color.WHITE); + String distance = String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0); + g2d.drawString(distance, metadataX, y); + g2d.setFont(new Font("Arial", Font.PLAIN, 18)); + g2d.setColor(new Color(150, 150, 150)); + g2d.drawString("Distance", metadataX, y + 25); + y += lineHeight + 30; + } + + // Duration + if (activity.getTotalDurationSeconds() != null) { + long hours = activity.getTotalDurationSeconds() / 3600; + long minutes = (activity.getTotalDurationSeconds() % 3600) / 60; + long seconds = activity.getTotalDurationSeconds() % 60; + + g2d.setFont(new Font("Arial", Font.BOLD, 28)); + g2d.setColor(Color.WHITE); + String duration; + if (hours > 0) { + duration = String.format("%d:%02d:%02d", hours, minutes, seconds); + } else { + duration = String.format("%d:%02d", minutes, seconds); + } + g2d.drawString(duration, metadataX, y); + g2d.setFont(new Font("Arial", Font.PLAIN, 18)); + g2d.setColor(new Color(150, 150, 150)); + g2d.drawString("Duration", metadataX, y + 25); + y += lineHeight + 30; + } + + // Elevation gain + if (activity.getElevationGain() != null) { + g2d.setFont(new Font("Arial", Font.BOLD, 28)); + g2d.setColor(Color.WHITE); + String elevation = String.format("%.0f m", activity.getElevationGain()); + g2d.drawString(elevation, metadataX, y); + g2d.setFont(new Font("Arial", Font.PLAIN, 18)); + g2d.setColor(new Color(150, 150, 150)); + g2d.drawString("Elevation Gain", metadataX, y + 25); + y += lineHeight + 30; + } + + // Branding + g2d.setFont(new Font("Arial", Font.PLAIN, 20)); + g2d.setColor(new Color(100, 100, 100)); + g2d.drawString("FitPub", metadataX, height - 40); + } + + /** + * Helper to safely extract Double from Map. + */ + private Double getDouble(Map map, String key) { + Object value = map.get(key); + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + /** + * Get the file path for an activity image. + */ + public File getActivityImageFile(UUID activityId) { + return new File(imagesPath, activityId + ".png"); + } + + /** + * Parses track points from JSONB string. + */ + private List> parseTrackPoints(String trackPointsJson) { + if (trackPointsJson == null || trackPointsJson.isEmpty()) { + return null; + } + + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(trackPointsJson); + + if (root.isArray()) { + List> trackPoints = new java.util.ArrayList<>(); + for (com.fasterxml.jackson.databind.JsonNode node : root) { + Map point = new java.util.LinkedHashMap<>(); + + if (node.has("latitude")) point.put("latitude", node.get("latitude").asDouble()); + if (node.has("longitude")) point.put("longitude", node.get("longitude").asDouble()); + if (node.has("elevation")) point.put("elevation", node.get("elevation").asDouble()); + + trackPoints.add(point); + } + return trackPoints; + } + } catch (Exception e) { + log.error("Error parsing track points JSON: " + e.getMessage(), e); + } + return null; + } +} diff --git a/src/main/resources/static/js/auth.js b/src/main/resources/static/js/auth.js index b57dbd7..d1a704b 100644 --- a/src/main/resources/static/js/auth.js +++ b/src/main/resources/static/js/auth.js @@ -247,6 +247,12 @@ const FitPubAuth = { return; } + // Activity detail pages are public (for viewing public activities) + // Pattern: /activities/{uuid} + if (currentPath.startsWith('/activities/') && currentPath.split('/').length === 3) { + return; + } + // Check if authenticated if (!this.isAuthenticated()) { // Redirect to login for protected pages diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 8ca2b59..6b7bf36 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -271,7 +271,11 @@ async function loadActivity() { try { - const response = await FitPubAuth.authenticatedFetch(`/api/activities/${activityId}`); + // Use authenticated fetch if user is logged in, otherwise regular fetch + // This allows public activities to be viewed without authentication + const response = FitPubAuth.isAuthenticated() + ? await FitPubAuth.authenticatedFetch(`/api/activities/${activityId}`) + : await fetch(`/api/activities/${activityId}`); if (response.ok) { const activity = await response.json();