From ca8dbb203921f4815e3f6e831efa462f4019973a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BCrgen=20Hermes?= Date: Sat, 14 Mar 2026 22:22:03 +0100 Subject: [PATCH] Async media download, TimerService hardening, SSRF protection Replace synchronous image download during import with async MediaDownloadService that downloads images in the background with retry logic, size limits (10 MB BoundedInputStream), and SSRF protection (HTTPS-only, private IP blocking). Overhaul TimerService: use ConcurrentHashMap for thread safety, add SLF4J logging, media publish retry (30 attempts), fix scheduling guard that dropped near-due tasks, add graceful shutdown. Fix ServerService domain being overwritten by Mastodon self-reported domain. Add LoadStatus enum to MediaEntity. UI shows download progress with auto-refresh. Enable button disabled while media loads. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + CHANGELOG.md | 172 ++++++++ compose.yml | 2 +- .../idh/autodone/config/SecurityConfig.java | 5 + .../autodone/controller/GroupController.java | 9 + .../autodone/controller/ImportController.java | 28 ++ .../dh/idh/autodone/entities/MediaEntity.java | 15 + .../idh/autodone/services/ImportService.java | 51 +-- .../services/MediaDownloadService.java | 416 ++++++++++++++++++ .../idh/autodone/services/MediaService.java | 6 + .../idh/autodone/services/ServerService.java | 1 + .../idh/autodone/services/StatusService.java | 9 +- .../idh/autodone/services/TimerService.java | 140 ++++-- .../resources/templates/entity/group.html | 2 + src/main/resources/templates/forms/group.html | 5 +- src/main/resources/templates/forms/media.html | 30 +- .../resources/templates/tables/media.html | 38 +- src/test/resources/test-import.tsv | 5 + 18 files changed, 846 insertions(+), 89 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/main/java/de/uoc/dh/idh/autodone/services/MediaDownloadService.java create mode 100644 src/test/resources/test-import.tsv diff --git a/.gitignore b/.gitignore index a922c05..baf6a0a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.* /target +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47a088b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,172 @@ +# Änderungsprotokoll – seit letztem Commit (697896b) + +## Übersicht + +Alle Änderungen betreffen das Scheduling, den Medien-Download, die Sicherheit und die UI-Darstellung von Medien-Zuständen. Neue Datei: `MediaDownloadService.java` (asynchroner Bild-Download mit Retry-Logik). Verschoben: `test-import.tsv` nach `src/test/resources/`. + +--- + +## 1. Docker / Infrastruktur + +### compose.yml +- **Mastodon-Image korrigiert**: `bitnami/mastodon:4.2.9` → `bitnamilegacy/mastodon:4.2.9` (altes Image war nicht mehr verfügbar) + +### .gitignore +- `.DS_Store` in allen Verzeichnissen ignoriert (ohne führenden `/`) + +--- + +## 2. Neue Datei: MediaDownloadService.java + +Komplett neuer Service für den asynchronen Download von Bildern aus URLs (statt synchron beim Import). + +**Kernfunktionen:** +- `downloadAllPending(List)` – Queued Downloads mit zeitlichem Abstand, priorisiert nach Status-Datum +- `downloadOne(UUID)` – Einzelner Download mit Status-Tracking (PENDING → LOADING → READY/ERROR) +- `fetchAndResize(UUID)` – HTTP-Download mit automatischem Resize auf konfigurierte Maximalgröße +- `publishIfOverdue(UUID)` – Veröffentlicht Medien sofort, falls der geplante Zeitpunkt bereits überschritten ist +- `openConnection(String)` – HTTP-Verbindung mit manuellem Redirect-Handling (max. 5 Redirects) + +**Sicherheit:** +- `validateUrl(String)` – SSRF-Schutz: nur HTTPS erlaubt, private/interne IPs geblockt (Loopback, Link-Local, Site-Local, Any-Local) +- `BoundedInputStream` (innere Klasse) – begrenzt Downloads auf 10 MB, schützt gegen Decompression Bombs + +**Fehlerbehandlung:** +- Download-Retries: max. 3 Versuche mit steigenden Wartezeiten (10s, 30s, 90s) +- Publish-Retries: gleiche Strategie bei fehlgeschlagener Mastodon-Veröffentlichung +- Sauberes Shutdown via `@PreDestroy` mit 10s Timeout + +**Ressourcen-Management:** +- InputStream in try-with-resources +- HttpURLConnection in try/finally mit `disconnect()` +- Graphics2D in try/finally mit `dispose()` + +--- + +## 3. Medien-Entity: LoadStatus + +### MediaEntity.java +- **Neues Enum `LoadStatus`**: `PENDING`, `LOADING`, `READY`, `ERROR` +- **Neue Felder**: `sourceUrl` (Quell-URL), `loadStatus` (Download-Status, als String persistiert) + +--- + +## 4. Import-Refactoring + +### ImportService.java +- **Synchroner Bild-Download entfernt**: `importMedia()` lädt Bilder nicht mehr selbst herunter +- Stattdessen: setzt nur `sourceUrl` und `loadStatus = PENDING` +- Entfernte Imports: `BufferedImage`, `ImageIO`, `URL`, `ByteArrayOutputStream`, Bild-Konfig-Konstanten +- Entfernte Logik: Skalierung, Content-Type-Prüfung, "Scaled down"-Beschreibung + +### ImportController.java +- **Async-Download-Trigger**: Nach dem Speichern der Gruppe werden alle PENDING-Medien gesammelt und an `MediaDownloadService.downloadAllPending()` übergeben + +--- + +## 5. TimerService – Überarbeitung + +### Thread-Sicherheit +- `HashMap` → `ConcurrentHashMap` für `scheduledMedia` und `scheduledStatus` +- `static` entfernt – Maps und Scheduler sind jetzt Instanz-Felder +- `computeIfAbsent` statt manueller Contains-Prüfung (verhindert Race Conditions bei doppeltem Scheduling) + +### Logging +- SLF4J-Logger hinzugefügt +- Logging bei: Scheduling, erfolgreicher Veröffentlichung, Fehlern, Retry-Versuchen, Init, Shutdown + +### Fehlerbehandlung +- Status-Publish: try/catch mit `TransactionTemplate` in der Lambda (Exceptions werden nicht mehr vom Scheduler verschluckt) +- Media-Publish: Neue Methode `scheduleMediaPublish()` mit Retry-Logik (10s Intervall, max. 30 Versuche) – wartet auf Download-Abschluss + +### Scheduling-Guard korrigiert +- **Vorher**: `delay > AUTODONE_SCHEDULING` – Tasks innerhalb der letzten 30s vor Fälligkeit wurden nach einem Gruppen-Save nicht erneut gescheduled +- **Nachher**: `delay > 0` – alle noch nicht fälligen Tasks werden gescheduled + +### Cancel-Verhalten +- `future.cancel(true)` → `future.cancel(false)` (kein Interrupt laufender Tasks) + +### Sauberes Shutdown +- `@PreDestroy` mit `scheduler.shutdownNow()` und `awaitTermination(10s)` + +--- + +## 6. ServerService – Domain-Fix + +### ServerService.java +- **Domain-Überschreibung verhindert**: Nach `copyFields(server, metadata(domain))` wird `server.domain = domain` explizit gesetzt +- **Problem**: Mastodon meldet in `/api/v2/instance` seine eigene Domain (z.B. `localhost:3000`), die per `copyFields` die vom Nutzer eingegebene Domain überschrieb + +--- + +## 7. StatusService – Null-sichere Media-IDs + +### StatusService.java +- `media_ids` werden jetzt gefiltert: nur Medien mit vorhandener `id` (= bereits bei Mastodon hochgeladen) werden übergeben +- Leere `media_ids`-Liste wird nicht mehr gesendet + +--- + +## 8. MediaService – Download-Guard + +### MediaService.java +- `publish()` prüft jetzt: `file == null` oder `loadStatus == PENDING/LOADING` → überspringt Veröffentlichung (statt Fehler) + +--- + +## 9. SecurityConfig – H2-Console im Testprofil + +### SecurityConfig.java +- `/h2-console/**` wird im `test`-Profil als öffentlich zugänglich konfiguriert + +--- + +## 10. UI-Änderungen + +### forms/group.html – Enable-Button mit Download-Sperre +- Enable-Checkbox wird deaktiviert, solange Medien noch heruntergeladen werden (`PENDING`/`LOADING`) und die Gruppe nicht bereits aktiviert ist +- Warntext in Orange: "(media still downloading...)" +- Normaler Text wenn keine Downloads laufen: "(activates scheduled submissions)" + +### entity/group.html – Auto-Refresh bei laufenden Downloads +- `` wird bedingt gerendert wenn `mediaLoading` true ist +- Seite lädt sich alle 5s automatisch neu bis alle Downloads abgeschlossen sind +- Kein JavaScript nötig (reine HTML-Lösung) + +### forms/media.html – Status-abhängige Vorschau +- **READY/null**: Bild-Vorschau wie bisher +- **PENDING**: Uhr-Icon mit "Waiting for download..." und Quell-URL +- **LOADING**: Spinner mit "Downloading..." +- **ERROR**: Warndreiecks-Icon mit "Download failed" und Quell-URL + +### tables/media.html – Status-abhängige Kartenansicht +- Gleiche Logik wie forms/media.html, angepasst für Card-Layout +- Zusätzlich: Quell-URL als Truncated-Text unter der Karte (nur wenn nicht READY) + +--- + +## 11. GroupController – mediaLoading-Attribut + +### GroupController.java +- Neues Model-Attribut `mediaLoading`: prüft ob irgendein Medium der Gruppe den Status `PENDING` oder `LOADING` hat +- Wird in `forms/group.html` und `entity/group.html` verwendet + +--- + +## 12. Verschobene/neue Dateien + +- `test-import.tsv` → `src/test/resources/test-import.tsv` (Maven-Testressourcen) +- `MediaDownloadService.java` (neu, untracked) + +--- + +## Zusammenfassung der Kategorien + +| Kategorie | Dateien | +|---|---| +| Bugfix | ServerService, TimerService (Scheduling-Guard), StatusService | +| Feature | MediaDownloadService (neu), MediaEntity (LoadStatus), ImportService/Controller (async Download) | +| Sicherheit | MediaDownloadService (SSRF-Schutz, BoundedInputStream) | +| Robustheit | TimerService (ConcurrentHashMap, Logging, Retry, Shutdown), MediaDownloadService (Retry, Ressourcen) | +| UI | forms/group.html, entity/group.html, forms/media.html, tables/media.html | +| Infrastruktur | compose.yml, .gitignore, SecurityConfig | diff --git a/compose.yml b/compose.yml index a01c6a3..4f3ef32 100644 --- a/compose.yml +++ b/compose.yml @@ -38,7 +38,7 @@ services: mastodon: <<: *service - image: docker.io/bitnami/mastodon:4.2.9 + image: docker.io/bitnamilegacy/mastodon:4.2.9 ports: [3000:3000] postgres: diff --git a/src/main/java/de/uoc/dh/idh/autodone/config/SecurityConfig.java b/src/main/java/de/uoc/dh/idh/autodone/config/SecurityConfig.java index 33fa7f7..3f9f9b8 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/config/SecurityConfig.java +++ b/src/main/java/de/uoc/dh/idh/autodone/config/SecurityConfig.java @@ -53,6 +53,11 @@ public SecurityFilterChain securityFilterChain(ClientRepository repository, Http authorizeHttpRequests.requestMatchers("/webjars/**").permitAll(); authorizeHttpRequests.requestMatchers(DEFAULT_LOGIN_PAGE_URL).permitAll(); paths.forEach((path) -> authorizeHttpRequests.requestMatchers(path).permitAll()); + + if (getEnvironment().matchesProfiles("test")) { + authorizeHttpRequests.requestMatchers("/h2-console/**").permitAll(); + } + authorizeHttpRequests.anyRequest().authenticated(); }); diff --git a/src/main/java/de/uoc/dh/idh/autodone/controller/GroupController.java b/src/main/java/de/uoc/dh/idh/autodone/controller/GroupController.java index f375565..860a585 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/controller/GroupController.java +++ b/src/main/java/de/uoc/dh/idh/autodone/controller/GroupController.java @@ -20,6 +20,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.LOADING; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.PENDING; + import de.uoc.dh.idh.autodone.entities.GroupEntity; import de.uoc.dh.idh.autodone.entities.StatusEntity; import de.uoc.dh.idh.autodone.services.GroupService; @@ -64,9 +67,15 @@ public String get(Model model, @RequestParam() Map params) { var status = mapFields(of("group", group), new StatusEntity()); var page = statusService.getPage(params.get("page"), params.get("sort"), group); + var mediaLoading = group.status != null && group.status.stream() + .filter(s -> s.media != null) + .flatMap(s -> s.media.stream()) + .anyMatch(m -> m.loadStatus == PENDING || m.loadStatus == LOADING); + model.addAttribute("group", group); model.addAttribute("status", status); model.addAttribute("page", page); + model.addAttribute("mediaLoading", mediaLoading); return "entity/group"; } else { var group = new GroupEntity(); diff --git a/src/main/java/de/uoc/dh/idh/autodone/controller/ImportController.java b/src/main/java/de/uoc/dh/idh/autodone/controller/ImportController.java index 6e07824..711d9d3 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/controller/ImportController.java +++ b/src/main/java/de/uoc/dh/idh/autodone/controller/ImportController.java @@ -4,8 +4,12 @@ import static de.uoc.dh.idh.autodone.utils.ObjectUtils.mapFields; import static de.uoc.dh.idh.autodone.utils.WebUtils.href; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.PENDING; + import java.text.ParseException; +import java.util.ArrayList; import java.util.Map; +import java.util.UUID; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; @@ -21,6 +25,7 @@ import de.uoc.dh.idh.autodone.entities.GroupEntity; import de.uoc.dh.idh.autodone.services.GroupService; import de.uoc.dh.idh.autodone.services.ImportService; +import de.uoc.dh.idh.autodone.services.MediaDownloadService; import de.uoc.dh.idh.autodone.services.StatusService; import jakarta.servlet.http.HttpSession; @@ -37,6 +42,9 @@ public class ImportController { @Autowired() private ImportService importService; + @Autowired() + private MediaDownloadService mediaDownloadService; + @Autowired() private StatusService statusService; @@ -83,6 +91,26 @@ public String post(@RequestBody() MultipartFile file, @RequestParam() Map(); + + if (save.status != null) { + for (var status : save.status) { + if (status.media != null) { + for (var media : status.media) { + if (media.loadStatus == PENDING) { + pendingMedia.add(media.uuid); + } + } + } + } + } + + if (!pendingMedia.isEmpty()) { + mediaDownloadService.downloadAllPending(pendingMedia); + } + return "redirect:/group?uuid=" + save.uuid; } } diff --git a/src/main/java/de/uoc/dh/idh/autodone/entities/MediaEntity.java b/src/main/java/de/uoc/dh/idh/autodone/entities/MediaEntity.java index 99c95f6..4fae20a 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/entities/MediaEntity.java +++ b/src/main/java/de/uoc/dh/idh/autodone/entities/MediaEntity.java @@ -1,5 +1,6 @@ package de.uoc.dh.idh.autodone.entities; +import static jakarta.persistence.EnumType.STRING; import static jakarta.persistence.GenerationType.UUID; import static java.util.Base64.getEncoder; @@ -10,6 +11,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; @@ -20,6 +22,12 @@ @JsonNaming(SnakeCaseStrategy.class) public class MediaEntity { + public enum LoadStatus { + PENDING, LOADING, READY, ERROR + } + + // + @Id() @GeneratedValue(strategy = UUID) public UUID uuid; @@ -43,6 +51,13 @@ public class MediaEntity { @Column() public String id; + @Column() + public String sourceUrl; + + @Enumerated(STRING) + @Column() + public LoadStatus loadStatus; + @Column() public String url; diff --git a/src/main/java/de/uoc/dh/idh/autodone/services/ImportService.java b/src/main/java/de/uoc/dh/idh/autodone/services/ImportService.java index 54deff5..201c0a9 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/services/ImportService.java +++ b/src/main/java/de/uoc/dh/idh/autodone/services/ImportService.java @@ -1,26 +1,16 @@ package de.uoc.dh.idh.autodone.services; -import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMG_FORMAT; -import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMG_SIZE_X; -import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMG_SIZE_Y; import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMPORT_SKIP; import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMPORT_SNIP; import static de.uoc.dh.idh.autodone.utils.ObjectUtils.mapFields; -import static java.awt.Image.SCALE_FAST; -import static java.awt.image.BufferedImage.TYPE_INT_ARGB; import static java.time.Instant.now; import static java.time.LocalDateTime.ofInstant; import static java.time.ZoneOffset.UTC; import static java.util.Map.of; -import static javax.imageio.ImageIO.read; -import static javax.imageio.ImageIO.write; -import java.awt.image.BufferedImage; import java.io.BufferedReader; -import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.io.InputStreamReader; -import java.net.URL; import java.text.ParseException; import java.util.ArrayList; import java.util.concurrent.atomic.AtomicInteger; @@ -121,11 +111,6 @@ public StatusEntity importStatus(String line, int number) throws Exception { throw new SecurityException("Illegal protocol"); } - if (status.media.get(0).description != null) { - status.exceptions.add(new ParseException("Image scaled down (4th column)", number)); - status.media.get(0).description = null; - } - if (columns.length > 4) { if (columns[4].length() > 1500) { status.exceptions.add(new ParseException("Image caption too long (5th column)", number)); @@ -146,40 +131,8 @@ public StatusEntity importStatus(String line, int number) throws Exception { public MediaEntity importMedia(String url) throws Exception { var media = new MediaEntity(); - var request = new URL(url).openConnection(); - - if (!(media.contentType = request.getContentType()).startsWith("image/")) { - throw new IllegalArgumentException("Not an image file"); - } - - if (request.getContentLength() < 1024000) { - media.file = request.getInputStream().readAllBytes(); - } else { - var buffer = new ByteArrayOutputStream(); - var source = read(request.getInputStream()); - - var scaleX = source.getWidth(); - var scaleY = source.getHeight(); - - if (scaleX > AUTODONE_IMG_SIZE_X) { - scaleX = AUTODONE_IMG_SIZE_X; - scaleY = (scaleX * source.getHeight()) / source.getWidth(); - } - - if (scaleY > AUTODONE_IMG_SIZE_Y) { - scaleY = AUTODONE_IMG_SIZE_Y; - scaleX = (scaleY * source.getWidth()) / source.getHeight(); - } - - var target = new BufferedImage(scaleX, scaleY, TYPE_INT_ARGB); - var scaled = source.getScaledInstance(scaleX, scaleY, SCALE_FAST); - target.getGraphics().drawImage(scaled, 0, 0, null, null); - write(target, AUTODONE_IMG_FORMAT, buffer); - - media.description = "Scaled down from original"; - media.file = buffer.toByteArray(); - } - + media.sourceUrl = url; + media.loadStatus = MediaEntity.LoadStatus.PENDING; return media; } diff --git a/src/main/java/de/uoc/dh/idh/autodone/services/MediaDownloadService.java b/src/main/java/de/uoc/dh/idh/autodone/services/MediaDownloadService.java new file mode 100644 index 0000000..68b5688 --- /dev/null +++ b/src/main/java/de/uoc/dh/idh/autodone/services/MediaDownloadService.java @@ -0,0 +1,416 @@ +package de.uoc.dh.idh.autodone.services; + +import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMG_FORMAT; +import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMG_SIZE_X; +import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_IMG_SIZE_Y; +import static de.uoc.dh.idh.autodone.config.AutodoneConfig.AUTODONE_SCHEDULING; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.ERROR; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.LOADING; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.PENDING; +import static de.uoc.dh.idh.autodone.entities.MediaEntity.LoadStatus.READY; +import static java.awt.Image.SCALE_FAST; +import static java.time.Instant.now; +import static java.awt.image.BufferedImage.TYPE_INT_ARGB; +import static java.util.concurrent.TimeUnit.SECONDS; +import static javax.imageio.ImageIO.read; +import static javax.imageio.ImageIO.write; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import de.uoc.dh.idh.autodone.entities.MediaEntity; +import de.uoc.dh.idh.autodone.repositories.MediaRepository; +import jakarta.annotation.PreDestroy; + +@Service() +public class MediaDownloadService { + + private static final Logger log = LoggerFactory.getLogger(MediaDownloadService.class); + + private static final String USER_AGENT = "autodone/1.0 (https://github.com/spinfo/autodone)"; + + private static final int CONNECT_TIMEOUT = 10000; + + private static final int READ_TIMEOUT = 30000; + + private static final int DOWNLOAD_DELAY = 2; + + private static final int MAX_RETRIES = 3; + + private static final int MAX_DOWNLOAD_SIZE = 10 * 1024 * 1024; // 10 MB + + private static final int[] RETRY_DELAYS = { 10, 30, 90 }; + + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + private final Map retryCounts = new ConcurrentHashMap<>(); + + private final Map publishRetryCounts = new ConcurrentHashMap<>(); + + // + + @Autowired() + private MediaRepository mediaRepository; + + @Autowired() + private MediaService mediaService; + + @Autowired() + private PlatformTransactionManager transactionManager; + + // + + public void downloadAllPending(List mediaUuids) { + var sorted = new TransactionTemplate(transactionManager).execute((ignored) -> { + return mediaUuids.stream() + .map(uuid -> mediaRepository.findById(uuid).orElse(null)) + .filter(media -> media != null && media.status != null) + .sorted((a, b) -> a.status.date.compareTo(b.status.date)) + .map(media -> media.uuid) + .toList(); + }); + + var delay = 0; + + for (var uuid : sorted) { + scheduler.schedule(() -> downloadOne(uuid), delay, SECONDS); + delay += DOWNLOAD_DELAY; + } + + log.info("Queued {} media downloads with {}s spacing, prioritized by status date", sorted.size(), DOWNLOAD_DELAY); + } + + @PreDestroy() + private void preDestroy() { + log.info("Shutting down MediaDownloadService, stopping scheduler"); + + scheduler.shutdownNow(); + + try { + if (!scheduler.awaitTermination(10, SECONDS)) { + log.warn("MediaDownloadService scheduler did not terminate within 10 seconds"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + // + + private void downloadOne(UUID uuid) { + var shouldProceed = new TransactionTemplate(transactionManager).execute((ignored) -> { + var media = mediaRepository.findById(uuid).orElse(null); + + if (media == null || (media.loadStatus != PENDING && media.loadStatus != ERROR)) { + return false; + } + + media.loadStatus = LOADING; + mediaRepository.save(media); + return true; + }); + + if (!Boolean.TRUE.equals(shouldProceed)) { + return; + } + + try { + var result = fetchAndResize(uuid); + + new TransactionTemplate(transactionManager).executeWithoutResult((ignored) -> { + var media = mediaRepository.findById(uuid).orElse(null); + + if (media == null) { + return; + } + + media.contentType = result.contentType; + media.file = result.file; + media.loadStatus = READY; + mediaRepository.save(media); + }); + + retryCounts.remove(uuid); + log.info("Downloaded media {} from {}", uuid, result.sourceUrl); + + publishIfOverdue(uuid); + } catch (Exception exception) { + var retries = retryCounts.getOrDefault(uuid, 0); + + if (retries < MAX_RETRIES) { + retryCounts.put(uuid, retries + 1); + var retryDelay = RETRY_DELAYS[retries]; + + new TransactionTemplate(transactionManager).executeWithoutResult((ignored) -> { + var media = mediaRepository.findById(uuid).orElse(null); + + if (media != null) { + media.loadStatus = PENDING; + mediaRepository.save(media); + } + }); + + scheduler.schedule(() -> downloadOne(uuid), retryDelay, SECONDS); + log.warn("Download failed for media {}, retry {}/{} in {}s: {}", + uuid, retries + 1, MAX_RETRIES, retryDelay, exception.getMessage()); + } else { + new TransactionTemplate(transactionManager).executeWithoutResult((ignored) -> { + var media = mediaRepository.findById(uuid).orElse(null); + + if (media != null) { + media.loadStatus = ERROR; + mediaRepository.save(media); + } + }); + + retryCounts.remove(uuid); + log.error("Download permanently failed for media {} after {} retries: {}", + uuid, MAX_RETRIES, exception.getMessage()); + } + } + } + + private void publishIfOverdue(UUID uuid) { + var shouldPublish = new TransactionTemplate(transactionManager).execute((ignored) -> { + var media = mediaRepository.findById(uuid).orElse(null); + + if (media == null || media.id != null) { + return false; + } + + if (media.status == null || media.status.id != null) { + return false; + } + + if (!media.status.group.enabled) { + return false; + } + + var publishTime = media.status.date.minusSeconds(AUTODONE_SCHEDULING); + return now().isAfter(publishTime); + }); + + if (!Boolean.TRUE.equals(shouldPublish)) { + return; + } + + try { + log.info("Download completed after scheduled time, publishing media {} immediately", uuid); + mediaService.publish(uuid); + publishRetryCounts.remove(uuid); + } catch (Exception exception) { + var retries = publishRetryCounts.getOrDefault(uuid, 0); + + if (retries < MAX_RETRIES) { + publishRetryCounts.put(uuid, retries + 1); + var retryDelay = RETRY_DELAYS[retries]; + + scheduler.schedule(() -> publishIfOverdue(uuid), retryDelay, SECONDS); + log.warn("Publish failed for media {}, retry {}/{} in {}s: {}", + uuid, retries + 1, MAX_RETRIES, retryDelay, exception.getMessage()); + } else { + publishRetryCounts.remove(uuid); + log.error("Publish permanently failed for media {} after {} retries: {}", + uuid, MAX_RETRIES, exception.getMessage()); + } + } + } + + // + + private DownloadResult fetchAndResize(UUID uuid) throws Exception { + var result = new DownloadResult(); + + var media = new TransactionTemplate(transactionManager).execute((ignored) -> { + return mediaRepository.findById(uuid).orElse(null); + }); + + if (media == null || media.sourceUrl == null) { + throw new IllegalStateException("No source URL for media " + uuid); + } + + result.sourceUrl = media.sourceUrl; + + var connection = openConnection(media.sourceUrl); + + try { + result.contentType = connection.getContentType(); + + if (result.contentType == null || !result.contentType.startsWith("image/")) { + throw new IllegalArgumentException("Not an image file: " + result.contentType); + } + + try (var inputStream = new BoundedInputStream(connection.getInputStream(), MAX_DOWNLOAD_SIZE)) { + if (connection.getContentLength() < 1024000 && connection.getContentLength() > 0) { + result.file = inputStream.readAllBytes(); + } else { + var buffer = new ByteArrayOutputStream(); + var source = read(inputStream); + + if (source == null) { + throw new IllegalArgumentException("Could not read image from URL"); + } + + var scaleX = source.getWidth(); + var scaleY = source.getHeight(); + + if (scaleX > AUTODONE_IMG_SIZE_X) { + scaleX = AUTODONE_IMG_SIZE_X; + scaleY = (scaleX * source.getHeight()) / source.getWidth(); + } + + if (scaleY > AUTODONE_IMG_SIZE_Y) { + scaleY = AUTODONE_IMG_SIZE_Y; + scaleX = (scaleY * source.getWidth()) / source.getHeight(); + } + + var target = new BufferedImage(scaleX, scaleY, TYPE_INT_ARGB); + var scaled = source.getScaledInstance(scaleX, scaleY, SCALE_FAST); + var graphics = target.createGraphics(); + + try { + graphics.drawImage(scaled, 0, 0, null); + } finally { + graphics.dispose(); + } + + write(target, AUTODONE_IMG_FORMAT, buffer); + + result.contentType = "image/" + AUTODONE_IMG_FORMAT; + result.file = buffer.toByteArray(); + } + } + } finally { + connection.disconnect(); + } + + return result; + } + + private HttpURLConnection openConnection(String url) throws Exception { + var maxRedirects = 5; + + for (var i = 0; i < maxRedirects; i++) { + validateUrl(url); + + var connection = (HttpURLConnection) URI.create(url).toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT); + connection.setReadTimeout(READ_TIMEOUT); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", USER_AGENT); + + var status = connection.getResponseCode(); + + if (status == HttpURLConnection.HTTP_MOVED_TEMP + || status == HttpURLConnection.HTTP_MOVED_PERM + || status == HttpURLConnection.HTTP_SEE_OTHER + || status == 307 || status == 308) { + url = connection.getHeaderField("Location"); + connection.disconnect(); + continue; + } + + return connection; + } + + throw new IllegalStateException("Too many redirects for URL: " + url); + } + + private void validateUrl(String url) throws Exception { + var uri = URI.create(url); + + if (!"https".equals(uri.getScheme())) { + throw new SecurityException("Only HTTPS URLs allowed: " + url); + } + + var host = uri.getHost(); + + if (host == null || host.isBlank()) { + throw new SecurityException("Missing host in URL: " + url); + } + + var address = InetAddress.getByName(host); + + if (address.isLoopbackAddress() + || address.isLinkLocalAddress() + || address.isSiteLocalAddress() + || address.isAnyLocalAddress()) { + throw new SecurityException("Private/internal host not allowed: " + host); + } + } + + // + + private static class DownloadResult { + + String contentType; + + byte[] file; + + String sourceUrl; + + } + + private static class BoundedInputStream extends FilterInputStream { + + private final long maxBytes; + + private long bytesRead = 0; + + BoundedInputStream(InputStream in, long maxBytes) { + super(in); + this.maxBytes = maxBytes; + } + + @Override + public int read() throws IOException { + if (bytesRead >= maxBytes) { + throw new IOException("Download exceeds size limit of " + maxBytes + " bytes"); + } + + var b = super.read(); + + if (b != -1) { + bytesRead++; + } + + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (bytesRead >= maxBytes) { + throw new IOException("Download exceeds size limit of " + maxBytes + " bytes"); + } + + var allowed = (int) Math.min(len, maxBytes - bytesRead); + var n = super.read(b, off, allowed); + + if (n > 0) { + bytesRead += n; + } + + return n; + } + } + +} diff --git a/src/main/java/de/uoc/dh/idh/autodone/services/MediaService.java b/src/main/java/de/uoc/dh/idh/autodone/services/MediaService.java index 81b7c91..3f50664 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/services/MediaService.java +++ b/src/main/java/de/uoc/dh/idh/autodone/services/MediaService.java @@ -50,6 +50,12 @@ public MediaEntity publish(UUID uuid) { } public MediaEntity publish(MediaEntity media) { + // Skip media that hasn't been downloaded yet + if (media.file == null || media.loadStatus == MediaEntity.LoadStatus.PENDING + || media.loadStatus == MediaEntity.LoadStatus.LOADING) { + return media; + } + var data = new LinkedMultiValueMap(); data.add("description", media.description); data.add("filename", media.uuid.toString()); diff --git a/src/main/java/de/uoc/dh/idh/autodone/services/ServerService.java b/src/main/java/de/uoc/dh/idh/autodone/services/ServerService.java index 26f1683..86ce7fa 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/services/ServerService.java +++ b/src/main/java/de/uoc/dh/idh/autodone/services/ServerService.java @@ -44,6 +44,7 @@ public ServerEntity getOne(String domain, boolean metadata) { if (metadata) { server = copyFields(server, metadata(domain)); + server.domain = domain; if (server.uuid == null) { server = serverRepository.save(copyFields(register(server.domain), server)); diff --git a/src/main/java/de/uoc/dh/idh/autodone/services/StatusService.java b/src/main/java/de/uoc/dh/idh/autodone/services/StatusService.java index 1275981..fdce5d1 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/services/StatusService.java +++ b/src/main/java/de/uoc/dh/idh/autodone/services/StatusService.java @@ -60,7 +60,14 @@ public StatusEntity publish(StatusEntity status) { data.put("visibility", status.group.visibility.name().toLowerCase()); if (status.media != null) { - data.put("media_ids", status.media.stream().map((media) -> media.id).toList()); + var mediaIds = status.media.stream() + .filter((media) -> media.id != null) + .map((media) -> media.id) + .toList(); + + if (!mediaIds.isEmpty()) { + data.put("media_ids", mediaIds); + } } if (status.group.threaded) { diff --git a/src/main/java/de/uoc/dh/idh/autodone/services/TimerService.java b/src/main/java/de/uoc/dh/idh/autodone/services/TimerService.java index 28a2324..a537603 100644 --- a/src/main/java/de/uoc/dh/idh/autodone/services/TimerService.java +++ b/src/main/java/de/uoc/dh/idh/autodone/services/TimerService.java @@ -7,12 +7,14 @@ import static java.util.concurrent.Executors.newScheduledThreadPool; import static java.util.concurrent.TimeUnit.SECONDS; -import java.util.HashMap; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; @@ -22,17 +24,26 @@ import de.uoc.dh.idh.autodone.entities.MediaEntity; import de.uoc.dh.idh.autodone.entities.StatusEntity; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.transaction.Transactional; @Service() @Transactional() public class TimerService { - private final static Map> scheduledMedia = new HashMap<>(); + private static final Logger log = LoggerFactory.getLogger(TimerService.class); - private final static Map> scheduledStatus = new HashMap<>(); + private static final int MEDIA_PUBLISH_RETRY_DELAY = 10; - private final static ScheduledExecutorService scheduler = newScheduledThreadPool(AUTODONE_THREADPOOL); + private static final int MEDIA_PUBLISH_MAX_RETRIES = 30; + + // + + private final Map> scheduledMedia = new ConcurrentHashMap<>(); + + private final Map> scheduledStatus = new ConcurrentHashMap<>(); + + private final ScheduledExecutorService scheduler = newScheduledThreadPool(AUTODONE_THREADPOOL); // @@ -65,28 +76,43 @@ public void unscheduleGroup(GroupEntity group) { // - public ScheduledFuture scheduleStatus(StatusEntity status) { + public ScheduledFuture scheduleStatus(StatusEntity status) { + if (status.id != null) { + return null; + } + var delay = between(now(), status.date).getSeconds(); - var future = scheduledStatus.get(status.uuid); - if (status.id == null) { - if (delay > AUTODONE_SCHEDULING && future == null) { - var uuid = status.uuid; + if (delay > 0) { + var uuid = status.uuid; - future = scheduledStatus.put(uuid, scheduler.schedule(() -> { + scheduledStatus.computeIfAbsent(uuid, (key) -> { + log.info("Scheduling status {} in {} seconds", uuid, delay); + + return scheduler.schedule(() -> { scheduledStatus.remove(uuid); - return statusService.publish(uuid); - }, delay, SECONDS)); - } - if (status.media != null) { - for (var media : status.media) { - scheduleMedia(media); - } + try { + new TransactionTemplate(transactionManager).executeWithoutResult((ignored) -> { + statusService.publish(uuid); + }); + log.info("Published status {}", uuid); + } catch (Exception exception) { + log.error("Failed to publish status {}: {}", uuid, exception.getMessage()); + } + + return null; + }, delay, SECONDS); + }); + } + + if (status.media != null) { + for (var media : status.media) { + scheduleMedia(media); } } - return future; + return scheduledStatus.get(status.uuid); } public boolean unscheduleStatus(StatusEntity status) { @@ -97,46 +123,92 @@ public boolean unscheduleStatus(StatusEntity status) { } var future = scheduledStatus.remove(status.uuid); - return future == null ? false : future.cancel(true); + return future != null && future.cancel(false); } // - public ScheduledFuture scheduleMedia(MediaEntity media) { + public ScheduledFuture scheduleMedia(MediaEntity media) { + if (media.id != null) { + return null; + } + var delay = between(now().plusSeconds(AUTODONE_SCHEDULING), media.status.date).getSeconds(); - var future = scheduledMedia.get(media.uuid); - if (media.id == null) { - if (delay > AUTODONE_SCHEDULING && future == null) { - var uuid = media.uuid; + if (delay > 0) { + var uuid = media.uuid; - future = scheduledMedia.put(uuid, scheduler.schedule(() -> { - scheduledMedia.remove(uuid); - return mediaService.publish(uuid); - }, delay, SECONDS)); - } + scheduledMedia.computeIfAbsent(uuid, (key) -> { + log.info("Scheduling media {} in {} seconds", uuid, delay); + return scheduleMediaPublish(uuid, delay, 0); + }); } - return future; + return scheduledMedia.get(media.uuid); + } + + private ScheduledFuture scheduleMediaPublish(UUID uuid, long delay, int attempt) { + return scheduler.schedule(() -> { + scheduledMedia.remove(uuid); + + try { + var published = new TransactionTemplate(transactionManager).execute((ignored) -> { + return mediaService.publish(uuid).id != null; + }); + + if (Boolean.TRUE.equals(published)) { + log.info("Published media {}", uuid); + } else if (attempt < MEDIA_PUBLISH_MAX_RETRIES) { + log.info("Media {} not ready, retrying in {}s (attempt {}/{})", + uuid, MEDIA_PUBLISH_RETRY_DELAY, attempt + 1, MEDIA_PUBLISH_MAX_RETRIES); + scheduledMedia.put(uuid, scheduleMediaPublish(uuid, MEDIA_PUBLISH_RETRY_DELAY, attempt + 1)); + } else { + log.error("Media {} not ready after {} attempts, giving up", uuid, MEDIA_PUBLISH_MAX_RETRIES); + } + } catch (Exception exception) { + log.error("Failed to publish media {}: {}", uuid, exception.getMessage()); + } + + return null; + }, delay, SECONDS); } public boolean unscheduleMedia(MediaEntity media) { var future = scheduledMedia.remove(media.uuid); - return future == null ? false : future.cancel(true); - + return future != null && future.cancel(false); } // @PostConstruct() private void postConstruct() { - for (var entity : new TransactionTemplate(transactionManager).execute((ignored) -> { + var entities = new TransactionTemplate(transactionManager).execute((ignored) -> { return statusService.getAll(now().plusSeconds(AUTODONE_SCHEDULING)); - })) { + }); + + for (var entity : entities) { new TransactionTemplate(transactionManager).executeWithoutResult((ignored) -> { scheduleStatus(statusService.getAny(entity.getUuid())); }); } + + log.info("TimerService initialized, {} status tasks scheduled", scheduledStatus.size()); + } + + @PreDestroy() + private void preDestroy() { + log.info("Shutting down TimerService, cancelling {} status and {} media tasks", + scheduledStatus.size(), scheduledMedia.size()); + + scheduler.shutdownNow(); + + try { + if (!scheduler.awaitTermination(10, SECONDS)) { + log.warn("TimerService scheduler did not terminate within 10 seconds"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } } } diff --git a/src/main/resources/templates/entity/group.html b/src/main/resources/templates/entity/group.html index 37ec391..ca2adbc 100644 --- a/src/main/resources/templates/entity/group.html +++ b/src/main/resources/templates/entity/group.html @@ -61,6 +61,8 @@

+ + diff --git a/src/main/resources/templates/forms/group.html b/src/main/resources/templates/forms/group.html index d3a1509..00111cd 100644 --- a/src/main/resources/templates/forms/group.html +++ b/src/main/resources/templates/forms/group.html @@ -30,11 +30,12 @@
- +
diff --git a/src/main/resources/templates/forms/media.html b/src/main/resources/templates/forms/media.html index dfacb93..09e72b8 100644 --- a/src/main/resources/templates/forms/media.html +++ b/src/main/resources/templates/forms/media.html @@ -5,7 +5,35 @@
- + + + + +
+
+ +
Waiting for download...
+
+
+
+
+ +
+
+
+
Downloading...
+
+
+
+ +
+
+ +
Download failed
+
+
+
+
diff --git a/src/main/resources/templates/tables/media.html b/src/main/resources/templates/tables/media.html index 662679e..6c94d63 100644 --- a/src/main/resources/templates/tables/media.html +++ b/src/main/resources/templates/tables/media.html @@ -1,10 +1,46 @@
- + + + + + + + + +
+
+ +
Waiting...
+
+
+
+ + + +
+
+
+
Loading...
+
+
+
+ + + +
+
+ +
Failed
+
+
+

+