Gymboard/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/FileService.java

190 lines
5.8 KiB
Java

package nl.andrewlalis.gymboardcdn.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.api.FileMetadataResponse;
import nl.andrewlalis.gymboardcdn.model.FileMetadata;
import nl.andrewlalis.gymboardcdn.util.ULID;
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.web.server.ResponseStatusException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
/**
* The service that manages storing and retrieving files from a base filesystem.
*/
@Service
public class FileService {
private static final Logger log = LoggerFactory.getLogger(FileService.class);
@Value("${app.files.storage-dir}")
private String storageDir;
@Value("${app.files.temp-dir}")
private String tempDir;
private final ULID ulid;
private final ObjectMapper objectMapper = new ObjectMapper();
public FileService(ULID ulid) {
this.ulid = ulid;
}
public Path getStoragePathForFile(String rawId) {
ULID.Value id = ULID.parseULID(rawId);
LocalDateTime time = dateFromULID(id);
Path dir = Path.of(storageDir)
.resolve(Integer.toString(time.getYear()))
.resolve(Integer.toString(time.getMonthValue()))
.resolve(Integer.toString(time.getDayOfMonth()));
return dir.resolve(rawId);
}
public String createNewFileIdentifier() {
return ulid.nextULID();
}
public Path saveToTempFile(InputStream in, String filename) throws IOException {
Path tempDir = getTempDir();
String suffix = null;
if (filename != null) {
int idx = filename.lastIndexOf('.');
if (idx >= 0) {
suffix = filename.substring(idx);
}
}
Path tempFile = Files.createTempFile(tempDir, null, suffix);
try (var out = Files.newOutputStream(tempFile)) {
in.transferTo(out);
}
return tempFile;
}
private static LocalDateTime dateFromULID(ULID.Value value) {
return Instant.ofEpochMilli(value.timestamp())
.atOffset(ZoneOffset.UTC)
.toLocalDateTime();
}
private Path getStorageDir() throws IOException {
Path dir = Path.of(storageDir);
if (Files.notExists(dir)) {
Files.createDirectories(dir);
}
return dir;
}
private Path getTempDir() throws IOException {
Path dir = Path.of(tempDir);
if (Files.notExists(dir)) {
Files.createDirectories(dir);
}
return dir;
}
public String saveFile(InputStream in, String filename, String mimeType) throws IOException {
ULID.Value id = ulid.nextValue();
FileMetadata meta = new FileMetadata();
meta.filename = filename;
meta.mimeType = mimeType;
byte[] metaBytes = objectMapper.writeValueAsBytes(meta);
Path filePath = getStoragePathForFile(id.toString());
Files.createDirectories(filePath.getParent());
try (var out = Files.newOutputStream(filePath)) {
// Write metadata first.
ByteBuffer metaBuffer = ByteBuffer.allocate(2 + metaBytes.length);
metaBuffer.putShort((short) metaBytes.length);
metaBuffer.put(metaBytes);
out.write(metaBuffer.array());
// Now write real data.
byte[] buffer = new byte[8192];
int readCount;
while ((readCount = in.read(buffer)) > 0) {
out.write(buffer, 0, readCount);
}
}
return id.toString();
}
public FileMetadata readMetadata(InputStream in) throws IOException {
ByteBuffer b = ByteBuffer.allocate(2);
int bytesRead = in.read(b.array());
if (bytesRead != 2) throw new IOException("Missing metadata length.");
short length = b.getShort();
b = ByteBuffer.allocate(length);
bytesRead = in.read(b.array());
if (bytesRead != length) throw new IOException("Metadata body does not equal length header.");
return objectMapper.readValue(b.array(), FileMetadata.class);
}
/**
* Streams the contents of a stored file to a client via the Http response.
* @param rawId The file's unique identifier.
* @param response The response to stream the content to.
*/
public void streamFile(String rawId, HttpServletResponse response) {
Path filePath = getStoragePathForFile(rawId);
if (!Files.exists(filePath)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
try (var in = Files.newInputStream(filePath)) {
FileMetadata metadata = readMetadata(in);
response.setContentType(metadata.mimeType);
response.setContentLengthLong(Files.size(filePath));
}
ULID.Value id = ULID.parseULID(rawId);
Instant timestamp = Instant.ofEpochMilli(id.timestamp());
LocalDateTime ldt = LocalDateTime.ofInstant(timestamp, ZoneOffset.UTC);
response.setContentType(file.getMimeType());
response.setContentLengthLong(file.getSize());
try {
Path filePath = getStoragePathForFile(file);
try (var in = Files.newInputStream(filePath)) {
in.transferTo(response.getOutputStream());
}
} catch (IOException e) {
log.error("Failed to write file to response.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
public FileMetadataResponse getFileMetadata(String id) {
Path filePath = getStoragePathForFile(id);
if (!Files.exists(filePath)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
try (var in = Files.newInputStream(filePath)) {
FileMetadata metadata = readMetadata(in);
return new FileMetadataResponse(
metadata.filename,
metadata.mimeType,
Files.size(filePath),
Files.getLastModifiedTime(filePath)
.toInstant().atOffset(ZoneOffset.UTC)
.toLocalDateTime().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
true
);
} catch (IOException e) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "IO error", e);
}
}
}