mirror of
https://github.com/RoastSlav/quickdrop.git
synced 2026-01-24 16:10:05 -06:00
the uploads are now chunked
This commit is contained in:
@@ -55,6 +55,7 @@ public class SecurityConfig {
|
||||
)
|
||||
.csrf(csrf -> csrf
|
||||
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
|
||||
.ignoringRequestMatchers("/api/file/upload-chunk")
|
||||
).headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/file")
|
||||
@@ -22,22 +25,41 @@ public class FileRestController {
|
||||
this.fileService = fileService;
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
public ResponseEntity<FileEntity> saveFile(@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(value = "description", required = false) String description,
|
||||
@RequestParam(value = "keepIndefinitely", defaultValue = "false") boolean keepIndefinitely,
|
||||
@RequestParam(value = "password", required = false) String password,
|
||||
@RequestParam(value = "hidden", defaultValue = "false") boolean hidden) {
|
||||
FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password, hidden);
|
||||
FileEntity fileEntity = fileService.saveFile(file, fileUploadRequest);
|
||||
if (fileEntity != null) {
|
||||
return ResponseEntity.ok(fileEntity);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().build();
|
||||
@PostMapping("/upload-chunk")
|
||||
public ResponseEntity<?> handleChunkUpload(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam("fileName") String fileName,
|
||||
@RequestParam("chunkNumber") int chunkNumber,
|
||||
@RequestParam("totalChunks") int totalChunks,
|
||||
@RequestParam(value = "description", required = false) String description,
|
||||
@RequestParam(value = "keepIndefinitely", defaultValue = "false") Boolean keepIndefinitely,
|
||||
@RequestParam(value = "password", required = false) String password,
|
||||
@RequestParam(value = "hidden", defaultValue = "false") Boolean hidden) {
|
||||
try {
|
||||
fileService.saveChunk(file, fileName, chunkNumber);
|
||||
|
||||
if (chunkNumber + 1 == totalChunks) {
|
||||
FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password, hidden);
|
||||
FileEntity fileEntity = fileService.assembleChunks(fileName, totalChunks, fileUploadRequest);
|
||||
|
||||
if (fileEntity != null) {
|
||||
return ResponseEntity.ok(fileEntity);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> response = new HashMap<>();
|
||||
response.put("message", "Chunk " + chunkNumber + " uploaded successfully");
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (IOException e) {
|
||||
fileService.deleteTempFiles(fileName);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body("{\"error\": \"Error processing chunk\"}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/share/{id}")
|
||||
public ResponseEntity<String> generateShareableLink(@PathVariable Long id, HttpServletRequest request) {
|
||||
FileEntity fileEntity = fileService.getFile(id);
|
||||
|
||||
@@ -15,8 +15,8 @@ import static org.rostislav.quickdrop.util.FileUtils.formatFileSize;
|
||||
public class ApplicationSettingsService {
|
||||
private final ApplicationSettingsRepository applicationSettingsRepository;
|
||||
private final ContextRefresher contextRefresher;
|
||||
private ApplicationSettingsEntity applicationSettings;
|
||||
private final ScheduleService scheduleService;
|
||||
private ApplicationSettingsEntity applicationSettings;
|
||||
|
||||
public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher, ScheduleService scheduleService) {
|
||||
this.contextRefresher = contextRefresher;
|
||||
|
||||
@@ -20,13 +20,12 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.*;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -44,6 +43,7 @@ public class FileService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ApplicationSettingsService applicationSettingsService;
|
||||
private final DownloadLogRepository downloadLogRepository;
|
||||
private final File tempDir = Paths.get(System.getProperty("java.io.tmpdir")).toFile();
|
||||
|
||||
@Lazy
|
||||
public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder, ApplicationSettingsService applicationSettingsService, DownloadLogRepository downloadLogRepository) {
|
||||
@@ -76,18 +76,61 @@ public class FileService {
|
||||
};
|
||||
}
|
||||
|
||||
public FileEntity saveFile(MultipartFile file, FileUploadRequest fileUploadRequest) {
|
||||
public void saveChunk(MultipartFile file, String fileName, int chunkNumber) throws IOException {
|
||||
File chunkFile = new File(tempDir, getFileChunkName(fileName) + chunkNumber);
|
||||
try (FileOutputStream fos = new FileOutputStream(chunkFile)) {
|
||||
fos.write(file.getBytes());
|
||||
}
|
||||
}
|
||||
|
||||
public FileEntity assembleChunks(String fileName, int totalChunks, FileUploadRequest fileUploadRequest) throws IOException {
|
||||
File finalFile = new File(tempDir, fileName);
|
||||
boolean successfullyCreated = finalFile.createNewFile();
|
||||
if (!successfullyCreated) {
|
||||
throw new IOException("Failed to create new file");
|
||||
}
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(finalFile)) {
|
||||
for (int i = 0; i < totalChunks; i++) {
|
||||
File partFile = new File(tempDir, getFileChunkName(fileName) + i);
|
||||
Files.copy(partFile.toPath(), fos);
|
||||
Files.delete(partFile.toPath());
|
||||
}
|
||||
}
|
||||
|
||||
return saveFile(finalFile, fileUploadRequest);
|
||||
}
|
||||
|
||||
public void deleteTempFiles(String fileName) {
|
||||
File[] tempFiles = tempDir.listFiles((dir, name) -> name.startsWith(getFileChunkName(fileName)));
|
||||
|
||||
if (tempFiles != null) {
|
||||
for (File tempFile : tempFiles) {
|
||||
if (tempFile.delete()) {
|
||||
logger.info("Deleted temp file: {}", tempFile);
|
||||
} else {
|
||||
logger.error("Failed to delete temp file: {}", tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String getFileChunkName(String fileName) {
|
||||
return fileName + "_chunk";
|
||||
}
|
||||
|
||||
public FileEntity saveFile(File file, FileUploadRequest fileUploadRequest) {
|
||||
if (!validateObjects(file, fileUploadRequest)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("File received: {}", file.getOriginalFilename());
|
||||
logger.info("File received: {}", file.getName());
|
||||
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
Path path = Path.of(applicationSettingsService.getFileStoragePath(), uuid);
|
||||
|
||||
if (fileUploadRequest.password == null || fileUploadRequest.password.isEmpty()) {
|
||||
if (!saveUnencryptedFile(file, path)) {
|
||||
if (!moveAndRenameUnencryptedFile(file, path)) {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
@@ -97,10 +140,10 @@ public class FileService {
|
||||
}
|
||||
|
||||
FileEntity fileEntity = new FileEntity();
|
||||
fileEntity.name = file.getOriginalFilename();
|
||||
fileEntity.name = file.getName();
|
||||
fileEntity.uuid = uuid;
|
||||
fileEntity.description = fileUploadRequest.description;
|
||||
fileEntity.size = file.getSize();
|
||||
fileEntity.size = file.getTotalSpace();
|
||||
fileEntity.keepIndefinitely = fileUploadRequest.keepIndefinitely;
|
||||
fileEntity.hidden = fileUploadRequest.hidden;
|
||||
|
||||
@@ -112,10 +155,9 @@ public class FileService {
|
||||
return fileRepository.save(fileEntity);
|
||||
}
|
||||
|
||||
private boolean saveUnencryptedFile(MultipartFile file, Path path) {
|
||||
private boolean moveAndRenameUnencryptedFile(File file, Path path) {
|
||||
try {
|
||||
Files.createFile(path);
|
||||
file.transferTo(path);
|
||||
Files.move(file.toPath(), path);
|
||||
logger.info("File saved: {}", path);
|
||||
} catch (
|
||||
Exception e) {
|
||||
@@ -125,22 +167,11 @@ public class FileService {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean saveEncryptedFile(Path savePath, MultipartFile file, FileUploadRequest fileUploadRequest) {
|
||||
Path tempFile;
|
||||
try {
|
||||
tempFile = Files.createTempFile("Unencrypted", "tmp");
|
||||
file.transferTo(tempFile);
|
||||
logger.info("Unencrypted temp file saved: {}", tempFile);
|
||||
} catch (
|
||||
Exception e) {
|
||||
logger.error("Error saving unencrypted temp file: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean saveEncryptedFile(Path savePath, File file, FileUploadRequest fileUploadRequest) {
|
||||
try {
|
||||
Path encryptedFile = Files.createFile(savePath);
|
||||
logger.info("Encrypting file: {}", encryptedFile);
|
||||
encryptFile(tempFile.toFile(), encryptedFile.toFile(), fileUploadRequest.password);
|
||||
encryptFile(file, encryptedFile.toFile(), fileUploadRequest.password);
|
||||
logger.info("Encrypted file saved: {}", encryptedFile);
|
||||
} catch (
|
||||
Exception e) {
|
||||
@@ -149,8 +180,8 @@ public class FileService {
|
||||
}
|
||||
|
||||
try {
|
||||
Files.delete(tempFile);
|
||||
logger.info("Temp file deleted: {}", tempFile);
|
||||
Files.delete(file.toPath());
|
||||
logger.info("Temp file deleted: {}", file);
|
||||
} catch (
|
||||
Exception e) {
|
||||
logger.error("Error deleting temp file: {}", e.getMessage());
|
||||
@@ -160,6 +191,7 @@ public class FileService {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
public List<FileEntity> getFiles() {
|
||||
return fileRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ spring.thymeleaf.cache=false
|
||||
server.tomcat.connection-timeout=60000
|
||||
logging.file.name=log/quickdrop.log
|
||||
#logging.level.org.springframework=DEBUG
|
||||
#logging.level.org.hibernate=DEBUG
|
||||
#logging.level.org.hibernate=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
@@ -1,5 +1,13 @@
|
||||
document.getElementById("uploadForm").addEventListener("submit", function (event) {
|
||||
event.preventDefault(); // Prevent the form from submitting synchronously
|
||||
event.preventDefault();
|
||||
|
||||
const file = document.getElementById("file").files[0];
|
||||
const passwordField = document.getElementById("password");
|
||||
const isPasswordProtected = passwordField && passwordField.value.trim() !== "";
|
||||
|
||||
const chunkSize = 10 * 1024 * 1024; // 10MB chunks
|
||||
const totalChunks = Math.ceil(file.size / chunkSize);
|
||||
let currentChunk = 0;
|
||||
|
||||
// Display the indicator
|
||||
document.getElementById("uploadIndicator").style.display = "block";
|
||||
@@ -8,67 +16,76 @@ document.getElementById("uploadForm").addEventListener("submit", function (event
|
||||
progressBar.style.width = "0%";
|
||||
progressBar.setAttribute("aria-valuenow", 0);
|
||||
|
||||
// Prepare form data
|
||||
const formData = new FormData(event.target);
|
||||
|
||||
// Create an AJAX request
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/file/upload", true);
|
||||
function uploadChunk() {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
// Add CSRF token if required
|
||||
const csrfTokenElement = document.querySelector('input[name="_csrf"]');
|
||||
if (csrfTokenElement) {
|
||||
xhr.setRequestHeader("X-CSRF-TOKEN", csrfTokenElement.value);
|
||||
}
|
||||
const chunkFormData = new FormData();
|
||||
chunkFormData.append("file", chunk);
|
||||
chunkFormData.append("fileName", file.name);
|
||||
chunkFormData.append("chunkNumber", currentChunk);
|
||||
chunkFormData.append("totalChunks", totalChunks);
|
||||
chunkFormData.append("description", formData.get("description"));
|
||||
chunkFormData.append("keepIndefinitely", formData.get("keepIndefinitely") || "false");
|
||||
chunkFormData.append("hidden", formData.get("hidden") || "false");
|
||||
chunkFormData.append("password", passwordField.value.trim() || ""); // Add password field
|
||||
|
||||
// Handle response
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
if (response.uuid) {
|
||||
// Redirect to the view page using the UUID from the JSON response
|
||||
window.location.href = "/file/" + response.uuid;
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/file/upload-chunk", true);
|
||||
|
||||
const csrfTokenElement = document.querySelector('input[name="_csrf"]');
|
||||
if (csrfTokenElement) {
|
||||
xhr.setRequestHeader("X-CSRF-TOKEN", csrfTokenElement.value);
|
||||
}
|
||||
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText); // Parse JSON response
|
||||
|
||||
currentChunk++;
|
||||
const percentComplete = (currentChunk / totalChunks) * 100;
|
||||
progressBar.style.width = percentComplete + "%";
|
||||
progressBar.setAttribute("aria-valuenow", percentComplete);
|
||||
|
||||
if (currentChunk < totalChunks) {
|
||||
uploadChunk();
|
||||
if (currentChunk === totalChunks - 1 && isPasswordProtected) {
|
||||
uploadStatus.innerText = "Upload complete. Encrypting..."
|
||||
}
|
||||
} else {
|
||||
uploadStatus.innerText = "Upload complete.";
|
||||
if (response.uuid) {
|
||||
window.location.href = "/file/" + response.uuid;
|
||||
} else {
|
||||
alert("Upload completed but no UUID received.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing JSON:", error);
|
||||
alert("Unexpected server response. Please try again.");
|
||||
}
|
||||
} else {
|
||||
alert("Unexpected response. Please try again.");
|
||||
console.error("Upload error:", xhr.responseText);
|
||||
alert("Chunk upload failed. Please try again.");
|
||||
document.getElementById("uploadIndicator").style.display = "none";
|
||||
}
|
||||
} else {
|
||||
alert("Upload failed. Please try again.");
|
||||
console.log(xhr.responseText);
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
alert("An error occurred during the upload. Please try again.");
|
||||
document.getElementById("uploadIndicator").style.display = "none";
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Handle network or server errors
|
||||
xhr.onerror = function () {
|
||||
alert("An error occurred during the upload. Please try again.");
|
||||
document.getElementById("uploadIndicator").style.display = "none";
|
||||
};
|
||||
xhr.send(chunkFormData);
|
||||
}
|
||||
|
||||
// Track upload progress
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (event.lengthComputable) {
|
||||
const percentComplete = (event.loaded / event.total) * 100;
|
||||
progressBar.style.width = percentComplete + "%";
|
||||
progressBar.setAttribute("aria-valuenow", percentComplete);
|
||||
|
||||
// Update the status text when upload is complete (100%)
|
||||
if (percentComplete === 100 && isPasswordProtected()) {
|
||||
uploadStatus.innerText = "Upload complete. Encrypting...";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Send the form data
|
||||
xhr.send(formData);
|
||||
uploadChunk();
|
||||
});
|
||||
|
||||
function isPasswordProtected() {
|
||||
const passwordField = document.getElementById("password");
|
||||
|
||||
return passwordField && passwordField.value.trim() !== "";
|
||||
}
|
||||
|
||||
function validateKeepIndefinitely() {
|
||||
const keepIndefinitely = document.getElementById("keepIndefinitely").checked;
|
||||
const password = document.getElementById("password").value;
|
||||
@@ -117,15 +134,4 @@ function parseSize(size) {
|
||||
const value = parseFloat(valueMatch[0]);
|
||||
|
||||
return value * (units[unit] || 1);
|
||||
}
|
||||
|
||||
function updateHiddenState(event, checkbox) {
|
||||
event.preventDefault();
|
||||
const hiddenField = checkbox.form.querySelector('input[name="hidden"][type="hidden"]');
|
||||
if (hiddenField) {
|
||||
hiddenField.value = checkbox.checked;
|
||||
}
|
||||
|
||||
console.log('Submitting hidden state form...');
|
||||
checkbox.form.submit();
|
||||
}
|
||||
@@ -15,8 +15,8 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -66,9 +66,7 @@ public class FileServiceTests {
|
||||
// Successfully saves an unencrypted file when no password is provided
|
||||
@Test
|
||||
void test_save_unencrypted_file_without_password() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("test.txt");
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
File file = mock(File.class);
|
||||
|
||||
FileEntity fileEntity = getFileEntity();
|
||||
fileEntity.passwordHash = null;
|
||||
@@ -86,9 +84,7 @@ public class FileServiceTests {
|
||||
// Successfully saves an encrypted file when a password is provided
|
||||
@Test
|
||||
void test_save_encrypted_file_with_password() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("test.txt");
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
File file = mock(File.class);
|
||||
|
||||
FileEntity fileEntity = getFileEntity();
|
||||
when(passwordEncoder.encode(anyString())).thenReturn(fileEntity.passwordHash);
|
||||
@@ -106,7 +102,7 @@ public class FileServiceTests {
|
||||
// Successfully encrypts a file
|
||||
@Test
|
||||
void test_encrypt_file() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
File file = mock(File.class);
|
||||
Path encryptedFile = Path.of(fileSavePath, "test.txt");
|
||||
|
||||
// Call the method responsible for encrypting files directly
|
||||
@@ -118,9 +114,7 @@ public class FileServiceTests {
|
||||
// Correctly encodes password when provided
|
||||
@Test
|
||||
void test_correctly_encodes_password_when_provided() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("test.txt");
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
File file = mock(File.class);
|
||||
|
||||
FileEntity fileEntity = getFileEntity();
|
||||
when(passwordEncoder.encode("securePassword")).thenReturn(fileEntity.passwordHash);
|
||||
@@ -134,9 +128,7 @@ public class FileServiceTests {
|
||||
|
||||
@Test
|
||||
void test_handles_empty_file_upload_request_gracefully() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("test.txt");
|
||||
when(file.getSize()).thenReturn(1024L);
|
||||
File file = mock(File.class);
|
||||
|
||||
when(fileRepository.save(any(FileEntity.class))).thenAnswer(invocation -> {
|
||||
FileEntity fileEntity = invocation.getArgument(0);
|
||||
@@ -155,7 +147,7 @@ public class FileServiceTests {
|
||||
|
||||
@Test
|
||||
void test_handles_null_file_upload_request() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
File file = mock(File.class);
|
||||
FileUploadRequest fileUploadRequest = null;
|
||||
|
||||
when(fileRepository.save(any(FileEntity.class))).thenReturn(getFileEntity());
|
||||
@@ -167,8 +159,7 @@ public class FileServiceTests {
|
||||
|
||||
@Test
|
||||
void test_handle_null_or_empty_multipartfile() {
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn(null);
|
||||
File file = mock(File.class);
|
||||
|
||||
FileEntity result = fileService.saveFile(file, getFileUploadRequest());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user