the uploads are now chunked

This commit is contained in:
Rostislav Raykov
2024-12-12 22:38:06 +02:00
parent bd8b831f3d
commit f45aad99b7
7 changed files with 172 additions and 119 deletions

View File

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

View File

@@ -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);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());