get rid of backwards compatibility complexity
This commit is contained in:
@@ -5,7 +5,6 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -65,7 +64,7 @@ public class DownloadController {
|
|||||||
return ResponseEntity.badRequest().build();
|
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()) {
|
if (data.isEmpty()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -96,39 +95,6 @@ public class DownloadController {
|
|||||||
return ResponseEntity.noContent().build();
|
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) {
|
private AvailableFile getAvailableFile(String id) {
|
||||||
Optional<File> fileOpt = fileRepository.findById(id);
|
Optional<File> fileOpt = fileRepository.findById(id);
|
||||||
if (fileOpt.isEmpty()) {
|
if (fileOpt.isEmpty()) {
|
||||||
@@ -152,28 +118,11 @@ public class DownloadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void deleteStoredFile(File file) {
|
private void deleteStoredFile(File file) {
|
||||||
if (!file.isChunked()) {
|
|
||||||
storageService.delete(file.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < file.getChunkCount(); i++) {
|
for (int i = 0; i < file.getChunkCount(); i++) {
|
||||||
storageService.delete(StorageKeys.chunk(file.getId(), 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) {
|
private record AvailableFile(File file, HttpStatus status) {
|
||||||
static AvailableFile ok(File file) {
|
static AvailableFile ok(File file) {
|
||||||
return new AvailableFile(file, HttpStatus.OK);
|
return new AvailableFile(file, HttpStatus.OK);
|
||||||
|
|||||||
@@ -49,9 +49,6 @@ public class File {
|
|||||||
public void setChunkCount(Integer chunkCount) {
|
public void setChunkCount(Integer chunkCount) {
|
||||||
this.chunkCount = chunkCount;
|
this.chunkCount = chunkCount;
|
||||||
}
|
}
|
||||||
public boolean isChunked() {
|
|
||||||
return chunkCount != null;
|
|
||||||
}
|
|
||||||
public Long getSize() {
|
public Long getSize() {
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,6 @@ public class FileCleanupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void deleteStoredFile(File file) {
|
private void deleteStoredFile(File file) {
|
||||||
if (!file.isChunked()) {
|
|
||||||
storageService.delete(file.getId());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (int i = 0; i < file.getChunkCount(); i++) {
|
for (int i = 0; i < file.getChunkCount(); i++) {
|
||||||
storageService.delete(StorageKeys.chunk(file.getId(), i));
|
storageService.delete(StorageKeys.chunk(file.getId(), i));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024;
|
var DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024;
|
||||||
|
|
||||||
async function generateFileKey() {
|
async function encryptFile(file, chunkSize = DEFAULT_CHUNK_SIZE) {
|
||||||
const key = await crypto.subtle.generateKey(
|
const key = await crypto.subtle.generateKey(
|
||||||
{ name: 'AES-GCM', length: 256 },
|
{ name: 'AES-GCM', length: 256 },
|
||||||
true,
|
true,
|
||||||
@@ -10,47 +10,31 @@ async function generateFileKey() {
|
|||||||
const rawKey = await crypto.subtle.exportKey('raw', key);
|
const rawKey = await crypto.subtle.exportKey('raw', key);
|
||||||
const hash = await hashKey(rawKey);
|
const hash = await hashKey(rawKey);
|
||||||
const base64urlKey = encodeKey(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) {
|
||||||
const start = index * chunkSize;
|
for (let index = 0; index < chunkCount; index++) {
|
||||||
const end = Math.min(start + chunkSize, file.size);
|
const start = index * chunkSize;
|
||||||
const plaintext = await file.slice(start, end).arrayBuffer();
|
const end = Math.min(start + chunkSize, file.size);
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
const plaintext = await file.slice(start, end).arrayBuffer();
|
||||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
|
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);
|
const payload = new Uint8Array(12 + ciphertext.byteLength);
|
||||||
payload.set(iv, 0);
|
payload.set(iv, 0);
|
||||||
payload.set(new Uint8Array(ciphertext), 12);
|
payload.set(new Uint8Array(ciphertext), 12);
|
||||||
|
|
||||||
return payload;
|
yield { index, 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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hashKey(rawKey) {
|
async function hashKey(rawKey) {
|
||||||
@@ -59,10 +43,6 @@ async function hashKey(rawKey) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decryptFile(payload, key) {
|
|
||||||
return decryptChunk(payload, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decryptChunk(payload, key) {
|
async function decryptChunk(payload, key) {
|
||||||
const bytes = new Uint8Array(payload);
|
const bytes = new Uint8Array(payload);
|
||||||
const iv = bytes.slice(0, 12);
|
const iv = bytes.slice(0, 12);
|
||||||
|
|||||||
@@ -50,17 +50,16 @@ async function startUpload() {
|
|||||||
{ swapStyle: 'innerHTML' });
|
{ swapStyle: 'innerHTML' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { key, hash, base64urlKey } = await generateFileKey();
|
const encryptedFile = await encryptFile(selectedFile);
|
||||||
const chunkCount = Math.max(1, Math.ceil(selectedFile.size / DEFAULT_CHUNK_SIZE));
|
|
||||||
|
|
||||||
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);
|
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);
|
setProgress(`Uploading chunk ${index + 1} of ${chunkCount}\u2026`, index + 0.5, chunkCount);
|
||||||
const chunkData = new FormData();
|
const chunkData = new FormData();
|
||||||
chunkData.append('chunk', new Blob([payload]), String(index));
|
chunkData.append('chunk', new Blob([payload]), String(index));
|
||||||
chunkData.append('hash', hash);
|
chunkData.append('hash', encryptedFile.hash);
|
||||||
chunkData.append('index', index);
|
chunkData.append('index', index);
|
||||||
|
|
||||||
const chunkResponse = await fetch('/upload/chunk', { method: 'POST', body: chunkData });
|
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(`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();
|
const metadata = new FormData();
|
||||||
metadata.append('hash', hash);
|
metadata.append('hash', encryptedFile.hash);
|
||||||
metadata.append('name', selectedFile.name);
|
metadata.append('name', selectedFile.name);
|
||||||
metadata.append('chunkCount', chunkCount);
|
metadata.append('chunkCount', encryptedFile.chunkCount);
|
||||||
metadata.append('size', selectedFile.size);
|
metadata.append('size', encryptedFile.size);
|
||||||
if (expiryDays) metadata.append('expiryDays', expiryDays);
|
if (expiryDays) metadata.append('expiryDays', expiryDays);
|
||||||
if (downloadLimit) metadata.append('downloadLimit', downloadLimit);
|
if (downloadLimit) metadata.append('downloadLimit', downloadLimit);
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ async function startUpload() {
|
|||||||
|
|
||||||
htmx.swap(dropZone, await response.text(), { swapStyle: 'innerHTML' });
|
htmx.swap(dropZone, await response.text(), { swapStyle: 'innerHTML' });
|
||||||
htmx.process(dropZone);
|
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) {
|
} catch (err) {
|
||||||
htmx.swap(dropZone, `
|
htmx.swap(dropZone, `
|
||||||
|
|||||||
Reference in New Issue
Block a user