feat(back-end): upload media
This commit is contained in:
@@ -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.enabled=${CLAMAV_ENABLED:false}
|
||||
|
||||
# Media configuration
|
||||
media.storage.root=${MEDIA_STORAGE_ROOT:storage_media}
|
||||
media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media}
|
||||
media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg}
|
||||
media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400}
|
||||
|
||||
# TWINT Configuration
|
||||
payment.twint.url=${TWINT_PAYMENT_URL:https://go.twint.ch/1/e/tw?tw=acq.gERQQytOTnyIMuQHUqn4hlxgciHE5X7nnqHnNSPAr2OF2K3uBlXJDr2n9JU3sgxa.}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user