get rid of backwards compatibility complexity
This commit is contained in:
@@ -5,7 +5,6 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -65,7 +64,7 @@ public class DownloadController {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Optional<byte[]> data = storageService.get(storageKey(file, index));
|
||||
Optional<byte[]> data = storageService.get(StorageKeys.chunk(file.getId(), index));
|
||||
if (data.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
@@ -96,39 +95,6 @@ public class DownloadController {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/download/{id}/data")
|
||||
@ResponseBody
|
||||
@Transactional
|
||||
public ResponseEntity<byte[]> data(@PathVariable String id) {
|
||||
AvailableFile available = getAvailableFile(id);
|
||||
if (!available.found()) {
|
||||
return ResponseEntity.status(available.status()).build();
|
||||
}
|
||||
|
||||
File file = available.file();
|
||||
if (file.isChunked()) {
|
||||
return ResponseEntity.status(HttpStatus.GONE).build();
|
||||
}
|
||||
|
||||
Optional<byte[]> data = storageService.get(id);
|
||||
if (data.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
file.setDownloads(file.getDownloads() + 1);
|
||||
fileRepository.save(file);
|
||||
|
||||
if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) {
|
||||
deleteStoredFile(file);
|
||||
fileRepository.delete(file);
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition(file.getName()))
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.body(data.get());
|
||||
}
|
||||
|
||||
private AvailableFile getAvailableFile(String id) {
|
||||
Optional<File> fileOpt = fileRepository.findById(id);
|
||||
if (fileOpt.isEmpty()) {
|
||||
@@ -152,28 +118,11 @@ public class DownloadController {
|
||||
}
|
||||
|
||||
private void deleteStoredFile(File file) {
|
||||
if (!file.isChunked()) {
|
||||
storageService.delete(file.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < file.getChunkCount(); i++) {
|
||||
storageService.delete(StorageKeys.chunk(file.getId(), i));
|
||||
}
|
||||
}
|
||||
|
||||
private String storageKey(File file, int index) {
|
||||
if (!file.isChunked()) {
|
||||
return file.getId();
|
||||
}
|
||||
return StorageKeys.chunk(file.getId(), index);
|
||||
}
|
||||
|
||||
private String contentDisposition(String filename) {
|
||||
return "attachment; filename=\""
|
||||
+ filename.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
|
||||
private record AvailableFile(File file, HttpStatus status) {
|
||||
static AvailableFile ok(File file) {
|
||||
return new AvailableFile(file, HttpStatus.OK);
|
||||
|
||||
@@ -49,9 +49,6 @@ public class File {
|
||||
public void setChunkCount(Integer chunkCount) {
|
||||
this.chunkCount = chunkCount;
|
||||
}
|
||||
public boolean isChunked() {
|
||||
return chunkCount != null;
|
||||
}
|
||||
public Long getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
@@ -51,11 +51,6 @@ public class FileCleanupService {
|
||||
}
|
||||
|
||||
private void deleteStoredFile(File file) {
|
||||
if (!file.isChunked()) {
|
||||
storageService.delete(file.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < file.getChunkCount(); i++) {
|
||||
storageService.delete(StorageKeys.chunk(file.getId(), i));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
|
||||
async function generateFileKey() {
|
||||
async function encryptFile(file, chunkSize = DEFAULT_CHUNK_SIZE) {
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
@@ -10,11 +10,19 @@ async function generateFileKey() {
|
||||
const rawKey = await crypto.subtle.exportKey('raw', key);
|
||||
const hash = await hashKey(rawKey);
|
||||
const base64urlKey = encodeKey(rawKey);
|
||||
const chunkCount = Math.max(1, Math.ceil(file.size / chunkSize));
|
||||
|
||||
return { key, rawKey, hash, base64urlKey };
|
||||
return {
|
||||
hash,
|
||||
base64urlKey,
|
||||
chunkCount,
|
||||
size: file.size,
|
||||
chunks: encryptedChunks(file, key, chunkCount, chunkSize)
|
||||
};
|
||||
}
|
||||
|
||||
async function encryptFileChunk(file, index, key, chunkSize = DEFAULT_CHUNK_SIZE) {
|
||||
async function* encryptedChunks(file, key, chunkCount, chunkSize) {
|
||||
for (let index = 0; index < chunkCount; index++) {
|
||||
const start = index * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
const plaintext = await file.slice(start, end).arrayBuffer();
|
||||
@@ -25,32 +33,8 @@ async function encryptFileChunk(file, index, key, chunkSize = DEFAULT_CHUNK_SIZE
|
||||
payload.set(iv, 0);
|
||||
payload.set(new Uint8Array(ciphertext), 12);
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function encryptFile(arrayBuffer) {
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, arrayBuffer);
|
||||
|
||||
// Payload: 12-byte IV prepended to ciphertext
|
||||
const payload = new Uint8Array(12 + ciphertext.byteLength);
|
||||
payload.set(iv, 0);
|
||||
payload.set(new Uint8Array(ciphertext), 12);
|
||||
|
||||
// SHA-256(rawKey) → file identifier sent to server; server never sees the key itself
|
||||
const rawKey = await crypto.subtle.exportKey('raw', key);
|
||||
const hash = await hashKey(rawKey);
|
||||
|
||||
const base64urlKey = encodeKey(rawKey);
|
||||
|
||||
return { payload, hash, base64urlKey };
|
||||
yield { index, payload };
|
||||
}
|
||||
}
|
||||
|
||||
async function hashKey(rawKey) {
|
||||
@@ -59,10 +43,6 @@ async function hashKey(rawKey) {
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function decryptFile(payload, key) {
|
||||
return decryptChunk(payload, key);
|
||||
}
|
||||
|
||||
async function decryptChunk(payload, key) {
|
||||
const bytes = new Uint8Array(payload);
|
||||
const iv = bytes.slice(0, 12);
|
||||
|
||||
@@ -50,17 +50,16 @@ async function startUpload() {
|
||||
{ swapStyle: 'innerHTML' });
|
||||
|
||||
try {
|
||||
const { key, hash, base64urlKey } = await generateFileKey();
|
||||
const chunkCount = Math.max(1, Math.ceil(selectedFile.size / DEFAULT_CHUNK_SIZE));
|
||||
const encryptedFile = await encryptFile(selectedFile);
|
||||
|
||||
for (let index = 0; index < chunkCount; index++) {
|
||||
for await (const { index, payload } of encryptedFile.chunks) {
|
||||
const chunkCount = encryptedFile.chunkCount;
|
||||
setProgress(`Encrypting chunk ${index + 1} of ${chunkCount}\u2026`, index, chunkCount);
|
||||
const payload = await encryptFileChunk(selectedFile, index, key);
|
||||
|
||||
setProgress(`Uploading chunk ${index + 1} of ${chunkCount}\u2026`, index + 0.5, chunkCount);
|
||||
const chunkData = new FormData();
|
||||
chunkData.append('chunk', new Blob([payload]), String(index));
|
||||
chunkData.append('hash', hash);
|
||||
chunkData.append('hash', encryptedFile.hash);
|
||||
chunkData.append('index', index);
|
||||
|
||||
const chunkResponse = await fetch('/upload/chunk', { method: 'POST', body: chunkData });
|
||||
@@ -68,12 +67,12 @@ async function startUpload() {
|
||||
setProgress(`Uploaded chunk ${index + 1} of ${chunkCount}`, index + 1, chunkCount);
|
||||
}
|
||||
|
||||
setProgress('Finalizing\u2026', chunkCount, chunkCount);
|
||||
setProgress('Finalizing\u2026', encryptedFile.chunkCount, encryptedFile.chunkCount);
|
||||
const metadata = new FormData();
|
||||
metadata.append('hash', hash);
|
||||
metadata.append('hash', encryptedFile.hash);
|
||||
metadata.append('name', selectedFile.name);
|
||||
metadata.append('chunkCount', chunkCount);
|
||||
metadata.append('size', selectedFile.size);
|
||||
metadata.append('chunkCount', encryptedFile.chunkCount);
|
||||
metadata.append('size', encryptedFile.size);
|
||||
if (expiryDays) metadata.append('expiryDays', expiryDays);
|
||||
if (downloadLimit) metadata.append('downloadLimit', downloadLimit);
|
||||
|
||||
@@ -82,7 +81,7 @@ async function startUpload() {
|
||||
|
||||
htmx.swap(dropZone, await response.text(), { swapStyle: 'innerHTML' });
|
||||
htmx.process(dropZone);
|
||||
htmx.find('#share-link').value = window.location.origin + '/download#' + base64urlKey;
|
||||
htmx.find('#share-link').value = window.location.origin + '/download#' + encryptedFile.base64urlKey;
|
||||
|
||||
} catch (err) {
|
||||
htmx.swap(dropZone, `
|
||||
|
||||
Reference in New Issue
Block a user