Прием и отдача файлов
This commit is contained in:
5
pom.xml
5
pom.xml
@@ -30,6 +30,11 @@
|
|||||||
<artifactId>jersey-media-json-jackson</artifactId>
|
<artifactId>jersey-media-json-jackson</artifactId>
|
||||||
<version>${jersey.version}</version>
|
<version>${jersey.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.glassfish.jersey.media</groupId>
|
||||||
|
<artifactId>jersey-media-multipart</artifactId>
|
||||||
|
<version>${jersey.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.slf4j</groupId>
|
<groupId>org.slf4j</groupId>
|
||||||
<artifactId>slf4j-simple</artifactId>
|
<artifactId>slf4j-simple</artifactId>
|
||||||
|
|||||||
@@ -1,7 +1,55 @@
|
|||||||
package im.rosetta;
|
package im.rosetta;
|
||||||
|
|
||||||
|
import im.rosetta.api.CdnResource;
|
||||||
|
import im.rosetta.storage.FileStore;
|
||||||
|
import org.glassfish.grizzly.http.server.HttpServer;
|
||||||
|
import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory;
|
||||||
|
import org.glassfish.jersey.media.multipart.MultiPartFeature;
|
||||||
|
import org.glassfish.jersey.server.ResourceConfig;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
public class Main {
|
public class Main {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) throws IOException, InterruptedException {
|
||||||
System.out.println("Hello world!");
|
int port = resolvePort(args);
|
||||||
|
Path filesDir = Path.of("files");
|
||||||
|
|
||||||
|
FileStore fileStore = new FileStore(filesDir);
|
||||||
|
|
||||||
|
ResourceConfig config = new ResourceConfig()
|
||||||
|
.register(MultiPartFeature.class)
|
||||||
|
.register(new CdnResource(fileStore));
|
||||||
|
|
||||||
|
URI baseUri = URI.create("http://0.0.0.0:" + port + "/");
|
||||||
|
HttpServer server = GrizzlyHttpServerFactory.createHttpServer(baseUri, config, false);
|
||||||
|
Runtime.getRuntime().addShutdownHook(new Thread(server::shutdownNow));
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
System.out.println("CDN started at " + baseUri);
|
||||||
|
Thread.currentThread().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int resolvePort(String[] args) {
|
||||||
|
// Если порт указан аргументом — используем его.
|
||||||
|
if (args != null && args.length > 0) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(args[0]);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если порт задан в окружении — используем его.
|
||||||
|
String envPort = System.getenv("PORT");
|
||||||
|
if (envPort != null && !envPort.isBlank()) {
|
||||||
|
try {
|
||||||
|
return Integer.parseInt(envPort);
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Значение по умолчанию.
|
||||||
|
return 8080;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
85
src/main/java/im/rosetta/api/CdnResource.java
Normal file
85
src/main/java/im/rosetta/api/CdnResource.java
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package im.rosetta.api;
|
||||||
|
|
||||||
|
import im.rosetta.storage.FileStore;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import jakarta.ws.rs.core.StreamingOutput;
|
||||||
|
import org.glassfish.jersey.media.multipart.FormDataParam;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Path("/")
|
||||||
|
public class CdnResource {
|
||||||
|
private final FileStore fileStore;
|
||||||
|
|
||||||
|
public CdnResource(FileStore fileStore) {
|
||||||
|
this.fileStore = fileStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("u")
|
||||||
|
@Consumes(MediaType.MULTIPART_FORM_DATA)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public Response upload(@FormDataParam("file") InputStream inputStream) {
|
||||||
|
if (inputStream == null) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("error", "file is required"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String tag = fileStore.save(inputStream);
|
||||||
|
return Response.ok(Map.of("t", tag)).build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Response.serverError()
|
||||||
|
.entity(Map.of("error", "upload failed"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("d/{tag}")
|
||||||
|
@Produces(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
public Response download(@PathParam("tag") String tag) {
|
||||||
|
if (!isValidTag(tag)) {
|
||||||
|
return Response.status(Response.Status.BAD_REQUEST)
|
||||||
|
.entity(Map.of("error", "invalid tag"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Optional<java.nio.file.Path> file = fileStore.getIfValid(tag);
|
||||||
|
if (file.isEmpty()) {
|
||||||
|
return Response.status(Response.Status.NOT_FOUND)
|
||||||
|
.entity(Map.of("error", "not found"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
StreamingOutput stream = output -> Files.copy(file.get(), output);
|
||||||
|
return Response.ok(stream)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + tag + "\"")
|
||||||
|
.build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return Response.serverError()
|
||||||
|
.entity(Map.of("error", "download failed"))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValidTag(String tag) {
|
||||||
|
try {
|
||||||
|
UUID.fromString(tag);
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/main/java/im/rosetta/storage/FileStore.java
Normal file
70
src/main/java/im/rosetta/storage/FileStore.java
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package im.rosetta.storage;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.StandardCopyOption;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class FileStore {
|
||||||
|
private static final Duration TTL = Duration.ofDays(7);
|
||||||
|
private final Path filesDir;
|
||||||
|
|
||||||
|
public FileStore(Path filesDir) throws IOException {
|
||||||
|
this.filesDir = filesDir.toAbsolutePath();
|
||||||
|
Files.createDirectories(this.filesDir);
|
||||||
|
cleanupExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String save(InputStream inputStream) throws IOException {
|
||||||
|
cleanupExpired();
|
||||||
|
String tag = UUID.randomUUID().toString();
|
||||||
|
Path target = filesDir.resolve(tag);
|
||||||
|
Files.copy(inputStream, target, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
Files.setLastModifiedTime(target, java.nio.file.attribute.FileTime.from(Instant.now()));
|
||||||
|
return tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Path> getIfValid(String tag) throws IOException {
|
||||||
|
cleanupExpired();
|
||||||
|
Path target = filesDir.resolve(tag).normalize();
|
||||||
|
if (!target.startsWith(filesDir)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (!Files.exists(target)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (isExpired(target)) {
|
||||||
|
Files.deleteIfExists(target);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return Optional.of(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очищает неиспользуемые файлы, которые старше TTL. Вызывается при каждом сохранении и загрузке, а также при инициализации.
|
||||||
|
* @throws IOException
|
||||||
|
*/
|
||||||
|
public void cleanupExpired() throws IOException {
|
||||||
|
if (!Files.exists(filesDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try (DirectoryStream<Path> stream = Files.newDirectoryStream(filesDir)) {
|
||||||
|
for (Path file : stream) {
|
||||||
|
if (Files.isRegularFile(file) && isExpired(file)) {
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExpired(Path file) throws IOException {
|
||||||
|
Instant lastModified = Files.getLastModifiedTime(file).toInstant();
|
||||||
|
return lastModified.plus(TTL).isBefore(Instant.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
BIN
target/classes/im/rosetta/api/CdnResource.class
Normal file
BIN
target/classes/im/rosetta/api/CdnResource.class
Normal file
Binary file not shown.
BIN
target/classes/im/rosetta/storage/FileStore.class
Normal file
BIN
target/classes/im/rosetta/storage/FileStore.class
Normal file
Binary file not shown.
Reference in New Issue
Block a user