Add upload feature
This commit is contained in:
47
src/main/java/sh/rhiobet/lalafin/api/UploadPrivateAPI.java
Normal file
47
src/main/java/sh/rhiobet/lalafin/api/UploadPrivateAPI.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
25
src/main/java/sh/rhiobet/lalafin/api/UploadPublicAPI.java
Normal file
25
src/main/java/sh/rhiobet/lalafin/api/UploadPublicAPI.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
22
src/main/java/sh/rhiobet/lalafin/api/model/UploadForm.java
Normal file
22
src/main/java/sh/rhiobet/lalafin/api/model/UploadForm.java
Normal file
@@ -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;
|
||||
|
||||
}
|
||||
12
src/main/java/sh/rhiobet/lalafin/api/model/UploadedFile.java
Normal file
12
src/main/java/sh/rhiobet/lalafin/api/model/UploadedFile.java
Normal file
@@ -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;
|
||||
|
||||
}
|
||||
44
src/main/java/sh/rhiobet/lalafin/file/FileHelper.java
Normal file
44
src/main/java/sh/rhiobet/lalafin/file/FileHelper.java
Normal file
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
25
src/main/java/sh/rhiobet/lalafin/upload/UploadResource.java
Normal file
25
src/main/java/sh/rhiobet/lalafin/upload/UploadResource.java
Normal file
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
195
src/main/java/sh/rhiobet/lalafin/upload/UploadService.java
Normal file
195
src/main/java/sh/rhiobet/lalafin/upload/UploadService.java
Normal file
@@ -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<UploadedFile> 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<Path> 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<Path> 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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# Configuration file
|
||||
quarkus:
|
||||
http:
|
||||
limits:
|
||||
max-body-size: 50G
|
||||
port: 8910
|
||||
proxy:
|
||||
proxy-address-forwarding: true
|
||||
|
||||
71
src/main/resources/templates/upload-index.html
Normal file
71
src/main/resources/templates/upload-index.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="stylesheet" href="/style/sakura.css">
|
||||
<title>{user}'s uploads</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>{user}'s uploads</h1>
|
||||
<hr />
|
||||
<form>
|
||||
<input name="file" type="file" id="file" />
|
||||
<input name="password" type="text" id="password" placeholder="password" />
|
||||
<input type="button" value="upload" onClick="submitUpload()" />
|
||||
</form>
|
||||
<p id="result"></p>
|
||||
<script>
|
||||
function submitUpload() {
|
||||
var file = document.getElementById("file").files[0];
|
||||
var password = document.getElementById("password").value;
|
||||
var form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("filename", file.name);
|
||||
form.append("password", password);
|
||||
|
||||
fetch("/api/private/upload", {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
document.getElementById("result").innerHTML = "Link: <a href=\""
|
||||
+ data + "\">Download</a>";
|
||||
updateHistory();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<hr />
|
||||
<h2>History</h2>
|
||||
<ul id="list" style="list-style-type: none;"></ul>
|
||||
<script>
|
||||
function updateHistory() {
|
||||
fetch("/api/private/upload")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById("list").innerHTML = "";
|
||||
for (uploadedFile of data) {
|
||||
document.getElementById("list").innerHTML += "<li>"
|
||||
+ uploadedFile.name + " -"
|
||||
+ " <a href=\"" + uploadedFile.downloadUrl + "\">⭳</a>"
|
||||
+ " <a href=\"#\" onclick=\"deleteUpload('" + uploadedFile.iv + "');\">🗑</a>"
|
||||
+ "</li>";
|
||||
}
|
||||
});
|
||||
}
|
||||
function deleteUpload(iv) {
|
||||
fetch("/api/private/upload/" + iv, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(response => {
|
||||
updateHistory();
|
||||
});
|
||||
}
|
||||
updateHistory();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user