diff --git a/pom.xml b/pom.xml index 0bf5c06..4eaead2 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,10 @@ io.quarkus quarkus-resteasy-jackson + + io.quarkus + quarkus-resteasy-multipart + io.quarkus quarkus-config-yaml diff --git a/src/main/java/sh/rhiobet/lalafin/api/UploadPrivateAPI.java b/src/main/java/sh/rhiobet/lalafin/api/UploadPrivateAPI.java new file mode 100644 index 0000000..dbfe9a1 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/api/UploadPrivateAPI.java @@ -0,0 +1,47 @@ +package sh.rhiobet.lalafin.api; + +import javax.inject.Inject; +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.jboss.resteasy.annotations.jaxrs.PathParam; +import org.jboss.resteasy.annotations.providers.multipart.MultipartForm; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import sh.rhiobet.lalafin.api.model.UploadForm; +import sh.rhiobet.lalafin.upload.UploadService; + +@Authenticated +@Path("/api/private/upload") +public class UploadPrivateAPI { + @Inject + UploadService uploadService; + + @Inject + SecurityIdentity securityIdentity; + + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public Response uploadFile(@MultipartForm UploadForm data) { + return uploadService.upload(securityIdentity.getPrincipal().getName(), data.filename, + data.password, data.file); + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response listUploads() { + return uploadService.list(securityIdentity.getPrincipal().getName()); + } + + @DELETE + @Path("/{iv}") + public Response getFile(@PathParam String iv) { + return uploadService.delete(securityIdentity.getPrincipal().getName(), iv); + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/api/UploadPublicAPI.java b/src/main/java/sh/rhiobet/lalafin/api/UploadPublicAPI.java new file mode 100644 index 0000000..2c18b2f --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/api/UploadPublicAPI.java @@ -0,0 +1,25 @@ +package sh.rhiobet.lalafin.api; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Response; +import org.jboss.resteasy.annotations.jaxrs.PathParam; +import sh.rhiobet.lalafin.upload.UploadService; + +@Path("/api/public/upload") +public class UploadPublicAPI { + @Inject + UploadService uploadService; + + @GET + @Path("/{user}/{iv}") + public Response getFile(@PathParam String user, @PathParam String iv, + @QueryParam("password") String password) { + if (password == null) { + password = ""; + } + return uploadService.download(user, iv, password); + } +} diff --git a/src/main/java/sh/rhiobet/lalafin/api/model/UploadForm.java b/src/main/java/sh/rhiobet/lalafin/api/model/UploadForm.java new file mode 100644 index 0000000..3b50117 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/api/model/UploadForm.java @@ -0,0 +1,22 @@ +package sh.rhiobet.lalafin.api.model; + +import java.io.InputStream; +import javax.ws.rs.FormParam; +import javax.ws.rs.core.MediaType; +import org.jboss.resteasy.annotations.providers.multipart.PartType; + +public class UploadForm { + + @FormParam("filename") + @PartType(MediaType.TEXT_PLAIN) + public String filename; + + @FormParam("file") + @PartType(MediaType.APPLICATION_OCTET_STREAM) + public InputStream file; + + @FormParam("password") + @PartType(MediaType.TEXT_PLAIN) + public String password; + +} diff --git a/src/main/java/sh/rhiobet/lalafin/api/model/UploadedFile.java b/src/main/java/sh/rhiobet/lalafin/api/model/UploadedFile.java new file mode 100644 index 0000000..c1411a2 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/api/model/UploadedFile.java @@ -0,0 +1,12 @@ +package sh.rhiobet.lalafin.api.model; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class UploadedFile { + + public String name; + public String iv; + public String downloadUrl; + +} diff --git a/src/main/java/sh/rhiobet/lalafin/file/FileHelper.java b/src/main/java/sh/rhiobet/lalafin/file/FileHelper.java new file mode 100644 index 0000000..26f5b11 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/file/FileHelper.java @@ -0,0 +1,44 @@ +package sh.rhiobet.lalafin.file; + +public class FileHelper { + + public static String getMimeType(String filename) { + String extension = filename.substring(filename.lastIndexOf('.') + 1); + + switch (extension) { + case "3gp": + return "video/3gpp"; + case "avi": + return "video/x-msvideo"; + case "flac": + return "audio/x-flac"; + case "flv": + return "video/x-flv"; + case "html": + return "text/html"; + case "jpg": + return "image/jpeg"; + case "mkv": + return "video/x-matroska"; + case "mp3": + return "audio/mp3"; + case "mp4": + return "video/mp4"; + case "png": + return "image/png"; + case "ts": + return "video/MP2T"; + case "wav": + return "audio/x-wav"; + case "webm": + return "video/webm"; + case "wmv": + return "video/x-ms-wmv"; + case "zip": + return "application/zip"; + default: + return "application/octet-stream"; + } + } + +} diff --git a/src/main/java/sh/rhiobet/lalafin/file/FileServeService.java b/src/main/java/sh/rhiobet/lalafin/file/FileServeService.java index 6edc9f3..b644e3f 100644 --- a/src/main/java/sh/rhiobet/lalafin/file/FileServeService.java +++ b/src/main/java/sh/rhiobet/lalafin/file/FileServeService.java @@ -62,7 +62,7 @@ public class FileServeService { response.header("Content-Range", "bytes " + rangeStart + "-" + fileSize + "/" + fileSize); } - response.header("Content-Type", this.getMimeType(fileInfo.filename)); + response.header("Content-Type", FileHelper.getMimeType(fileInfo.filename)); if (path.toString().contains("/.thumbnails/")) { response.header("Cache-Control", "max-age=604800"); } @@ -72,43 +72,4 @@ public class FileServeService { } } - private String getMimeType(String filename) { - String extension = filename.substring(filename.lastIndexOf('.') + 1); - - switch (extension) { - case "3gp": - return "video/3gpp"; - case "avi": - return "video/x-msvideo"; - case "flac": - return "audio/x-flac"; - case "flv": - return "video/x-flv"; - case "html": - return "text/html"; - case "jpg": - return "image/jpeg"; - case "mkv": - return "video/x-matroska"; - case "mp3": - return "audio/mp3"; - case "mp4": - return "video/mp4"; - case "png": - return "image/png"; - case "ts": - return "video/MP2T"; - case "wav": - return "audio/x-wav"; - case "webm": - return "video/webm"; - case "wmv": - return "video/x-ms-wmv"; - case "zip": - return "application/zip"; - default: - return "application/octet-stream"; - } - } - } diff --git a/src/main/java/sh/rhiobet/lalafin/upload/UploadResource.java b/src/main/java/sh/rhiobet/lalafin/upload/UploadResource.java new file mode 100644 index 0000000..a37a560 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/upload/UploadResource.java @@ -0,0 +1,25 @@ +package sh.rhiobet.lalafin.upload; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.core.Response; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; + +@Authenticated +@Path("/upload") +public class UploadResource { + @Inject + SecurityIdentity securityIdentity; + + @Inject + UploadService fileUploadService; + + @GET + @Path("/") + public Response serveIndex() { + return fileUploadService.index(securityIdentity.getPrincipal().getName()); + } + +} diff --git a/src/main/java/sh/rhiobet/lalafin/upload/UploadService.java b/src/main/java/sh/rhiobet/lalafin/upload/UploadService.java new file mode 100644 index 0000000..462b293 --- /dev/null +++ b/src/main/java/sh/rhiobet/lalafin/upload/UploadService.java @@ -0,0 +1,195 @@ +package sh.rhiobet.lalafin.upload; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.ResponseBuilder; +import javax.xml.bind.DatatypeConverter; +import io.quarkus.qute.Location; +import io.quarkus.qute.Template; +import io.quarkus.qute.TemplateInstance; +import sh.rhiobet.lalafin.api.configuration.FileApiConfiguration; +import sh.rhiobet.lalafin.api.model.UploadedFile; +import sh.rhiobet.lalafin.file.FileHelper; + +@ApplicationScoped +public class UploadService { + + @Inject + FileApiConfiguration fileApiConfiguration; + + @Location("upload-index.html") + Template uploadTemplate; + + public Response upload(String user, String filename, String passphrase, + InputStream inputStream) { + SecureRandom random = new SecureRandom(); + byte ivBytes[] = new byte[16]; + random.nextBytes(ivBytes); + String ivString = DatatypeConverter.printHexBinary(ivBytes); + Path filePath = Paths.get(fileApiConfiguration.directory() + "/upload/" + user + "/" + + ivString + "/" + filename); + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(ivBytes); + byte[] passphraseHash = digest.digest(passphrase.getBytes(StandardCharsets.UTF_8)); + Key aesKey = new SecretKeySpec(passphraseHash, "AES"); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, aesKey, iv); + CipherInputStream cipherInputStream = new CipherInputStream(inputStream, cipher); + filePath.getParent().toFile().mkdirs(); + Files.copy(cipherInputStream, filePath); + + digest = MessageDigest.getInstance("SHA-256"); + digest.update(user.getBytes(StandardCharsets.UTF_8)); + byte[] keyHash = digest.digest(passphraseHash); + Files.writeString(filePath.getParent().resolve(".key"), + DatatypeConverter.printHexBinary(keyHash)); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | IOException | InvalidAlgorithmParameterException e) { + e.printStackTrace(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } + + return Response + .ok("/api/public/upload/" + user + "/" + ivString + "?password=" + passphrase) + .build(); + } + + public Response list(String user) { + Path folderPath = Paths.get(fileApiConfiguration.directory() + "/upload/" + user); + if (!Files.exists(folderPath)) { + folderPath.toFile().mkdirs(); + } + + List uploadedFiles = new ArrayList<>(); + try { + Files.list(folderPath).forEach(p -> { + final UploadedFile uploadedFile = new UploadedFile(); + uploadedFile.iv = p.getFileName().toString(); + uploadedFile.downloadUrl = "/api/public/upload/" + user + "/" + uploadedFile.iv; + Optional filePathOptional; + try { + filePathOptional = Files.list(p) + .filter(p2 -> !p2.getFileName().toString().equals(".key")).findFirst(); + if (filePathOptional.isPresent()) { + uploadedFile.name = filePathOptional.get().getFileName().toString(); + uploadedFiles.add(uploadedFile); + } + } catch (IOException e) { + e.printStackTrace(); + } + }); + return Response.ok(uploadedFiles).build(); + } catch (IOException e) { + e.printStackTrace(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } + } + + public Response index(String user) { + TemplateInstance uploadTemplateInstance = uploadTemplate.instance().data("user", user); + + ResponseBuilder response = Response.ok(uploadTemplateInstance.render()); + response.header("Content-Type", "text/html"); + return response.build(); + } + + public Response download(String user, String ivString, String password) { + Path folderPath = + Paths.get(fileApiConfiguration.directory() + "/upload/" + user + "/" + ivString); + byte ivBytes[] = DatatypeConverter.parseHexBinary(ivString); + + if (!Files.exists(folderPath)) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + + try { + Optional filePathOptional = Files.list(folderPath) + .filter(p -> !p.getFileName().toString().equals(".key")).findFirst(); + if (!filePathOptional.isPresent()) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + Path filePath = filePathOptional.get(); + Path keyPath = filePath.getParent().resolve(".key"); + String keyHashString = Files.readString(keyPath); + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(ivBytes); + byte[] passphraseHash = digest.digest(password.getBytes(StandardCharsets.UTF_8)); + + digest = MessageDigest.getInstance("SHA-256"); + digest.update(user.getBytes(StandardCharsets.UTF_8)); + byte[] keyHash = digest.digest(passphraseHash); + if (!keyHashString.equals(DatatypeConverter.printHexBinary(keyHash))) { + return Response.status(Response.Status.NOT_ACCEPTABLE).entity("Wrong password") + .build(); + } + + Key aesKey = new SecretKeySpec(passphraseHash, "AES"); + IvParameterSpec iv = new IvParameterSpec(ivBytes); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, aesKey, iv); + + FileChannel channel = FileChannel.open(filePath); + InputStream is = Channels.newInputStream(channel); + CipherInputStream cipherInputStream = new CipherInputStream(is, cipher); + + ResponseBuilder response = Response.ok(filePath.toFile()); + response.entity(cipherInputStream); + response.header("Accept-Ranges", "bytes"); + response.header("Content-Disposition", + "inline; filename=\"" + filePath.getFileName().toString() + "\""); + response.header("Content-Type", + FileHelper.getMimeType(filePath.getFileName().toString())); + return response.build(); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | IOException | InvalidAlgorithmParameterException e) { + e.printStackTrace(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } + } + + public Response delete(String user, String ivString) { + Path folderPath = + Paths.get(fileApiConfiguration.directory() + "/upload/" + user + "/" + ivString); + if (!Files.exists(folderPath)) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + try { + Files.walk(folderPath).sorted(Comparator.reverseOrder()).map(Path::toFile) + .forEach(File::delete); + return Response.ok().build(); + } catch (IOException e) { + e.printStackTrace(); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + } + } + +} diff --git a/src/main/resources/application.yaml.example b/src/main/resources/application.yaml.example index ac0e32e..422e07f 100644 --- a/src/main/resources/application.yaml.example +++ b/src/main/resources/application.yaml.example @@ -1,6 +1,8 @@ # Configuration file quarkus: http: + limits: + max-body-size: 50G port: 8910 proxy: proxy-address-forwarding: true diff --git a/src/main/resources/templates/upload-index.html b/src/main/resources/templates/upload-index.html new file mode 100644 index 0000000..ac0cfb2 --- /dev/null +++ b/src/main/resources/templates/upload-index.html @@ -0,0 +1,71 @@ + + + + + + + + {user}'s uploads + + + +

{user}'s uploads

+
+
+ + + +
+

+ +
+

History

+
    + + + + \ No newline at end of file