working download

This commit is contained in:
Gregor Lohaus
2026-02-25 13:11:11 +01:00
parent 12b5afe120
commit b4e033f905
8 changed files with 231 additions and 2 deletions

View File

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

View File

@@ -15,6 +15,8 @@ public class File {
private String name;
private LocalDateTime expireyDateTime;
private Integer downloadLimit;
@Column(columnDefinition = "integer default 0")
private int downloads = 0;
public LocalDateTime getExpireyDateTime() {
return expireyDateTime;
}
@@ -45,6 +47,12 @@ public class File {
public void setDownloadLimit(Integer 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) {
this.path = path;
this.name = name;

View File

@@ -13,4 +13,5 @@ public abstract class AbstractStorageService {
abstract public OptionalLong put(String id, byte[] data);
abstract public Optional<byte[]> get(String id);
abstract public boolean delete(String id);
}

View File

@@ -19,4 +19,9 @@ public class DummyStorageService extends AbstractStorageService {
public Optional<byte[]> get(String id) {
return Optional.empty();
}
@Override
public boolean delete(String id) {
return false;
}
}

View File

@@ -33,4 +33,13 @@ public class LocalStorageService extends AbstractStorageService {
return Optional.empty();
}
}
@Override
public boolean delete(String id) {
try {
return Files.deleteIfExists(root.resolve(id));
} catch (IOException e) {
return false;
}
}
}

View 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">&#x2705;</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">&#x26A0;</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>`;
}

View File

@@ -65,10 +65,11 @@ async function startUpload() {
const response = await fetch('/upload', { method: 'POST', body: formData });
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();
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) {
dropZone.innerHTML = `

View 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">&#x1F512; 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">&#x1F512;</div>
<div class="fw-medium mb-2" th:if="${filename != null}" th:text="${filename}"></div>
<div class="drop-zone-text" id="download-status">Preparing&hellip;</div>
</div>
</main>
<footer class="text-center p-4 small">
<a href="https://github.com/gregor-lohaus/gtransfer">Open source</a>
&middot; No tracking &middot; No ads
</footer>
</body>
</html>