diff --git a/pom.xml b/pom.xml
index d1a0f85..1d4f395 100644
--- a/pom.xml
+++ b/pom.xml
@@ -30,6 +30,11 @@
jersey-media-json-jackson
${jersey.version}
+
+ org.glassfish.jersey.media
+ jersey-media-multipart
+ ${jersey.version}
+
org.slf4j
slf4j-simple
diff --git a/src/main/java/im/rosetta/Main.java b/src/main/java/im/rosetta/Main.java
index 70dda04..c1ce47b 100644
--- a/src/main/java/im/rosetta/Main.java
+++ b/src/main/java/im/rosetta/Main.java
@@ -1,7 +1,55 @@
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 static void main(String[] args) {
- System.out.println("Hello world!");
+ public static void main(String[] args) throws IOException, InterruptedException {
+ 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;
}
}
\ No newline at end of file
diff --git a/src/main/java/im/rosetta/api/CdnResource.java b/src/main/java/im/rosetta/api/CdnResource.java
new file mode 100644
index 0000000..875d255
--- /dev/null
+++ b/src/main/java/im/rosetta/api/CdnResource.java
@@ -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 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;
+ }
+ }
+}
diff --git a/src/main/java/im/rosetta/storage/FileStore.java b/src/main/java/im/rosetta/storage/FileStore.java
new file mode 100644
index 0000000..bc9fc93
--- /dev/null
+++ b/src/main/java/im/rosetta/storage/FileStore.java
@@ -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 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 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());
+ }
+}
diff --git a/target/classes/im/rosetta/Main.class b/target/classes/im/rosetta/Main.class
index a07f8ce..6b8187b 100644
Binary files a/target/classes/im/rosetta/Main.class and b/target/classes/im/rosetta/Main.class differ
diff --git a/target/classes/im/rosetta/api/CdnResource.class b/target/classes/im/rosetta/api/CdnResource.class
new file mode 100644
index 0000000..38e4475
Binary files /dev/null and b/target/classes/im/rosetta/api/CdnResource.class differ
diff --git a/target/classes/im/rosetta/storage/FileStore.class b/target/classes/im/rosetta/storage/FileStore.class
new file mode 100644
index 0000000..9102e2d
Binary files /dev/null and b/target/classes/im/rosetta/storage/FileStore.class differ