working download
This commit is contained in:
@@ -0,0 +1,86 @@
|
|||||||
|
package com.gregor_lohaus.gtransfer.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
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;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
|
||||||
|
import com.gregor_lohaus.gtransfer.model.File;
|
||||||
|
import com.gregor_lohaus.gtransfer.model.FileRepository;
|
||||||
|
import com.gregor_lohaus.gtransfer.services.filewriter.AbstractStorageService;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class DownloadController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FileRepository fileRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AbstractStorageService storageService;
|
||||||
|
|
||||||
|
@GetMapping("/download/{id}")
|
||||||
|
public String page(@PathVariable String id, Model model) {
|
||||||
|
fileRepository.findById(id)
|
||||||
|
.ifPresent(f -> model.addAttribute("filename", f.getName()));
|
||||||
|
return "download/page";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/download/{id}/data")
|
||||||
|
@ResponseBody
|
||||||
|
@Transactional
|
||||||
|
public ResponseEntity<byte[]> data(@PathVariable String id) {
|
||||||
|
Optional<File> fileOpt = fileRepository.findById(id);
|
||||||
|
if (fileOpt.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
File file = fileOpt.get();
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
if (file.getExpireyDateTime() != null && LocalDateTime.now().isAfter(file.getExpireyDateTime())) {
|
||||||
|
storageService.delete(id);
|
||||||
|
fileRepository.delete(file);
|
||||||
|
return ResponseEntity.status(HttpStatus.GONE).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check download limit before serving
|
||||||
|
if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) {
|
||||||
|
storageService.delete(id);
|
||||||
|
fileRepository.delete(file);
|
||||||
|
return ResponseEntity.status(HttpStatus.GONE).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<byte[]> data = storageService.get(id);
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
file.setDownloads(file.getDownloads() + 1);
|
||||||
|
fileRepository.save(file);
|
||||||
|
|
||||||
|
// Clean up if limit now reached
|
||||||
|
if (file.getDownloadLimit() != null && file.getDownloads() >= file.getDownloadLimit()) {
|
||||||
|
storageService.delete(id);
|
||||||
|
fileRepository.delete(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
String disposition = "attachment; filename=\""
|
||||||
|
+ file.getName().replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, disposition)
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.body(data.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ public class File {
|
|||||||
private String name;
|
private String name;
|
||||||
private LocalDateTime expireyDateTime;
|
private LocalDateTime expireyDateTime;
|
||||||
private Integer downloadLimit;
|
private Integer downloadLimit;
|
||||||
|
@Column(columnDefinition = "integer default 0")
|
||||||
|
private int downloads = 0;
|
||||||
public LocalDateTime getExpireyDateTime() {
|
public LocalDateTime getExpireyDateTime() {
|
||||||
return expireyDateTime;
|
return expireyDateTime;
|
||||||
}
|
}
|
||||||
@@ -45,6 +47,12 @@ public class File {
|
|||||||
public void setDownloadLimit(Integer downloadLimit) {
|
public void setDownloadLimit(Integer downloadLimit) {
|
||||||
this.downloadLimit = downloadLimit;
|
this.downloadLimit = downloadLimit;
|
||||||
}
|
}
|
||||||
|
public int getDownloads() {
|
||||||
|
return downloads;
|
||||||
|
}
|
||||||
|
public void setDownloads(int downloads) {
|
||||||
|
this.downloads = downloads;
|
||||||
|
}
|
||||||
public File(String id, String path, String name, LocalDateTime expDateTime) {
|
public File(String id, String path, String name, LocalDateTime expDateTime) {
|
||||||
this.path = path;
|
this.path = path;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public abstract class AbstractStorageService {
|
|||||||
|
|
||||||
abstract public OptionalLong put(String id, byte[] data);
|
abstract public OptionalLong put(String id, byte[] data);
|
||||||
abstract public Optional<byte[]> get(String id);
|
abstract public Optional<byte[]> get(String id);
|
||||||
|
abstract public boolean delete(String id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,9 @@ public class DummyStorageService extends AbstractStorageService {
|
|||||||
public Optional<byte[]> get(String id) {
|
public Optional<byte[]> get(String id) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean delete(String id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,13 @@ public class LocalStorageService extends AbstractStorageService {
|
|||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean delete(String id) {
|
||||||
|
try {
|
||||||
|
return Files.deleteIfExists(root.resolve(id));
|
||||||
|
} catch (IOException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
Backend/src/main/resources/static/download.js
Normal file
87
Backend/src/main/resources/static/download.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
(async function () {
|
||||||
|
const fragment = location.hash.slice(1);
|
||||||
|
if (!fragment) {
|
||||||
|
showError('No decryption key found in URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setStatus('Deriving key\u2026');
|
||||||
|
|
||||||
|
// Decode base64url → raw key bytes
|
||||||
|
const base64 = fragment.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawKeyBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Import key
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw', rawKeyBytes, { name: 'AES-GCM' }, false, ['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive hash → verify it matches the file ID in the URL
|
||||||
|
const hash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', rawKeyBytes)))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const pathId = location.pathname.split('/').pop();
|
||||||
|
if (hash !== pathId) {
|
||||||
|
showError('Invalid link — key does not match file.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('Downloading\u2026');
|
||||||
|
const response = await fetch(location.pathname + '/data');
|
||||||
|
|
||||||
|
if (response.status === 410) {
|
||||||
|
showError('This file has expired or reached its download limit.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
showError(`Download failed (${response.status}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract filename from Content-Disposition header
|
||||||
|
const disposition = response.headers.get('Content-Disposition') || '';
|
||||||
|
const filename = disposition.match(/filename="?([^"]+)"?/)?.[1] || 'download';
|
||||||
|
|
||||||
|
setStatus('Decrypting\u2026');
|
||||||
|
const encrypted = new Uint8Array(await response.arrayBuffer());
|
||||||
|
const iv = encrypted.slice(0, 12);
|
||||||
|
const ciphertext = encrypted.slice(12);
|
||||||
|
|
||||||
|
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
||||||
|
|
||||||
|
// Trigger browser download
|
||||||
|
const url = URL.createObjectURL(new Blob([plaintext]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
showSuccess(filename);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError('Decryption failed: ' + err.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
const el = document.getElementById('download-status');
|
||||||
|
if (el) el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(filename) {
|
||||||
|
document.getElementById('download-state').innerHTML = `
|
||||||
|
<div class="drop-zone-icon mb-3">✅</div>
|
||||||
|
<div class="fw-medium mb-1">${filename}</div>
|
||||||
|
<div class="drop-zone-text mb-3">Your download has started.</div>
|
||||||
|
<a href="/" class="btn btn-link drop-zone-text text-decoration-none">Send a file</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
document.getElementById('download-state').innerHTML = `
|
||||||
|
<div class="drop-zone-icon mb-3">⚠</div>
|
||||||
|
<div class="drop-zone-text mb-3">${msg}</div>
|
||||||
|
<a href="/" class="btn btn-link drop-zone-text text-decoration-none">Go home</a>`;
|
||||||
|
}
|
||||||
@@ -65,10 +65,11 @@ async function startUpload() {
|
|||||||
const response = await fetch('/upload', { method: 'POST', body: formData });
|
const response = await fetch('/upload', { method: 'POST', body: formData });
|
||||||
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
if (!response.ok) throw new Error(`Server error ${response.status}`);
|
||||||
|
|
||||||
// Server returns HTML fragment; append key fragment client-side
|
// Server returns HTML fragment; prepend origin and append key fragment client-side
|
||||||
dropZone.innerHTML = await response.text();
|
dropZone.innerHTML = await response.text();
|
||||||
htmx.process(dropZone);
|
htmx.process(dropZone);
|
||||||
document.getElementById('share-link').value += '#' + base64urlKey;
|
const shareLink = document.getElementById('share-link');
|
||||||
|
shareLink.value = window.location.origin + shareLink.value + '#' + base64urlKey;
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
dropZone.innerHTML = `
|
dropZone.innerHTML = `
|
||||||
|
|||||||
32
Backend/src/main/resources/templates/download/page.html
Normal file
32
Backend/src/main/resources/templates/download/page.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" data-bs-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title th:text="${filename != null ? filename + ' — GTransfer' : 'GTransfer'}">GTransfer</title>
|
||||||
|
<link rel="stylesheet" th:href="@{/webjars/bootstrap/dist/css/bootstrap.min.css}">
|
||||||
|
<link rel="stylesheet" th:href="@{/style.css}">
|
||||||
|
<script th:src="@{/download.js}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
|
||||||
|
<nav class="navbar px-4 pt-3">
|
||||||
|
<a class="brand fw-bold text-decoration-none fs-4" href="/">G<span>Transfer</span></a>
|
||||||
|
<span class="badge-e2e rounded-pill fw-medium px-3 py-1">🔒 End-to-end encrypted</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 d-flex align-items-center justify-content-center py-5 px-3">
|
||||||
|
<div id="download-state" class="drop-zone text-center py-5 px-4" style="max-width: 520px; width: 100%;">
|
||||||
|
<div class="drop-zone-icon mb-3">🔒</div>
|
||||||
|
<div class="fw-medium mb-2" th:if="${filename != null}" th:text="${filename}"></div>
|
||||||
|
<div class="drop-zone-text" id="download-status">Preparing…</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="text-center p-4 small">
|
||||||
|
<a href="https://github.com/gregor-lohaus/gtransfer">Open source</a>
|
||||||
|
· No tracking · No ads
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user