Add upload feature

This commit is contained in:
2021-05-31 00:46:07 +02:00
parent 87ed050f95
commit da4a99b555
11 changed files with 448 additions and 40 deletions

View File

@@ -42,6 +42,10 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-multipart</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-config-yaml</artifactId>

View 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);
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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";
}
}
}

View File

@@ -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";
}
}
}

View 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());
}
}

View 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();
}
}
}

View File

@@ -1,6 +1,8 @@
# Configuration file
quarkus:
http:
limits:
max-body-size: 50G
port: 8910
proxy:
proxy-address-forwarding: true

View 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>