Moved to a custom property for the file size which is now also reflected on the front-end. Also moved to using .transferTo on the multipart to reduce memory usage.

This commit is contained in:
Rostislav Raykov
2024-10-25 23:41:17 +03:00
parent f0d4c43a1f
commit 4b8dd406dd
7 changed files with 535 additions and 93 deletions

397
README.md
View File

@@ -4,52 +4,282 @@
# QuickDrop
QuickDrop is an easy-to-use file sharing application that allows users to upload files without an account,
generate download links, and manage file availability, file encryption and optional password
QuickDrop
is
an
easy-to-use
file
sharing
application
that
allows
users
to
upload
files
without
an
account,
generate
download
links,
and
manage
file
availability,
file
encryption
and
optional
password
protection.
## Features
- **File Upload**: Users can upload files without needing to create an account.
- **Download Links**: Generate download links for easy sharing.
- **File Management**: Manage file availability with options to keep files indefinitely or delete them.
- **Password Protection**: Optionally protect files with a password.
- **File Encryption**: Encrypt files to ensure privacy.
- **Whole app password protection**: Optionally protect the entire app with a password.
-
*
*
File
Upload
**:
Users
can
upload
files
without
needing
to
create
an
account.
-
*
*
Download
Links
**:
Generate
download
links
for
easy
sharing.
-
*
*
File
Management
**:
Manage
file
availability
with
options
to
keep
files
indefinitely
or
delete
them.
-
*
*
Password
Protection
**:
Optionally
protect
files
with
a
password.
-
*
*
File
Encryption
**:
Encrypt
files
to
ensure
privacy.
-
*
*
Whole
app
password
protection
**:
Optionally
protect
the
entire
app
with
a
password.
## Technologies Used
- **Java**
- **SQLite**
- **Spring Framework**
- **Spring Security**
- **Spring Data JPA (Hibernate)**
- **Spring Web**
- **Spring Boot**
- **Thymeleaf**
- **Bootstrap**
- **Maven**
-
*
*
Java
**
-
*
*
SQLite
**
-
*
*
Spring
Framework
**
-
*
*
Spring
Security
**
-
*
*
Spring
Data
JPA (
Hibernate)
**
-
*
*
Spring
Web
**
-
*
*
Spring
Boot
**
-
*
*
Thymeleaf
**
-
*
*
Bootstrap
**
-
*
*
Maven
**
## Getting Started
### Installation
**Installation with Docker**
*
1. Pull the Docker image:
*
Installation
with
Docker
**
1.
Pull
the
Docker
image:
```
docker pull roastslav/quickdrop:latest
```
2. Run the Docker container:
2.
Run
the
Docker
container:
```
docker run -d -p 8080:8080 roastslav/quickdrop:latest
```
Optional: Use a volume to persist the uploaded files or if you want to change the default configuration:
Optional:
Use
a
volume
to
persist
the
uploaded
files
or
if
you
want
to
change
the
default
configuration:
```
docker run -d -p 8080:8080 \
@@ -59,51 +289,125 @@ docker run -d -p 8080:8080 \
quickdrop
```
**Installation without Docker**
*
*
Installation
without
Docker
**
Prerequisites
- Java 21 or higher
- Maven
- SQLite
1. Clone the repository:
-
Java
21
or
higher
-
Maven
-
SQLite
1.
Clone
the
repository:
```
git clone https://github.com/RoastSlav/quickdrop.git
cd quickdrop
```
2. Build the application:
2.
Build
the
application:
```
mvn clean package
```
3. Run the application:
3.
Run
the
application:
```
java -jar target/quickdrop-0.0.1-SNAPSHOT.jar
```
4. Using an external application.properties file:
- Create an **application.properties** file in the same directory as the JAR file or specify its location in the
start command.
4.
- Add your custom settings, for example (Listed below are the default values):
Using
an
external
application.properties
file:
-
Create
an
*
*
application.properties
**
file
in
the
same
directory
as
the
JAR
file
or
specify
its
location
in
the
start
command.
-
Add
your
custom
settings,
for
example (
Listed
below
are
the
default
values):
```
spring.servlet.multipart.max-file-size=1024MB
spring.servlet.multipart.max-request-size=1024MB
server.tomcat.connection-timeout=60000
file.save.path=files
file.max.age=30 (In days)
file.max.age=30
logging.file.name=log/quickdrop.log
file.deletion.cron=0 0 2 * * *
app.basic.password=test
app.enable.password=false
max-upload-file-size=1GB
```
- Run the application with the external configuration:
-
Run
the
application
with
the
external
configuration:
```
java -jar target/quickdrop-0.0.1-SNAPSHOT.jar --spring.config.location=./application.properties
@@ -111,4 +415,17 @@ java -jar target/quickdrop-0.0.1-SNAPSHOT.jar --spring.config.location=./applica
## License
This project is licensed under the MIT License. See the `LICENSE` file for details.
This
project
is
licensed
under
the
MIT
License.
See
the
`LICENSE`
file
for
details.

View File

@@ -0,0 +1,28 @@
package org.rostislav.quickdrop.config;
import jakarta.servlet.MultipartConfigElement;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
@Configuration
public class MultipartConfig {
private final long ADDITIONAL_REQUEST_SIZE = 1024L * 1024L * 10L; // 10 MB
@Value("${max-upload-file-size}")
private String maxUploadFileSize;
@Bean
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setMaxFileSize(DataSize.parse(maxUploadFileSize));
DataSize maxRequestSize = DataSize.parse(maxUploadFileSize);
maxRequestSize = DataSize.ofBytes(maxRequestSize.toBytes() + ADDITIONAL_REQUEST_SIZE);
factory.setMaxRequestSize(maxRequestSize);
return factory.createMultipartConfig();
}
}

View File

@@ -5,6 +5,7 @@ import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
import org.rostislav.quickdrop.model.FileEntity;
import org.rostislav.quickdrop.service.FileService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -21,13 +22,16 @@ import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes;
@RequestMapping("/file")
public class FileViewController {
private final FileService fileService;
@Value("${max-upload-file-size}")
private String maxFileSize;
public FileViewController(FileService fileService) {
this.fileService = fileService;
}
@GetMapping("/upload")
public String showUploadFile() {
public String showUploadFile(Model model) {
model.addAttribute("maxFileSize", maxFileSize);
return "upload";
}

View File

@@ -67,18 +67,6 @@ public class FileService {
};
}
private boolean saveUnencryptedFile(MultipartFile file, Path path) {
try {
Files.createFile(path);
Files.write(path, file.getBytes());
logger.info("File saved: {}", path);
} catch (Exception e) {
logger.error("Error saving file: {}", e.getMessage());
return false;
}
return true;
}
public FileEntity saveFile(MultipartFile file, FileUploadRequest fileUploadRequest) {
if (!validateObjects(file, fileUploadRequest)) {
return null;
@@ -114,17 +102,27 @@ public class FileService {
return fileRepository.save(fileEntity);
}
public List<FileEntity> getFiles() {
return fileRepository.findAll();
private boolean saveUnencryptedFile(MultipartFile file, Path path) {
try {
Files.createFile(path);
file.transferTo(path);
logger.info("File saved: {}", path);
} catch (
Exception e) {
logger.error("Error saving file: {}", e.getMessage());
return false;
}
return true;
}
public boolean saveEncryptedFile(Path savePath, MultipartFile file, FileUploadRequest fileUploadRequest) {
Path tempFile;
try {
tempFile = Files.createTempFile("Unencrypted", "tmp");
Files.write(tempFile, file.getBytes());
file.transferTo(tempFile);
logger.info("Unencrypted temp file saved: {}", tempFile);
} catch (Exception e) {
} catch (
Exception e) {
logger.error("Error saving unencrypted temp file: {}", e.getMessage());
return false;
}
@@ -134,7 +132,8 @@ public class FileService {
logger.info("Encrypting file: {}", encryptedFile);
encryptFile(tempFile.toFile(), encryptedFile.toFile(), fileUploadRequest.password);
logger.info("Encrypted file saved: {}", encryptedFile);
} catch (Exception e) {
} catch (
Exception e) {
logger.error("Error encrypting file: {}", e.getMessage());
return false;
}
@@ -142,7 +141,8 @@ public class FileService {
try {
Files.delete(tempFile);
logger.info("Temp file deleted: {}", tempFile);
} catch (Exception e) {
} catch (
Exception e) {
logger.error("Error deleting temp file: {}", e.getMessage());
return false;
}
@@ -150,6 +150,10 @@ public class FileService {
return true;
}
public List<FileEntity> getFiles() {
return fileRepository.findAll();
}
public ResponseEntity<StreamingResponseBody> downloadFile(Long id, String password) {
FileEntity fileEntity = fileRepository.findById(id).orElse(null);
if (fileEntity == null) {
@@ -165,7 +169,8 @@ public class FileService {
logger.info("Decrypting file: {}", pathOfFile);
decryptFile(pathOfFile.toFile(), outputFile.toFile(), password);
logger.info("File decrypted: {}", outputFile);
} catch (Exception e) {
} catch (
Exception e) {
logger.error("Error decrypting file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
@@ -179,11 +184,12 @@ public class FileService {
Resource resource = new UrlResource(outputFile.toUri());
logger.info("Sending file: {}", fileEntity);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileEntity.name, StandardCharsets.UTF_8) + "\"")
.header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength()))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileEntity.name, StandardCharsets.UTF_8) + "\"")
.header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength()))
.body(responseBody);
} catch (Exception e) {
} catch (
Exception e) {
logger.error("Error reading file: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
@@ -213,7 +219,8 @@ public class FileService {
try {
Files.delete(path);
logger.info("File deleted: {}", path);
} catch (Exception e) {
} catch (
Exception e) {
return false;
}
return true;

View File

@@ -8,8 +8,6 @@ spring.jpa.hibernate.ddl-auto=update
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false
spring.servlet.multipart.max-file-size=1025MB
spring.servlet.multipart.max-request-size=1025MB
server.tomcat.connection-timeout=60000
file.save.path=files
file.max.age=30
@@ -17,5 +15,6 @@ logging.file.name=log/quickdrop.log
file.deletion.cron=0 0 2 * * *
app.basic.password=test
app.enable.password=false
max-upload-file-size=1GB
#logging.level.org.springframework=DEBUG
#logging.level.org.hibernate=DEBUG

View File

@@ -70,8 +70,9 @@ function isPasswordProtected() {
}
function validateFileSize() {
const maxFileSize = document.getElementsByClassName('maxFileSize')[0].innerText;
const file = document.getElementById('file').files[0];
const maxSize = 1024 * 1024 * 1024; // 1GB
const maxSize = parseSize(maxFileSize);
const fileSizeAlert = document.getElementById('fileSizeAlert');
if (file.size > maxSize) {
@@ -80,4 +81,25 @@ function validateFileSize() {
} else {
fileSizeAlert.style.display = 'none';
}
}
function parseSize(size) {
const units = {
B: 1,
KB: 1024,
MB: 1024 * 1024,
GB: 1024 * 1024 * 1024
};
const unitMatch = size.match(/[a-zA-Z]+/);
const valueMatch = size.match(/[0-9.]+/);
if (!unitMatch || !valueMatch) {
throw new Error("Invalid size format");
}
const unit = unitMatch[0];
const value = parseFloat(valueMatch[0]);
return value * (units[unit] || 1);
}

View File

@@ -2,17 +2,27 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Upload File</title>
<meta content="width=device-width, initial-scale=1" name="viewport">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/images/favicon.png" rel="icon" type="image/png">
<title>
Upload
File</title>
<meta content="width=device-width, initial-scale=1"
name="viewport">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet">
<link href="/images/favicon.png"
rel="icon"
type="image/png">
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
<div class="container">
<a class="navbar-brand d-flex align-items-center" href="/">
<img alt="Website Logo" class="me-2" height="40" src="/images/favicon.png">
<a class="navbar-brand d-flex align-items-center"
href="/">
<img alt="Website Logo"
class="me-2"
height="40"
src="/images/favicon.png">
QuickDrop
</a>
<button
@@ -26,10 +36,13 @@
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="collapse navbar-collapse"
id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/file/list">View Files</a>
<a class="nav-link"
href="/file/list">View
Files</a>
</li>
</ul>
</div>
@@ -38,10 +51,33 @@
<!-- Main Content -->
<div class="container">
<h1 class="text-center mb-4">Upload a File</h1>
<p class="text-center mb-2">Max file size: 1GB</p>
<h1 class="text-center mb-4">
Upload
a
File</h1>
<p class="text-center mb-2">
Max
file
size:
<span class="maxFileSize"
th:text="${maxFileSize}">1GB</span>
</p>
<p class="text-center mb-4">
Files are deleted after 30 days if the option for indefinite upload is not selected
Files
are
deleted
after
30
days
if
the
option
for
indefinite
upload
is
not
selected
</p>
<div class="row justify-content-center">
<div class="col-12 col-md-8 col-lg-6">
@@ -53,21 +89,28 @@
th:action="@{/file/upload}"
>
<!-- CSRF Token -->
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden"/>
<input th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"
type="hidden"/>
<!-- UUID -->
<input name="uuid" th:value="${uuid}" type="hidden"/>
<input name="uuid"
th:value="${uuid}"
type="hidden"/>
<!-- File Input -->
<div class="mb-3">
<label class="form-label" for="file">Select a file:</label>
<label class="form-label"
for="file">Select
a
file:</label>
<input
class="form-control"
id="file"
name="file"
onchange="validateFileSize()"
required
type="file"
onchange="validateFileSize()"
/>
</div>
@@ -80,13 +123,14 @@
size
exceeds
the
1GB
<span th:text="${maxFileSize}">1GB</span>
limit.
</div>
<!-- Description Input -->
<div class="mb-3">
<label class="form-label" for="description">Description:</label>
<label class="form-label"
for="description">Description:</label>
<input
class="form-control"
id="description"
@@ -103,14 +147,18 @@
name="keepIndefinitely"
type="checkbox"
/>
<label class="form-check-label" for="keepIndefinitely">
Keep indefinitely
<label class="form-check-label"
for="keepIndefinitely">
Keep
indefinitely
</label>
</div>
<!-- Password Input -->
<div class="mb-3">
<label class="form-label" for="password">Password (Optional):</label>
<label class="form-label"
for="password">Password
(Optional):</label>
<input
class="form-control"
id="password"
@@ -120,15 +168,23 @@
</div>
<!-- Submit Button -->
<button class="btn btn-primary w-100" type="submit">Upload</button>
<button class="btn btn-primary w-100"
type="submit">
Upload
</button>
</form>
</div>
</div>
<!-- Upload Indicator -->
<div class="mt-3 text-center">
<div id="uploadIndicator" style="display: none;">
<p class="text-info" id="uploadStatus">Upload started...</p>
<div class="progress" style="width: 50%; margin: 0 auto;">
<div id="uploadIndicator"
style="display: none;">
<p class="text-info"
id="uploadStatus">
Upload
started...</p>
<div class="progress"
style="width: 50%; margin: 0 auto;">
<div
aria-valuemax="100"
aria-valuemin="0"
@@ -143,7 +199,16 @@
</div>
<div class="container mt-4">
<p class="text-center text-muted">
Note: All password-protected files are also encrypted for additional security.
Note:
All
password-protected
files
are
also
encrypted
for
additional
security.
</p>
</div>
</div>