Moar federation
This commit is contained in:
parent
d47daa6dfc
commit
4c9bcc718f
5 changed files with 376 additions and 29 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, Object> 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<org.springframework.core.io.Resource> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> firstPoint = trackPoints.get(0);
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> trackPoints = new java.util.ArrayList<>();
|
||||
for (com.fasterxml.jackson.databind.JsonNode node : root) {
|
||||
Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue