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