dev #37
30
README.md
30
README.md
@@ -11,7 +11,7 @@ Un'applicazione Full Stack (Angular + Spring Boot) per calcolare preventivi di s
|
|||||||
|
|
||||||
## Stack Tecnologico
|
## 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.
|
- **Frontend**: Angular 19, Angular Material, Three.js.
|
||||||
- **Slicer**: OrcaSlicer (invocato via CLI).
|
- **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.
|
* **Node.js 22** e **npm** installati.
|
||||||
* **PostgreSQL** attivo.
|
* **PostgreSQL** attivo.
|
||||||
* **OrcaSlicer** installato sul sistema.
|
* **OrcaSlicer** installato sul sistema.
|
||||||
|
* **FFmpeg** installato sul sistema o presente nell'immagine Docker del backend.
|
||||||
|
|
||||||
## Avvio Rapido
|
## Avvio Rapido
|
||||||
|
|
||||||
### 1. Database
|
### 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
|
### 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
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
@@ -57,11 +63,29 @@ I prezzi non sono più gestiti tramite variabili d'ambiente fisse ma tramite tab
|
|||||||
* `/backend`: API Spring Boot.
|
* `/backend`: API Spring Boot.
|
||||||
* `/frontend`: Applicazione Angular.
|
* `/frontend`: Applicazione Angular.
|
||||||
* `/backend/profiles`: Contiene i file di configurazione per OrcaSlicer.
|
* `/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
|
## Troubleshooting
|
||||||
|
|
||||||
### Percorso OrcaSlicer
|
### 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).
|
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
|
### Database connection
|
||||||
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
|
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ARG ORCA_DOWNLOAD_URL
|
|||||||
|
|
||||||
# Install system dependencies for OrcaSlicer (same as before)
|
# Install system dependencies for OrcaSlicer (same as before)
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
ffmpeg \
|
||||||
wget \
|
wget \
|
||||||
assimp-utils \
|
assimp-utils \
|
||||||
libgl1 \
|
libgl1 \
|
||||||
|
|||||||
@@ -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<AdminMediaAssetDto> 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<List<AdminMediaAssetDto>> listAssets() {
|
||||||
|
return ResponseEntity.ok(adminMediaControllerService.listAssets());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/assets/{mediaAssetId}")
|
||||||
|
public ResponseEntity<AdminMediaAssetDto> getAsset(@PathVariable UUID mediaAssetId) {
|
||||||
|
return ResponseEntity.ok(adminMediaControllerService.getAsset(mediaAssetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/assets/{mediaAssetId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<AdminMediaAssetDto> updateAsset(@PathVariable UUID mediaAssetId,
|
||||||
|
@RequestBody AdminUpdateMediaAssetRequest payload) {
|
||||||
|
return ResponseEntity.ok(adminMediaControllerService.updateAsset(mediaAssetId, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/usages")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<AdminMediaUsageDto> createUsage(@RequestBody AdminCreateMediaUsageRequest payload) {
|
||||||
|
return ResponseEntity.ok(adminMediaControllerService.createUsage(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/usages/{mediaUsageId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<AdminMediaUsageDto> updateUsage(@PathVariable UUID mediaUsageId,
|
||||||
|
@RequestBody AdminUpdateMediaUsageRequest payload) {
|
||||||
|
return ResponseEntity.ok(adminMediaControllerService.updateUsage(mediaUsageId, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/usages/{mediaUsageId}")
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<Void> deleteUsage(@PathVariable UUID mediaUsageId) {
|
||||||
|
adminMediaControllerService.deleteUsage(mediaUsageId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminMediaVariantDto> variants = new ArrayList<>();
|
||||||
|
private List<AdminMediaUsageDto> 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<AdminMediaVariantDto> getVariants() {
|
||||||
|
return variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setVariants(List<AdminMediaVariantDto> variants) {
|
||||||
|
this.variants = variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AdminMediaUsageDto> getUsages() {
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsages(List<AdminMediaUsageDto> usages) {
|
||||||
|
this.usages = usages;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
backend/src/main/java/com/printcalculator/entity/MediaAsset.java
Normal file
177
backend/src/main/java/com/printcalculator/entity/MediaAsset.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
131
backend/src/main/java/com/printcalculator/entity/MediaUsage.java
Normal file
131
backend/src/main/java/com/printcalculator/entity/MediaUsage.java
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<MediaAsset, UUID> {
|
||||||
|
List<MediaAsset> findAllByOrderByCreatedAtDesc();
|
||||||
|
}
|
||||||
@@ -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<MediaUsage, UUID> {
|
||||||
|
List<MediaUsage> findByMediaAsset_IdOrderBySortOrderAscCreatedAtAsc(UUID mediaAssetId);
|
||||||
|
|
||||||
|
List<MediaUsage> findByMediaAsset_IdIn(Collection<UUID> 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<MediaUsage> findByUsageScope(@Param("usageType") String usageType,
|
||||||
|
@Param("usageKey") String usageKey,
|
||||||
|
@Param("ownerId") UUID ownerId);
|
||||||
|
}
|
||||||
@@ -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<MediaVariant, UUID> {
|
||||||
|
List<MediaVariant> findByMediaAsset_IdOrderByCreatedAtAsc(UUID mediaAssetId);
|
||||||
|
|
||||||
|
List<MediaVariant> findByMediaAsset_IdIn(Collection<UUID> mediaAssetIds);
|
||||||
|
}
|
||||||
@@ -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<String> ALLOWED_STATUSES = Set.of(
|
||||||
|
STATUS_UPLOADED, STATUS_PROCESSING, STATUS_READY, STATUS_FAILED, STATUS_ARCHIVED
|
||||||
|
);
|
||||||
|
private static final Set<String> ALLOWED_VISIBILITIES = Set.of(VISIBILITY_PUBLIC, VISIBILITY_PRIVATE);
|
||||||
|
private static final Set<String> ALLOWED_UPLOAD_MIME_TYPES = Set.of(
|
||||||
|
"image/jpeg", "image/png", "image/webp"
|
||||||
|
);
|
||||||
|
private static final Map<String, String> GENERATED_FORMAT_MIME_TYPES = Map.of(
|
||||||
|
FORMAT_JPEG, "image/jpeg",
|
||||||
|
FORMAT_WEBP, "image/webp",
|
||||||
|
FORMAT_AVIF, "image/avif"
|
||||||
|
);
|
||||||
|
private static final Map<String, String> GENERATED_FORMAT_EXTENSIONS = Map.of(
|
||||||
|
FORMAT_JPEG, "jpg",
|
||||||
|
FORMAT_WEBP, "webp",
|
||||||
|
FORMAT_AVIF, "avif"
|
||||||
|
);
|
||||||
|
private static final List<PresetDefinition> 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<MediaVariant> 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<AdminMediaAssetDto> 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<MediaVariant> generateDerivedVariants(MediaAsset asset, Path sourceFile, Path tempDirectory) throws IOException {
|
||||||
|
Path generatedDirectory = Files.createDirectories(tempDirectory.resolve("generated"));
|
||||||
|
String storageFolder = extractStorageFolder(asset.getStorageKey());
|
||||||
|
|
||||||
|
List<PendingGeneratedVariant> 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<String> 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<String> 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<MediaVariant> variants = mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(asset.getId());
|
||||||
|
List<String> 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<String> movedStorageKeys) {
|
||||||
|
List<String> 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<MediaUsage> existingUsages = mediaUsageRepository.findByUsageScope(usageType, usageKey, ownerId);
|
||||||
|
List<MediaUsage> 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<AdminMediaAssetDto> toAssetDtos(List<MediaAsset> assets) {
|
||||||
|
if (assets == null || assets.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<UUID> assetIds = assets.stream()
|
||||||
|
.map(MediaAsset::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
Map<UUID, List<MediaVariant>> variantsByAssetId = mediaVariantRepository.findByMediaAsset_IdIn(assetIds)
|
||||||
|
.stream()
|
||||||
|
.sorted(this::compareVariants)
|
||||||
|
.collect(Collectors.groupingBy(variant -> variant.getMediaAsset().getId(), LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
|
Map<UUID, List<MediaUsage>> 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<MediaVariant> variants, List<MediaUsage> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ clamav.host=${CLAMAV_HOST:clamav}
|
|||||||
clamav.port=${CLAMAV_PORT:3310}
|
clamav.port=${CLAMAV_PORT:3310}
|
||||||
clamav.enabled=${CLAMAV_ENABLED:false}
|
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
|
# TWINT Configuration
|
||||||
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
|
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
|
||||||
|
|
||||||
|
|||||||
@@ -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<UUID, MediaAsset> assets = new LinkedHashMap<>();
|
||||||
|
private final Map<UUID, MediaVariant> variants = new LinkedHashMap<>();
|
||||||
|
private final Map<UUID, MediaUsage> 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<MediaVariant> iterable = invocation.getArgument(0);
|
||||||
|
List<MediaVariant> 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<MediaUsage> iterable = invocation.getArgument(0);
|
||||||
|
List<MediaUsage> 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",
|
||||||
|
"<svg/>".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<MediaVariant> variantsForAssets(Collection<UUID> assetIds) {
|
||||||
|
return variants.values().stream()
|
||||||
|
.filter(variant -> assetIds.contains(variant.getMediaAsset().getId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MediaUsage> usagesForAssets(Collection<UUID> assetIds) {
|
||||||
|
return usages.values().stream()
|
||||||
|
.filter(usage -> assetIds.contains(usage.getMediaAsset().getId()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
56
db.sql
56
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
|
CREATE INDEX IF NOT EXISTS ix_custom_quote_attachments_request
|
||||||
ON custom_quote_request_attachments (request_id);
|
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
|
ALTER TABLE quote_sessions
|
||||||
DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request;
|
DROP CONSTRAINT IF EXISTS fk_quote_sessions_source_request;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
# L'immagine usa il tag specificato nel file .env o passato da riga di comando
|
# 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}
|
image: ${REGISTRY_URL}/${REPO_OWNER}/print-calculator-backend:${TAG}
|
||||||
container_name: print-calculator-backend-${ENV}
|
container_name: print-calculator-backend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
@@ -29,6 +31,10 @@ services:
|
|||||||
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- 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
|
restart: always
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
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_quotes:/app/storage_quotes
|
||||||
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders
|
- /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_requests:/app/storage_requests
|
||||||
|
- /mnt/cache/appdata/print-calculator/${ENV}/storage_media:/app/storage_media
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user