get rid of backwards compatibility complexity

This commit is contained in:
2026-06-05 14:29:45 +02:00
parent af02e24b4e
commit 0c3f8353be
5 changed files with 31 additions and 111 deletions

View File

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

View File

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

View File

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

View File

@@ -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,47 +10,31 @@ 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) {
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const plaintext = await file.slice(start, end).arrayBuffer();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
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();
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
const payload = new Uint8Array(12 + ciphertext.byteLength);
payload.set(iv, 0);
payload.set(new Uint8Array(ciphertext), 12);
const payload = new Uint8Array(12 + ciphertext.byteLength);
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);

View File

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