diff --git a/README.md b/README.md index f7a89c4..7b05c94 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s ## Stack Tecnologico -- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL, Flyway. +- **Backend**: Java 21, Spring Boot 3.4, PostgreSQL. - **Frontend**: Angular 19, Angular Material, Three.js. - **Slicer**: OrcaSlicer (invocato via CLI). @@ -21,14 +21,20 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s * **Node.js 22** e **npm** installati. * **PostgreSQL** attivo. * **OrcaSlicer** installato sul sistema. +* **FFmpeg** installato sul sistema o presente nell'immagine Docker del backend. ## Avvio Rapido ### 1. Database -Crea un database PostgreSQL chiamato `printcalc`. Le tabelle verranno create automaticamente al primo avvio tramite Flyway. +Crea un database PostgreSQL chiamato `printcalc`. Lo schema viene gestito dal progetto tramite configurazione JPA/SQL del repository. ### 2. Backend -Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. +Configura il percorso di OrcaSlicer in `backend/src/main/resources/application.properties` o tramite la variabile d'ambiente `SLICER_PATH`. Per il media service pubblico puoi configurare anche: + +- `MEDIA_STORAGE_ROOT` per la root `storage_media` usata dal backend (`original/`, `public/`, `private/`) +- `MEDIA_PUBLIC_BASE_URL` per gli URL assoluti restituiti dalle API admin, ad esempio `https://example.com/media` +- `MEDIA_FFMPEG_PATH` per il binario `ffmpeg` +- `MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES` per il limite per asset immagine ```bash cd backend @@ -57,11 +63,29 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab * `/backend`: API Spring Boot. * `/frontend`: Applicazione Angular. * `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer. +* `/storage_media`: Originali e varianti media pubbliche/private su filesystem. + +## Media pubblici + +Il backend salva sempre l'originale in `storage_media/original/` e precomputa le varianti pubbliche in `storage_media/public/`. La cartella `storage_media/private/` è predisposta per asset non pubblici. + +Nel deploy Docker il volume media atteso è `/mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media`. + +Nginx non deve passare dal backend per i file pubblici. Configurazione attesa: + +```nginx +location /media/ { + alias /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/; +} +``` ## Troubleshooting ### Percorso OrcaSlicer Assicurati che `slicer.path` punti al binario corretto. Su macOS è solitamente `/Applications/OrcaSlicer.app/Contents/MacOS/OrcaSlicer`. Su Linux è il percorso all'AppImage (estratta o meno). +### FFmpeg e media pubblici +Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e AVIF. Se gli URL media restituiti dalle API admin non sono raggiungibili, controlla che `MEDIA_PUBLIC_BASE_URL` corrisponda al `location /media/` esposto da Nginx e che il volume `storage_media` sia montato correttamente. + ### Database connection Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente. diff --git a/backend/Dockerfile b/backend/Dockerfile index 6067fdf..870eb8d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,7 @@ ARG ORCA_DOWNLOAD_URL # Install system dependencies for OrcaSlicer (same as before) RUN apt-get update && apt-get install -y \ + ffmpeg \ wget \ assimp-utils \ libgl1 \ diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java new file mode 100644 index 0000000..df5d06f --- /dev/null +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminMediaController.java @@ -0,0 +1,82 @@ +package com.printcalculator.controller.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.AdminUpdateMediaUsageRequest; +import com.printcalculator.service.admin.AdminMediaControllerService; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/admin/media") +@Transactional(readOnly = true) +public class AdminMediaController { + + private final AdminMediaControllerService adminMediaControllerService; + + public AdminMediaController(AdminMediaControllerService adminMediaControllerService) { + this.adminMediaControllerService = adminMediaControllerService; + } + + @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Transactional + public ResponseEntity uploadAsset(@RequestParam("file") MultipartFile file, + @RequestParam(value = "title", required = false) String title, + @RequestParam(value = "altText", required = false) String altText, + @RequestParam(value = "visibility", required = false) String visibility) { + return ResponseEntity.ok(adminMediaControllerService.uploadAsset(file, title, altText, visibility)); + } + + @GetMapping("/assets") + public ResponseEntity> listAssets() { + return ResponseEntity.ok(adminMediaControllerService.listAssets()); + } + + @GetMapping("/assets/{mediaAssetId}") + public ResponseEntity getAsset(@PathVariable UUID mediaAssetId) { + return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId)); + } + + @PatchMapping("/assets/{mediaAssetId}") + @Transactional + public ResponseEntity updateAsset(@PathVariable UUID mediaAssetId, + @RequestBody AdminUpdateMediaAssetRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.updateAsset(mediaAssetId, payload)); + } + + @PostMapping("/usages") + @Transactional + public ResponseEntity createUsage(@RequestBody AdminCreateMediaUsageRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.createUsage(payload)); + } + + @PatchMapping("/usages/{mediaUsageId}") + @Transactional + public ResponseEntity updateUsage(@PathVariable UUID mediaUsageId, + @RequestBody AdminUpdateMediaUsageRequest payload) { + return ResponseEntity.ok(adminMediaControllerService.updateUsage(mediaUsageId, payload)); + } + + @DeleteMapping("/usages/{mediaUsageId}") + @Transactional + public ResponseEntity deleteUsage(@PathVariable UUID mediaUsageId) { + adminMediaControllerService.deleteUsage(mediaUsageId); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java new file mode 100644 index 0000000..e5cf4b2 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminCreateMediaUsageRequest.java @@ -0,0 +1,69 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class AdminCreateMediaUsageRequest { + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java new file mode 100644 index 0000000..d934610 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaAssetDto.java @@ -0,0 +1,153 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class AdminMediaAssetDto { + private UUID id; + private String originalFilename; + private String storageKey; + private String mimeType; + private Long fileSizeBytes; + private String sha256Hex; + private Integer widthPx; + private Integer heightPx; + private String status; + private String visibility; + private String title; + private String altText; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + private List variants = new ArrayList<>(); + private List usages = new ArrayList<>(); + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + public List getVariants() { + return variants; + } + + public void setVariants(List variants) { + this.variants = variants; + } + + public List getUsages() { + return usages; + } + + public void setUsages(List usages) { + this.usages = usages; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java new file mode 100644 index 0000000..23ff087 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaUsageDto.java @@ -0,0 +1,88 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminMediaUsageDto { + private UUID id; + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java new file mode 100644 index 0000000..6a200bf --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminMediaVariantDto.java @@ -0,0 +1,106 @@ +package com.printcalculator.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +public class AdminMediaVariantDto { + private UUID id; + private String variantName; + private String format; + private String storageKey; + private String mimeType; + private Integer widthPx; + private Integer heightPx; + private Long fileSizeBytes; + private Boolean isGenerated; + private String publicUrl; + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Boolean getIsGenerated() { + return isGenerated; + } + + public void setIsGenerated(Boolean generated) { + isGenerated = generated; + } + + public String getPublicUrl() { + return publicUrl; + } + + public void setPublicUrl(String publicUrl) { + this.publicUrl = publicUrl; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java new file mode 100644 index 0000000..2d3d8ff --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaAssetRequest.java @@ -0,0 +1,40 @@ +package com.printcalculator.dto; + +public class AdminUpdateMediaAssetRequest { + private String title; + private String altText; + private String visibility; + private String status; + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java new file mode 100644 index 0000000..62af38b --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpdateMediaUsageRequest.java @@ -0,0 +1,69 @@ +package com.printcalculator.dto; + +import java.util.UUID; + +public class AdminUpdateMediaUsageRequest { + private String usageType; + private String usageKey; + private UUID ownerId; + private UUID mediaAssetId; + private Integer sortOrder; + private Boolean isPrimary; + private Boolean isActive; + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public UUID getMediaAssetId() { + return mediaAssetId; + } + + public void setMediaAssetId(UUID mediaAssetId) { + this.mediaAssetId = mediaAssetId; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaAsset.java b/backend/src/main/java/com/printcalculator/entity/MediaAsset.java new file mode 100644 index 0000000..9c26d00 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaAsset.java @@ -0,0 +1,177 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_asset", indexes = { + @Index(name = "ix_media_asset_status_visibility_created_at", columnList = "status, visibility, created_at") +}) +public class MediaAsset { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_asset_id", nullable = false) + private UUID id; + + @Column(name = "original_filename", nullable = false, length = Integer.MAX_VALUE) + private String originalFilename; + + @Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true) + private String storageKey; + + @Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "file_size_bytes", nullable = false) + private Long fileSizeBytes; + + @Column(name = "sha256_hex", nullable = false, length = Integer.MAX_VALUE) + private String sha256Hex; + + @Column(name = "width_px") + private Integer widthPx; + + @Column(name = "height_px") + private Integer heightPx; + + @Column(name = "status", nullable = false, length = Integer.MAX_VALUE) + private String status; + + @Column(name = "visibility", nullable = false, length = Integer.MAX_VALUE) + private String visibility; + + @Column(name = "title", length = Integer.MAX_VALUE) + private String title; + + @Column(name = "alt_text", length = Integer.MAX_VALUE) + private String altText; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + @ColumnDefault("now()") + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getOriginalFilename() { + return originalFilename; + } + + public void setOriginalFilename(String originalFilename) { + this.originalFilename = originalFilename; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public String getSha256Hex() { + return sha256Hex; + } + + public void setSha256Hex(String sha256Hex) { + this.sha256Hex = sha256Hex; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getVisibility() { + return visibility; + } + + public void setVisibility(String visibility) { + this.visibility = visibility; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaUsage.java b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java new file mode 100644 index 0000000..991ca0e --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaUsage.java @@ -0,0 +1,131 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_usage", indexes = { + @Index(name = "ix_media_usage_scope", columnList = "usage_type, usage_key, is_active, sort_order") +}) +public class MediaUsage { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_usage_id", nullable = false) + private UUID id; + + @Column(name = "usage_type", nullable = false, length = Integer.MAX_VALUE) + private String usageType; + + @Column(name = "usage_key", nullable = false, length = Integer.MAX_VALUE) + private String usageKey; + + @Column(name = "owner_id") + private UUID ownerId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "media_asset_id", nullable = false) + private MediaAsset mediaAsset; + + @ColumnDefault("0") + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @ColumnDefault("false") + @Column(name = "is_primary", nullable = false) + private Boolean isPrimary; + + @ColumnDefault("true") + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getUsageType() { + return usageType; + } + + public void setUsageType(String usageType) { + this.usageType = usageType; + } + + public String getUsageKey() { + return usageKey; + } + + public void setUsageKey(String usageKey) { + this.usageKey = usageKey; + } + + public UUID getOwnerId() { + return ownerId; + } + + public void setOwnerId(UUID ownerId) { + this.ownerId = ownerId; + } + + public MediaAsset getMediaAsset() { + return mediaAsset; + } + + public void setMediaAsset(MediaAsset mediaAsset) { + this.mediaAsset = mediaAsset; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public Boolean getIsPrimary() { + return isPrimary; + } + + public void setIsPrimary(Boolean primary) { + isPrimary = primary; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean active) { + isActive = active; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/entity/MediaVariant.java b/backend/src/main/java/com/printcalculator/entity/MediaVariant.java new file mode 100644 index 0000000..e5a757d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/entity/MediaVariant.java @@ -0,0 +1,154 @@ +package com.printcalculator.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "media_variant", indexes = { + @Index(name = "ix_media_variant_asset", columnList = "media_asset_id") +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_media_variant_asset_name_format", columnNames = {"media_asset_id", "variant_name", "format"}) +}) +public class MediaVariant { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "media_variant_id", nullable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "media_asset_id", nullable = false) + private MediaAsset mediaAsset; + + @Column(name = "variant_name", nullable = false, length = Integer.MAX_VALUE) + private String variantName; + + @Column(name = "format", nullable = false, length = Integer.MAX_VALUE) + private String format; + + @Column(name = "storage_key", nullable = false, length = Integer.MAX_VALUE, unique = true) + private String storageKey; + + @Column(name = "mime_type", nullable = false, length = Integer.MAX_VALUE) + private String mimeType; + + @Column(name = "width_px", nullable = false) + private Integer widthPx; + + @Column(name = "height_px", nullable = false) + private Integer heightPx; + + @Column(name = "file_size_bytes", nullable = false) + private Long fileSizeBytes; + + @ColumnDefault("true") + @Column(name = "is_generated", nullable = false) + private Boolean isGenerated; + + @ColumnDefault("now()") + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public MediaAsset getMediaAsset() { + return mediaAsset; + } + + public void setMediaAsset(MediaAsset mediaAsset) { + this.mediaAsset = mediaAsset; + } + + public String getVariantName() { + return variantName; + } + + public void setVariantName(String variantName) { + this.variantName = variantName; + } + + public String getFormat() { + return format; + } + + public void setFormat(String format) { + this.format = format; + } + + public String getStorageKey() { + return storageKey; + } + + public void setStorageKey(String storageKey) { + this.storageKey = storageKey; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public Integer getWidthPx() { + return widthPx; + } + + public void setWidthPx(Integer widthPx) { + this.widthPx = widthPx; + } + + public Integer getHeightPx() { + return heightPx; + } + + public void setHeightPx(Integer heightPx) { + this.heightPx = heightPx; + } + + public Long getFileSizeBytes() { + return fileSizeBytes; + } + + public void setFileSizeBytes(Long fileSizeBytes) { + this.fileSizeBytes = fileSizeBytes; + } + + public Boolean getIsGenerated() { + return isGenerated; + } + + public void setIsGenerated(Boolean generated) { + isGenerated = generated; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java new file mode 100644 index 0000000..e41b95a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaAssetRepository.java @@ -0,0 +1,11 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaAsset; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface MediaAssetRepository extends JpaRepository { + List findAllByOrderByCreatedAtDesc(); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java new file mode 100644 index 0000000..258e751 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaUsageRepository.java @@ -0,0 +1,27 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaUsage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public interface MediaUsageRepository extends JpaRepository { + List findByMediaAsset_IdOrderBySortOrderAscCreatedAtAsc(UUID mediaAssetId); + + List findByMediaAsset_IdIn(Collection mediaAssetIds); + + @Query(""" + select usage from MediaUsage usage + where usage.usageType = :usageType + and usage.usageKey = :usageKey + and ((:ownerId is null and usage.ownerId is null) or usage.ownerId = :ownerId) + order by usage.sortOrder asc, usage.createdAt asc + """) + List findByUsageScope(@Param("usageType") String usageType, + @Param("usageKey") String usageKey, + @Param("ownerId") UUID ownerId); +} diff --git a/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java b/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java new file mode 100644 index 0000000..013346d --- /dev/null +++ b/backend/src/main/java/com/printcalculator/repository/MediaVariantRepository.java @@ -0,0 +1,14 @@ +package com.printcalculator.repository; + +import com.printcalculator.entity.MediaVariant; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; + +public interface MediaVariantRepository extends JpaRepository { + List findByMediaAsset_IdOrderByCreatedAtAsc(UUID mediaAssetId); + + List findByMediaAsset_IdIn(Collection mediaAssetIds); +} diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java new file mode 100644 index 0000000..f70e075 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminMediaControllerService.java @@ -0,0 +1,727 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminMediaVariantDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.dto.AdminUpdateMediaUsageRequest; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaAssetRepository; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import com.printcalculator.service.media.MediaFfmpegService; +import com.printcalculator.service.media.MediaImageInspector; +import com.printcalculator.service.media.MediaStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Transactional(readOnly = true) +public class AdminMediaControllerService { + + private static final Logger logger = LoggerFactory.getLogger(AdminMediaControllerService.class); + + private static final String STATUS_UPLOADED = "UPLOADED"; + private static final String STATUS_PROCESSING = "PROCESSING"; + private static final String STATUS_READY = "READY"; + private static final String STATUS_FAILED = "FAILED"; + private static final String STATUS_ARCHIVED = "ARCHIVED"; + + private static final String VISIBILITY_PUBLIC = "PUBLIC"; + private static final String VISIBILITY_PRIVATE = "PRIVATE"; + + private static final String FORMAT_ORIGINAL = "ORIGINAL"; + private static final String FORMAT_JPEG = "JPEG"; + private static final String FORMAT_WEBP = "WEBP"; + private static final String FORMAT_AVIF = "AVIF"; + + private static final Set ALLOWED_STATUSES = Set.of( + STATUS_UPLOADED, STATUS_PROCESSING, STATUS_READY, STATUS_FAILED, STATUS_ARCHIVED + ); + private static final Set ALLOWED_VISIBILITIES = Set.of(VISIBILITY_PUBLIC, VISIBILITY_PRIVATE); + private static final Set ALLOWED_UPLOAD_MIME_TYPES = Set.of( + "image/jpeg", "image/png", "image/webp" + ); + private static final Map GENERATED_FORMAT_MIME_TYPES = Map.of( + FORMAT_JPEG, "image/jpeg", + FORMAT_WEBP, "image/webp", + FORMAT_AVIF, "image/avif" + ); + private static final Map GENERATED_FORMAT_EXTENSIONS = Map.of( + FORMAT_JPEG, "jpg", + FORMAT_WEBP, "webp", + FORMAT_AVIF, "avif" + ); + private static final List PRESETS = List.of( + new PresetDefinition("thumb", 320), + new PresetDefinition("card", 640), + new PresetDefinition("hero", 1280) + ); + private static final DateTimeFormatter STORAGE_FOLDER_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM"); + + private final MediaAssetRepository mediaAssetRepository; + private final MediaVariantRepository mediaVariantRepository; + private final MediaUsageRepository mediaUsageRepository; + private final MediaStorageService mediaStorageService; + private final MediaImageInspector mediaImageInspector; + private final MediaFfmpegService mediaFfmpegService; + private final ClamAVService clamAVService; + private final long maxUploadFileSizeBytes; + + public AdminMediaControllerService(MediaAssetRepository mediaAssetRepository, + MediaVariantRepository mediaVariantRepository, + MediaUsageRepository mediaUsageRepository, + MediaStorageService mediaStorageService, + MediaImageInspector mediaImageInspector, + MediaFfmpegService mediaFfmpegService, + ClamAVService clamAVService, + @Value("${media.upload.max-file-size-bytes:26214400}") long maxUploadFileSizeBytes) { + this.mediaAssetRepository = mediaAssetRepository; + this.mediaVariantRepository = mediaVariantRepository; + this.mediaUsageRepository = mediaUsageRepository; + this.mediaStorageService = mediaStorageService; + this.mediaImageInspector = mediaImageInspector; + this.mediaFfmpegService = mediaFfmpegService; + this.clamAVService = clamAVService; + this.maxUploadFileSizeBytes = maxUploadFileSizeBytes; + } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public AdminMediaAssetDto uploadAsset(MultipartFile file, + String title, + String altText, + String visibility) { + validateUpload(file); + + Path tempDirectory = null; + MediaAsset asset = null; + + try { + String normalizedVisibility = normalizeVisibility(visibility, true); + tempDirectory = Files.createTempDirectory("media-asset-"); + Path uploadFile = tempDirectory.resolve("upload.bin"); + file.transferTo(uploadFile); + + try (InputStream inputStream = Files.newInputStream(uploadFile)) { + clamAVService.scan(inputStream); + } + + MediaImageInspector.ImageMetadata metadata = mediaImageInspector.inspect(uploadFile); + if (!ALLOWED_UPLOAD_MIME_TYPES.contains(metadata.mimeType())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Unsupported image type. Allowed: jpg, jpeg, png, webp." + ); + } + + String storageFolder = buildStorageFolder(); + String originalStorageKey = storageFolder + "/original." + metadata.fileExtension(); + String normalizedFilename = sanitizeOriginalFilename(file.getOriginalFilename(), metadata.fileExtension()); + String normalizedTitle = normalizeText(title); + String normalizedAltText = normalizeText(altText); + long originalFileSize = Files.size(uploadFile); + String sha256Hex = computeSha256(uploadFile); + + mediaStorageService.storeOriginal(uploadFile, originalStorageKey); + + OffsetDateTime now = OffsetDateTime.now(); + asset = new MediaAsset(); + asset.setOriginalFilename(normalizedFilename); + asset.setStorageKey(originalStorageKey); + asset.setMimeType(metadata.mimeType()); + asset.setFileSizeBytes(originalFileSize); + asset.setSha256Hex(sha256Hex); + asset.setWidthPx(metadata.widthPx()); + asset.setHeightPx(metadata.heightPx()); + asset.setStatus(STATUS_UPLOADED); + asset.setVisibility(normalizedVisibility); + asset.setTitle(normalizedTitle); + asset.setAltText(normalizedAltText); + asset.setCreatedAt(now); + asset.setUpdatedAt(now); + asset = mediaAssetRepository.save(asset); + + MediaVariant originalVariant = new MediaVariant(); + originalVariant.setMediaAsset(asset); + originalVariant.setVariantName("original"); + originalVariant.setFormat(FORMAT_ORIGINAL); + originalVariant.setStorageKey(originalStorageKey); + originalVariant.setMimeType(metadata.mimeType()); + originalVariant.setWidthPx(metadata.widthPx()); + originalVariant.setHeightPx(metadata.heightPx()); + originalVariant.setFileSizeBytes(originalFileSize); + originalVariant.setIsGenerated(false); + originalVariant.setCreatedAt(now); + mediaVariantRepository.save(originalVariant); + + asset.setStatus(STATUS_PROCESSING); + asset.setUpdatedAt(OffsetDateTime.now()); + asset = mediaAssetRepository.save(asset); + + List generatedVariants = generateDerivedVariants(asset, uploadFile, tempDirectory); + mediaVariantRepository.saveAll(generatedVariants); + + asset.setStatus(STATUS_READY); + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + + return getAsset(asset.getId()); + } catch (ResponseStatusException e) { + markFailed(asset, e.getReason(), e); + throw e; + } catch (IOException e) { + markFailed(asset, "Media processing failed.", e); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Media processing failed."); + } finally { + deleteRecursively(tempDirectory); + } + } + + public List listAssets() { + return toAssetDtos(mediaAssetRepository.findAllByOrderByCreatedAtDesc()); + } + + public AdminMediaAssetDto getAsset(UUID mediaAssetId) { + MediaAsset asset = getAssetOrThrow(mediaAssetId); + return toAssetDtos(List.of(asset)).getFirst(); + } + + @Transactional(noRollbackFor = ResponseStatusException.class) + public AdminMediaAssetDto updateAsset(UUID mediaAssetId, AdminUpdateMediaAssetRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaAsset asset = getAssetOrThrow(mediaAssetId); + String requestedVisibility = normalizeVisibility(payload.getVisibility(), false); + String requestedStatus = normalizeStatus(payload.getStatus(), false); + + if (requestedVisibility != null && !requestedVisibility.equals(asset.getVisibility())) { + moveGeneratedVariants(asset, requestedVisibility); + asset.setVisibility(requestedVisibility); + } + if (requestedStatus != null) { + asset.setStatus(requestedStatus); + } + if (payload.getTitle() != null) { + asset.setTitle(normalizeText(payload.getTitle())); + } + if (payload.getAltText() != null) { + asset.setAltText(normalizeText(payload.getAltText())); + } + + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + return getAsset(asset.getId()); + } + + @Transactional + public AdminMediaUsageDto createUsage(AdminCreateMediaUsageRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaAsset asset = getAssetOrThrow(payload.getMediaAssetId()); + String usageType = requireUsageType(payload.getUsageType()); + String usageKey = requireUsageKey(payload.getUsageKey()); + boolean isPrimary = Boolean.TRUE.equals(payload.getIsPrimary()); + + if (isPrimary) { + unsetPrimaryForScope(usageType, usageKey, payload.getOwnerId(), null); + } + + MediaUsage usage = new MediaUsage(); + usage.setUsageType(usageType); + usage.setUsageKey(usageKey); + usage.setOwnerId(payload.getOwnerId()); + usage.setMediaAsset(asset); + usage.setSortOrder(payload.getSortOrder() != null ? payload.getSortOrder() : 0); + usage.setIsPrimary(isPrimary); + usage.setIsActive(payload.getIsActive() == null || payload.getIsActive()); + usage.setCreatedAt(OffsetDateTime.now()); + + MediaUsage saved = mediaUsageRepository.save(usage); + return toUsageDto(saved); + } + + @Transactional + public AdminMediaUsageDto updateUsage(UUID mediaUsageId, AdminUpdateMediaUsageRequest payload) { + if (payload == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Payload is required."); + } + + MediaUsage usage = getUsageOrThrow(mediaUsageId); + + if (payload.getUsageType() != null) { + usage.setUsageType(requireUsageType(payload.getUsageType())); + } + if (payload.getUsageKey() != null) { + usage.setUsageKey(requireUsageKey(payload.getUsageKey())); + } + if (payload.getOwnerId() != null) { + usage.setOwnerId(payload.getOwnerId()); + } + if (payload.getMediaAssetId() != null) { + usage.setMediaAsset(getAssetOrThrow(payload.getMediaAssetId())); + } + if (payload.getSortOrder() != null) { + usage.setSortOrder(payload.getSortOrder()); + } + if (payload.getIsActive() != null) { + usage.setIsActive(payload.getIsActive()); + } + if (payload.getIsPrimary() != null) { + usage.setIsPrimary(payload.getIsPrimary()); + } + + if (Boolean.TRUE.equals(usage.getIsPrimary())) { + unsetPrimaryForScope(usage.getUsageType(), usage.getUsageKey(), usage.getOwnerId(), usage.getId()); + } + + MediaUsage saved = mediaUsageRepository.save(usage); + return toUsageDto(saved); + } + + @Transactional + public void deleteUsage(UUID mediaUsageId) { + mediaUsageRepository.delete(getUsageOrThrow(mediaUsageId)); + } + + private List generateDerivedVariants(MediaAsset asset, Path sourceFile, Path tempDirectory) throws IOException { + Path generatedDirectory = Files.createDirectories(tempDirectory.resolve("generated")); + String storageFolder = extractStorageFolder(asset.getStorageKey()); + + List pendingVariants = new ArrayList<>(); + for (PresetDefinition preset : PRESETS) { + VariantDimensions dimensions = computeVariantDimensions( + asset.getWidthPx(), + asset.getHeightPx(), + preset.maxDimension() + ); + + for (String format : List.of(FORMAT_JPEG, FORMAT_WEBP, FORMAT_AVIF)) { + String extension = GENERATED_FORMAT_EXTENSIONS.get(format); + Path outputFile = generatedDirectory.resolve(preset.name() + "." + extension); + mediaFfmpegService.generateVariant(sourceFile, outputFile, dimensions.widthPx(), dimensions.heightPx(), format); + + MediaVariant variant = new MediaVariant(); + variant.setMediaAsset(asset); + variant.setVariantName(preset.name()); + variant.setFormat(format); + variant.setStorageKey(storageFolder + "/" + preset.name() + "." + extension); + variant.setMimeType(GENERATED_FORMAT_MIME_TYPES.get(format)); + variant.setWidthPx(dimensions.widthPx()); + variant.setHeightPx(dimensions.heightPx()); + variant.setFileSizeBytes(Files.size(outputFile)); + variant.setIsGenerated(true); + variant.setCreatedAt(OffsetDateTime.now()); + + pendingVariants.add(new PendingGeneratedVariant(variant, outputFile)); + } + } + + List storedKeys = new ArrayList<>(); + try { + for (PendingGeneratedVariant pendingVariant : pendingVariants) { + storeGeneratedVariant(asset.getVisibility(), pendingVariant); + storedKeys.add(pendingVariant.variant().getStorageKey()); + } + } catch (IOException e) { + cleanupStoredGeneratedVariants(asset.getVisibility(), storedKeys); + throw e; + } + + return pendingVariants.stream() + .map(PendingGeneratedVariant::variant) + .toList(); + } + + private void storeGeneratedVariant(String visibility, PendingGeneratedVariant pendingVariant) throws IOException { + if (VISIBILITY_PUBLIC.equals(visibility)) { + mediaStorageService.storePublic(pendingVariant.file(), pendingVariant.variant().getStorageKey()); + return; + } + mediaStorageService.storePrivate(pendingVariant.file(), pendingVariant.variant().getStorageKey()); + } + + private void cleanupStoredGeneratedVariants(String visibility, Collection storageKeys) { + for (String storageKey : storageKeys) { + try { + mediaStorageService.deleteGenerated(visibility, storageKey); + } catch (IOException cleanupException) { + logger.warn("Failed to clean up media variant {}", storageKey, cleanupException); + } + } + } + + private void moveGeneratedVariants(MediaAsset asset, String requestedVisibility) { + List variants = mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(asset.getId()); + List movedStorageKeys = new ArrayList<>(); + try { + for (MediaVariant variant : variants) { + if (FORMAT_ORIGINAL.equals(variant.getFormat())) { + continue; + } + mediaStorageService.moveGenerated(variant.getStorageKey(), asset.getVisibility(), requestedVisibility); + movedStorageKeys.add(variant.getStorageKey()); + } + } catch (IOException e) { + reverseMovedVariants(asset.getVisibility(), requestedVisibility, movedStorageKeys); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to move media files."); + } + } + + private void reverseMovedVariants(String originalVisibility, String requestedVisibility, List movedStorageKeys) { + List reversedOrder = new ArrayList<>(movedStorageKeys); + java.util.Collections.reverse(reversedOrder); + for (String storageKey : reversedOrder) { + try { + mediaStorageService.moveGenerated(storageKey, requestedVisibility, originalVisibility); + } catch (IOException reverseException) { + logger.error("Failed to restore media variant {}", storageKey, reverseException); + } + } + } + + private void unsetPrimaryForScope(String usageType, String usageKey, UUID ownerId, UUID excludeUsageId) { + List existingUsages = mediaUsageRepository.findByUsageScope(usageType, usageKey, ownerId); + List usagesToUpdate = existingUsages.stream() + .filter(existing -> excludeUsageId == null || !existing.getId().equals(excludeUsageId)) + .filter(existing -> Boolean.TRUE.equals(existing.getIsPrimary())) + .peek(existing -> existing.setIsPrimary(false)) + .toList(); + + if (!usagesToUpdate.isEmpty()) { + mediaUsageRepository.saveAll(usagesToUpdate); + } + } + + private List toAssetDtos(List assets) { + if (assets == null || assets.isEmpty()) { + return List.of(); + } + + List assetIds = assets.stream() + .map(MediaAsset::getId) + .filter(Objects::nonNull) + .toList(); + + Map> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .sorted(this::compareVariants) + .collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList())); + + Map> usagesByAssetId = mediaUsageRepository.findByMediaAsset_IdIn(assetIds) + .stream() + .sorted(Comparator + .comparing(MediaUsage::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(MediaUsage::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .collect(Collectors.groupingBy(usage -> usage.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList())); + + return assets.stream() + .map(asset -> toAssetDto( + asset, + variantsByAssetId.getOrDefault(asset.getId(), List.of()), + usagesByAssetId.getOrDefault(asset.getId(), List.of()) + )) + .toList(); + } + + private AdminMediaAssetDto toAssetDto(MediaAsset asset, List variants, List usages) { + AdminMediaAssetDto dto = new AdminMediaAssetDto(); + dto.setId(asset.getId()); + dto.setOriginalFilename(asset.getOriginalFilename()); + dto.setStorageKey(asset.getStorageKey()); + dto.setMimeType(asset.getMimeType()); + dto.setFileSizeBytes(asset.getFileSizeBytes()); + dto.setSha256Hex(asset.getSha256Hex()); + dto.setWidthPx(asset.getWidthPx()); + dto.setHeightPx(asset.getHeightPx()); + dto.setStatus(asset.getStatus()); + dto.setVisibility(asset.getVisibility()); + dto.setTitle(asset.getTitle()); + dto.setAltText(asset.getAltText()); + dto.setCreatedAt(asset.getCreatedAt()); + dto.setUpdatedAt(asset.getUpdatedAt()); + dto.setVariants(variants.stream().map(variant -> toVariantDto(asset, variant)).toList()); + dto.setUsages(usages.stream().map(this::toUsageDto).toList()); + return dto; + } + + private AdminMediaVariantDto toVariantDto(MediaAsset asset, MediaVariant variant) { + AdminMediaVariantDto dto = new AdminMediaVariantDto(); + dto.setId(variant.getId()); + dto.setVariantName(variant.getVariantName()); + dto.setFormat(variant.getFormat()); + dto.setStorageKey(variant.getStorageKey()); + dto.setMimeType(variant.getMimeType()); + dto.setWidthPx(variant.getWidthPx()); + dto.setHeightPx(variant.getHeightPx()); + dto.setFileSizeBytes(variant.getFileSizeBytes()); + dto.setIsGenerated(variant.getIsGenerated()); + dto.setCreatedAt(variant.getCreatedAt()); + if (VISIBILITY_PUBLIC.equals(asset.getVisibility()) && !FORMAT_ORIGINAL.equals(variant.getFormat())) { + dto.setPublicUrl(mediaStorageService.buildPublicUrl(variant.getStorageKey())); + } + return dto; + } + + private AdminMediaUsageDto toUsageDto(MediaUsage usage) { + AdminMediaUsageDto dto = new AdminMediaUsageDto(); + dto.setId(usage.getId()); + dto.setUsageType(usage.getUsageType()); + dto.setUsageKey(usage.getUsageKey()); + dto.setOwnerId(usage.getOwnerId()); + dto.setMediaAssetId(usage.getMediaAsset().getId()); + dto.setSortOrder(usage.getSortOrder()); + dto.setIsPrimary(usage.getIsPrimary()); + dto.setIsActive(usage.getIsActive()); + dto.setCreatedAt(usage.getCreatedAt()); + return dto; + } + + private int compareVariants(MediaVariant left, MediaVariant right) { + return Comparator + .comparingInt((MediaVariant variant) -> variantNameOrder(variant.getVariantName())) + .thenComparingInt(variant -> formatOrder(variant.getFormat())) + .thenComparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)) + .compare(left, right); + } + + private int variantNameOrder(String variantName) { + if ("original".equalsIgnoreCase(variantName)) { + return 0; + } + if ("thumb".equalsIgnoreCase(variantName)) { + return 10; + } + if ("card".equalsIgnoreCase(variantName)) { + return 20; + } + if ("hero".equalsIgnoreCase(variantName)) { + return 30; + } + return 100; + } + + private int formatOrder(String format) { + return switch (format) { + case FORMAT_ORIGINAL -> 0; + case FORMAT_JPEG -> 10; + case FORMAT_WEBP -> 20; + case FORMAT_AVIF -> 30; + default -> 100; + }; + } + + private MediaAsset getAssetOrThrow(UUID mediaAssetId) { + if (mediaAssetId == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Media asset id is required."); + } + return mediaAssetRepository.findById(mediaAssetId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media asset not found.")); + } + + private MediaUsage getUsageOrThrow(UUID mediaUsageId) { + return mediaUsageRepository.findById(mediaUsageId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Media usage not found.")); + } + + private void validateUpload(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file is required."); + } + if (file.getSize() < 0 || file.getSize() > maxUploadFileSizeBytes) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Image file exceeds the maximum allowed size."); + } + } + + private String requireUsageType(String usageType) { + if (usageType == null || usageType.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageType is required."); + } + return usageType.trim().toUpperCase(Locale.ROOT); + } + + private String requireUsageKey(String usageKey) { + if (usageKey == null || usageKey.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "usageKey is required."); + } + return usageKey.trim(); + } + + private String normalizeVisibility(String visibility, boolean defaultPublic) { + if (visibility == null) { + return defaultPublic ? VISIBILITY_PUBLIC : null; + } + String normalized = visibility.trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank()) { + return defaultPublic ? VISIBILITY_PUBLIC : null; + } + if (!ALLOWED_VISIBILITIES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid visibility. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_VISIBILITIES)) + ); + } + return normalized; + } + + private String normalizeStatus(String status, boolean required) { + if (status == null) { + if (required) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required."); + } + return null; + } + String normalized = status.trim().toUpperCase(Locale.ROOT); + if (normalized.isBlank()) { + if (required) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Status is required."); + } + return null; + } + if (!ALLOWED_STATUSES.contains(normalized)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid status. Allowed: " + String.join(", ", new LinkedHashSet<>(ALLOWED_STATUSES)) + ); + } + return normalized; + } + + private String normalizeText(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isBlank() ? null : normalized; + } + + private String sanitizeOriginalFilename(String originalFilename, String extension) { + String cleaned = StringUtils.cleanPath(originalFilename == null ? "" : originalFilename); + int separatorIndex = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\')); + String basename = separatorIndex >= 0 ? cleaned.substring(separatorIndex + 1) : cleaned; + basename = basename.replace("\r", "_").replace("\n", "_"); + if (basename.isBlank()) { + return "upload." + extension; + } + return basename; + } + + private String buildStorageFolder() { + return STORAGE_FOLDER_FORMATTER.format(LocalDate.now()) + "/" + UUID.randomUUID(); + } + + private String extractStorageFolder(String originalStorageKey) { + Path path = Paths.get(originalStorageKey).normalize(); + Path parent = path.getParent(); + if (parent == null) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid media storage key."); + } + return parent.toString().replace('\\', '/'); + } + + private VariantDimensions computeVariantDimensions(Integer widthPx, Integer heightPx, int maxDimension) { + if (widthPx == null || heightPx == null || widthPx <= 0 || heightPx <= 0) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Invalid image dimensions."); + } + double scale = Math.min(1.0d, (double) maxDimension / Math.max(widthPx, heightPx)); + int targetWidth = Math.max(1, (int) Math.round(widthPx * scale)); + int targetHeight = Math.max(1, (int) Math.round(heightPx * scale)); + return new VariantDimensions(targetWidth, targetHeight); + } + + private String computeSha256(Path file) throws IOException { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available.", e); + } + + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = inputStream.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + private void markFailed(MediaAsset asset, String message, Exception exception) { + if (asset == null || asset.getId() == null) { + logger.warn("Media upload failed before asset persistence: {}", message, exception); + return; + } + asset.setStatus(STATUS_FAILED); + asset.setUpdatedAt(OffsetDateTime.now()); + mediaAssetRepository.save(asset); + logger.warn("Media asset {} marked as FAILED: {}", asset.getId(), message, exception); + } + + private void deleteRecursively(Path directory) { + if (directory == null || !Files.exists(directory)) { + return; + } + try (var walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (IOException e) { + logger.warn("Failed to clean temporary media directory {}", directory, e); + } catch (UncheckedIOException e) { + logger.warn("Failed to clean temporary media directory {}", directory, e); + } + } + + private record PresetDefinition(String name, int maxDimension) { + } + + private record VariantDimensions(int widthPx, int heightPx) { + } + + private record PendingGeneratedVariant(MediaVariant variant, Path file) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java new file mode 100644 index 0000000..32188d1 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaFfmpegService.java @@ -0,0 +1,96 @@ +package com.printcalculator.service.media; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Service +public class MediaFfmpegService { + + private final String ffmpegPath; + + public MediaFfmpegService(@Value("${media.ffmpeg.path:ffmpeg}") String ffmpegPath) { + this.ffmpegPath = ffmpegPath; + } + + public void generateVariant(Path source, Path target, int widthPx, int heightPx, String format) throws IOException { + if (widthPx <= 0 || heightPx <= 0) { + throw new IllegalArgumentException("Variant dimensions must be positive."); + } + + List command = new ArrayList<>(); + command.add(ffmpegPath); + command.add("-y"); + command.add("-hide_banner"); + command.add("-loglevel"); + command.add("error"); + command.add("-i"); + command.add(source.toAbsolutePath().toString()); + command.add("-vf"); + command.add("scale=" + widthPx + ":" + heightPx + ":flags=lanczos,setsar=1"); + command.add("-frames:v"); + command.add("1"); + command.add("-an"); + + switch (format) { + case "JPEG" -> { + command.add("-c:v"); + command.add("mjpeg"); + command.add("-q:v"); + command.add("2"); + } + case "WEBP" -> { + command.add("-c:v"); + command.add("libwebp"); + command.add("-quality"); + command.add("82"); + } + case "AVIF" -> { + command.add("-c:v"); + command.add("libaom-av1"); + command.add("-still-picture"); + command.add("1"); + command.add("-crf"); + command.add("30"); + command.add("-b:v"); + command.add("0"); + } + default -> throw new IllegalArgumentException("Unsupported media format: " + format); + } + + command.add(target.toAbsolutePath().toString()); + + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + String output; + try (InputStream processStream = process.getInputStream()) { + output = new String(processStream.readAllBytes(), StandardCharsets.UTF_8); + } + + int exitCode; + try { + exitCode = process.waitFor(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("FFmpeg execution interrupted.", e); + } + + if (exitCode != 0 || !Files.exists(target) || Files.size(target) == 0) { + throw new IOException("FFmpeg failed to generate media variant. " + truncate(output)); + } + } + + private String truncate(String output) { + if (output == null || output.isBlank()) { + return ""; + } + String normalized = output.trim().replace('\n', ' '); + return normalized.length() <= 300 ? normalized : normalized.substring(0, 300); + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java b/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java new file mode 100644 index 0000000..55449ed --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaImageInspector.java @@ -0,0 +1,122 @@ +package com.printcalculator.service.media; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +@Service +public class MediaImageInspector { + + private static final byte[] PNG_SIGNATURE = new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A + }; + + public ImageMetadata inspect(Path file) throws IOException { + try (InputStream inputStream = Files.newInputStream(file)) { + byte[] header = inputStream.readNBytes(64); + if (isJpeg(header)) { + return readWithImageIo(file, "image/jpeg", "jpg"); + } + if (isPng(header)) { + return readWithImageIo(file, "image/png", "png"); + } + if (isWebp(header)) { + Dimensions dimensions = readWebpDimensions(header); + return new ImageMetadata("image/webp", "webp", dimensions.width(), dimensions.height()); + } + } + + throw new IllegalArgumentException("Unsupported image type. Allowed: jpg, jpeg, png, webp."); + } + + private ImageMetadata readWithImageIo(Path file, String mimeType, String extension) throws IOException { + BufferedImage image = ImageIO.read(file.toFile()); + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + throw new IllegalArgumentException("Uploaded image is invalid or unreadable."); + } + return new ImageMetadata(mimeType, extension, image.getWidth(), image.getHeight()); + } + + private boolean isJpeg(byte[] header) { + return header.length >= 3 + && (header[0] & 0xFF) == 0xFF + && (header[1] & 0xFF) == 0xD8 + && (header[2] & 0xFF) == 0xFF; + } + + private boolean isPng(byte[] header) { + if (header.length < PNG_SIGNATURE.length) { + return false; + } + for (int i = 0; i < PNG_SIGNATURE.length; i++) { + if (header[i] != PNG_SIGNATURE[i]) { + return false; + } + } + return true; + } + + private boolean isWebp(byte[] header) { + return header.length >= 16 + && "RIFF".equals(ascii(header, 0, 4)) + && "WEBP".equals(ascii(header, 8, 4)); + } + + private Dimensions readWebpDimensions(byte[] header) { + if (header.length < 30) { + throw new IllegalArgumentException("Uploaded WebP image is invalid."); + } + + String chunkType = ascii(header, 12, 4); + return switch (chunkType) { + case "VP8X" -> new Dimensions( + littleEndian24(header, 24) + 1, + littleEndian24(header, 27) + 1 + ); + case "VP8 " -> new Dimensions( + littleEndian16(header, 26) & 0x3FFF, + littleEndian16(header, 28) & 0x3FFF + ); + case "VP8L" -> { + int packed = littleEndian32(header, 21); + int width = (packed & 0x3FFF) + 1; + int height = ((packed >> 14) & 0x3FFF) + 1; + yield new Dimensions(width, height); + } + default -> throw new IllegalArgumentException("Uploaded WebP image is invalid."); + }; + } + + private String ascii(byte[] header, int offset, int length) { + return new String(header, offset, length, StandardCharsets.US_ASCII); + } + + private int littleEndian16(byte[] header, int offset) { + return (header[offset] & 0xFF) | ((header[offset + 1] & 0xFF) << 8); + } + + private int littleEndian24(byte[] header, int offset) { + return (header[offset] & 0xFF) + | ((header[offset + 1] & 0xFF) << 8) + | ((header[offset + 2] & 0xFF) << 16); + } + + private int littleEndian32(byte[] header, int offset) { + return (header[offset] & 0xFF) + | ((header[offset + 1] & 0xFF) << 8) + | ((header[offset + 2] & 0xFF) << 16) + | ((header[offset + 3] & 0xFF) << 24); + } + + private record Dimensions(int width, int height) { + } + + public record ImageMetadata(String mimeType, String fileExtension, int widthPx, int heightPx) { + } +} diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java new file mode 100644 index 0000000..1a282d6 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java @@ -0,0 +1,130 @@ +package com.printcalculator.service.media; + +import com.printcalculator.exception.StorageException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Locale; + +@Service +public class MediaStorageService { + + private final Path normalizedRootLocation; + private final Path originalRootLocation; + private final Path publicRootLocation; + private final Path privateRootLocation; + private final String publicBaseUrl; + + public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot, + @Value("${media.public.base-url:http://localhost:8080/media}") String publicBaseUrl) { + this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize(); + this.originalRootLocation = normalizedRootLocation.resolve("original").normalize(); + this.publicRootLocation = normalizedRootLocation.resolve("public").normalize(); + this.privateRootLocation = normalizedRootLocation.resolve("private").normalize(); + this.publicBaseUrl = publicBaseUrl; + init(); + } + + public void init() { + try { + Files.createDirectories(originalRootLocation); + Files.createDirectories(publicRootLocation); + Files.createDirectories(privateRootLocation); + } catch (IOException e) { + throw new StorageException("Could not initialize media storage.", e); + } + } + + public void storeOriginal(Path source, String storageKey) throws IOException { + copy(source, resolveOriginal(storageKey)); + } + + public void storePublic(Path source, String storageKey) throws IOException { + copy(source, resolvePublic(storageKey)); + } + + public void storePrivate(Path source, String storageKey) throws IOException { + copy(source, resolvePrivate(storageKey)); + } + + public void deleteGenerated(String visibility, String storageKey) throws IOException { + Files.deleteIfExists(resolve(resolveVariantRoot(normalizeVisibility(visibility)), storageKey)); + } + + public void moveGenerated(String storageKey, String fromVisibility, String toVisibility) throws IOException { + String normalizedFrom = normalizeVisibility(fromVisibility); + String normalizedTo = normalizeVisibility(toVisibility); + if (normalizedFrom.equals(normalizedTo)) { + return; + } + + Path source = resolve(resolveVariantRoot(normalizedFrom), storageKey); + Path target = resolve(resolveVariantRoot(normalizedTo), storageKey); + Files.createDirectories(target.getParent()); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + public String buildPublicUrl(String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + return null; + } + String normalizedKey = storageKey.startsWith("/") ? storageKey.substring(1) : storageKey; + if (publicBaseUrl.endsWith("/")) { + return publicBaseUrl + normalizedKey; + } + return publicBaseUrl + "/" + normalizedKey; + } + + private void copy(Path source, Path destination) throws IOException { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + + private Path resolveOriginal(String storageKey) { + return resolve(originalRootLocation, storageKey); + } + + private Path resolvePublic(String storageKey) { + return resolve(publicRootLocation, storageKey); + } + + private Path resolvePrivate(String storageKey) { + return resolve(privateRootLocation, storageKey); + } + + private Path resolveVariantRoot(String visibility) { + return switch (visibility) { + case "PUBLIC" -> publicRootLocation; + case "PRIVATE" -> privateRootLocation; + default -> throw new StorageException("Unsupported media visibility: " + visibility); + }; + } + + private Path resolve(Path baseRoot, String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + throw new StorageException("Storage key is required."); + } + Path relativePath = Paths.get(storageKey).normalize(); + if (relativePath.isAbsolute()) { + throw new StorageException("Absolute paths are not allowed."); + } + + Path resolved = baseRoot.resolve(relativePath).normalize(); + if (!resolved.startsWith(baseRoot)) { + throw new StorageException("Cannot access files outside media storage root."); + } + return resolved; + } + + private String normalizeVisibility(String visibility) { + if (visibility == null || visibility.isBlank()) { + throw new StorageException("Visibility is required."); + } + return visibility.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 0915c7e..4edba9a 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -26,6 +26,12 @@ clamav.host=${CLAMAV_HOST:clamav} clamav.port=${CLAMAV_PORT:3310} clamav.enabled=${CLAMAV_ENABLED:false} +# Media configuration +media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} +media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media} +media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} +media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} + # TWINT Configuration payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.} diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java new file mode 100644 index 0000000..6e78caf --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminMediaControllerServiceTest.java @@ -0,0 +1,400 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.dto.AdminCreateMediaUsageRequest; +import com.printcalculator.dto.AdminMediaAssetDto; +import com.printcalculator.dto.AdminMediaUsageDto; +import com.printcalculator.dto.AdminUpdateMediaAssetRequest; +import com.printcalculator.entity.MediaAsset; +import com.printcalculator.entity.MediaUsage; +import com.printcalculator.entity.MediaVariant; +import com.printcalculator.repository.MediaAssetRepository; +import com.printcalculator.repository.MediaUsageRepository; +import com.printcalculator.repository.MediaVariantRepository; +import com.printcalculator.service.media.MediaFfmpegService; +import com.printcalculator.service.media.MediaImageInspector; +import com.printcalculator.service.media.MediaStorageService; +import com.printcalculator.service.storage.ClamAVService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AdminMediaControllerServiceTest { + + @Mock + private MediaAssetRepository mediaAssetRepository; + @Mock + private MediaVariantRepository mediaVariantRepository; + @Mock + private MediaUsageRepository mediaUsageRepository; + @Mock + private MediaImageInspector mediaImageInspector; + @Mock + private MediaFfmpegService mediaFfmpegService; + @Mock + private ClamAVService clamAVService; + + @TempDir + Path tempDir; + + private AdminMediaControllerService service; + private Path storageRoot; + + private final Map assets = new LinkedHashMap<>(); + private final Map variants = new LinkedHashMap<>(); + private final Map usages = new LinkedHashMap<>(); + + @BeforeEach + void setUp() throws Exception { + storageRoot = tempDir.resolve("storage_media"); + MediaStorageService mediaStorageService = new MediaStorageService( + storageRoot.toString(), + "https://cdn.example/media" + ); + + service = new AdminMediaControllerService( + mediaAssetRepository, + mediaVariantRepository, + mediaUsageRepository, + mediaStorageService, + mediaImageInspector, + mediaFfmpegService, + clamAVService, + 1024 * 1024 + ); + + when(clamAVService.scan(any())).thenReturn(true); + + when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> { + MediaAsset asset = invocation.getArgument(0); + if (asset.getId() == null) { + asset.setId(UUID.randomUUID()); + } + assets.put(asset.getId(), asset); + return asset; + }); + when(mediaAssetRepository.findById(any(UUID.class))).thenAnswer(invocation -> + Optional.ofNullable(assets.get(invocation.getArgument(0))) + ); + when(mediaAssetRepository.findAllByOrderByCreatedAtDesc()).thenAnswer(invocation -> assets.values().stream() + .sorted(Comparator.comparing(MediaAsset::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)).reversed()) + .toList()); + + when(mediaVariantRepository.save(any(MediaVariant.class))).thenAnswer(invocation -> persistVariant(invocation.getArgument(0))); + when(mediaVariantRepository.saveAll(any())).thenAnswer(invocation -> { + Iterable iterable = invocation.getArgument(0); + List saved = new ArrayList<>(); + for (MediaVariant variant : iterable) { + saved.add(persistVariant(variant)); + } + return saved; + }); + when(mediaVariantRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation -> + variantsForAssets(invocation.getArgument(0)) + ); + when(mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(any(UUID.class))).thenAnswer(invocation -> + variants.values().stream() + .filter(variant -> variant.getMediaAsset().getId().equals(invocation.getArgument(0))) + .sorted(Comparator.comparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo))) + .toList() + ); + + when(mediaUsageRepository.save(any(MediaUsage.class))).thenAnswer(invocation -> persistUsage(invocation.getArgument(0))); + when(mediaUsageRepository.saveAll(any())).thenAnswer(invocation -> { + Iterable iterable = invocation.getArgument(0); + List saved = new ArrayList<>(); + for (MediaUsage usage : iterable) { + saved.add(persistUsage(usage)); + } + return saved; + }); + when(mediaUsageRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation -> + usagesForAssets(invocation.getArgument(0)) + ); + when(mediaUsageRepository.findByUsageScope(anyString(), anyString(), nullable(UUID.class))).thenAnswer(invocation -> + usages.values().stream() + .filter(usage -> usage.getUsageType().equals(invocation.getArgument(0))) + .filter(usage -> usage.getUsageKey().equals(invocation.getArgument(1))) + .filter(usage -> { + UUID ownerId = invocation.getArgument(2); + return ownerId == null ? usage.getOwnerId() == null : ownerId.equals(usage.getOwnerId()); + }) + .sorted(Comparator.comparing(MediaUsage::getSortOrder).thenComparing(MediaUsage::getCreatedAt)) + .toList() + ); + when(mediaUsageRepository.findById(any(UUID.class))).thenAnswer(invocation -> + Optional.ofNullable(usages.get(invocation.getArgument(0))) + ); + + doAnswer(invocation -> { + Path outputFile = invocation.getArgument(1); + String format = invocation.getArgument(4); + Files.createDirectories(outputFile.getParent()); + Files.writeString(outputFile, "generated-" + format, StandardCharsets.UTF_8); + return null; + }).when(mediaFfmpegService).generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), anyString()); + } + + @Test + void uploadAsset_withValidImage_shouldPersistMetadataAndExposePublicUrls() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "landing-hero.png", + "image/png", + "png-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null); + + assertEquals("READY", dto.getStatus()); + assertEquals("PUBLIC", dto.getVisibility()); + assertEquals("landing-hero.png", dto.getOriginalFilename()); + assertEquals("Landing hero", dto.getTitle()); + assertEquals("Main headline", dto.getAltText()); + assertEquals("image/png", dto.getMimeType()); + assertEquals(1600, dto.getWidthPx()); + assertEquals(900, dto.getHeightPx()); + assertEquals(file.getSize(), dto.getFileSizeBytes()); + assertEquals(64, dto.getSha256Hex().length()); + assertEquals(10, dto.getVariants().size()); + + long publicVariants = dto.getVariants().stream() + .filter(variant -> !"ORIGINAL".equals(variant.getFormat())) + .count(); + assertEquals(9, publicVariants); + assertTrue(dto.getVariants().stream() + .filter(variant -> "WEBP".equals(variant.getFormat()) && "hero".equals(variant.getVariantName())) + .allMatch(variant -> variant.getPublicUrl().startsWith("https://cdn.example/media/"))); + assertTrue(dto.getVariants().stream() + .filter(variant -> "ORIGINAL".equals(variant.getFormat())) + .allMatch(variant -> variant.getPublicUrl() == null)); + + MediaVariant heroWebp = variants.values().stream() + .filter(variant -> "hero".equals(variant.getVariantName())) + .filter(variant -> "WEBP".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + assertTrue(Files.exists(storageRoot.resolve("public").resolve(heroWebp.getStorageKey()))); + + MediaVariant originalVariant = variants.values().stream() + .filter(variant -> "ORIGINAL".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + assertTrue(Files.exists(storageRoot.resolve("original").resolve(originalVariant.getStorageKey()))); + } + + @Test + void uploadAsset_withUnsupportedImageType_shouldReturnBadRequest() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/svg+xml", "svg", 400, 400) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "logo.svg", + "image/svg+xml", + "".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.uploadAsset(file, null, null, null) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(assets.isEmpty()); + assertTrue(variants.isEmpty()); + } + + @Test + void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() { + service = new AdminMediaControllerService( + mediaAssetRepository, + mediaVariantRepository, + mediaUsageRepository, + new MediaStorageService(storageRoot.toString(), "https://cdn.example/media"), + mediaImageInspector, + mediaFfmpegService, + clamAVService, + 4 + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "big.png", + "image/png", + "12345".getBytes(StandardCharsets.UTF_8) + ); + + ResponseStatusException ex = assertThrows( + ResponseStatusException.class, + () -> service.uploadAsset(file, null, null, null) + ); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + verifyNoInteractions(mediaAssetRepository, mediaVariantRepository, mediaUsageRepository, mediaImageInspector, mediaFfmpegService); + } + + @Test + void createUsage_withPrimaryFlag_shouldUnsetExistingPrimaryAndMapUsageOnAsset() { + MediaAsset asset = persistAsset(seedAsset("PUBLIC")); + + MediaUsage existingPrimary = new MediaUsage(); + existingPrimary.setId(UUID.randomUUID()); + existingPrimary.setUsageType("HOME"); + existingPrimary.setUsageKey("landing"); + existingPrimary.setOwnerId(null); + existingPrimary.setMediaAsset(asset); + existingPrimary.setSortOrder(0); + existingPrimary.setIsPrimary(true); + existingPrimary.setIsActive(true); + existingPrimary.setCreatedAt(OffsetDateTime.now().minusDays(1)); + persistUsage(existingPrimary); + + AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest(); + payload.setUsageType("home"); + payload.setUsageKey("landing"); + payload.setMediaAssetId(asset.getId()); + payload.setSortOrder(5); + payload.setIsPrimary(true); + + AdminMediaUsageDto created = service.createUsage(payload); + + assertEquals("HOME", created.getUsageType()); + assertEquals("landing", created.getUsageKey()); + assertEquals(asset.getId(), created.getMediaAssetId()); + assertEquals(5, created.getSortOrder()); + assertTrue(created.getIsPrimary()); + assertFalse(usages.get(existingPrimary.getId()).getIsPrimary()); + + AdminMediaAssetDto assetDto = service.getAsset(asset.getId()); + assertEquals(2, assetDto.getUsages().size()); + assertTrue(assetDto.getUsages().stream().anyMatch(usage -> usage.getId().equals(created.getId()) && usage.getIsPrimary())); + } + + @Test + void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception { + when(mediaImageInspector.inspect(any(Path.class))).thenReturn( + new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800) + ); + + MockMultipartFile file = new MockMultipartFile( + "file", + "gallery.jpg", + "image/jpeg", + "jpeg-image-content".getBytes(StandardCharsets.UTF_8) + ); + + AdminMediaAssetDto uploaded = service.uploadAsset(file, null, null, "PUBLIC"); + MediaVariant thumbJpeg = variants.values().stream() + .filter(variant -> "thumb".equals(variant.getVariantName())) + .filter(variant -> "JPEG".equals(variant.getFormat())) + .findFirst() + .orElseThrow(); + + Path publicFile = storageRoot.resolve("public").resolve(thumbJpeg.getStorageKey()); + assertTrue(Files.exists(publicFile)); + + AdminUpdateMediaAssetRequest payload = new AdminUpdateMediaAssetRequest(); + payload.setVisibility("PRIVATE"); + + AdminMediaAssetDto updated = service.updateAsset(uploaded.getId(), payload); + + assertEquals("PRIVATE", updated.getVisibility()); + assertFalse(Files.exists(publicFile)); + assertTrue(Files.exists(storageRoot.resolve("private").resolve(thumbJpeg.getStorageKey()))); + assertTrue(updated.getVariants().stream() + .filter(variant -> !"ORIGINAL".equals(variant.getFormat())) + .allMatch(variant -> variant.getPublicUrl() == null)); + } + + private MediaAsset seedAsset(String visibility) { + MediaAsset asset = new MediaAsset(); + asset.setId(UUID.randomUUID()); + asset.setOriginalFilename("asset.png"); + asset.setStorageKey("2026/03/" + UUID.randomUUID() + "/original.png"); + asset.setMimeType("image/png"); + asset.setFileSizeBytes(123L); + asset.setSha256Hex("a".repeat(64)); + asset.setWidthPx(1200); + asset.setHeightPx(800); + asset.setStatus("READY"); + asset.setVisibility(visibility); + asset.setCreatedAt(OffsetDateTime.now()); + asset.setUpdatedAt(OffsetDateTime.now()); + return asset; + } + + private MediaAsset persistAsset(MediaAsset asset) { + assets.put(asset.getId(), asset); + return asset; + } + + private MediaVariant persistVariant(MediaVariant variant) { + if (variant.getId() == null) { + variant.setId(UUID.randomUUID()); + } + variants.put(variant.getId(), variant); + return variant; + } + + private MediaUsage persistUsage(MediaUsage usage) { + if (usage.getId() == null) { + usage.setId(UUID.randomUUID()); + } + usages.put(usage.getId(), usage); + return usage; + } + + private List variantsForAssets(Collection assetIds) { + return variants.values().stream() + .filter(variant -> assetIds.contains(variant.getMediaAsset().getId())) + .toList(); + } + + private List usagesForAssets(Collection assetIds) { + return usages.values().stream() + .filter(usage -> assetIds.contains(usage.getMediaAsset().getId())) + .toList(); + } +} diff --git a/db.sql b/db.sql index d7fd322..64f2c47 100644 --- a/db.sql +++ b/db.sql @@ -919,6 +919,62 @@ CREATE TABLE IF NOT EXISTS custom_quote_request_attachments CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request ON custom_quote_request_attachments (request_id); +CREATE TABLE IF NOT EXISTS media_asset +( + media_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + original_filename text NOT NULL, + storage_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), + sha256_hex text NOT NULL, + width_px integer, + height_px integer, + status text NOT NULL CHECK (status IN ('UPLOADED', 'PROCESSING', 'READY', 'FAILED', 'ARCHIVED')), + visibility text NOT NULL CHECK (visibility IN ('PUBLIC', 'PRIVATE')), + title text, + alt_text text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_media_asset_status_visibility_created_at + ON media_asset (status, visibility, created_at DESC); + +CREATE TABLE IF NOT EXISTS media_variant +( + media_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, + variant_name text NOT NULL, + format text NOT NULL CHECK (format IN ('ORIGINAL', 'JPEG', 'WEBP', 'AVIF')), + storage_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + width_px integer NOT NULL, + height_px integer NOT NULL, + file_size_bytes bigint NOT NULL CHECK (file_size_bytes >= 0), + is_generated boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_media_variant_asset_name_format UNIQUE (media_asset_id, variant_name, format) +); + +CREATE INDEX IF NOT EXISTS ix_media_variant_asset + ON media_variant (media_asset_id); + +CREATE TABLE IF NOT EXISTS media_usage +( + media_usage_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + usage_type text NOT NULL, + usage_key text NOT NULL, + owner_id uuid, + media_asset_id uuid NOT NULL REFERENCES media_asset (media_asset_id) ON DELETE CASCADE, + sort_order integer NOT NULL DEFAULT 0, + is_primary boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS ix_media_usage_scope + ON media_usage (usage_type, usage_key, is_active, sort_order); + ALTER TABLE quote_sessions DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request; diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index a3db5b0..3b2853c 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -1,6 +1,8 @@ services: backend: # L'immagine usa il tag specificato nel file .env o passato da riga di comando + # Nginx esterno deve servire /media/ con un alias verso + # /mnt/cache/appdata/print-calculator/${ENV}/storage_media/public/ image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-backend:${TAG} container_name: print-calculator-backend-${ENV} ports: @@ -29,6 +31,10 @@ services: - ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480} - TEMP_DIR=/app/temp - PROFILES_DIR=/app/profiles + - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} + - MEDIA_PUBLIC_BASE_URL=${MEDIA_PUBLIC_BASE_URL:-http://localhost:8080/media} + - MEDIA_FFMPEG_PATH=${MEDIA_FFMPEG_PATH:-ffmpeg} + - MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:-26214400} restart: always logging: driver: "json-file" @@ -40,6 +46,7 @@ services: - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders - /mnt/cache/appdata/print-calculator/${ENV}/storage_requests:/app/storage_requests + - /mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media extra_hosts: - "host.docker.internal:host-gateway"