Merge pull request 'feature/download' (#7) from feature/download into main

Reviewed-on: https://git.walzen665.de/Walzen665/fileshare-service/pulls/7
This commit is contained in:
Max W. 2024-02-18 10:30:45 +00:00
commit f419492cca
11 changed files with 344 additions and 42 deletions

View File

@ -1,32 +1,121 @@
<div class="container mx-auto p-4 mt-4"> <div class="container mx-auto p-4 mt-4 animate-in fade-in slide-in-from-left-6 duration-300">
<h1 class="text-4xl font-bold text-center text-gray-800 mb-2">Download File</h1> <h1 class="text-4xl font-bold text-center text-gray-800 mb-2">Download File</h1>
<div
*ngIf="!fileDownloadStarted && !fileDownloadFinished"
>
<p class="text-md text-center text-gray-600 mb-6">Access your files quickly and securely</p> <p class="text-md text-center text-gray-600 mb-6">Access your files quickly and securely</p>
<div class="bg-white shadow-lg rounded-lg p-6 flex flex-col items-center justify-center"> <div class="bg-white shadow-lg rounded-lg p-6 flex flex-col items-center justify-center">
<img class="w-28 mt-6 mb-6" src="./assets/cloud-arrow-down-solid.svg"> <img class="w-56 mt-6 mb-6" src="./assets/cloud-arrow-down-solid.svg">
<div class="w-full max-w-md mb-6 relative"> <input type="text" class="input input-bordered text-center w-full max-w-md mb-6" placeholder="Enter download code/link"
<input type="text" class="input input-bordered w-full pl-10" placeholder="Enter download code/link" /> [(ngModel)]="inputFileId"
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> (keydown.enter)="requestDownload()"/>
<span class="text-gray-500 sm:text-sm">
<i class="fas fa-link"></i> <div class="mb-6 w-full flex flex-col items-center justify-center">
</span> <input
*ngIf="downloadInfo && downloadInfo.passwordProtected && downloadInfo.downloadable"
[ngClass]="{'input-error': passwordWrong}"
type="password"
class="input input-bordered text-center w-full max-w-md"
placeholder="Enter file password..."
[(ngModel)]="filePassword"/>
<div class="label" *ngIf="passwordWrong">
<span class="label-text-alt text-red-500">Password wrong | Check capslock</span>
</div> </div>
</div> </div>
<button class="btn btn-primary w-full max-w-xs flex justify-center items-center mb-4">
<i class="fas fa-cloud-download-alt mr-2"></i> <button class="btn btn-primary w-full max-w-md flex justify-center items-center mb-4 mt-3" (click)="requestDownload()">
Download Download
</button> </button>
<button
(click)="download_not_possible.showModal()"
class="btn btn-secondary btn-outline w-full max-w-md flex justify-center items-center mb-4">
Reset
</button>
<p class="text-gray-600 text-center w-2/3">Files are available for a limited time. Ensure the code or link is correct and the file is still downloadable.</p> <p class="text-gray-600 text-center w-2/3">Files are available for a limited time. Ensure the code or link is correct and the file is still downloadable.</p>
</div> </div>
</div> </div>
<dialog #password_modal class="modal"> <div
*ngIf="fileDownloadStarted && !fileDownloadFinished"
>
<div class="bg-white shadow-lg rounded-lg p-6 flex flex-col items-center justify-center">
<div class="radial-progress mb-6" [style.--value]="downloadProgress" style="--size:12rem; --thickness: 2rem;" role="progressbar">{{ downloadProgress | number:'1.0-0' }}%</div>
<h3 class="text-xl font-bold text-center text-gray-800 mb-2">Downloading...</h3>
<p class="text-gray-600 text-center w-2/3 mb-6">Your file is being downloaded in the background. Please wait...</p>
<p class="text-gray-600 text-center w-2/3 mb-6">{{ downloadDuration }} seconds passed.</p>
</div>
</div>
<div
*ngIf="fileDownloadFinished"
>
<div class="bg-white shadow-lg rounded-lg p-6 flex flex-col items-center justify-center">
<img class="w-16 mb-6" src="./assets/circle-check-solid.svg" alt="Success">
<p class="text-xl font-bold text-center text-gray-800 mb-6">Download complete!</p>
<p class="text-gray-600 text-center w-2/3 mb-3">File <strong>{{ fileName }}</strong> has been downloaded and saved to your drive.</p>
<p class="text-gray-600 text-center w-2/3 mb-6">The download took {{ downloadDuration }} seconds.</p>
<button class="btn btn-primary w-full max-w-md flex justify-center items-center mb-4" >
Save to disk (again)
</button>
<button class="btn btn-primary w-full max-w-md flex justify-center items-center mb-4" routerLink="/home">
Back to home
</button>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center">
<div *ngIf="fileDownloadStarted && !fileDownloadFinished" class="card w-96 bg-base-100 shadow-xl">
<div class="card-body flex flex-col items-center justify-center">
<h2 class="card-title text-center">Did you know?</h2>
<p class="text-center">{{ funfact }}</p>
</div>
</div>
</div>
<dialog #download_not_possible class="modal">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg">Please enter the file password</h3> <h3 class="font-bold text-lg">The file cant be downloaded!</h3>
<p class="py-4">Please enter the download password:</p> <div class="flex items-center justify-center flex-col w-full">
<input type="password" placeholder="Password..." class="input input-bordered w-full max-w-xs" /> <img class="w-24 mt-6 mb-6" src="./assets/poop-solid.svg">
<div class="modal-action"> <h4 class="text-xl mb-1">Oh no...</h4>
<p class="text-gray-400 mb-6">What happened here?</p>
<p class="text-gray-800 text-center mb-3">The file you are trying to download is not available anymore.</p>
<p class="text-gray-800 text-center mb-6">This issue may occur if the download limit has been reached or the download period has expired.</p>
<form method="dialog"> <form method="dialog">
<button class="btn">Cancel download</button> <button class="btn">Okay</button>
</form> </form>
</div> </div>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
</div> </div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog> </dialog>
<dialog #file_not_found_modal class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg">File not found!</h3>
<div class="flex items-center justify-center flex-col w-full">
<img class="w-24 mt-6 mb-6" src="./assets/poop-solid.svg">
<h4 class="text-xl mb-1">Oh no...</h4>
<p class="text-gray-400 mb-6">What happened here?</p>
<p class="text-gray-800 text-center mb-3">The file you are trying to download does not exist.</p>
<p class="text-gray-800 text-center mb-6">Please check the file ID.</p>
<form method="dialog">
<button class="btn">Okay</button>
</form>
</div>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<div class="invisible h-0 w-0">
<img src="./assets/circle-check-solid.svg">
</div>

View File

@ -1,25 +1,86 @@
import { Component } from '@angular/core'; import {Component, ElementRef, ViewChild} from '@angular/core';
import axios from "axios"; import axios from "axios";
import {DevelopmentStore} from "../../store/DevelopmentStore";
import {FormsModule} from "@angular/forms";
import {DecimalPipe, NgClass, NgIf} from "@angular/common";
import funfacts from "../../assets/funfacts";
import {RouterLink} from "@angular/router";
@Component({ @Component({
selector: 'app-download', selector: 'app-download',
standalone: true, standalone: true,
imports: [], imports: [
FormsModule,
NgIf,
DecimalPipe,
RouterLink,
NgClass
],
templateUrl: './download.component.html', templateUrl: './download.component.html',
styleUrl: './download.component.scss' styleUrl: './download.component.scss'
}) })
export class DownloadComponent { export class DownloadComponent {
constructor() { @ViewChild('download_not_possible') download_not_possible: ElementRef<HTMLDialogElement> | undefined;
@ViewChild('file_not_found_modal') file_not_found_modal: ElementRef<HTMLDialogElement> | undefined;
inputFileId: string = "2402183";
fileId: string = "";
filePassword: string = "";
fileName: string = "";
downloadInfo: DownloadInfo | null = null;
fileDownloadStarted: boolean = false;
fileDownloadFinished: boolean = false;
waitingForPassword: boolean = false;
downloadProgress: number = 0;
downloadDuration: string = "";
passwordWrong: boolean = false;
funfact: string = "";
constructor(private developmentStore: DevelopmentStore) {
this.funfact = funfacts[Math.floor(Math.random() * funfacts.length)];
this.speedTest(); this.speedTest();
} }
requestDownload() {
if(this.waitingForPassword) {
console.log("Requesting download with password");
this.downloadFile();
return;
}
console.log(this.inputFileId);
this.fileId = this.inputFileId; // TODO: Implement link extraction logic
this.getDownloadInfo();
}
processDownloadInfo() {
if(!this.downloadInfo?.passwordProtected && this.downloadInfo?.downloadable) {
console.log("Proceeding with download");
this.downloadFile();
return;
}
else if(!this.downloadInfo?.downloadable) {
this.download_not_possible?.nativeElement.showModal();
return;
}
else if(this.downloadInfo?.passwordProtected) {
this.waitingForPassword = true;
console.log("Password protected");
}
}
private speedTest() { private speedTest() {
const start = new Date().getTime(); // Start timer const start = new Date().getTime(); // Start timer
axios({ axios({
method: 'get', method: 'get',
url: 'http://localhost:/api/v1/speed-test', url: this.developmentStore.getBaseUrl() + 'api/v1/speed-test',
responseType: 'arraybuffer', responseType: 'arraybuffer',
headers: { headers: {
'Access-Control-Allow-Origin': '*', // Allow CORS 'Access-Control-Allow-Origin': '*', // Allow CORS
@ -38,4 +99,94 @@ export class DownloadComponent {
console.error('Error during download test:', error); console.error('Error during download test:', error);
}); });
} }
private getDownloadInfo() {
axios({
method: 'get',
url: this.developmentStore.getBaseUrl() + 'api/v1/download-info?fileId=' + this.fileId,
responseType: 'json',
headers: {
'Access-Control-Allow-Origin': '*', // Allow CORS
}
})
.then(response => {
this.downloadInfo = response.data;
console.log(response.data);
this.processDownloadInfo();
})
.catch(error => {
this.file_not_found_modal?.nativeElement.showModal();
console.error('Error during download info request:', error);
});
}
private downloadFile() {
const startTime = new Date().getTime();
this.fileDownloadStarted = true;
axios({
method: 'get',
url: this.developmentStore.getBaseUrl() + 'api/v1/download?fileId=' + this.fileId + '&password=' + this.filePassword,
responseType: 'arraybuffer',
headers: {
'Access-Control-Allow-Origin': '*', // Allow CORS
},
onDownloadProgress: (progressEvent) => {
const endTime = new Date().getTime();
const duration = (endTime - startTime) / 1000;
this.downloadDuration = duration.toFixed(0);
// Calculate the percentage of download completed
if(progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
this.downloadProgress = percentCompleted;
console.log(percentCompleted + '%'); // Log the percentage or update any progress UI component
}
}
})
.then(response => {
const endTime = new Date().getTime();
const duration = (endTime - startTime) / 1000;
this.downloadDuration = duration.toFixed(2);
this.fileDownloadFinished = true;
const contentDisposition = response.headers['content-disposition'];
let filename = "default_filename"; // Default filename in case parsing fails
if (contentDisposition) {
const filenameRegex = /filename="?([^"]+)"?/;
const matches = contentDisposition.match(filenameRegex);
if (matches && matches[1]) {
filename = matches[1];
}
}
this.fileName = filename;
const blob = new Blob([response.data], {type: 'application/octet-stream'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename; // You can specify a filename here
document.body.appendChild(a);
a.click();
// Clean up by revoking the Blob URL and removing the temporary anchor
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
})
.catch(error => {
this.fileDownloadStarted = false;
this.wrongPassword();
console.error('Error during download request:', error);
});
}
private wrongPassword() {
this.passwordWrong = true;
}
}
interface DownloadInfo {
downloadable: boolean;
passwordProtected: boolean;
singleDownload: boolean;
fileId: string;
} }

View File

@ -1,4 +1,4 @@
<div class="container mx-auto p-4 mt-4"> <div class="container mx-auto p-4 mt-4 animate-in fade-in slide-in-from-right-6 duration-300">
<h1 class="text-4xl font-bold text-center text-gray-800 mb-5">Upload Your File</h1> <h1 class="text-4xl font-bold text-center text-gray-800 mb-5">Upload Your File</h1>
<div class="bg-white shadow-lg rounded-lg lg:p-6 p-3"> <div class="bg-white shadow-lg rounded-lg lg:p-6 p-3">
<div *ngIf="!uploadStarted" class="flex flex-col items-center justify-center lg:p-5"> <div *ngIf="!uploadStarted" class="flex flex-col items-center justify-center lg:p-5">

View File

@ -147,7 +147,7 @@ export class UploadComponent {
axios({ axios({
method: 'post', method: 'post',
url: 'http://localhost/api/v1/upload-speed-test', url: this.developmentStore.getBaseUrl() + 'api/v1/upload-speed-test',
data: uint8View, data: uint8View,
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',

View File

@ -0,0 +1,24 @@
let funfacts = [
"Honey never spoils. Archaeologists have found pots of honey in ancient Egyptian tombs that are over 3,000 years old and still edible.",
"Octopuses have three hearts and blue blood.",
"Bananas are berries, but strawberries are not.",
"The shortest war in history was between Britain and Zanzibar on August 27, 1896. Zanzibar surrendered after 38 minutes.",
"A day on Venus is longer than a year on Venus.",
"The Eiffel Tower can be 15 cm taller during the summer when the iron heats up and expands.",
"Cows have best friends and can become stressed if they are separated.",
"A group of flamingos is called a flamboyance.",
"The unicorn is the national animal of Scotland.",
"More people are killed each year by cows than by sharks.",
"The total weight of all the ants on Earth is about the same as the weight of all the humans on Earth.",
"Wombat poop is cube-shaped.",
"The inventor of the frisbee was turned into a frisbee after he died.",
"There are more possible iterations of a game of chess than there are atoms in the known universe.",
"The heart of a blue whale is so large that a human can swim through the arteries.",
"Vending machines kill 4 times as many people as sharks per year.",
"Butterflies taste with their feet.",
"In Switzerland, it is illegal to own just one guinea pig because they are prone to loneliness.",
"Snails can sleep for up to three years.",
"The old Twitter bird actually had a name - Larry."
];
export default funfacts;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path fill="#2b3440" d="M254.4 6.6c3.5-4.3 9-6.5 14.5-5.7C315.8 7.2 352 47.4 352 96c0 11.2-1.9 22-5.5 32H352c35.3 0 64 28.7 64 64c0 19.1-8.4 36.3-21.7 48H408c39.8 0 72 32.2 72 72c0 23.2-11 43.8-28 57c34.1 5.7 60 35.3 60 71c0 39.8-32.2 72-72 72H72c-39.8 0-72-32.2-72-72c0-35.7 25.9-65.3 60-71c-17-13.2-28-33.8-28-57c0-39.8 32.2-72 72-72h13.7C104.4 228.3 96 211.1 96 192c0-35.3 28.7-64 64-64h16.2c44.1-.1 79.8-35.9 79.8-80c0-9.2-1.5-17.9-4.3-26.1c-1.8-5.2-.8-11.1 2.8-15.4z"/></svg>

After

Width:  |  Height:  |  Size: 694 B

View File

@ -14,6 +14,8 @@ import org.springframework.stereotype.Repository;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.util.Map; import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
@Repository @Repository
@RequiredArgsConstructor @RequiredArgsConstructor
@ -44,11 +46,17 @@ public class FileUploadRepository {
r.db("sharepulse").table("file_uploads").insert(map).run(connection); r.db("sharepulse").table("file_uploads").insert(map).run(connection);
} }
public FileUpload retrieveFileUploadByFileId(String fileId) { public Optional<FileUpload> retrieveFileUploadByFileId(String fileId) {
return r.db("sharepulse").table("file_uploads") try {
FileUpload fileUpload = r.db("sharepulse").table("file_uploads")
.filter(r.hashMap("fileId", fileId)) .filter(r.hashMap("fileId", fileId))
.run(connection, FileUpload.class) .run(connection, FileUpload.class)
.next(); .next();
return Optional.ofNullable(fileUpload);
} catch (NoSuchElementException e) {
return Optional.empty();
}
} }
public void updateFileUpload(FileUpload updatedFileUpload) { public void updateFileUpload(FileUpload updatedFileUpload) {

View File

@ -28,9 +28,9 @@ public class TestRestResource extends ApiRestController {
} }
@GetMapping("test") @GetMapping("test")
public String test(@RequestParam String id) { public FileUpload test(@RequestParam String id) {
FileUpload fileUpload = fileUploadRepository.retrieveFileUploadByFileId(id); FileUpload fileUpload = fileUploadRepository.retrieveFileUploadByFileId(id).orElse(null);
return fileUpload != null ? fileUpload.toString() : "FileUpload not found for id: " + id; return fileUpload;
} }
@GetMapping("test/download") @GetMapping("test/download")

View File

@ -22,6 +22,7 @@ import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
@Slf4j @Slf4j
@RestController @RestController
@ -41,7 +42,11 @@ public class Download extends ApiRestController {
@RequestParam(value = "password", required = false) String password) throws IOException { @RequestParam(value = "password", required = false) String password) throws IOException {
FileUpload fileUpload = fileService.getFileUploadByFileId(fileId); Optional<FileUpload> optionalFileUpload = fileService.getFileUploadByFileId(fileId);
FileUpload fileUpload = optionalFileUpload.orElse(null);
if(optionalFileUpload.isEmpty()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
try { try {
fileSecurityService.verifyDownloadPermission(fileUpload, password); fileSecurityService.verifyDownloadPermission(fileUpload, password);
@ -63,6 +68,7 @@ public class Download extends ApiRestController {
headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); headers.add(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate");
headers.add(HttpHeaders.PRAGMA, "no-cache"); headers.add(HttpHeaders.PRAGMA, "no-cache");
headers.add(HttpHeaders.EXPIRES, "0"); headers.add(HttpHeaders.EXPIRES, "0");
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "Content-Disposition");
return ResponseEntity.ok() return ResponseEntity.ok()
.headers(headers) .headers(headers)
@ -73,12 +79,19 @@ public class Download extends ApiRestController {
@GetMapping("/download-info") @GetMapping("/download-info")
public ResponseEntity<Object> getDownloadInfo(@RequestParam String fileId) { public ResponseEntity<Object> getDownloadInfo(@RequestParam String fileId) {
FileUpload fileUpload = fileService.getFileUploadByFileId(fileId); Optional<FileUpload> optionalFileUpload = fileService.getFileUploadByFileId(fileId);
FileUpload fileUpload = optionalFileUpload.orElse(null);
if(optionalFileUpload.isEmpty()) {
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
boolean downloadable = !fileUpload.isSingleDownload() || fileUpload.getDownloadCount() == 0;
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("fileId", fileUpload.getFileId()); response.put("fileId", fileUpload.getFileId());
response.put("passwordProtected", fileUpload.isPasswordProtected()); response.put("passwordProtected", fileUpload.isPasswordProtected());
response.put("singleDownload", fileUpload.isSingleDownload()); response.put("singleDownload", fileUpload.isSingleDownload());
response.put("downloadable", downloadable);
return new ResponseEntity<>(response, HttpStatus.OK); return new ResponseEntity<>(response, HttpStatus.OK);
} }

View File

@ -45,6 +45,14 @@ public class Upload extends ApiRestController {
} }
FileUpload fileUpload = fileService.processUploadedFile(file, request.getRemoteAddr(), passwordProtected, singleDownload, fileDescription); FileUpload fileUpload = fileService.processUploadedFile(file, request.getRemoteAddr(), passwordProtected, singleDownload, fileDescription);
if(fileUpload == null) {
log.debug("File Upload failed for IP: " + request.getRemoteAddr());
Map<String, Object> response = new HashMap<>();
response.put("message", "FileUpload failed. FileName might have invalid characters.");
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
log.debug("User uploaded file " + file.getOriginalFilename() + " from IP " + request.getRemoteAddr() + " successfully."); log.debug("User uploaded file " + file.getOriginalFilename() + " from IP " + request.getRemoteAddr() + " successfully.");
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();

View File

@ -3,7 +3,6 @@ package de.w665.sharepulse.service;
import de.w665.sharepulse.db.repo.FileUploadRepository; import de.w665.sharepulse.db.repo.FileUploadRepository;
import de.w665.sharepulse.model.FileUpload; import de.w665.sharepulse.model.FileUpload;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.tomcat.util.http.fileupload.FileUtils; import org.apache.tomcat.util.http.fileupload.FileUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -17,6 +16,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Date; import java.util.Date;
import java.util.Optional;
@Slf4j @Slf4j
@Service @Service
@ -70,6 +70,14 @@ public class FileService {
password = fileSecurityService.generateFilePassword(); password = fileSecurityService.generateFilePassword();
} }
/*String encodedFileName = "";
try {
encodedFileName = URLEncoder.encode(file.getOriginalFilename(), StandardCharsets.UTF_8.toString());
} catch (UnsupportedEncodingException e) {
log.error("Error encoding file name: " + e.getMessage());
return null;
}*/
FileUpload fileUpload = FileUpload.builder() FileUpload fileUpload = FileUpload.builder()
.fileId(fileId) .fileId(fileId)
.fileName(file.getOriginalFilename()) .fileName(file.getOriginalFilename())
@ -104,7 +112,7 @@ public class FileService {
return new File(getTempDirPath() + File.separator + fileId); return new File(getTempDirPath() + File.separator + fileId);
} }
public FileUpload getFileUploadByFileId(String fileId) { public Optional<FileUpload> getFileUploadByFileId(String fileId) {
return fileUploadRepository.retrieveFileUploadByFileId(fileId); return fileUploadRepository.retrieveFileUploadByFileId(fileId);
} }