Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,19 @@
<maven.build.timestamp>${maven.build.timestamp}</maven.build.timestamp>
<mapstruct.version>1.6.3</mapstruct.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
<awssdk.version>2.54.10</awssdk.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${awssdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
Expand Down Expand Up @@ -161,6 +173,10 @@
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package fr.avenirsesr.portfolio.file.application.adapter.controller;

import fr.avenirsesr.portfolio.file.domain.exception.FileNotFoundException;
import fr.avenirsesr.portfolio.file.domain.exception.FileStorageException;
import fr.avenirsesr.portfolio.file.domain.model.FileResource;
import fr.avenirsesr.portfolio.file.domain.model.enums.EFileType;
import fr.avenirsesr.portfolio.file.domain.port.input.FileResourceService;
import fr.avenirsesr.portfolio.file.domain.port.output.service.FileStorageService;
import fr.avenirsesr.portfolio.file.infrastructure.configuration.FileStorageConstants;
import jakarta.validation.Valid;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MimeType;
Expand All @@ -23,7 +29,7 @@
@RequestMapping("/storage")
public class StorageController {
private final FileResourceService fileResourceService;
private final FileStorageService fileStorageService;
private final ResourceLoader resourceLoader;

@GetMapping("/{fileId}")
public ResponseEntity<ByteArrayResource> getResourceByFileId(@Valid @PathVariable UUID fileId) {
Expand All @@ -38,24 +44,37 @@ public ResponseEntity<ByteArrayResource> getResourceByFileId(@Valid @PathVariabl
@GetMapping("/default/cover-picture")
public ResponseEntity<ByteArrayResource> getDefaultCoverPicture() {
log.debug("Received request to get default cover photo");
byte[] photo = fileStorageService.get(FileStorageConstants.COVER_DEFAULT_PATH);

return ResponseEntity.ok()
.contentType(
MediaType.asMediaType(
MimeType.valueOf(FileStorageConstants.COVER_DEFAULT_FILE_TYPE.getMimeType())))
.body(new ByteArrayResource(photo));
return serveDefaultPicture(
FileStorageConstants.COVER_DEFAULT_PATH, FileStorageConstants.COVER_DEFAULT_FILE_TYPE);
}

@GetMapping("/default/profile-picture")
public ResponseEntity<ByteArrayResource> getDefaultProfilePicture() {
log.debug("Received request to get default profile photo");
byte[] photo = fileStorageService.get(FileStorageConstants.PROFILE_DEFAULT_PATH);
return serveDefaultPicture(
FileStorageConstants.PROFILE_DEFAULT_PATH, FileStorageConstants.PROFILE_DEFAULT_FILE_TYPE);
}

return ResponseEntity.ok()
.contentType(
MediaType.asMediaType(
MimeType.valueOf(FileStorageConstants.PROFILE_DEFAULT_FILE_TYPE.getMimeType())))
.body(new ByteArrayResource(photo));
/**
* Serves a fallback picture from a Spring resource location rather than from the storage backend.
* These pictures ship with the deployment and belong to no user, so making them travel through
* the bucket would tie a static asset to the availability of the storage backend.
*/
private ResponseEntity<ByteArrayResource> serveDefaultPicture(
String location, EFileType fileType) {
Resource resource = resourceLoader.getResource(location);

if (!resource.exists()) {
log.error("No default picture found at location {}", location);
throw new FileNotFoundException();
}

try (InputStream content = resource.getInputStream()) {
return ResponseEntity.ok()
.contentType(MediaType.asMediaType(MimeType.valueOf(fileType.getMimeType())))
.body(new ByteArrayResource(content.readAllBytes()));
} catch (IOException e) {
throw new FileStorageException("Failed to read default picture at location " + location, e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,25 @@

import fr.avenirsesr.portfolio.file.application.adapter.dto.FileDTO;
import fr.avenirsesr.portfolio.file.domain.model.File;
import fr.avenirsesr.portfolio.file.infrastructure.configuration.FileStorageConstants;
import java.util.Optional;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper(componentModel = "spring")
public interface FileDtoMapper {
@Mapping(source = "size", target = "fileSize")
@Mapping(source = "uri", target = "url")
FileDTO fromDomain(File file);
default FileDTO fromDomain(File file) {
if (file == null) {
return null;
}

return new FileDTO(
file.getId(),
file.getFileName(),
file.getFileType(),
file.getSize(),
FileStorageConstants.publicUrlOf(file.getId()),
file.getUploadedAt());
}

default FileDTO fromDomain(Optional<File> file) {
return file.map(this::fromDomain).orElse(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ static FileData mapFileData(File file, String defaultUrl) {
return new FileData(
Optional.ofNullable(file.getId()),
Optional.ofNullable(file.getFileName()),
FileStorageConstants.PHOTO_ENDPOINT_PREFIX + "/" + file.getId());
FileStorageConstants.publicUrlOf(file.getId()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,19 @@

import fr.avenirsesr.portfolio.file.domain.exception.FileStorageException;
import fr.avenirsesr.portfolio.file.domain.model.FileResource;
import java.util.UUID;

/**
* Storage backend abstraction.
*
* <p>Uploading returns a locator that is opaque to the domain: it is persisted as {@code File.uri}
* and handed back to the very same adapter to read or delete the content. Each adapter picks its
* own format — an absolute path for local storage, an object key for S3 — so the domain never has
* to interpret it.
*/
public interface FileStorageService {
byte[] get(String path) throws FileStorageException;

String upload(FileResource fileResource) throws FileStorageException;

void delete(UUID fileId) throws FileStorageException;
byte[] get(String locator) throws FileStorageException;

void deleteByPath(String path) throws FileStorageException;
void delete(String locator) throws FileStorageException;
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public FileDownload download(UUID fileId) {
@Override
public void delete(UUID fileId) {
var file = fileRepository.findById(fileId).orElseThrow(FileNotFoundException::new);
fileStorageService.deleteByPath(file.getUri());
fileStorageService.delete(file.getUri());
fileRepository.removeFromDatabase(file);
log.info("File deleted: {}", file);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,22 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

/**
* Stores files on the local filesystem. The locator handed back to the domain is the absolute path
* of the written file.
*
* <p>Default backend: it stays active as long as {@code file.storage.type} is not set to {@code
* s3}.
*/
@Slf4j
@Component
@Primary
@ConditionalOnProperty(name = "file.storage.type", havingValue = "local", matchIfMissing = true)
public class FileStorageServiceImpl implements FileStorageService {

@Override
Expand All @@ -39,8 +47,8 @@ public String upload(FileResource fileResource) {
}

@Override
public byte[] get(String path) {
File file = new File(path);
public byte[] get(String locator) {
File file = new File(locator);

if (!file.exists()) {
throw new FileNotFoundException();
Expand All @@ -49,45 +57,23 @@ public byte[] get(String path) {
try {
return java.nio.file.Files.readAllBytes(file.toPath());
} catch (IOException e) {
throw new FileStorageException("Failed to read file at path " + path, e);
throw new FileStorageException("Failed to read file at path " + locator, e);
}
}

@Override
public void delete(UUID id) {
String uploadDir = System.getProperty("user.dir") + FileStorageConstants.STORAGE_PATH;

File dir = new File(uploadDir);
if (!dir.exists()) {
throw new IllegalStateException("File storage directory does not exist");
}

File[] matchingFiles = dir.listFiles((d, name) -> name.startsWith(id.toString() + "."));
if (matchingFiles == null || matchingFiles.length == 0) {
log.error("No file with id {} found", id);
throw new FileNotFoundException();
}

File fileToDelete = matchingFiles[0];
if (!fileToDelete.delete()) {
throw new FileStorageException("Failed to delete file with id " + id, null);
}
log.info("File with id {} has been deleted", id);
}

@Override
public void deleteByPath(String path) {
File file = new File(path);
public void delete(String locator) {
File file = new File(locator);

if (!file.exists()) {
log.error("No file found at path {}", path);
log.error("No file found at path {}", locator);
throw new FileNotFoundException();
}

if (!file.delete()) {
throw new FileStorageException("Failed to delete file at path " + path, null);
throw new FileStorageException("Failed to delete file at path " + locator, null);
}

log.info("File at path {} has been deleted", path);
log.info("File at path {} has been deleted", locator);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
Expand Down Expand Up @@ -65,9 +64,9 @@ private boolean ensurePlaceholderExists(String path) {
}

@Override
public byte[] get(String path) {
log.debug("Mocking get file resource {} return placeholder file", path);
File file = new File(path);
public byte[] get(String locator) {
log.debug("Mocking get file resource {} return placeholder file", locator);
File file = new File(locator);

if (!file.exists()) {
throw new FileNotFoundException();
Expand All @@ -81,12 +80,7 @@ public byte[] get(String path) {
}

@Override
public void delete(UUID id) {
log.debug("Mocking delete file resource {}", id);
}

@Override
public void deleteByPath(String path) throws FileStorageException {
log.debug("Mocking delete file resource by path {}", path);
public void delete(String locator) {
log.debug("Mocking delete file resource {}", locator);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package fr.avenirsesr.portfolio.file.infrastructure.adapter.service;

import fr.avenirsesr.portfolio.file.domain.exception.FileNotFoundException;
import fr.avenirsesr.portfolio.file.domain.exception.FileStorageException;
import fr.avenirsesr.portfolio.file.domain.model.FileResource;
import fr.avenirsesr.portfolio.file.domain.port.output.service.FileStorageService;
import fr.avenirsesr.portfolio.file.infrastructure.configuration.S3StorageProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.core.ResponseBytes;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.S3Exception;

/**
* Stores files in an S3-compatible bucket. The locator handed back to the domain is the object key.
*/
@Slf4j
@Component
@Primary
@RequiredArgsConstructor
@ConditionalOnProperty(name = "file.storage.type", havingValue = "s3")
public class S3FileStorageService implements FileStorageService {
private final S3Client s3Client;
private final S3StorageProperties properties;

@Override
public String upload(FileResource fileResource) {
var key = fileResource.id() + "." + fileResource.fileType().name().toLowerCase();

try {
s3Client.putObject(
PutObjectRequest.builder()
.bucket(properties.getBucket())
.key(key)
.contentType(fileResource.fileType().getMimeType())
.build(),
RequestBody.fromBytes(fileResource.content()));
} catch (S3Exception e) {
throw new FileStorageException("Failed to upload file " + fileResource.fileName(), e);
}

log.info("File {} has been uploaded as {}", fileResource.fileName(), key);
return key;
}

@Override
public byte[] get(String locator) {
try {
ResponseBytes<?> object =
s3Client.getObjectAsBytes(
GetObjectRequest.builder().bucket(properties.getBucket()).key(locator).build());
return object.asByteArray();
} catch (NoSuchKeyException e) {
log.error("No object found for key {}", locator);
throw new FileNotFoundException();
} catch (S3Exception e) {
throw new FileStorageException("Failed to read object with key " + locator, e);
}
}

/**
* Deletes the object. S3 answers successfully for a key that does not exist, so unlike the local
* adapter this never reports a missing file.
*/
@Override
public void delete(String locator) {
try {
s3Client.deleteObject(
DeleteObjectRequest.builder().bucket(properties.getBucket()).key(locator).build());
} catch (S3Exception e) {
throw new FileStorageException("Failed to delete object with key " + locator, e);
}

log.info("Object with key {} has been deleted", locator);
}
}
Loading
Loading