├── .dockerignore ├── .github └── FUNDING.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── mvnw ├── mvnw.cmd ├── pom.xml └── src ├── main ├── java │ └── org │ │ └── rostislav │ │ └── quickdrop │ │ ├── QuickdropApplication.java │ │ ├── config │ │ ├── GlobalControllerAdvice.java │ │ ├── MultipartConfig.java │ │ ├── MultipartProperties.java │ │ ├── SecurityConfig.java │ │ └── WebConfig.java │ │ ├── controller │ │ ├── AdminViewController.java │ │ ├── FileRestController.java │ │ ├── FileViewController.java │ │ ├── IndexViewController.java │ │ └── PasswordController.java │ │ ├── entity │ │ ├── ApplicationSettingsEntity.java │ │ ├── DownloadLog.java │ │ ├── FileEntity.java │ │ ├── FileRenewalLog.java │ │ └── ShareTokenEntity.java │ │ ├── interceptor │ │ ├── AdminPasswordInterceptor.java │ │ ├── AdminPasswordSetupInterceptor.java │ │ └── FilePasswordInterceptor.java │ │ ├── model │ │ ├── AboutInfoView.java │ │ ├── AnalyticsDataView.java │ │ ├── ApplicationSettingsViewModel.java │ │ ├── ChunkInfo.java │ │ ├── FileActionLogDTO.java │ │ ├── FileEntityView.java │ │ ├── FileSession.java │ │ └── FileUploadRequest.java │ │ ├── repository │ │ ├── ApplicationSettingsRepository.java │ │ ├── DownloadLogRepository.java │ │ ├── FileRepository.java │ │ ├── RenewalLogRepository.java │ │ └── ShareTokenRepository.java │ │ ├── service │ │ ├── AnalyticsService.java │ │ ├── ApplicationSettingsService.java │ │ ├── AsyncFileMergeService.java │ │ ├── FileEncryptionService.java │ │ ├── FileService.java │ │ ├── ScheduleService.java │ │ ├── SessionService.java │ │ └── SystemInfoService.java │ │ └── util │ │ ├── DataValidator.java │ │ └── FileUtils.java └── resources │ ├── application.properties │ ├── db │ └── migration │ │ ├── V1__Create_schema.sql │ │ ├── V2__Add_AdminDashboardButtonEnabled.sql │ │ ├── V3__Add_Share_Token_table.sql │ │ └── V4__Add_encrypted_column.sql │ ├── static │ ├── images │ │ └── favicon.png │ └── js │ │ ├── fileView.js │ │ ├── settings.js │ │ └── upload.js │ └── templates │ ├── admin-password.html │ ├── app-password.html │ ├── dashboard.html │ ├── error.html │ ├── file-history.html │ ├── file-password.html │ ├── file-share-view.html │ ├── fileView.html │ ├── invalid-share-link.html │ ├── listFiles.html │ ├── settings.html │ ├── upload.html │ └── welcome.html └── test └── resources ├── application-test.properties └── application.properties /.dockerignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | target 4 | docker-compose.yml 5 | Jenkinsfile 6 | Dockerfile 7 | mvnw 8 | mvnw.cmd -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | # github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | ko_fi: roastslav 7 | #tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | # polar: # Replace with a single Polar username 13 | # buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | # thanks_dev: # Replace with a single thanks.dev username 15 | #custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | target/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/**/target/ 5 | !**/src/test/**/target/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | 16 | ### IntelliJ IDEA ### 17 | .idea 18 | *.iws 19 | *.iml 20 | *.ipr 21 | 22 | ### NetBeans ### 23 | /nbproject/private/ 24 | /nbbuild/ 25 | /dist/ 26 | /nbdist/ 27 | /.nb-gradle/ 28 | build/ 29 | !**/src/main/**/build/ 30 | !**/src/test/**/build/ 31 | 32 | ### VS Code ### 33 | .vscode/ 34 | /db/ 35 | /log/ 36 | /files/ 37 | .qodo -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the application using the maven image 2 | FROM maven:3.9.9 AS builder 3 | 4 | WORKDIR /build 5 | COPY . . 6 | RUN mvn clean package 7 | 8 | # Create a slimmed version of the Java JRE using the Corretto image 9 | FROM amazoncorretto:21.0.6-alpine AS corretto-jdk 10 | 11 | RUN apk add --no-cache binutils 12 | # Build small JRE image 13 | RUN $JAVA_HOME/bin/jlink \ 14 | --verbose \ 15 | --add-modules ALL-MODULE-PATH \ 16 | --strip-debug \ 17 | --no-man-pages \ 18 | --no-header-files \ 19 | --compress=zip-4 \ 20 | --output /slim_jre 21 | 22 | # Use a small Linux distro for final image 23 | FROM alpine:latest 24 | ENV JAVA_HOME=/jre 25 | ENV PATH="${JAVA_HOME}/bin:${PATH}" 26 | 27 | # Copy the JRE into Alpine image 28 | COPY --from=corretto-jdk /slim_jre $JAVA_HOME 29 | 30 | # Copy the compiled app into Alpine image 31 | COPY --from=builder build/target/quickdrop-0.0.1-SNAPSHOT.jar app/quickdrop.jar 32 | 33 | WORKDIR /app 34 | 35 | VOLUME ["/app/db", "/app/log", "/app/files"] 36 | 37 | EXPOSE 8080 38 | 39 | ENTRYPOINT ["java", "-jar", "/app/quickdrop.jar"] -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | pipeline { 2 | agent any 3 | 4 | environment { 5 | MAVEN_HOME = tool name: 'Maven', type: 'hudson.tasks.Maven$MavenInstallation' 6 | DOCKER_IMAGE = "roastslav/quickdrop:latest" 7 | DOCKER_CREDENTIALS_ID = 'dockerhub-credentials' 8 | } 9 | 10 | stages { 11 | stage('Checkout') { 12 | steps { 13 | checkout scm 14 | } 15 | } 16 | 17 | stage('Build and Test') { 18 | steps { 19 | sh "${MAVEN_HOME}/bin/mvn clean package" 20 | } 21 | } 22 | 23 | stage('Docker Build and Push Multi-Arch') { 24 | steps { 25 | script { 26 | withCredentials([usernamePassword(credentialsId: DOCKER_CREDENTIALS_ID, 27 | passwordVariable: 'DOCKER_PASS', usernameVariable: 'DOCKER_USER')]) { 28 | 29 | sh """ 30 | BUILDER_NAME=\$(docker buildx create --driver docker-container) 31 | docker buildx use \$BUILDER_NAME 32 | docker buildx inspect --bootstrap 33 | 34 | # Login 35 | echo "\$DOCKER_PASS" | docker login -u "\$DOCKER_USER" --password-stdin 36 | 37 | # Build & push multi-arch 38 | docker buildx build \\ 39 | --platform linux/amd64,linux/arm64 \\ 40 | -t ${DOCKER_IMAGE} \\ 41 | --push . 42 | 43 | # Logout 44 | docker logout 45 | 46 | # Remove the ephemeral builder 47 | docker buildx rm \$BUILDER_NAME || true 48 | """ 49 | } 50 | } 51 | } 52 | } 53 | 54 | stage('Cleanup') { 55 | steps { 56 | sh "docker system prune -f" 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rostislav 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://jenkins.tyron.rocks/buildStatus/icon?job=quickdrop)](https://jenkins.tyron.rocks/job/quickdrop) 2 | [![MIT License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 3 | [![Docker Pulls](https://img.shields.io/docker/pulls/roastslav/quickdrop?logo=docker&style=flat)](https://hub.docker.com/r/roastslav/quickdrop) 4 | 5 | 6 | # QuickDrop 7 | 8 | QuickDrop is an easy-to-use file sharing application that allows users to upload files without an account, 9 | generate download links, and manage file availability, file encryption and optional password 10 | protection. 11 | 12 | This project is made with the self-hosting community in mind as a self-hosted file-sharing application. 13 | 14 | # Features 15 | 16 | ## File Upload 17 | - Users can upload files without needing to create an account. 18 | - Supports **Chunked Uploads** for reliable file transfers. 19 | 20 | ## File Management 21 | - Manage file availability with options to keep files indefinitely or delete them. 22 | - Add hidden files that are only accessible via their unique link. 23 | - Password-protected files can be updated (e.g., "kept indefinitely"). 24 | 25 | ## Whole App Password Protection 26 | - Protect the entire app with a password to restrict access. 27 | 28 | ## Shareable Links 29 | - **Two types of share links** managed through a single, streamlined modal: 30 | - **Normal Links**: Require passwords if the file or app is password-protected. 31 | - **Token-Based (Unrestricted) Links**: Single-use links with customizable expiration days. These bypass password requirements but provide controlled access. 32 | - **QR Code Generation**: Generates QR codes for easy sharing. 33 | 34 | ## Password Protection 35 | - Files can be protected with a password for added security. 36 | - Password-protected files are encrypted to ensure privacy and secure storage. 37 | 38 | ## Admin Panel 39 | - Centralized management for files and settings. 40 | - Adjustable file size limits and file lifetime configurations in the admin panel. 41 | - Logs and activity tracking for enhanced oversight. 42 | - Disable “View Files” : Turn off the built-in file listing page for enhanced privacy (removes the "hidden" files option as it removes the need for it). 43 | 44 | --- 45 | 46 | ## Technologies Used 47 | 48 | - **Java** 49 | - **SQLite** 50 | - **Spring Framework** 51 | - **Spring Security** 52 | - **Spring Data JPA (Hibernate)** 53 | - **Spring Web** 54 | - **Spring Boot** 55 | - **Thymeleaf** 56 | - **Bootstrap** 57 | - **Maven** 58 | 59 | ## Getting Started 60 | 61 | ### Installation 62 | 63 | **Installation with Docker** 64 | 65 | 1. Pull the Docker image: 66 | 67 | ``` 68 | docker pull roastslav/quickdrop:latest 69 | ``` 70 | 71 | 2. Run the Docker container: 72 | 73 | ``` 74 | docker run -d -p 8080:8080 roastslav/quickdrop:latest 75 | ``` 76 | 77 | Optional: Use volumes to persist the uploaded files when you update the container: 78 | 79 | ``` 80 | docker run -d -p 8080:8080 \ 81 | -v /path/to/db:/app/db \ 82 | -v /path/to/log:/app/log \ 83 | -v /path/to/files:/app/files \ 84 | quickdrop 85 | ``` 86 | 87 | **Installation without Docker** 88 | 89 | Prerequisites 90 | 91 | - Java 21 or higher 92 | - Maven 93 | - SQLite 94 | 95 | 1. Clone the repository: 96 | 97 | ``` 98 | git clone https://github.com/RoastSlav/quickdrop.git 99 | cd quickdrop 100 | ``` 101 | 102 | 2. Build the application: 103 | 104 | ``` 105 | mvn clean package 106 | ``` 107 | 108 | 3. Run the application: 109 | 110 | ``` 111 | java -jar target/quickdrop-0.0.1-SNAPSHOT.jar 112 | ``` 113 | 114 | ## Updates 115 | 116 | To update the app, you need to: 117 | 118 | 1. Stop and remove the old container. 119 | 2. Pull the new image. 120 | 3. Start the updated container. 121 | 122 | If you want to ensure file and database persistence between updates, you can use Docker volumes. (Check docker installation above) 123 | 124 | ## License 125 | 126 | This project is licensed under the MIT License. See the `LICENSE` file for details. 127 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | quickdrop: 3 | image: roastslav/quickdrop:latest 4 | ports: 5 | - 8080:8080 6 | environment: 7 | - PUID=1000 8 | - PGID=1000 9 | - TZ=/etc/UTC 10 | volumes: 11 | - ./db:/app/db 12 | - ./log:/app/log 13 | - ./files:/app/files 14 | restart: unless-stopped 15 | -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and app-password.html for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 4.0.0 7 | 8 | org.springframework.boot 9 | spring-boot-starter-parent 10 | 3.3.10 11 | 12 | 13 | org.rostislav 14 | quickdrop 15 | 0.0.1-SNAPSHOT 16 | quickdrop 17 | quickdrop 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 21 33 | 21 34 | 35 | 36 | 37 | org.springframework.boot 38 | spring-boot-starter-data-jpa 39 | 40 | 41 | org.springframework.boot 42 | spring-boot-starter-security 43 | 44 | 45 | org.springframework.boot 46 | spring-boot-starter-thymeleaf 47 | 48 | 49 | org.springframework.boot 50 | spring-boot-starter-web 51 | 52 | 53 | org.thymeleaf.extras 54 | thymeleaf-extras-springsecurity6 55 | 56 | 57 | org.springframework.boot 58 | spring-boot-starter-logging 59 | 60 | 61 | org.flywaydb 62 | flyway-core 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-starter-test 67 | test 68 | 69 | 70 | junit 71 | junit 72 | 73 | 74 | 75 | 76 | org.springframework.security 77 | spring-security-test 78 | test 79 | 80 | 81 | org.xerial 82 | sqlite-jdbc 83 | 3.47.0.0 84 | 85 | 86 | org.hibernate 87 | hibernate-community-dialects 88 | 6.6.1.Final 89 | 90 | 91 | org.junit.jupiter 92 | junit-jupiter-engine 93 | 5.11.3 94 | test 95 | 96 | 97 | org.junit.jupiter 98 | junit-jupiter-api 99 | 5.11.3 100 | test 101 | 102 | 103 | org.springframework.boot 104 | spring-boot-starter-actuator 105 | 106 | 107 | org.springframework.cloud 108 | spring-cloud-starter 109 | 4.1.3 110 | 111 | 112 | 113 | 114 | 115 | 116 | org.springframework.boot 117 | spring-boot-maven-plugin 118 | 119 | 120 | 121 | junit 122 | junit 123 | 124 | 125 | 126 | 127 | 128 | org.apache.maven.plugins 129 | maven-compiler-plugin 130 | 3.13.0 131 | 132 | 21 133 | 21 134 | 135 | 136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/QuickdropApplication.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop; 2 | 3 | import jakarta.annotation.PostConstruct; 4 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 5 | import org.slf4j.Logger; 6 | import org.slf4j.LoggerFactory; 7 | import org.springframework.boot.SpringApplication; 8 | import org.springframework.boot.autoconfigure.SpringBootApplication; 9 | import org.springframework.scheduling.annotation.EnableScheduling; 10 | 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | 14 | @SpringBootApplication 15 | @EnableScheduling 16 | public class QuickdropApplication { 17 | private static final Logger logger = LoggerFactory.getLogger(QuickdropApplication.class); 18 | 19 | private final ApplicationSettingsService applicationSettingsService; 20 | 21 | public QuickdropApplication(ApplicationSettingsService applicationSettingsService) { 22 | this.applicationSettingsService = applicationSettingsService; 23 | } 24 | 25 | public static void main(String[] args) { 26 | try { 27 | Files.createDirectories(Path.of("./db")); 28 | } catch (Exception e) { 29 | logger.error("Error creating directory for database", e); 30 | } 31 | SpringApplication.run(QuickdropApplication.class, args); 32 | } 33 | 34 | @PostConstruct 35 | public void createFileSavePath() { 36 | try { 37 | Files.createDirectories(Path.of(applicationSettingsService.getFileStoragePath())); 38 | logger.info("File save path created: {}", applicationSettingsService.getFileStoragePath()); 39 | } catch ( 40 | Exception e) { 41 | logger.error("Failed to create file save path: {}", applicationSettingsService.getFileStoragePath()); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/config/GlobalControllerAdvice.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.config; 2 | 3 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 4 | import org.springframework.ui.Model; 5 | import org.springframework.web.bind.annotation.ControllerAdvice; 6 | import org.springframework.web.bind.annotation.ModelAttribute; 7 | 8 | @ControllerAdvice 9 | public class GlobalControllerAdvice { 10 | 11 | private final ApplicationSettingsService applicationSettingsService; 12 | 13 | public GlobalControllerAdvice(ApplicationSettingsService applicationSettingsService) { 14 | this.applicationSettingsService = applicationSettingsService; 15 | } 16 | 17 | @ModelAttribute 18 | public void addGlobalAttributes(Model model) { 19 | model.addAttribute("isFileListPageEnabled", applicationSettingsService.isFileListPageEnabled()); 20 | model.addAttribute("isAppPasswordSet", applicationSettingsService.isAppPasswordEnabled()); 21 | model.addAttribute("isAdminDashboardButtonEnabled", applicationSettingsService.isAdminDashboardButtonEnabled()); 22 | model.addAttribute("isEncryptionEnabled", applicationSettingsService.isEncryptionEnabled()); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.config; 2 | 3 | import jakarta.servlet.MultipartConfigElement; 4 | import org.springframework.boot.web.servlet.MultipartConfigFactory; 5 | import org.springframework.cloud.context.config.annotation.RefreshScope; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.util.unit.DataSize; 9 | 10 | @Configuration 11 | public class MultipartConfig { 12 | private final long ADDITIONAL_REQUEST_SIZE = 1024L * 1024L * 10L; // 10 MB 13 | 14 | @Bean 15 | @RefreshScope 16 | public MultipartConfigElement multipartConfigElement(MultipartProperties multipartProperties) { 17 | MultipartConfigFactory factory = new MultipartConfigFactory(); 18 | 19 | factory.setMaxFileSize(DataSize.parse(multipartProperties.getMaxFileSize())); 20 | 21 | DataSize maxRequestSize = DataSize.parse(multipartProperties.getMaxFileSize()); 22 | maxRequestSize = DataSize.ofBytes(maxRequestSize.toBytes() + ADDITIONAL_REQUEST_SIZE); 23 | factory.setMaxRequestSize(maxRequestSize); 24 | 25 | return factory.createMultipartConfig(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/config/MultipartProperties.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.config; 2 | 3 | import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; 4 | import org.springframework.cloud.context.config.annotation.RefreshScope; 5 | import org.springframework.stereotype.Component; 6 | 7 | @RefreshScope 8 | @Component 9 | public class MultipartProperties { 10 | private final ApplicationSettingsRepository applicationSettingsRepository; 11 | 12 | public MultipartProperties(ApplicationSettingsRepository applicationSettingsRepository) { 13 | this.applicationSettingsRepository = applicationSettingsRepository; 14 | } 15 | 16 | public String getMaxFileSize() { 17 | return "" + applicationSettingsRepository.findById(1L).orElseThrow().getMaxFileSize(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.config; 2 | 3 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.cloud.context.config.annotation.RefreshScope; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.security.authentication.AuthenticationProvider; 10 | import org.springframework.security.authentication.BadCredentialsException; 11 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 12 | import org.springframework.security.config.Customizer; 13 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 14 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 15 | import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; 16 | import org.springframework.security.core.Authentication; 17 | import org.springframework.security.core.AuthenticationException; 18 | import org.springframework.security.crypto.bcrypt.BCrypt; 19 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 20 | import org.springframework.security.crypto.password.PasswordEncoder; 21 | import org.springframework.security.web.SecurityFilterChain; 22 | import org.springframework.security.web.csrf.CookieCsrfTokenRepository; 23 | import org.springframework.web.cors.CorsConfiguration; 24 | import org.springframework.web.cors.CorsConfigurationSource; 25 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 26 | 27 | import java.util.Arrays; 28 | import java.util.List; 29 | 30 | @Configuration 31 | @EnableWebSecurity 32 | public class SecurityConfig { 33 | private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class); 34 | private final ApplicationSettingsService applicationSettingsService; 35 | 36 | public SecurityConfig(ApplicationSettingsService applicationSettingsService) { 37 | this.applicationSettingsService = applicationSettingsService; 38 | } 39 | 40 | @Bean 41 | @RefreshScope 42 | public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 43 | if (applicationSettingsService.isAppPasswordEnabled()) { 44 | http.authorizeHttpRequests(authz -> authz 45 | .requestMatchers("/password/login", "/favicon.ico", "/error", "/file/share/**", "/api/file/download/**").permitAll() 46 | .anyRequest().authenticated() 47 | ).formLogin(form -> form 48 | .loginPage("/password/login") 49 | .permitAll() 50 | .failureUrl("/password/login?error") 51 | .defaultSuccessUrl("/", true) 52 | ).authenticationProvider(authenticationProvider() 53 | ); 54 | } else { 55 | http.authorizeHttpRequests(authz -> authz 56 | .anyRequest().permitAll()); 57 | } 58 | 59 | http.csrf(csrf -> csrf 60 | .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) 61 | ).headers(headers -> headers 62 | .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable) 63 | .contentSecurityPolicy(csp -> csp.policyDirectives("frame-ancestors *;")) 64 | ).cors(Customizer.withDefaults()); 65 | 66 | return http.build(); 67 | } 68 | 69 | @Bean 70 | public CorsConfigurationSource corsConfigurationSource() { 71 | CorsConfiguration configuration = new CorsConfiguration(); 72 | configuration.addAllowedOriginPattern("*"); 73 | configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); 74 | configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-CSRF-TOKEN")); 75 | configuration.setAllowCredentials(true); 76 | configuration.setExposedHeaders(Arrays.asList("Authorization", "Content-Disposition")); 77 | 78 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 79 | source.registerCorsConfiguration("/**", configuration); 80 | return source; 81 | } 82 | 83 | @Bean 84 | public AuthenticationProvider authenticationProvider() { 85 | return new AuthenticationProvider() { 86 | @Override 87 | public Authentication authenticate(Authentication authentication) throws AuthenticationException { 88 | String providedPassword = (String) authentication.getCredentials(); 89 | if (BCrypt.checkpw(providedPassword, applicationSettingsService.getAppPasswordHash())) { 90 | logger.info("Valid login - {}", authentication.getDetails()); 91 | return new UsernamePasswordAuthenticationToken("appUser", providedPassword, List.of()); 92 | } else { 93 | logger.warn("Invalid password provided - {}", authentication.getDetails()); 94 | throw new BadCredentialsException("Invalid password"); 95 | } 96 | } 97 | 98 | @Override 99 | public boolean supports(Class authentication) { 100 | return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); 101 | } 102 | }; 103 | } 104 | 105 | @Bean 106 | public PasswordEncoder passwordEncoder() { 107 | return new BCryptPasswordEncoder(); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/config/WebConfig.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.config; 2 | 3 | import org.rostislav.quickdrop.interceptor.AdminPasswordInterceptor; 4 | import org.rostislav.quickdrop.interceptor.AdminPasswordSetupInterceptor; 5 | import org.rostislav.quickdrop.interceptor.FilePasswordInterceptor; 6 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.web.servlet.ServletContextInitializer; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | import org.springframework.web.servlet.config.annotation.InterceptorRegistry; 12 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 13 | 14 | @Configuration 15 | public class WebConfig implements WebMvcConfigurer { 16 | 17 | private final AdminPasswordSetupInterceptor adminPasswordSetupInterceptor; 18 | private final AdminPasswordInterceptor adminPasswordInterceptor; 19 | private final ApplicationSettingsService applicationSettingsService; 20 | private final FilePasswordInterceptor filePasswordInterceptor; 21 | 22 | @Autowired 23 | public WebConfig(AdminPasswordSetupInterceptor adminPasswordSetupInterceptor, AdminPasswordInterceptor adminPasswordInterceptor, ApplicationSettingsService applicationSettingsService, FilePasswordInterceptor filePasswordInterceptor) { 24 | this.adminPasswordSetupInterceptor = adminPasswordSetupInterceptor; 25 | this.adminPasswordInterceptor = adminPasswordInterceptor; 26 | this.applicationSettingsService = applicationSettingsService; 27 | this.filePasswordInterceptor = filePasswordInterceptor; 28 | } 29 | 30 | @Override 31 | public void addInterceptors(InterceptorRegistry registry) { 32 | registry.addInterceptor(adminPasswordSetupInterceptor) 33 | .addPathPatterns("/**") 34 | .excludePathPatterns("/admin/setup", "/static/**", "/css/**", "/js/**", "/images/**"); 35 | 36 | registry.addInterceptor(adminPasswordInterceptor) 37 | .addPathPatterns("/admin/**", "/file/history/*") 38 | .excludePathPatterns("/admin/password", "/admin/setup"); 39 | 40 | registry.addInterceptor(filePasswordInterceptor) 41 | .addPathPatterns("/file/**", "/api/file/share/**") 42 | .excludePathPatterns("/file/upload", "/file/list", "/file/password", "/file/password/**", "/file/history/*", "/file/search"); 43 | } 44 | 45 | @Bean 46 | public ServletContextInitializer servletContextInitializer() { 47 | return servletContext -> servletContext.setSessionTimeout((int) applicationSettingsService.getSessionLifetime()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.controller; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpSession; 5 | import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; 6 | import org.rostislav.quickdrop.model.AnalyticsDataView; 7 | import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; 8 | import org.rostislav.quickdrop.model.FileEntityView; 9 | import org.rostislav.quickdrop.service.*; 10 | import org.springframework.security.crypto.bcrypt.BCrypt; 11 | import org.springframework.stereotype.Controller; 12 | import org.springframework.ui.Model; 13 | import org.springframework.web.bind.annotation.*; 14 | 15 | import java.util.List; 16 | import java.util.UUID; 17 | 18 | import static org.rostislav.quickdrop.util.FileUtils.bytesToMegabytes; 19 | import static org.rostislav.quickdrop.util.FileUtils.megabytesToBytes; 20 | 21 | @Controller 22 | @RequestMapping("/admin") 23 | public class AdminViewController { 24 | private final ApplicationSettingsService applicationSettingsService; 25 | private final AnalyticsService analyticsService; 26 | private final FileService fileService; 27 | private final SessionService sessionService; 28 | private final SystemInfoService systemInfoService; 29 | 30 | public AdminViewController(ApplicationSettingsService applicationSettingsService, AnalyticsService analyticsService, FileService fileService, SessionService sessionService, SystemInfoService systemInfoService) { 31 | this.applicationSettingsService = applicationSettingsService; 32 | this.analyticsService = analyticsService; 33 | this.fileService = fileService; 34 | this.sessionService = sessionService; 35 | this.systemInfoService = systemInfoService; 36 | } 37 | 38 | @GetMapping("/dashboard") 39 | public String getDashboardPage(Model model) { 40 | List files = fileService.getAllFilesWithDownloadCounts(); 41 | model.addAttribute("files", files); 42 | 43 | AnalyticsDataView analytics = analyticsService.getAnalytics(); 44 | model.addAttribute("analytics", analytics); 45 | 46 | return "dashboard"; 47 | } 48 | 49 | @GetMapping("/setup") 50 | public String showSetupPage() { 51 | if (applicationSettingsService.isAdminPasswordSet()) { 52 | return "redirect:dashboard"; 53 | } 54 | return "welcome"; 55 | } 56 | 57 | @PostMapping("/setup") 58 | public String setAdminPassword(String adminPassword) { 59 | applicationSettingsService.setAdminPassword(adminPassword); 60 | return "redirect:dashboard"; 61 | } 62 | 63 | @GetMapping("/settings") 64 | public String getSettingsPage(Model model) { 65 | ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); 66 | 67 | ApplicationSettingsViewModel applicationSettingsViewModel = new ApplicationSettingsViewModel(settings); 68 | applicationSettingsViewModel.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); 69 | 70 | model.addAttribute("settings", applicationSettingsViewModel); 71 | model.addAttribute("aboutInfo", systemInfoService.getAboutInfo()); 72 | return "settings"; 73 | } 74 | 75 | @PostMapping("/save") 76 | public String saveSettings(ApplicationSettingsViewModel settings) { 77 | settings.setMaxFileSize(megabytesToBytes(settings.getMaxFileSize())); 78 | 79 | applicationSettingsService.updateApplicationSettings(settings, settings.getAppPassword()); 80 | return "redirect:dashboard"; 81 | } 82 | 83 | @PostMapping("/password") 84 | public String checkAdminPassword(@RequestParam String password, HttpServletRequest request) { 85 | String adminPasswordHash = applicationSettingsService.getAdminPasswordHash(); 86 | 87 | if (BCrypt.checkpw(password, adminPasswordHash)) { 88 | String adminAccessToken = sessionService.addAdminToken(UUID.randomUUID().toString()); 89 | HttpSession session = request.getSession(); 90 | session.setAttribute("admin-session-token", adminAccessToken); 91 | return "redirect:dashboard"; 92 | } else { 93 | return "redirect:password"; 94 | } 95 | } 96 | 97 | @GetMapping("/password") 98 | public String showAdminPasswordPage() { 99 | return "admin-password"; 100 | } 101 | 102 | @PostMapping("/keep-indefinitely/{uuid}") 103 | public String updateKeepIndefinitely(@PathVariable String uuid, @RequestParam(required = false, defaultValue = "false") boolean keepIndefinitely, HttpServletRequest request) { 104 | fileService.updateKeepIndefinitely(uuid, keepIndefinitely, request); 105 | return "redirect:/admin/dashboard"; 106 | } 107 | 108 | 109 | @PostMapping("/toggle-hidden/{uuid}") 110 | public String toggleHidden(@PathVariable String uuid) { 111 | fileService.toggleHidden(uuid); 112 | return "redirect:/admin/dashboard"; 113 | } 114 | 115 | @PostMapping("/delete/{uuid}") 116 | public String deleteFile(@PathVariable String uuid) { 117 | fileService.deleteFileFromDatabaseAndFileSystem(uuid); 118 | 119 | return "redirect:/admin/dashboard"; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/controller/FileRestController.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.controller; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.rostislav.quickdrop.entity.FileEntity; 5 | import org.rostislav.quickdrop.entity.ShareTokenEntity; 6 | import org.rostislav.quickdrop.model.FileUploadRequest; 7 | import org.rostislav.quickdrop.service.AsyncFileMergeService; 8 | import org.rostislav.quickdrop.service.FileService; 9 | import org.rostislav.quickdrop.service.SessionService; 10 | import org.rostislav.quickdrop.util.FileUtils; 11 | import org.slf4j.Logger; 12 | import org.slf4j.LoggerFactory; 13 | import org.springframework.http.HttpStatus; 14 | import org.springframework.http.ResponseEntity; 15 | import org.springframework.web.bind.annotation.*; 16 | import org.springframework.web.multipart.MultipartFile; 17 | import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; 18 | 19 | import java.io.IOException; 20 | import java.time.LocalDate; 21 | 22 | import static org.springframework.http.ResponseEntity.ok; 23 | 24 | @RestController 25 | @RequestMapping("/api/file") 26 | public class FileRestController { 27 | private static final Logger logger = LoggerFactory.getLogger(FileRestController.class); 28 | private final FileService fileService; 29 | private final SessionService sessionService; 30 | private final AsyncFileMergeService asyncFileMergeService; 31 | 32 | public FileRestController(FileService fileService, SessionService sessionService, AsyncFileMergeService asyncFileMergeService) { 33 | this.fileService = fileService; 34 | this.sessionService = sessionService; 35 | this.asyncFileMergeService = asyncFileMergeService; 36 | } 37 | 38 | @PostMapping("/upload-chunk") 39 | public ResponseEntity handleChunkUpload( 40 | @RequestParam("file") MultipartFile file, 41 | @RequestParam("fileName") String fileName, 42 | @RequestParam("chunkNumber") int chunkNumber, 43 | @RequestParam("totalChunks") int totalChunks, 44 | @RequestParam(value = "fileSize", required = false) Long fileSize, 45 | @RequestParam(value = "description", required = false) String description, 46 | @RequestParam(value = "keepIndefinitely", defaultValue = "false") Boolean keepIndefinitely, 47 | @RequestParam(value = "password", required = false) String password, 48 | @RequestParam(value = "hidden", defaultValue = "false") Boolean hidden) { 49 | 50 | if (chunkNumber == 0) { 51 | logger.info("Upload started for file: {}", fileName); 52 | } 53 | 54 | try { 55 | logger.info("Submitting chunk {} of {} for file: {}", chunkNumber, totalChunks, fileName); 56 | 57 | FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password, hidden, fileName, totalChunks, fileSize); 58 | FileEntity fileEntity = asyncFileMergeService.submitChunk(fileUploadRequest, file, chunkNumber); 59 | return ResponseEntity.ok(fileEntity); 60 | } catch (IOException e) { 61 | logger.error("Error processing chunk {} for file {}: {}", chunkNumber, fileName, e.getMessage()); 62 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) 63 | .body("{\"error\": \"Error processing chunk\"}"); 64 | } 65 | } 66 | 67 | @PostMapping("/share/{uuid}") 68 | public ResponseEntity generateShareableLink(@PathVariable String uuid, @RequestParam("expirationDate") LocalDate expirationDate, @RequestParam("nOfDownloads") int numberOfDownloads, HttpServletRequest request) { 69 | FileEntity fileEntity = fileService.getFile(uuid); 70 | if (fileEntity == null) { 71 | return ResponseEntity.badRequest().body("File not found."); 72 | } 73 | 74 | ShareTokenEntity token; 75 | if (fileEntity.passwordHash != null && !fileEntity.passwordHash.isEmpty()) { 76 | String sessionToken = (String) request.getSession().getAttribute("file-session-token"); 77 | if (sessionToken == null || !sessionService.validateFileSessionToken(sessionToken, uuid)) { 78 | return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); 79 | } 80 | token = fileService.generateShareToken(uuid, expirationDate, sessionToken, numberOfDownloads); 81 | } else { 82 | token = fileService.generateShareToken(uuid, expirationDate, numberOfDownloads); 83 | } 84 | String shareLink = FileUtils.getShareLink(request, token.shareToken); 85 | return ok(shareLink); 86 | } 87 | 88 | @GetMapping("/download/{uuid}/{token}") 89 | public ResponseEntity downloadFile(@PathVariable String uuid, @PathVariable String token, HttpServletRequest request) { 90 | try { 91 | StreamingResponseBody responseBody = fileService.streamFileAndUpdateToken(uuid, token, request); 92 | if (responseBody == null) { 93 | return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); 94 | } 95 | 96 | FileEntity fileEntity = fileService.getFile(uuid); 97 | 98 | return ok() 99 | .header("Content-Disposition", "attachment; filename=\"" + fileEntity.name + "\"") 100 | .header("Content-Type", "application/octet-stream") 101 | .body(responseBody); 102 | } catch (IllegalArgumentException e) { 103 | return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); 104 | } catch (Exception e) { 105 | return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/controller/FileViewController.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.controller; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpSession; 5 | import org.rostislav.quickdrop.entity.DownloadLog; 6 | import org.rostislav.quickdrop.entity.FileEntity; 7 | import org.rostislav.quickdrop.entity.FileRenewalLog; 8 | import org.rostislav.quickdrop.entity.ShareTokenEntity; 9 | import org.rostislav.quickdrop.model.FileActionLogDTO; 10 | import org.rostislav.quickdrop.model.FileEntityView; 11 | import org.rostislav.quickdrop.service.AnalyticsService; 12 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 13 | import org.rostislav.quickdrop.service.FileService; 14 | import org.rostislav.quickdrop.service.SessionService; 15 | import org.springframework.http.ResponseEntity; 16 | import org.springframework.stereotype.Controller; 17 | import org.springframework.ui.Model; 18 | import org.springframework.web.bind.annotation.*; 19 | import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; 20 | 21 | import java.util.ArrayList; 22 | import java.util.Comparator; 23 | import java.util.List; 24 | import java.util.UUID; 25 | 26 | import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes; 27 | 28 | @Controller 29 | @RequestMapping("/file") 30 | public class FileViewController { 31 | private final FileService fileService; 32 | private final ApplicationSettingsService applicationSettingsService; 33 | private final AnalyticsService analyticsService; 34 | private final SessionService sessionService; 35 | 36 | public FileViewController(FileService fileService, ApplicationSettingsService applicationSettingsService, AnalyticsService analyticsService, SessionService sessionService) { 37 | this.fileService = fileService; 38 | this.applicationSettingsService = applicationSettingsService; 39 | this.analyticsService = analyticsService; 40 | this.sessionService = sessionService; 41 | } 42 | 43 | @GetMapping("/upload") 44 | public String showUploadFile(Model model) { 45 | model.addAttribute("maxFileSize", applicationSettingsService.getFormattedMaxFileSize()); 46 | model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); 47 | return "upload"; 48 | } 49 | 50 | @GetMapping("/list") 51 | public String listFiles(Model model) { 52 | if (!applicationSettingsService.isFileListPageEnabled()) { 53 | return "redirect:/"; 54 | } 55 | 56 | List files = fileService.getNotHiddenFiles(); 57 | model.addAttribute("files", files); 58 | return "listFiles"; 59 | } 60 | 61 | @GetMapping("/{uuid}") 62 | public String filePage(@PathVariable String uuid, Model model, HttpServletRequest request) { 63 | FileEntity fileEntity = fileService.getFile(uuid); 64 | model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); 65 | 66 | populateModelAttributes(fileEntity, model, request); 67 | 68 | return "fileView"; 69 | } 70 | 71 | @GetMapping("/history/{uuid}") 72 | public String viewFileHistory(@PathVariable String uuid, Model model) { 73 | FileEntity fileEntity = fileService.getFile(uuid); 74 | long totalDownloads = analyticsService.getTotalDownloadsByFile(uuid); 75 | FileEntityView fileEntityView = new FileEntityView(fileEntity, totalDownloads); 76 | 77 | List actionLogs = new ArrayList<>(); 78 | 79 | List downloadLogs = analyticsService.getDownloadsByFile(uuid); 80 | List renewalLogs = analyticsService.getRenewalLogsByFile(uuid); 81 | downloadLogs.forEach(log -> actionLogs.add(new FileActionLogDTO(log))); 82 | renewalLogs.forEach(log -> actionLogs.add(new FileActionLogDTO(log))); 83 | 84 | actionLogs.sort(Comparator.comparing(FileActionLogDTO::getActionDate).reversed()); 85 | 86 | model.addAttribute("file", fileEntityView); 87 | model.addAttribute("actionLogs", actionLogs); 88 | 89 | return "file-history"; 90 | } 91 | 92 | 93 | @PostMapping("/password") 94 | public String checkPassword(String uuid, String password, HttpServletRequest request, Model model) { 95 | if (fileService.checkFilePassword(uuid, password)) { 96 | String fileSessionToken = sessionService.addFileSessionToken(UUID.randomUUID().toString(), password, uuid); 97 | HttpSession session = request.getSession(); 98 | session.setAttribute("file-session-token", fileSessionToken); 99 | return "redirect:/file/" + uuid; 100 | } else { 101 | model.addAttribute("uuid", uuid); 102 | return "file-password"; 103 | } 104 | } 105 | 106 | @GetMapping("/password/{uuid}") 107 | public String passwordPage(@PathVariable String uuid, Model model) { 108 | model.addAttribute("uuid", uuid); 109 | return "file-password"; 110 | } 111 | 112 | @GetMapping("/download/{uuid}") 113 | public ResponseEntity downloadFile(@PathVariable String uuid, HttpServletRequest request) { 114 | return fileService.downloadFile(uuid, request); 115 | } 116 | 117 | @PostMapping("/extend/{uuid}") 118 | public String extendFile(@PathVariable String uuid, Model model, HttpServletRequest request) { 119 | fileService.extendFile(uuid, request); 120 | 121 | FileEntity fileEntity = fileService.getFile(uuid); 122 | populateModelAttributes(fileEntity, model, request); 123 | model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); 124 | return "fileView"; 125 | } 126 | 127 | @PostMapping("/delete/{uuid}") 128 | public String deleteFile(@PathVariable String uuid) { 129 | if (fileService.deleteFileFromDatabaseAndFileSystem(uuid)) { 130 | return "redirect:/file/list"; 131 | } else { 132 | return "redirect:/file/" + uuid; 133 | } 134 | } 135 | 136 | @GetMapping("/search") 137 | public String searchFiles(String query, Model model) { 138 | List files = fileService.searchNotHiddenFiles(query); 139 | model.addAttribute("files", files); 140 | return "listFiles"; 141 | } 142 | 143 | @PostMapping("/keep-indefinitely/{uuid}") 144 | public String updateKeepIndefinitely(@PathVariable String uuid, @RequestParam(required = false, defaultValue = "false") boolean keepIndefinitely, HttpServletRequest request) { 145 | FileEntity fileEntity = fileService.updateKeepIndefinitely(uuid, keepIndefinitely, request); 146 | if (fileEntity != null) { 147 | return "redirect:/file/" + fileEntity.uuid; 148 | } 149 | return "redirect:/file/list"; 150 | } 151 | 152 | 153 | @PostMapping("/toggle-hidden/{uuid}") 154 | public String toggleHidden(@PathVariable String uuid) { 155 | FileEntity fileEntity = fileService.toggleHidden(uuid); 156 | if (fileEntity != null) { 157 | return "redirect:/file/" + fileEntity.uuid; 158 | } 159 | return "redirect:/file/list"; 160 | } 161 | 162 | @GetMapping("/share/{token}") 163 | public String viewSharedFile(@PathVariable String token, Model model) { 164 | ShareTokenEntity tokenEntity = fileService.getShareTokenEntityByToken(token); 165 | 166 | if (!fileService.validateShareToken(tokenEntity)) { 167 | return "invalid-share-link"; 168 | } 169 | 170 | FileEntity file = fileService.getFile(tokenEntity.file.uuid); 171 | if (file == null) { 172 | return "redirect:/file/list"; 173 | } 174 | 175 | model.addAttribute("file", new FileEntityView(file, analyticsService.getTotalDownloadsByFile(file.uuid))); 176 | model.addAttribute("downloadLink", "/api/file/download/" + file.uuid + "/" + token); 177 | 178 | return "file-share-view"; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.controller; 2 | 3 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 4 | import org.springframework.stereotype.Controller; 5 | import org.springframework.ui.Model; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | 8 | @Controller 9 | public class IndexViewController { 10 | private final ApplicationSettingsService applicationSettingsService; 11 | 12 | public IndexViewController(ApplicationSettingsService applicationSettingsService) { 13 | this.applicationSettingsService = applicationSettingsService; 14 | } 15 | 16 | @GetMapping("/") 17 | public String getIndexPage(Model model) { 18 | model.addAttribute("maxFileSize", applicationSettingsService.getFormattedMaxFileSize()); 19 | model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); 20 | return "upload"; 21 | } 22 | 23 | @GetMapping("/error") 24 | public String getErrorPage() { 25 | return "error"; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/controller/PasswordController.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.controller; 2 | 3 | import org.springframework.stereotype.Controller; 4 | import org.springframework.web.bind.annotation.GetMapping; 5 | import org.springframework.web.bind.annotation.RequestMapping; 6 | 7 | @Controller 8 | @RequestMapping("/password") 9 | public class PasswordController { 10 | @GetMapping("/login") 11 | public String passwordPage() { 12 | return "app-password"; 13 | } 14 | 15 | @GetMapping("/admin") 16 | public String adminPasswordPage() { 17 | return "admin/admin-password"; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.entity; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.GenerationType; 6 | import jakarta.persistence.Id; 7 | import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; 8 | 9 | @Entity 10 | public class ApplicationSettingsEntity { 11 | @Id 12 | @GeneratedValue(strategy = GenerationType.IDENTITY) 13 | private Long id; 14 | private long maxFileSize; 15 | private long maxFileLifeTime; 16 | private String fileStoragePath; 17 | private String logStoragePath; 18 | private String fileDeletionCron; 19 | private boolean appPasswordEnabled; 20 | private String appPasswordHash; 21 | private String adminPasswordHash; 22 | private long sessionLifetime; 23 | private boolean isFileListPageEnabled; 24 | private boolean isAdminDashboardButtonEnabled; 25 | private boolean disableEncryption; 26 | 27 | public ApplicationSettingsEntity() { 28 | } 29 | 30 | public ApplicationSettingsEntity(ApplicationSettingsViewModel settings) { 31 | this.id = settings.getId(); 32 | this.maxFileSize = settings.getMaxFileSize(); 33 | this.maxFileLifeTime = settings.getMaxFileLifeTime(); 34 | this.fileStoragePath = settings.getFileStoragePath(); 35 | this.logStoragePath = settings.getLogStoragePath(); 36 | this.fileDeletionCron = settings.getFileDeletionCron(); 37 | this.appPasswordEnabled = settings.isAppPasswordEnabled(); 38 | this.isFileListPageEnabled = settings.isFileListPageEnabled(); 39 | this.isAdminDashboardButtonEnabled = settings.isAdminDashboardButtonEnabled(); 40 | this.disableEncryption = settings.isEncryptionDisabled(); 41 | } 42 | 43 | public long getMaxFileSize() { 44 | return maxFileSize; 45 | } 46 | 47 | public void setMaxFileSize(long maxFileSize) { 48 | this.maxFileSize = maxFileSize; 49 | } 50 | 51 | public long getMaxFileLifeTime() { 52 | return maxFileLifeTime; 53 | } 54 | 55 | public void setMaxFileLifeTime(long maxFileLifeTime) { 56 | this.maxFileLifeTime = maxFileLifeTime; 57 | } 58 | 59 | public String getFileStoragePath() { 60 | return fileStoragePath; 61 | } 62 | 63 | public void setFileStoragePath(String fileStoragePath) { 64 | this.fileStoragePath = fileStoragePath; 65 | } 66 | 67 | public String getLogStoragePath() { 68 | return logStoragePath; 69 | } 70 | 71 | public void setLogStoragePath(String logStoragePath) { 72 | this.logStoragePath = logStoragePath; 73 | } 74 | 75 | public String getFileDeletionCron() { 76 | return fileDeletionCron; 77 | } 78 | 79 | public void setFileDeletionCron(String fileDeletionCron) { 80 | this.fileDeletionCron = fileDeletionCron; 81 | } 82 | 83 | public boolean isAppPasswordEnabled() { 84 | return appPasswordEnabled; 85 | } 86 | 87 | public void setAppPasswordEnabled(boolean appPasswordEnabled) { 88 | this.appPasswordEnabled = appPasswordEnabled; 89 | } 90 | 91 | public String getAppPasswordHash() { 92 | return appPasswordHash; 93 | } 94 | 95 | public void setAppPasswordHash(String appPasswordHash) { 96 | this.appPasswordHash = appPasswordHash; 97 | } 98 | 99 | public String getAdminPasswordHash() { 100 | return adminPasswordHash; 101 | } 102 | 103 | public void setAdminPasswordHash(String adminPasswordHash) { 104 | this.adminPasswordHash = adminPasswordHash; 105 | } 106 | 107 | public Long getId() { 108 | return id; 109 | } 110 | 111 | public void setId(Long id) { 112 | this.id = id; 113 | } 114 | 115 | public long getSessionLifetime() { 116 | return sessionLifetime; 117 | } 118 | 119 | public void setSessionLifetime(long sessionLifetime) { 120 | this.sessionLifetime = sessionLifetime; 121 | } 122 | 123 | public boolean isFileListPageEnabled() { 124 | return isFileListPageEnabled; 125 | } 126 | 127 | public void setFileListPageEnabled(boolean fileListPageEnabled) { 128 | isFileListPageEnabled = fileListPageEnabled; 129 | } 130 | 131 | public boolean isAdminDashboardButtonEnabled() { 132 | return isAdminDashboardButtonEnabled; 133 | } 134 | 135 | public void setAdminDashboardButtonEnabled(boolean adminDashboardButtonEnabled) { 136 | isAdminDashboardButtonEnabled = adminDashboardButtonEnabled; 137 | } 138 | 139 | public boolean isDisableEncryption() { 140 | return disableEncryption; 141 | } 142 | 143 | public void setDisableEncryption(boolean disableEncryption) { 144 | this.disableEncryption = disableEncryption; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/entity/DownloadLog.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.entity; 2 | 3 | import jakarta.persistence.*; 4 | 5 | import java.time.LocalDateTime; 6 | 7 | @Entity 8 | public class DownloadLog { 9 | @Id 10 | @GeneratedValue(strategy = GenerationType.IDENTITY) 11 | private Long id; 12 | @ManyToOne(fetch = FetchType.LAZY) 13 | @JoinColumn(name = "file_id", nullable = false) 14 | private FileEntity file; 15 | private String downloaderIp; 16 | private LocalDateTime downloadDate; 17 | private String userAgent; 18 | 19 | public DownloadLog() { 20 | } 21 | 22 | public DownloadLog(FileEntity file, String downloaderIp, String userAgent) { 23 | this.file = file; 24 | this.downloaderIp = downloaderIp; 25 | this.downloadDate = LocalDateTime.now(); 26 | this.userAgent = userAgent; 27 | } 28 | 29 | // Getters and Setters 30 | public Long getId() { 31 | return id; 32 | } 33 | 34 | public void setId(Long id) { 35 | this.id = id; 36 | } 37 | 38 | public FileEntity getFile() { 39 | return file; 40 | } 41 | 42 | public void setFile(FileEntity file) { 43 | this.file = file; 44 | } 45 | 46 | public String getDownloaderIp() { 47 | return downloaderIp; 48 | } 49 | 50 | public void setDownloaderIp(String downloaderIp) { 51 | this.downloaderIp = downloaderIp; 52 | } 53 | 54 | public LocalDateTime getDownloadDate() { 55 | return downloadDate; 56 | } 57 | 58 | public void setDownloadDate(LocalDateTime downloadDate) { 59 | this.downloadDate = downloadDate; 60 | } 61 | 62 | public String getUserAgent() { 63 | return userAgent; 64 | } 65 | 66 | public void setUserAgent(String userAgent) { 67 | this.userAgent = userAgent; 68 | } 69 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/entity/FileEntity.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.entity; 2 | 3 | import jakarta.persistence.*; 4 | 5 | import java.time.LocalDate; 6 | 7 | @Entity 8 | public class FileEntity { 9 | @Id 10 | @GeneratedValue(strategy = GenerationType.IDENTITY) 11 | public Long id; 12 | public String name; 13 | public String uuid; 14 | public String description; 15 | public long size; 16 | public boolean keepIndefinitely; 17 | public LocalDate uploadDate; 18 | public String passwordHash; 19 | public boolean hidden; 20 | public boolean encrypted; 21 | 22 | @PrePersist 23 | public void prePersist() { 24 | uploadDate = LocalDate.now(); 25 | } 26 | 27 | @Override 28 | public String toString() { 29 | return "FileEntity{" + 30 | "id=" + id + 31 | ", name='" + name + '\'' + 32 | ", uuid='" + uuid + '\'' + 33 | ", description='" + description + '\'' + 34 | ", size=" + size + 35 | ", keepIndefinitely=" + keepIndefinitely + 36 | ", uploadDate=" + uploadDate + 37 | ", hidden=" + hidden + 38 | ", encrypted=" + encrypted + 39 | '}'; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/entity/FileRenewalLog.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.entity; 2 | 3 | import jakarta.persistence.*; 4 | 5 | import java.time.LocalDateTime; 6 | 7 | @Entity 8 | public class FileRenewalLog { 9 | @Id 10 | @GeneratedValue(strategy = GenerationType.IDENTITY) 11 | private Long id; 12 | @ManyToOne(fetch = FetchType.LAZY) 13 | @JoinColumn(name = "file_id", nullable = false) 14 | private FileEntity file; 15 | private LocalDateTime actionDate; 16 | private String ipAddress; 17 | private String userAgent; 18 | 19 | public FileRenewalLog() { 20 | this.actionDate = LocalDateTime.now(); 21 | } 22 | 23 | public FileRenewalLog(FileEntity file, String ipAddress, String userAgent) { 24 | this.file = file; 25 | this.ipAddress = ipAddress; 26 | this.userAgent = userAgent; 27 | this.actionDate = LocalDateTime.now(); 28 | } 29 | 30 | public FileEntity getFile() { 31 | return file; 32 | } 33 | 34 | public void setFile(FileEntity file) { 35 | this.file = file; 36 | } 37 | 38 | public LocalDateTime getActionDate() { 39 | return actionDate; 40 | } 41 | 42 | public void setActionDate(LocalDateTime actionDate) { 43 | this.actionDate = actionDate; 44 | } 45 | 46 | public String getIpAddress() { 47 | return ipAddress; 48 | } 49 | 50 | public void setIpAddress(String ipAddress) { 51 | this.ipAddress = ipAddress; 52 | } 53 | 54 | public String getUserAgent() { 55 | return userAgent; 56 | } 57 | 58 | public void setUserAgent(String userAgent) { 59 | this.userAgent = userAgent; 60 | } 61 | 62 | public Long getId() { 63 | return id; 64 | } 65 | 66 | public void setId(Long id) { 67 | this.id = id; 68 | } 69 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/entity/ShareTokenEntity.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.entity; 2 | 3 | import jakarta.persistence.*; 4 | 5 | import java.time.LocalDate; 6 | 7 | @Entity 8 | public class ShareTokenEntity { 9 | @ManyToOne(fetch = FetchType.EAGER) 10 | @JoinColumn(name = "file_id", nullable = false) 11 | public FileEntity file; 12 | public String shareToken; 13 | public LocalDate tokenExpirationDate; 14 | public Integer numberOfAllowedDownloads; 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.IDENTITY) 17 | private Long id; 18 | 19 | public ShareTokenEntity() { 20 | } 21 | 22 | public ShareTokenEntity(String token, FileEntity file, LocalDate tokenExpirationDate, Integer numberOfDownloads) { 23 | this.shareToken = token; 24 | this.file = file; 25 | this.tokenExpirationDate = tokenExpirationDate; 26 | this.numberOfAllowedDownloads = numberOfDownloads; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordInterceptor.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.interceptor; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpServletResponse; 5 | import org.rostislav.quickdrop.service.SessionService; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.web.servlet.HandlerInterceptor; 8 | 9 | @Component 10 | public class AdminPasswordInterceptor implements HandlerInterceptor { 11 | 12 | private final SessionService sessionService; 13 | 14 | public AdminPasswordInterceptor(SessionService sessionService) { 15 | this.sessionService = sessionService; 16 | } 17 | 18 | @Override 19 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 20 | Object sessionToken = request.getSession().getAttribute("admin-session-token"); 21 | 22 | 23 | if (sessionToken == null || sessionToken.toString().isEmpty()) { 24 | response.sendRedirect("/admin/password"); 25 | return false; 26 | } 27 | 28 | if (!sessionService.validateAdminToken(sessionToken.toString())) { 29 | response.sendRedirect("/admin/password"); 30 | return false; 31 | } 32 | 33 | return true; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordSetupInterceptor.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.interceptor; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpServletResponse; 5 | import org.rostislav.quickdrop.service.ApplicationSettingsService; 6 | import org.springframework.stereotype.Component; 7 | import org.springframework.web.servlet.HandlerInterceptor; 8 | 9 | @Component 10 | public class AdminPasswordSetupInterceptor implements HandlerInterceptor { 11 | 12 | private final ApplicationSettingsService applicationSettingsService; 13 | 14 | public AdminPasswordSetupInterceptor(ApplicationSettingsService applicationSettingsService) { 15 | this.applicationSettingsService = applicationSettingsService; 16 | } 17 | 18 | @Override 19 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 20 | String requestURI = request.getRequestURI(); 21 | if (!applicationSettingsService.isAdminPasswordSet() 22 | && !requestURI.startsWith("/admin/setup") 23 | && !requestURI.startsWith("/static/") 24 | && !requestURI.startsWith("/css/") 25 | && !requestURI.startsWith("/js/") 26 | && !requestURI.startsWith("/images/")) { 27 | response.sendRedirect("/admin/setup"); 28 | return false; 29 | } 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/interceptor/FilePasswordInterceptor.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.interceptor; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import jakarta.servlet.http.HttpServletResponse; 5 | import org.rostislav.quickdrop.entity.FileEntity; 6 | import org.rostislav.quickdrop.service.FileService; 7 | import org.rostislav.quickdrop.service.SessionService; 8 | import org.springframework.stereotype.Component; 9 | import org.springframework.web.servlet.HandlerInterceptor; 10 | import org.springframework.web.servlet.HandlerMapping; 11 | 12 | import java.util.Map; 13 | 14 | @Component 15 | public class FilePasswordInterceptor implements HandlerInterceptor { 16 | 17 | private final FileService fileService; 18 | private final SessionService sessionService; 19 | 20 | public FilePasswordInterceptor(FileService fileService, SessionService sessionService) { 21 | this.fileService = fileService; 22 | this.sessionService = sessionService; 23 | } 24 | 25 | @Override 26 | public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 27 | @SuppressWarnings("unchecked") 28 | Map pathVariables = (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); 29 | 30 | //For shared files, no password is required 31 | if ("GET".equalsIgnoreCase(request.getMethod()) && request.getRequestURI().startsWith("/file/share/")) { 32 | return true; 33 | } 34 | 35 | String uuid = pathVariables != null ? pathVariables.get("uuid") : null; 36 | 37 | if (uuid == null || uuid.isEmpty()) { 38 | response.sendError(HttpServletResponse.SC_BAD_REQUEST, "File UUID is missing."); 39 | return false; 40 | } 41 | 42 | FileEntity fileEntity = fileService.getFile(uuid); 43 | if (fileEntity == null) { 44 | response.sendError(HttpServletResponse.SC_NOT_FOUND, "File not found."); 45 | return false; 46 | } 47 | 48 | String sessionToken = (String) request.getSession().getAttribute("file-session-token"); 49 | if (fileEntity.passwordHash != null && 50 | (sessionToken == null || !sessionService.validateFileSessionToken(sessionToken, uuid))) { 51 | 52 | response.sendRedirect("/file/password/" + uuid); 53 | return false; 54 | } 55 | 56 | return true; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/AboutInfoView.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | public class AboutInfoView { 4 | private String appVersion; 5 | private String sqliteVersion; 6 | private String javaVersion; 7 | private String osInfo; 8 | 9 | public AboutInfoView() { 10 | } 11 | 12 | public AboutInfoView(String appVersion, String sqliteVersion, String javaVersion, String osInfo) { 13 | this.appVersion = appVersion; 14 | this.sqliteVersion = sqliteVersion; 15 | this.javaVersion = javaVersion; 16 | this.osInfo = osInfo; 17 | } 18 | 19 | public String getAppVersion() { 20 | return appVersion; 21 | } 22 | 23 | public void setAppVersion(String appVersion) { 24 | this.appVersion = appVersion; 25 | } 26 | 27 | public String getSqliteVersion() { 28 | return sqliteVersion; 29 | } 30 | 31 | public void setSqliteVersion(String sqliteVersion) { 32 | this.sqliteVersion = sqliteVersion; 33 | } 34 | 35 | public String getJavaVersion() { 36 | return javaVersion; 37 | } 38 | 39 | public void setJavaVersion(String javaVersion) { 40 | this.javaVersion = javaVersion; 41 | } 42 | 43 | public String getOsInfo() { 44 | return osInfo; 45 | } 46 | 47 | public void setOsInfo(String osInfo) { 48 | this.osInfo = osInfo; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/AnalyticsDataView.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | public class AnalyticsDataView { 4 | private long totalDownloads; 5 | private String totalSpaceUsed; 6 | 7 | public long getTotalDownloads() { 8 | return totalDownloads; 9 | } 10 | 11 | public void setTotalDownloads(long totalDownloads) { 12 | this.totalDownloads = totalDownloads; 13 | } 14 | 15 | public String getTotalSpaceUsed() { 16 | return totalSpaceUsed; 17 | } 18 | 19 | public void setTotalSpaceUsed(String totalSpaceUsed) { 20 | this.totalSpaceUsed = totalSpaceUsed; 21 | } 22 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; 4 | 5 | public class ApplicationSettingsViewModel { 6 | private Long id; 7 | 8 | private long maxFileSize; 9 | private long maxFileLifeTime; 10 | private String fileStoragePath; 11 | private String logStoragePath; 12 | private String fileDeletionCron; 13 | private boolean appPasswordEnabled; 14 | private String appPassword; 15 | private long sessionLifeTime; 16 | private boolean isFileListPageEnabled; 17 | private boolean isAdminDashboardButtonEnabled; 18 | private boolean encryptionDisabled; 19 | 20 | public ApplicationSettingsViewModel() { 21 | } 22 | 23 | public ApplicationSettingsViewModel(ApplicationSettingsEntity settings) { 24 | this.id = settings.getId(); 25 | this.maxFileSize = settings.getMaxFileSize(); 26 | this.maxFileLifeTime = settings.getMaxFileLifeTime(); 27 | this.fileStoragePath = settings.getFileStoragePath(); 28 | this.logStoragePath = settings.getLogStoragePath(); 29 | this.fileDeletionCron = settings.getFileDeletionCron(); 30 | this.appPasswordEnabled = settings.isAppPasswordEnabled(); 31 | this.sessionLifeTime = settings.getSessionLifetime(); 32 | this.isFileListPageEnabled = settings.isFileListPageEnabled(); 33 | this.isAdminDashboardButtonEnabled = settings.isAdminDashboardButtonEnabled(); 34 | this.encryptionDisabled = settings.isDisableEncryption(); 35 | } 36 | 37 | public Long getId() { 38 | return id; 39 | } 40 | 41 | public void setId(Long id) { 42 | this.id = id; 43 | } 44 | 45 | public long getMaxFileSize() { 46 | return maxFileSize; 47 | } 48 | 49 | public void setMaxFileSize(long maxFileSize) { 50 | this.maxFileSize = maxFileSize; 51 | } 52 | 53 | public long getMaxFileLifeTime() { 54 | return maxFileLifeTime; 55 | } 56 | 57 | public void setMaxFileLifeTime(long maxFileLifeTime) { 58 | this.maxFileLifeTime = maxFileLifeTime; 59 | } 60 | 61 | public String getFileStoragePath() { 62 | return fileStoragePath; 63 | } 64 | 65 | public void setFileStoragePath(String fileStoragePath) { 66 | this.fileStoragePath = fileStoragePath; 67 | } 68 | 69 | public String getLogStoragePath() { 70 | return logStoragePath; 71 | } 72 | 73 | public void setLogStoragePath(String logStoragePath) { 74 | this.logStoragePath = logStoragePath; 75 | } 76 | 77 | public String getFileDeletionCron() { 78 | return fileDeletionCron; 79 | } 80 | 81 | public void setFileDeletionCron(String fileDeletionCron) { 82 | this.fileDeletionCron = fileDeletionCron; 83 | } 84 | 85 | public boolean isAppPasswordEnabled() { 86 | return appPasswordEnabled; 87 | } 88 | 89 | public void setAppPasswordEnabled(boolean appPasswordEnabled) { 90 | this.appPasswordEnabled = appPasswordEnabled; 91 | } 92 | 93 | public String getAppPassword() { 94 | return appPassword; 95 | } 96 | 97 | public void setAppPassword(String appPassword) { 98 | this.appPassword = appPassword; 99 | } 100 | 101 | public long getSessionLifeTime() { 102 | return sessionLifeTime; 103 | } 104 | 105 | public void setSessionLifeTime(long sessionLifeTime) { 106 | this.sessionLifeTime = sessionLifeTime; 107 | } 108 | 109 | public boolean isFileListPageEnabled() { 110 | return isFileListPageEnabled; 111 | } 112 | 113 | public void setFileListPageEnabled(boolean fileListPageEnabled) { 114 | isFileListPageEnabled = fileListPageEnabled; 115 | } 116 | 117 | public boolean isAdminDashboardButtonEnabled() { 118 | return isAdminDashboardButtonEnabled; 119 | } 120 | 121 | public void setAdminDashboardButtonEnabled(boolean adminDashboardButtonEnabled) { 122 | isAdminDashboardButtonEnabled = adminDashboardButtonEnabled; 123 | } 124 | 125 | public boolean isEncryptionDisabled() { 126 | return encryptionDisabled; 127 | } 128 | 129 | public void setEncryptionDisabled(boolean encryptionDisabled) { 130 | this.encryptionDisabled = encryptionDisabled; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/ChunkInfo.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | import java.io.File; 4 | 5 | public class ChunkInfo { 6 | public int chunkNumber; 7 | public File chunkFile; 8 | public boolean isLastChunk; 9 | 10 | public ChunkInfo() { 11 | 12 | } 13 | 14 | public ChunkInfo(int chunkNumber, File chunkFile, boolean isLastChunk) { 15 | this.chunkNumber = chunkNumber; 16 | this.chunkFile = chunkFile; 17 | this.isLastChunk = isLastChunk; 18 | } 19 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/FileActionLogDTO.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | import org.rostislav.quickdrop.entity.DownloadLog; 4 | import org.rostislav.quickdrop.entity.FileRenewalLog; 5 | 6 | import java.time.LocalDateTime; 7 | 8 | public class FileActionLogDTO { 9 | private String actionType; // "Download" or "Lifetime Renewed" 10 | private LocalDateTime actionDate; 11 | private String ipAddress; 12 | private String userAgent; 13 | 14 | public FileActionLogDTO(String actionType, LocalDateTime actionDate, String ipAddress, String userAgent) { 15 | this.actionType = actionType; 16 | this.actionDate = actionDate; 17 | this.ipAddress = ipAddress; 18 | this.userAgent = userAgent; 19 | } 20 | 21 | public FileActionLogDTO(DownloadLog downloadLog) { 22 | this.actionType = "Download"; 23 | this.actionDate = downloadLog.getDownloadDate(); 24 | this.ipAddress = downloadLog.getDownloaderIp(); 25 | this.userAgent = downloadLog.getUserAgent(); 26 | } 27 | 28 | public FileActionLogDTO(FileRenewalLog renewalLog) { 29 | this.actionType = "Lifetime Renewed"; 30 | this.actionDate = renewalLog.getActionDate(); 31 | this.ipAddress = renewalLog.getIpAddress(); 32 | this.userAgent = renewalLog.getUserAgent(); 33 | } 34 | 35 | // Getters and setters 36 | public String getActionType() { 37 | return actionType; 38 | } 39 | 40 | public void setActionType(String actionType) { 41 | this.actionType = actionType; 42 | } 43 | 44 | public LocalDateTime getActionDate() { 45 | return actionDate; 46 | } 47 | 48 | public void setActionDate(LocalDateTime actionDate) { 49 | this.actionDate = actionDate; 50 | } 51 | 52 | public String getIpAddress() { 53 | return ipAddress; 54 | } 55 | 56 | public void setIpAddress(String ipAddress) { 57 | this.ipAddress = ipAddress; 58 | } 59 | 60 | public String getUserAgent() { 61 | return userAgent; 62 | } 63 | 64 | public void setUserAgent(String userAgent) { 65 | this.userAgent = userAgent; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/FileEntityView.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | import org.rostislav.quickdrop.entity.FileEntity; 4 | 5 | import java.time.LocalDate; 6 | 7 | import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; 8 | 9 | public class FileEntityView { 10 | public Long id; 11 | public String name; 12 | public String uuid; 13 | public String description; 14 | public String size; 15 | public boolean keepIndefinitely; 16 | public LocalDate uploadDate; 17 | public long totalDownloads; 18 | public boolean hidden; 19 | 20 | public FileEntityView() { 21 | } 22 | 23 | public FileEntityView(FileEntity fileEntity, long totalDownloads) { 24 | this.id = fileEntity.id; 25 | this.name = fileEntity.name; 26 | this.uuid = fileEntity.uuid; 27 | this.description = fileEntity.description; 28 | this.size = formatFileSize(fileEntity.size); 29 | this.keepIndefinitely = fileEntity.keepIndefinitely; 30 | this.uploadDate = fileEntity.uploadDate; 31 | this.totalDownloads = totalDownloads; 32 | this.hidden = fileEntity.hidden; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/FileSession.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | public class FileSession { 4 | private String password; 5 | private String fileUuid; 6 | 7 | public FileSession() { 8 | } 9 | 10 | public FileSession(String password, String fileUuid) { 11 | this.password = password; 12 | this.fileUuid = fileUuid; 13 | } 14 | 15 | public String getPassword() { 16 | return password; 17 | } 18 | 19 | public void setPassword(String password) { 20 | this.password = password; 21 | } 22 | 23 | public String getFileUuid() { 24 | return fileUuid; 25 | } 26 | 27 | public void setFileUuid(String fileUuid) { 28 | this.fileUuid = fileUuid; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.model; 2 | 3 | public class FileUploadRequest { 4 | public String fileName; 5 | public int totalChunks; 6 | public Long fileSize; 7 | public String description; 8 | public boolean keepIndefinitely; 9 | public String password; 10 | public boolean hidden; 11 | 12 | public FileUploadRequest() { 13 | } 14 | 15 | public FileUploadRequest(String description, boolean keepIndefinitely, String password, boolean hidden, String fileName, int totalChunks, Long fileSize) { 16 | this.description = description; 17 | this.keepIndefinitely = keepIndefinitely; 18 | this.password = password; 19 | this.hidden = hidden; 20 | this.fileName = fileName; 21 | this.totalChunks = totalChunks; 22 | this.fileSize = fileSize; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.repository; 2 | 3 | 4 | import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | 7 | public interface ApplicationSettingsRepository extends JpaRepository { 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/repository/DownloadLogRepository.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.repository; 2 | 3 | import jakarta.transaction.Transactional; 4 | import org.rostislav.quickdrop.entity.DownloadLog; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.Modifying; 7 | import org.springframework.data.jpa.repository.Query; 8 | 9 | import java.util.List; 10 | 11 | public interface DownloadLogRepository extends JpaRepository { 12 | @Query("SELECT COUNT(dl) FROM DownloadLog dl") 13 | long countAllDownloads(); 14 | 15 | @Query("SELECT COUNT(dl) FROM DownloadLog dl WHERE dl.file.uuid = :uuid") 16 | long countDownloadsByFileId(String uuid); 17 | 18 | List findByFileUuid(String fileUUID); 19 | 20 | @Modifying 21 | @Transactional 22 | @Query("DELETE FROM DownloadLog dl WHERE dl.file.id = :id") 23 | void deleteByFileId(Long id); 24 | } 25 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/repository/FileRepository.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.repository; 2 | 3 | import org.rostislav.quickdrop.entity.FileEntity; 4 | import org.rostislav.quickdrop.model.FileEntityView; 5 | import org.springframework.data.jpa.repository.JpaRepository; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.data.repository.query.Param; 8 | 9 | import java.time.LocalDate; 10 | import java.util.List; 11 | import java.util.Optional; 12 | 13 | public interface FileRepository extends JpaRepository { 14 | @Query("SELECT f FROM FileEntity f WHERE f.uuid = :uuid") 15 | Optional findByUUID(@Param("uuid") String uuid); 16 | 17 | @Query("SELECT f FROM FileEntity f WHERE f.keepIndefinitely = false AND f.uploadDate < :thresholdDate") 18 | List getFilesForDeletion(@Param("thresholdDate") LocalDate thresholdDate); 19 | 20 | @Query("SELECT f FROM FileEntity f WHERE (LOWER(f.name) LIKE LOWER(CONCAT('%', :searchString, '%')) OR LOWER(f.description) LIKE LOWER(CONCAT('%', :searchString, '%')) OR LOWER(f.uuid) LIKE LOWER(CONCAT('%', :searchString, '%')))") 21 | List searchFiles(@Param("searchString") String searchString); 22 | 23 | @Query("SELECT f FROM FileEntity f WHERE f.hidden = false") 24 | List findAllNotHiddenFiles(); 25 | 26 | @Query("SELECT SUM(f.size) FROM FileEntity f") 27 | Long totalFileSizeForAllFiles(); 28 | 29 | @Query("SELECT f FROM FileEntity f WHERE f.hidden = false AND (LOWER(f.name) LIKE LOWER(CONCAT('%', :searchString, '%')) OR LOWER(f.description) LIKE LOWER(CONCAT('%', :searchString, '%')) OR LOWER(f.uuid) LIKE LOWER(CONCAT('%', :searchString, '%')))") 30 | List searchNotHiddenFiles(@Param("searchString") String query); 31 | 32 | @Query(""" 33 | SELECT new org.rostislav.quickdrop.model.FileEntityView( 34 | f, 35 | CAST(SUM(CASE WHEN dl.id IS NOT NULL THEN 1 ELSE 0 END) AS long) 36 | ) 37 | FROM FileEntity f 38 | LEFT JOIN DownloadLog dl ON dl.file.id = f.id 39 | GROUP BY f 40 | """) 41 | List findAllFilesWithDownloadCounts(); 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/repository/RenewalLogRepository.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.repository; 2 | 3 | import org.rostislav.quickdrop.entity.FileRenewalLog; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Query; 6 | 7 | import java.util.List; 8 | 9 | public interface RenewalLogRepository extends JpaRepository { 10 | 11 | @Query("SELECT f FROM FileRenewalLog f WHERE f.file.uuid = :uuid") 12 | List findByFileUuid(String uuid); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/repository/ShareTokenRepository.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.repository; 2 | 3 | import org.rostislav.quickdrop.entity.ShareTokenEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Query; 6 | 7 | import java.util.List; 8 | 9 | public interface ShareTokenRepository extends JpaRepository { 10 | @Query("SELECT s FROM ShareTokenEntity s WHERE s.shareToken = :shareToken") 11 | ShareTokenEntity getShareTokenEntityByToken(String shareToken); 12 | 13 | @Query("SELECT s FROM ShareTokenEntity s WHERE s.tokenExpirationDate < CURRENT_DATE OR s.numberOfAllowedDownloads = 0") 14 | List getShareTokenEntitiesForDeletion(); 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/AnalyticsService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | 4 | import org.rostislav.quickdrop.entity.DownloadLog; 5 | import org.rostislav.quickdrop.entity.FileRenewalLog; 6 | import org.rostislav.quickdrop.model.AnalyticsDataView; 7 | import org.rostislav.quickdrop.repository.DownloadLogRepository; 8 | import org.rostislav.quickdrop.repository.RenewalLogRepository; 9 | import org.springframework.stereotype.Service; 10 | 11 | import java.util.List; 12 | 13 | import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; 14 | 15 | @Service 16 | public class AnalyticsService { 17 | private final FileService fileService; 18 | private final DownloadLogRepository downloadLogRepository; 19 | private final RenewalLogRepository renewalLogRepository; 20 | 21 | public AnalyticsService(FileService fileService, DownloadLogRepository downloadLogRepository, RenewalLogRepository renewalLogRepository) { 22 | this.fileService = fileService; 23 | this.downloadLogRepository = downloadLogRepository; 24 | this.renewalLogRepository = renewalLogRepository; 25 | } 26 | 27 | public AnalyticsDataView getAnalytics() { 28 | long totalDownloads = downloadLogRepository.countAllDownloads(); 29 | long totalSpaceUsed = fileService.calculateTotalSpaceUsed(); 30 | 31 | AnalyticsDataView analytics = new AnalyticsDataView(); 32 | analytics.setTotalDownloads(totalDownloads); 33 | analytics.setTotalSpaceUsed(formatFileSize(totalSpaceUsed)); 34 | return analytics; 35 | } 36 | 37 | public long getTotalDownloads() { 38 | return downloadLogRepository.countAllDownloads(); 39 | } 40 | 41 | public long getTotalDownloadsByFile(String uuid) { 42 | return downloadLogRepository.countDownloadsByFileId(uuid); 43 | } 44 | 45 | public List getDownloadsByFile(String fileUUID) { 46 | return downloadLogRepository.findByFileUuid(fileUUID); 47 | } 48 | 49 | public List getRenewalLogsByFile(String fileUUID) { 50 | return renewalLogRepository.findByFileUuid(fileUUID); 51 | } 52 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; 5 | import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; 6 | import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; 7 | import org.springframework.beans.factory.annotation.Qualifier; 8 | import org.springframework.cloud.context.refresh.ContextRefresher; 9 | import org.springframework.security.crypto.bcrypt.BCrypt; 10 | import org.springframework.stereotype.Service; 11 | 12 | import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; 13 | 14 | @Service 15 | public class ApplicationSettingsService { 16 | private final ApplicationSettingsRepository applicationSettingsRepository; 17 | private final ContextRefresher contextRefresher; 18 | private final ScheduleService scheduleService; 19 | private ApplicationSettingsEntity applicationSettings; 20 | 21 | public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher, ScheduleService scheduleService) { 22 | this.contextRefresher = contextRefresher; 23 | this.applicationSettingsRepository = applicationSettingsRepository; 24 | this.scheduleService = scheduleService; 25 | 26 | this.applicationSettings = applicationSettingsRepository.findById(1L).orElseGet(() -> { 27 | ApplicationSettingsEntity settings = new ApplicationSettingsEntity(); 28 | settings.setMaxFileSize(1024L * 1024L * 1024L); 29 | settings.setMaxFileLifeTime(30L); 30 | settings.setFileStoragePath("files"); 31 | settings.setLogStoragePath("logs"); 32 | settings.setFileDeletionCron("0 0 2 * * *"); 33 | settings.setAppPasswordEnabled(false); 34 | settings.setAppPasswordHash(""); 35 | settings.setAdminPasswordHash(""); 36 | settings.setSessionLifetime(30); 37 | settings.setFileListPageEnabled(true); 38 | settings.setAdminDashboardButtonEnabled(true); 39 | settings.setDisableEncryption(false); 40 | settings = applicationSettingsRepository.save(settings); 41 | scheduleService.updateSchedule(settings.getFileDeletionCron(), settings.getMaxFileLifeTime()); 42 | return settings; 43 | }); 44 | } 45 | 46 | public ApplicationSettingsEntity getApplicationSettings() { 47 | return applicationSettings; 48 | } 49 | 50 | public void updateApplicationSettings(ApplicationSettingsViewModel settings, String appPassword) { 51 | ApplicationSettingsEntity applicationSettingsEntity = applicationSettingsRepository.findById(1L).orElseThrow(); 52 | applicationSettingsEntity.setMaxFileSize(settings.getMaxFileSize()); 53 | applicationSettingsEntity.setMaxFileLifeTime(settings.getMaxFileLifeTime()); 54 | applicationSettingsEntity.setFileStoragePath(settings.getFileStoragePath()); 55 | applicationSettingsEntity.setLogStoragePath(settings.getLogStoragePath()); 56 | applicationSettingsEntity.setFileDeletionCron(settings.getFileDeletionCron()); 57 | applicationSettingsEntity.setSessionLifetime(settings.getSessionLifeTime()); 58 | applicationSettingsEntity.setFileListPageEnabled(settings.isFileListPageEnabled()); 59 | applicationSettingsEntity.setAdminDashboardButtonEnabled(settings.isAdminDashboardButtonEnabled()); 60 | applicationSettingsEntity.setDisableEncryption(settings.isEncryptionDisabled()); 61 | 62 | if (appPassword != null && !appPassword.isEmpty()) { 63 | applicationSettingsEntity.setAppPasswordEnabled(settings.isAppPasswordEnabled()); 64 | applicationSettingsEntity.setAppPasswordHash(BCrypt.hashpw(appPassword, BCrypt.gensalt())); 65 | } else if (!settings.isAppPasswordEnabled()) { 66 | applicationSettingsEntity.setAppPasswordEnabled(false); 67 | } 68 | 69 | applicationSettingsRepository.save(applicationSettingsEntity); 70 | this.applicationSettings = applicationSettingsEntity; 71 | 72 | scheduleService.updateSchedule(applicationSettingsEntity.getFileDeletionCron(), applicationSettingsEntity.getMaxFileLifeTime()); 73 | contextRefresher.refresh(); 74 | } 75 | 76 | public long getMaxFileSize() { 77 | return applicationSettings.getMaxFileSize(); 78 | } 79 | 80 | public String getFormattedMaxFileSize() { 81 | return formatFileSize(applicationSettings.getMaxFileSize()); 82 | } 83 | 84 | public long getMaxFileLifeTime() { 85 | return applicationSettings.getMaxFileLifeTime(); 86 | } 87 | 88 | public String getFileStoragePath() { 89 | return applicationSettings.getFileStoragePath(); 90 | } 91 | 92 | public String getLogStoragePath() { 93 | return applicationSettings.getLogStoragePath(); 94 | } 95 | 96 | public String getFileDeletionCron() { 97 | return applicationSettings.getFileDeletionCron(); 98 | } 99 | 100 | public boolean isAppPasswordEnabled() { 101 | return applicationSettings.isAppPasswordEnabled(); 102 | } 103 | 104 | public String getAppPasswordHash() { 105 | return applicationSettings.getAppPasswordHash(); 106 | } 107 | 108 | public String getAdminPasswordHash() { 109 | return applicationSettings.getAdminPasswordHash(); 110 | } 111 | 112 | public boolean isFileListPageEnabled() { 113 | return applicationSettings.isFileListPageEnabled(); 114 | } 115 | 116 | public boolean isAdminPasswordSet() { 117 | return !applicationSettings.getAdminPasswordHash().isEmpty(); 118 | } 119 | 120 | public void setAdminPassword(String adminPassword) { 121 | ApplicationSettingsEntity applicationSettingsEntity = applicationSettingsRepository.findById(1L).orElseThrow(); 122 | applicationSettingsEntity.setAdminPasswordHash(BCrypt.hashpw(adminPassword, BCrypt.gensalt())); 123 | applicationSettingsRepository.save(applicationSettingsEntity); 124 | this.applicationSettings = applicationSettingsEntity; 125 | } 126 | 127 | public boolean checkForAdminPassword(HttpServletRequest request) { 128 | String password = (String) request.getSession().getAttribute("adminPassword"); 129 | String adminPasswordHash = getAdminPasswordHash(); 130 | return password != null && password.equals(adminPasswordHash); 131 | } 132 | 133 | public long getSessionLifetime() { 134 | return applicationSettings.getSessionLifetime(); 135 | } 136 | 137 | public boolean isAdminDashboardButtonEnabled() { 138 | return applicationSettings.isAdminDashboardButtonEnabled(); 139 | } 140 | 141 | public boolean isEncryptionEnabled() { 142 | return !applicationSettings.isDisableEncryption(); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/AsyncFileMergeService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | import org.rostislav.quickdrop.entity.FileEntity; 4 | import org.rostislav.quickdrop.model.ChunkInfo; 5 | import org.rostislav.quickdrop.model.FileUploadRequest; 6 | import org.rostislav.quickdrop.repository.FileRepository; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.web.multipart.MultipartFile; 11 | 12 | import java.io.*; 13 | import java.nio.file.Paths; 14 | import java.util.UUID; 15 | import java.util.concurrent.*; 16 | 17 | @Service 18 | public class AsyncFileMergeService { 19 | private static final Logger logger = LoggerFactory.getLogger(AsyncFileMergeService.class); 20 | private final ConcurrentMap mergeTasks = new ConcurrentHashMap<>(); 21 | private final ExecutorService executorService = Executors.newCachedThreadPool(); 22 | private final ApplicationSettingsService applicationSettingsService; 23 | private final FileEncryptionService fileEncryptionService; 24 | private final FileService fileService; 25 | 26 | private final File tempDir = new File(System.getProperty("java.io.tmpdir")); 27 | private final FileRepository fileRepository; 28 | 29 | public AsyncFileMergeService(ApplicationSettingsService applicationSettingsService, 30 | FileEncryptionService fileEncryptionService, 31 | FileService fileService, FileRepository fileRepository) { 32 | this.applicationSettingsService = applicationSettingsService; 33 | this.fileEncryptionService = fileEncryptionService; 34 | this.fileService = fileService; 35 | this.fileRepository = fileRepository; 36 | } 37 | 38 | public FileEntity submitChunk(FileUploadRequest request, MultipartFile multipartChunk, int chunkNumber) throws IOException { 39 | File savedChunk = new File(tempDir, request.fileName + "_chunk_" + chunkNumber); 40 | multipartChunk.transferTo(savedChunk); 41 | logger.info("Chunk {} for file {} saved to {}", chunkNumber, request.fileName, savedChunk.getAbsolutePath()); 42 | 43 | MergeTask mergeTask = mergeTasks.computeIfAbsent(request.fileName, key -> { 44 | MergeTask task = new MergeTask(request); 45 | executorService.submit(task); 46 | return task; 47 | }); 48 | boolean isLastChunk = (chunkNumber == request.totalChunks - 1); 49 | mergeTask.enqueueChunk(new ChunkInfo(chunkNumber, savedChunk, isLastChunk)); 50 | 51 | if (isLastChunk) { 52 | try { 53 | return mergeTask.getMergeCompletionFuture().get(); 54 | } catch (InterruptedException | ExecutionException e) { 55 | logger.error("Error waiting for merge completion: {}", e.getMessage()); 56 | Thread.currentThread().interrupt(); 57 | throw new IOException("Merge task interrupted", e); 58 | } 59 | } 60 | return null; 61 | } 62 | 63 | private void cleanUpChunks(FileUploadRequest request) { 64 | for (int i = 0; i < request.totalChunks; i++) { 65 | File chunkFile = new File(tempDir, request.fileName + "_chunk_" + i); 66 | if (chunkFile.exists() && !chunkFile.delete()) { 67 | logger.warn("Failed to delete chunk file: {}", chunkFile.getAbsolutePath()); 68 | } 69 | logger.info("Cleaning up chunk {}", i); 70 | } 71 | } 72 | 73 | private class MergeTask implements Runnable { 74 | 75 | private final BlockingQueue queue = new LinkedBlockingQueue<>(); 76 | private final CompletableFuture mergeCompletionFuture = new CompletableFuture<>(); 77 | private final FileUploadRequest request; 78 | private int processedChunks = 0; 79 | private String uuid; 80 | 81 | MergeTask(FileUploadRequest request) { 82 | this.request = request; 83 | do { 84 | uuid = UUID.randomUUID().toString(); 85 | } while (fileRepository.findByUUID(uuid).isPresent()); 86 | } 87 | 88 | public void enqueueChunk(ChunkInfo chunkInfo) { 89 | queue.add(chunkInfo); 90 | } 91 | 92 | public CompletableFuture getMergeCompletionFuture() { 93 | return mergeCompletionFuture; 94 | } 95 | 96 | @Override 97 | public void run() { 98 | File finalFile = Paths.get(applicationSettingsService.getFileStoragePath(), uuid).toFile(); 99 | 100 | try (OutputStream finalOut = fileService.shouldEncrypt(request) ? 101 | fileEncryptionService.getEncryptedOutputStream(finalFile, request.password) : 102 | new BufferedOutputStream(new FileOutputStream(finalFile, true))) { 103 | 104 | while (processedChunks < request.totalChunks) { 105 | ChunkInfo info = queue.take(); 106 | try (InputStream in = new BufferedInputStream(new FileInputStream(info.chunkFile))) { 107 | in.transferTo(finalOut); 108 | } 109 | 110 | if (!info.chunkFile.delete()) { 111 | logger.warn("Failed to delete chunk file: {}", info.chunkFile.getAbsolutePath()); 112 | } 113 | 114 | processedChunks++; 115 | logger.info("Merged chunk {} for file {}", info.chunkNumber, request.fileName); 116 | if (info.isLastChunk) { 117 | break; 118 | } 119 | } 120 | logger.info("All {} chunks merged for file {}", request.totalChunks, request.fileName); 121 | 122 | FileEntity fileEntity = fileService.saveFile(finalFile, request, uuid); 123 | if (fileEntity != null) { 124 | logger.info("File {} saved successfully with UUID {}", request.fileName, fileEntity.uuid); 125 | } else { 126 | logger.error("Saving file {} failed", request.fileName); 127 | } 128 | mergeCompletionFuture.complete(fileEntity); 129 | } catch (Exception e) { 130 | logger.error("Error merging chunks for file {}: {}", request.fileName, e.getMessage()); 131 | mergeCompletionFuture.completeExceptionally(e); 132 | cleanUpChunks(request); 133 | e.printStackTrace(); 134 | } finally { 135 | mergeTasks.remove(request.fileName); 136 | } 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/FileEncryptionService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | import org.springframework.stereotype.Service; 4 | 5 | import javax.crypto.*; 6 | import javax.crypto.spec.IvParameterSpec; 7 | import javax.crypto.spec.PBEKeySpec; 8 | import javax.crypto.spec.SecretKeySpec; 9 | import java.io.*; 10 | import java.security.InvalidAlgorithmParameterException; 11 | import java.security.InvalidKeyException; 12 | import java.security.NoSuchAlgorithmException; 13 | import java.security.SecureRandom; 14 | import java.security.spec.InvalidKeySpecException; 15 | 16 | @Service 17 | public class FileEncryptionService { 18 | 19 | private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; 20 | private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256"; 21 | private static final int ITERATION_COUNT = 65536; 22 | private static final int KEY_LENGTH = 128; 23 | 24 | public SecretKey generateKeyFromPassword(String password, byte[] salt) 25 | throws NoSuchAlgorithmException, InvalidKeySpecException { 26 | PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, ITERATION_COUNT, KEY_LENGTH); 27 | SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM); 28 | byte[] keyBytes = keyFactory.generateSecret(spec).getEncoded(); 29 | return new SecretKeySpec(keyBytes, "AES"); 30 | } 31 | 32 | private byte[] generateRandomBytes() { 33 | byte[] bytes = new byte[16]; 34 | new SecureRandom().nextBytes(bytes); 35 | return bytes; 36 | } 37 | 38 | @SuppressWarnings("ResultOfMethodCallIgnored") 39 | public void decryptFile(File inputFile, File outputFile, String password) throws IOException, NoSuchAlgorithmException, InvalidKeySpecException, NoSuchPaddingException, InvalidAlgorithmParameterException, InvalidKeyException { 40 | try (FileInputStream fis = new FileInputStream(inputFile)) { 41 | byte[] salt = new byte[16]; 42 | byte[] iv = new byte[16]; 43 | 44 | fis.read(salt); 45 | fis.read(iv); 46 | IvParameterSpec ivSpec = new IvParameterSpec(iv); 47 | SecretKey secretKey = generateKeyFromPassword(password, salt); 48 | 49 | Cipher cipher = Cipher.getInstance(ALGORITHM); 50 | cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); 51 | 52 | try (CipherInputStream cis = new CipherInputStream(fis, cipher); 53 | FileOutputStream fos = new FileOutputStream(outputFile)) { 54 | 55 | byte[] buffer = new byte[8192]; 56 | int bytesRead; 57 | while ((bytesRead = cis.read(buffer)) != -1) { 58 | fos.write(buffer, 0, bytesRead); 59 | } 60 | } 61 | } 62 | } 63 | 64 | public InputStream getDecryptedInputStream(File inputFile, String password) throws Exception { 65 | FileInputStream fis = new FileInputStream(inputFile); 66 | byte[] salt = new byte[16]; 67 | byte[] iv = new byte[16]; 68 | 69 | fis.read(salt); 70 | fis.read(iv); 71 | IvParameterSpec ivSpec = new IvParameterSpec(iv); 72 | SecretKey secretKey = generateKeyFromPassword(password, salt); 73 | 74 | Cipher cipher = Cipher.getInstance(ALGORITHM); 75 | cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec); 76 | CipherInputStream cipherInputStream = new CipherInputStream(fis, cipher); 77 | return cipherInputStream; 78 | } 79 | 80 | public OutputStream getEncryptedOutputStream(File finalFile, String password) throws Exception { 81 | FileOutputStream fos = new FileOutputStream(finalFile, true); 82 | byte[] salt = generateRandomBytes(); 83 | byte[] iv = generateRandomBytes(); 84 | 85 | fos.write(salt); 86 | fos.write(iv); 87 | 88 | SecretKey secretKey = generateKeyFromPassword(password, salt); 89 | IvParameterSpec ivSpec = new IvParameterSpec(iv); 90 | Cipher cipher = Cipher.getInstance(ALGORITHM); 91 | 92 | cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec); 93 | return new CipherOutputStream(fos, cipher); 94 | } 95 | } -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/ScheduleService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | import jakarta.transaction.Transactional; 4 | import org.rostislav.quickdrop.entity.FileEntity; 5 | import org.rostislav.quickdrop.repository.DownloadLogRepository; 6 | import org.rostislav.quickdrop.repository.FileRepository; 7 | import org.rostislav.quickdrop.repository.ShareTokenRepository; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | import org.springframework.scheduling.annotation.Scheduled; 11 | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; 12 | import org.springframework.scheduling.support.CronTrigger; 13 | import org.springframework.stereotype.Service; 14 | 15 | import java.time.LocalDate; 16 | import java.util.List; 17 | import java.util.concurrent.ScheduledFuture; 18 | 19 | @Service 20 | public class ScheduleService { 21 | private static final Logger logger = LoggerFactory.getLogger(ScheduleService.class); 22 | private final FileRepository fileRepository; 23 | private final FileService fileService; 24 | private final ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); 25 | private final DownloadLogRepository downloadLogRepository; 26 | private final ShareTokenRepository shareTokenRepository; 27 | private ScheduledFuture scheduledTask; 28 | 29 | public ScheduleService(FileRepository fileRepository, FileService fileService, DownloadLogRepository downloadLogRepository, ShareTokenRepository shareTokenRepository) { 30 | this.fileRepository = fileRepository; 31 | this.fileService = fileService; 32 | taskScheduler.setPoolSize(1); 33 | taskScheduler.initialize(); 34 | this.downloadLogRepository = downloadLogRepository; 35 | this.shareTokenRepository = shareTokenRepository; 36 | } 37 | 38 | @Transactional 39 | public void updateSchedule(String cronExpression, long maxFileLifeTime) { 40 | if (scheduledTask != null) { 41 | scheduledTask.cancel(false); 42 | } 43 | 44 | scheduledTask = taskScheduler.schedule( 45 | () -> deleteOldFiles(maxFileLifeTime), 46 | new CronTrigger(cronExpression) 47 | ); 48 | } 49 | 50 | @Transactional 51 | public void deleteOldFiles(long maxFileLifeTime) { 52 | logger.info("Deleting old files"); 53 | LocalDate thresholdDate = LocalDate.now().minusDays(maxFileLifeTime); 54 | List filesForDeletion = fileRepository.getFilesForDeletion(thresholdDate); 55 | for (FileEntity file : filesForDeletion) { 56 | logger.info("Deleting file: {}", file); 57 | boolean deleted = fileService.deleteFileFromFileSystem(file.uuid); 58 | if (deleted) { 59 | downloadLogRepository.deleteByFileId(file.id); 60 | fileRepository.delete(file); 61 | } else { 62 | logger.error("Failed to delete file: {}", file); 63 | } 64 | } 65 | logger.info("Deleted {} files", filesForDeletion.size()); 66 | } 67 | 68 | @Transactional 69 | @Scheduled(cron = "0 0 3 * * *") 70 | public void cleanDatabaseFromDeletedFiles() { 71 | logger.info("Cleaning database from deleted files"); 72 | 73 | List toDelete = fileRepository.findAll().stream() 74 | .filter(file -> !fileService.fileExistsInFileSystem(file.uuid)) 75 | .toList(); 76 | 77 | toDelete.forEach(file -> fileService.removeFileFromDatabase(file.uuid)); 78 | } 79 | 80 | @Transactional 81 | @Scheduled(cron = "0 30 3 * * *") 82 | public void cleanShareTokens() { 83 | logger.info("Cleaning invalid share tokens"); 84 | shareTokenRepository.getShareTokenEntitiesForDeletion().forEach(e -> { 85 | logger.info("Deleting share token: {}", e); 86 | shareTokenRepository.delete(e); 87 | } 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/SessionService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | import jakarta.servlet.http.HttpSession; 4 | import jakarta.servlet.http.HttpSessionEvent; 5 | import jakarta.servlet.http.HttpSessionListener; 6 | import org.rostislav.quickdrop.model.FileSession; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.util.Map; 12 | import java.util.Set; 13 | import java.util.concurrent.ConcurrentHashMap; 14 | 15 | @Component 16 | public class SessionService implements HttpSessionListener { 17 | private static final Logger logger = LoggerFactory.getLogger(SessionService.class); 18 | private static final Set adminSessionTokens = ConcurrentHashMap.newKeySet(); 19 | private static final Map fileSessions = new ConcurrentHashMap<>(); 20 | 21 | @Override 22 | public void sessionDestroyed(HttpSessionEvent se) { 23 | HttpSession session = se.getSession(); 24 | Object adminToken = session.getAttribute("admin-session-token"); 25 | if (adminToken != null) { 26 | adminSessionTokens.remove(adminToken.toString()); 27 | logger.info("Session destroyed, admin session token invalidated: {}", adminToken); 28 | } 29 | 30 | Object fileSessionToken = session.getAttribute("file-session-token"); 31 | if (fileSessionToken != null) { 32 | fileSessions.remove(fileSessionToken.toString()); 33 | logger.info("Session destroyed, file session token invalidated: {}", fileSessionToken); 34 | } 35 | } 36 | 37 | public String addAdminToken(String token) { 38 | adminSessionTokens.add(token); 39 | logger.info("admin session token added: {}", token); 40 | return token; 41 | } 42 | 43 | public String addFileSessionToken(String token, String password, String fileUuid) { 44 | fileSessions.put(token, new FileSession(password, fileUuid)); 45 | logger.info("file session token added: {}", token); 46 | return token; 47 | } 48 | 49 | public boolean validateAdminToken(String string) { 50 | return adminSessionTokens.contains(string); 51 | } 52 | 53 | public boolean validateFileSessionToken(String sessionToken, String uuid) { 54 | FileSession fileSession = fileSessions.get(sessionToken); 55 | 56 | if (fileSession == null) { 57 | return false; 58 | } 59 | 60 | return fileSession.getFileUuid().equals(uuid); 61 | } 62 | 63 | public FileSession getPasswordForFileSessionToken(String sessionToken) { 64 | return fileSessions.get(sessionToken); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/service/SystemInfoService.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.service; 2 | 3 | import org.rostislav.quickdrop.model.AboutInfoView; 4 | import org.springframework.beans.factory.annotation.Value; 5 | import org.springframework.stereotype.Service; 6 | 7 | import javax.sql.DataSource; 8 | import java.sql.Connection; 9 | import java.sql.PreparedStatement; 10 | import java.sql.ResultSet; 11 | import java.sql.SQLException; 12 | 13 | @Service 14 | public class SystemInfoService { 15 | 16 | private final DataSource dataSource; 17 | 18 | @Value("${app.version}") 19 | private String appVersion; 20 | 21 | public SystemInfoService(DataSource dataSource) { 22 | this.dataSource = dataSource; 23 | } 24 | 25 | public String getSqliteVersion() { 26 | String query = "SELECT sqlite_version()"; 27 | 28 | try (Connection connection = dataSource.getConnection(); 29 | PreparedStatement statement = connection.prepareStatement(query); 30 | ResultSet rs = statement.executeQuery()) { 31 | 32 | if (rs.next()) { 33 | return rs.getString(1); 34 | } 35 | } catch (SQLException ignored) { 36 | } 37 | return "Unknown"; 38 | } 39 | 40 | public String getAppVersion() { 41 | return appVersion; 42 | } 43 | 44 | public String getJavaVersion() { 45 | return System.getProperty("java.version"); 46 | } 47 | 48 | public String getOsInfo() { 49 | return System.getProperty("os.name") + " (" + System.getProperty("os.version") + ")"; 50 | } 51 | 52 | public AboutInfoView getAboutInfo() { 53 | return new AboutInfoView(getAppVersion(), getSqliteVersion(), getJavaVersion(), getOsInfo()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/util/DataValidator.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.util; 2 | 3 | public class DataValidator { 4 | private DataValidator() { 5 | // To prevent instantiation 6 | } 7 | 8 | public static boolean validateObjects(Object... objs) { 9 | for (Object temp : objs) { 10 | if (temp != null) { 11 | if (temp instanceof String value && value.trim().isEmpty()) { 12 | return false; 13 | } 14 | } else { 15 | return false; 16 | } 17 | } 18 | return true; 19 | } 20 | 21 | public static double nullToZero(Double value) { 22 | if (value == null) { 23 | return 0; 24 | } 25 | return value; 26 | } 27 | 28 | public static int nullToZero(Integer value) { 29 | if (value == null) { 30 | return 0; 31 | } 32 | return value; 33 | } 34 | 35 | public static long nullToZero(Long value) { 36 | if (value == null) { 37 | return 0; 38 | } 39 | return value; 40 | } 41 | 42 | public static boolean nullToFalse(Boolean value) { 43 | if (value == null) { 44 | return false; 45 | } 46 | return value; 47 | } 48 | 49 | public static String nullToEmpty(String value) { 50 | if (value == null) { 51 | return ""; 52 | } 53 | return value; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/org/rostislav/quickdrop/util/FileUtils.java: -------------------------------------------------------------------------------- 1 | package org.rostislav.quickdrop.util; 2 | 3 | import jakarta.servlet.http.HttpServletRequest; 4 | import org.rostislav.quickdrop.entity.FileEntity; 5 | import org.springframework.ui.Model; 6 | 7 | public class FileUtils { 8 | private FileUtils() { 9 | // To prevent instantiation 10 | } 11 | 12 | public static String formatFileSize(long size) { 13 | String[] units = {"B", "KB", "MB", "GB", "TB"}; 14 | int unitIndex = 0; 15 | double sizeInUnits = size; 16 | 17 | while (sizeInUnits >= 1024 && unitIndex < units.length - 1) { 18 | sizeInUnits /= 1024.0; 19 | unitIndex++; 20 | } 21 | 22 | return String.format("%.2f %s", sizeInUnits, units[unitIndex]); 23 | } 24 | 25 | public static String getDownloadLink(HttpServletRequest request, FileEntity fileEntity) { 26 | String scheme = request.getHeader("X-Forwarded-Proto"); 27 | if (scheme == null) { 28 | scheme = request.getScheme(); // Fallback to the default scheme 29 | } 30 | return scheme + "://" + request.getServerName() + "/file/" + fileEntity.uuid; 31 | } 32 | 33 | public static String getShareLink(HttpServletRequest request, String token) { 34 | return request.getScheme() + "://" + request.getServerName() + "/file/share/" + token; 35 | } 36 | 37 | public static long bytesToMegabytes(long bytes) { 38 | return bytes / 1024 / 1024; 39 | } 40 | 41 | public static long megabytesToBytes(long megabytes) { 42 | return megabytes * 1024 * 1024; 43 | } 44 | 45 | public static void populateModelAttributes(FileEntity fileEntity, Model model, HttpServletRequest request) { 46 | model.addAttribute("file", fileEntity); 47 | model.addAttribute("fileSize", formatFileSize(fileEntity.size)); 48 | model.addAttribute("downloadLink", getDownloadLink(request, fileEntity)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=default 2 | spring.application.name=quickdrop 3 | spring.datasource.driver-class-name=org.sqlite.JDBC 4 | spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect 5 | spring.datasource.url=jdbc:sqlite:db/quickdrop.db 6 | spring.jpa.show-sql=true 7 | spring.jpa.hibernate.ddl-auto=none 8 | spring.thymeleaf.prefix=classpath:/templates/ 9 | spring.thymeleaf.suffix=.html 10 | spring.thymeleaf.cache=false 11 | server.tomcat.connection-timeout=60000 12 | logging.file.name=log/quickdrop.log 13 | server.error.path=/error 14 | #logging.level.org.springframework=DEBUG 15 | #logging.level.org.hibernate=DEBUG 16 | #logging.level.org.springframework.security=DEBUG 17 | spring.flyway.baseline-on-migrate=true 18 | spring.flyway.baseline-version=1 19 | spring.flyway.locations=classpath:db/migration 20 | app.version=1.4.3 -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1__Create_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS application_settings_entity 2 | ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | max_file_size BIGINT, 5 | max_file_life_time BIGINT, 6 | file_storage_path VARCHAR(255), 7 | log_storage_path VARCHAR(255), 8 | file_deletion_cron VARCHAR(255), 9 | app_password_enabled BOOLEAN, 10 | app_password_hash VARCHAR(255), 11 | admin_password_hash VARCHAR(255), 12 | session_lifetime BIGINT DEFAULT 30, 13 | is_file_list_page_enabled BOOLEAN DEFAULT TRUE 14 | ); 15 | 16 | CREATE TABLE IF NOT EXISTS file_entity 17 | ( 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, 19 | name VARCHAR(255), 20 | uuid VARCHAR(255), 21 | description VARCHAR(255), 22 | size BIGINT, 23 | keep_indefinitely BOOLEAN, 24 | upload_date DATE, 25 | password_hash VARCHAR(255), 26 | hidden BOOLEAN DEFAULT FALSE 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS download_log 30 | ( 31 | id INTEGER PRIMARY KEY AUTOINCREMENT, 32 | download_date TIMESTAMP, 33 | user_agent VARCHAR(255), 34 | downloader_ip VARCHAR(255), 35 | file_id INTEGER, 36 | FOREIGN KEY (file_id) REFERENCES file_entity (id) 37 | ); 38 | 39 | CREATE TABLE IF NOT EXISTS file_renewal_log 40 | ( 41 | id INTEGER PRIMARY KEY AUTOINCREMENT, 42 | file_id INTEGER NOT NULL, 43 | action_date TIMESTAMP, 44 | user_agent VARCHAR(255), 45 | ip_address VARCHAR(255), 46 | FOREIGN KEY (file_id) REFERENCES file_entity (id) 47 | ); 48 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V2__Add_AdminDashboardButtonEnabled.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE application_settings_entity 2 | ADD COLUMN is_admin_dashboard_button_enabled BOOLEAN NOT NULL DEFAULT 1; -------------------------------------------------------------------------------- /src/main/resources/db/migration/V3__Add_Share_Token_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS share_token_entity 2 | ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | file_id INTEGER NOT NULL, 5 | share_token VARCHAR(255), 6 | token_expiration_date DATE, 7 | number_of_allowed_downloads INTEGER, 8 | CONSTRAINT fk_file FOREIGN KEY (file_id) REFERENCES file_entity (id) 9 | ); 10 | 11 | -- Removing share_token and token_expiration_date columns 12 | 13 | ALTER TABLE file_entity 14 | RENAME TO file_entity_old; 15 | 16 | CREATE TABLE file_entity 17 | ( 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, 19 | description VARCHAR(255), 20 | keep_indefinitely BOOLEAN, 21 | name VARCHAR(255), 22 | size BIGINT, 23 | upload_date DATE, 24 | uuid VARCHAR(255), 25 | password_hash VARCHAR(255), 26 | hidden BOOLEAN DEFAULT FALSE 27 | ); 28 | 29 | INSERT INTO file_entity (id, 30 | description, 31 | keep_indefinitely, 32 | name, 33 | size, 34 | upload_date, 35 | uuid, 36 | password_hash, 37 | hidden) 38 | SELECT id, 39 | description, 40 | keep_indefinitely, 41 | name, 42 | size, 43 | upload_date, 44 | uuid, 45 | password_hash, 46 | hidden 47 | FROM file_entity_old; 48 | 49 | DROP TABLE file_entity_old; -------------------------------------------------------------------------------- /src/main/resources/db/migration/V4__Add_encrypted_column.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE file_entity 2 | ADD COLUMN encrypted BOOLEAN NOT NULL DEFAULT 0; 3 | 4 | ALTER TABLE application_settings_entity 5 | ADD COLUMN disable_encryption BOOLEAN NOT NULL DEFAULT 0; 6 | 7 | UPDATE file_entity 8 | SET encrypted = 1 9 | WHERE password_hash IS NOT NULL 10 | AND password_hash <> ''; -------------------------------------------------------------------------------- /src/main/resources/static/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoastSlav/quickdrop/7a85c1641d34279c9f27f643a70f3df4c50b5afb/src/main/resources/static/images/favicon.png -------------------------------------------------------------------------------- /src/main/resources/static/js/fileView.js: -------------------------------------------------------------------------------- 1 | function confirmDelete() { 2 | return confirm("Are you sure you want to delete this file? This action cannot be undone."); 3 | } 4 | 5 | function updateCheckboxState(event, checkbox) { 6 | event.preventDefault(); 7 | const hiddenField = checkbox.form.querySelector('input[name="keepIndefinitely"][type="hidden"]'); 8 | if (hiddenField) { 9 | hiddenField.value = checkbox.checked; 10 | } 11 | 12 | console.log('Submitting form...'); 13 | checkbox.form.submit(); 14 | } 15 | 16 | function initializeModal() { 17 | const downloadLink = document.getElementById("downloadLink").innerText; 18 | updateShareLink(downloadLink); 19 | document.getElementById('unrestrictedLink').checked = false; 20 | document.getElementById('daysValidContainer').style.display = 'none'; 21 | document.getElementById('generateLinkButton').disabled = true; 22 | } 23 | 24 | function openShareModal() { 25 | const downloadLink = document.getElementById("downloadLink").innerText; 26 | 27 | const shareLinkInput = document.getElementById("shareLink"); 28 | shareLinkInput.value = downloadLink; 29 | 30 | const shareQRCode = document.getElementById("shareQRCode"); 31 | QRCode.toCanvas(shareQRCode, encodeURI(downloadLink), { 32 | width: 150, 33 | margin: 2 34 | }, function (error) { 35 | if (error) { 36 | console.error("QR Code generation failed:", error); 37 | } 38 | }); 39 | 40 | const shareModal = new bootstrap.Modal(document.getElementById('shareModal')); 41 | shareModal.show(); 42 | } 43 | 44 | function generateShareLink(fileUuid, daysValid, allowedNumberOfDownloads) { 45 | const csrfToken = document.querySelector('meta[name="_csrf"]').content; 46 | const expirationDate = new Date(); 47 | expirationDate.setDate(expirationDate.getDate() + daysValid); 48 | const expirationDateStr = expirationDate.toISOString().split('T')[0]; 49 | 50 | return fetch(`/api/file/share/${fileUuid}?expirationDate=${expirationDateStr}&nOfDownloads=${allowedNumberOfDownloads}`, { 51 | method: 'POST', 52 | credentials: 'same-origin', 53 | headers: { 54 | 'Content-Type': 'application/json', 55 | 'X-XSRF-TOKEN': csrfToken, 56 | }, 57 | }) 58 | .then((response) => { 59 | if (!response.ok) throw new Error("Failed to generate share link"); 60 | return response.text(); 61 | }); 62 | } 63 | 64 | 65 | function copyShareLink() { 66 | const shareLinkInput = document.getElementById('shareLink'); 67 | navigator.clipboard.writeText(shareLinkInput.value) 68 | .then(() => { 69 | alert("Link copied to clipboard!"); 70 | }) 71 | .catch((err) => { 72 | console.error("Failed to copy link:", err); 73 | }); 74 | } 75 | 76 | function createShareLink() { 77 | const fileUuid = document.getElementById('fileUuid').textContent.trim(); 78 | const daysValidInput = document.getElementById('daysValid'); 79 | const daysValid = parseInt(daysValidInput.value, 10); 80 | const allowedNumberOfDownloadsInput = document.getElementById('allowedNumberOfDownloadsCount'); 81 | const allowedNumberOfDownloads = parseInt(allowedNumberOfDownloadsInput.value, 10); 82 | 83 | if (isNaN(daysValid) || daysValid < 1) { 84 | alert("Please enter a valid number of days."); 85 | return; 86 | } 87 | 88 | if (isNaN(allowedNumberOfDownloads) || allowedNumberOfDownloads < 1) { 89 | alert("Please enter a valid number of downloads."); 90 | return; 91 | } 92 | 93 | const spinner = document.getElementById('spinner'); 94 | const generateLinkButton = document.getElementById('generateLinkButton'); 95 | 96 | spinner.style.display = 'inline-block'; 97 | generateLinkButton.disabled = true; 98 | 99 | generateShareLink(fileUuid, daysValid, allowedNumberOfDownloads) 100 | .then((shareLink) => { 101 | updateShareLink(shareLink); // Update with the token-based link 102 | }) 103 | .catch((error) => { 104 | console.error(error); 105 | alert("Failed to generate share link."); 106 | }).finally(() => { 107 | spinner.style.display = 'none'; 108 | generateLinkButton.disabled = false; 109 | }); 110 | } 111 | 112 | function updateShareLink(link) { 113 | const shareLinkInput = document.getElementById('shareLink'); 114 | const qrCodeContainer = document.getElementById('shareQRCode'); 115 | 116 | shareLinkInput.value = link; 117 | qrCodeContainer.innerHTML = ''; 118 | QRCode.toCanvas(qrCodeContainer, link, {width: 150, height: 150}); 119 | } 120 | 121 | 122 | function toggleLinkType() { 123 | const unrestrictedLinkCheckbox = document.getElementById('unrestrictedLink'); 124 | const daysValidContainer = document.getElementById('daysValidContainer'); 125 | const generateLinkButton = document.getElementById('generateLinkButton'); 126 | const allowedNumberOfDownloads = document.getElementById('allowedNumberOfDownloads'); 127 | 128 | if (unrestrictedLinkCheckbox.checked) { 129 | daysValidContainer.style.display = 'block'; 130 | allowedNumberOfDownloads.style.display = 'block'; 131 | generateLinkButton.disabled = false; 132 | } else { 133 | daysValidContainer.style.display = 'none'; 134 | allowedNumberOfDownloads.style.display = 'none'; 135 | generateLinkButton.disabled = true; 136 | initializeModal(); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/main/resources/static/js/settings.js: -------------------------------------------------------------------------------- 1 | function togglePasswordField() { 2 | const checkbox = document.getElementById("appPasswordEnabled"); 3 | const passwordField = document.getElementById("passwordInputGroup"); 4 | passwordField.style.display = checkbox.checked ? "block" : "none"; 5 | } 6 | 7 | document.addEventListener("DOMContentLoaded", function () { 8 | togglePasswordField(); 9 | }); -------------------------------------------------------------------------------- /src/main/resources/static/js/upload.js: -------------------------------------------------------------------------------- 1 | // upload.js 2 | 3 | let isUploading = false; 4 | let indefiniteNoPwWarningShown = false; 5 | 6 | document.addEventListener("DOMContentLoaded", () => { 7 | const uploadForm = document.getElementById("uploadForm"); 8 | uploadForm.addEventListener("submit", onUploadFormSubmit); 9 | }); 10 | 11 | // Unified way to show an inline message in our #messageContainer 12 | function showMessage(type, text) { 13 | // type: "success", "info", "danger", "warning" 14 | const container = document.getElementById("messageContainer"); 15 | container.innerHTML = ` 16 | `; 20 | } 21 | 22 | // Called when user hits "Upload" 23 | function onUploadFormSubmit(event) { 24 | event.preventDefault(); 25 | 26 | if (isUploading) return; // Prevent duplicate clicks 27 | isUploading = true; 28 | 29 | // 1) Check "Keep Indefinitely" + no password 30 | const keepIndefinitely = document.getElementById("keepIndefinitely").checked; 31 | const password = document.getElementById("password").value.trim(); 32 | 33 | if (keepIndefinitely && !password) { 34 | // If we haven’t shown the warning yet, show it now and bail 35 | if (!indefiniteNoPwWarningShown) { 36 | indefiniteNoPwWarningShown = true; 37 | showMessage("warning", 38 | "You selected ‘Keep indefinitely’ but provided no password. " + 39 | "This file will only be deletable by an admin. " + 40 | "If that’s what you want, click ‘Upload’ again to confirm. " + 41 | "Otherwise, add a password or uncheck ‘Keep indefinitely’." 42 | ); 43 | isUploading = false; // Let them try again 44 | return; 45 | } 46 | // If the warning was already shown, we just proceed here 47 | } 48 | 49 | // 2) Everything is good, proceed 50 | startChunkUpload(); 51 | } 52 | 53 | 54 | function startChunkUpload() { 55 | const file = document.getElementById("file").files[0]; 56 | if (!file) { 57 | showMessage("danger", "No file selected."); 58 | isUploading = false; 59 | return; 60 | } 61 | 62 | // Initialize progress bar 63 | document.getElementById("uploadIndicator").style.display = "block"; 64 | const progressBar = document.getElementById("uploadProgress"); 65 | progressBar.style.width = "0%"; 66 | progressBar.setAttribute("aria-valuenow", 0); 67 | document.getElementById("uploadStatus").innerText = "Upload started..."; 68 | 69 | const chunkSize = 10 * 1024 * 1024; // 10MB 70 | const totalChunks = Math.ceil(file.size / chunkSize); 71 | let currentChunk = 0; 72 | 73 | // Recursive function to upload chunk by chunk 74 | function uploadNextChunk() { 75 | const start = currentChunk * chunkSize; 76 | const end = Math.min(start + chunkSize, file.size); 77 | const chunk = file.slice(start, end); 78 | 79 | const formData = buildChunkFormData(chunk, currentChunk, file.name, totalChunks, file.size); 80 | 81 | const xhr = new XMLHttpRequest(); 82 | xhr.open("POST", "/api/file/upload-chunk", true); 83 | 84 | // Set CSRF token if present 85 | const csrfTokenElement = document.querySelector('input[name="_csrf"]'); 86 | if (csrfTokenElement) { 87 | xhr.setRequestHeader("X-CSRF-TOKEN", csrfTokenElement.value); 88 | } 89 | 90 | xhr.onload = () => { 91 | if (xhr.status === 200) { 92 | // If responseText is empty (null response), ignore it. 93 | let response = null; 94 | if (xhr.responseText && xhr.responseText.trim().length > 0) { 95 | try { 96 | response = JSON.parse(xhr.responseText); 97 | } catch (e) { 98 | console.warn("Failed to parse server response:", e); 99 | } 100 | } 101 | 102 | currentChunk++; 103 | const percentComplete = (currentChunk / totalChunks) * 100; 104 | progressBar.style.width = percentComplete + "%"; 105 | progressBar.setAttribute("aria-valuenow", percentComplete); 106 | 107 | if (currentChunk < totalChunks) { 108 | // Continue uploading remaining chunks. 109 | if (currentChunk === totalChunks - 1 && document.getElementById("password").value.trim()) { 110 | document.getElementById("uploadStatus").innerText = "Upload complete. Encrypting..."; 111 | } 112 | uploadNextChunk(); 113 | } else { 114 | // Final chunk response handling. 115 | document.getElementById("uploadStatus").innerText = "Upload complete."; 116 | if (response && response.uuid) { 117 | window.location.href = "/file/" + response.uuid; 118 | } else { 119 | // No file entity returned; warn the user. 120 | showMessage("warning", "Upload finished but no file information was returned from the server."); 121 | isUploading = false; 122 | } 123 | } 124 | } else { 125 | console.error("Upload error:", xhr.responseText); 126 | showMessage("danger", "Upload failed. Please try again."); 127 | resetUploadUI(); 128 | } 129 | }; 130 | 131 | xhr.onerror = () => { 132 | showMessage("danger", "An error occurred during the upload. Please try again."); 133 | resetUploadUI(); 134 | }; 135 | 136 | xhr.send(formData); 137 | } 138 | 139 | // Begin the upload process. 140 | uploadNextChunk(); 141 | } 142 | 143 | function buildChunkFormData(chunk, chunkNumber, fileName, totalChunks, fileSize) { 144 | const uploadForm = document.getElementById("uploadForm"); 145 | const formData = new FormData(); 146 | 147 | // Chunk metadata 148 | formData.append("file", chunk); 149 | formData.append("fileName", fileName); 150 | formData.append("chunkNumber", chunkNumber); 151 | formData.append("totalChunks", totalChunks); 152 | formData.append("fileSize", fileSize); 153 | 154 | // Keep Indefinitely + hidden 155 | const keepIndefinitelyCheckbox = document.getElementById("keepIndefinitely"); 156 | formData.append("keepIndefinitely", keepIndefinitelyCheckbox.checked ? "true" : "false"); 157 | const hiddenCheckbox = document.getElementById("hidden"); 158 | if (hiddenCheckbox) { 159 | formData.append("hidden", hiddenCheckbox.checked ? "true" : "false"); 160 | } 161 | 162 | // Gather other fields (excluding file inputs/checkboxes) 163 | Array.from(uploadForm.elements).forEach((el) => { 164 | if (el.name && el.type !== "file" && el.type !== "checkbox") { 165 | formData.append(el.name, el.value.trim()); 166 | } 167 | }); 168 | 169 | return formData; 170 | } 171 | 172 | // Reset UI if something fails 173 | function resetUploadUI() { 174 | document.getElementById("uploadIndicator").style.display = "none"; 175 | isUploading = false; 176 | } 177 | 178 | function validateFileSize() { 179 | const fileSizeSpan = document.querySelector('.maxFileSize'); 180 | const file = document.getElementById('file').files[0]; 181 | if (!file || !fileSizeSpan) return; 182 | 183 | const maxSize = parseSize(fileSizeSpan.innerText); 184 | const fileSizeAlert = document.getElementById('fileSizeAlert'); 185 | 186 | if (file.size > maxSize) { 187 | fileSizeAlert.style.display = 'block'; 188 | document.getElementById('file').value = ''; 189 | } else { 190 | fileSizeAlert.style.display = 'none'; 191 | } 192 | } 193 | 194 | function parseSize(size) { 195 | // Example: "1GB" -> parse 196 | const units = {B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024}; 197 | const unitMatch = size.match(/[a-zA-Z]+/); 198 | const valueMatch = size.match(/[0-9.]+/); 199 | 200 | if (!unitMatch || !valueMatch) { 201 | throw new Error("Invalid maxFileSize format"); 202 | } 203 | const unit = unitMatch[0]; 204 | const value = parseFloat(valueMatch[0]); 205 | return value * (units[unit] || 1); 206 | } 207 | -------------------------------------------------------------------------------- /src/main/resources/templates/admin-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Enter Admin Password 7 | 9 | 11 | 14 | 15 | 16 | 17 |
18 |
19 |
20 |

21 | Admin Password Required

22 |
23 |
24 |
27 | 28 | 31 | 32 | 33 |
34 | 36 | 43 |
44 | 45 | 46 | 50 |
51 | 52 | 53 |
54 |

56 |
57 |
58 |
59 |
60 |
61 |
62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/main/resources/templates/app-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Password 7 | Required 8 | 10 | 12 | 15 | 16 | 17 | 18 |
19 |
20 |
21 |

22 | Please 23 | Enter 24 | the 25 | Password 26 | to 27 | Continue

28 |
29 |
30 |
32 | 35 |
36 | 38 | 45 |
46 | 50 |
51 |
52 |

54 |
55 |
56 |
57 |
58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/main/resources/templates/dashboard.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | QuickDrop Admin 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 45 | 46 | 47 |
48 |

Admin Dashboard

49 | 50 | 51 |
52 |
53 |

Analytics

54 |
55 |
56 |
57 |
58 |
Total Downloads
59 | Excluding deleted files 60 |

0

61 |
62 |
63 |
Total Space Used
64 |

0 MB

65 |
66 |
67 |
68 |
69 | 70 | 71 |
72 |
73 |

Files

74 |
75 |
76 | 77 | 78 |
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 116 | 117 | 118 | 135 | 136 | 137 | 161 | 162 | 163 |
NameUpload Date/Last RenewedSizeDownloadsKeep IndefinitelyHiddenActions
---- 100 |
102 | 103 | 104 |
105 | 113 |
114 |
115 |
119 |
121 | 122 | 123 |
124 | 132 |
133 |
134 |
138 | 139 |
140 | 142 | View 143 | 144 | 146 | History 147 | 148 | 150 | Download 151 | 152 |
156 | 157 | 158 |
159 |
160 |
164 |
165 | 166 | 167 |
168 |
169 |
170 | 171 |
File Name
172 |

173 | Uploaded/Renewed: 174 | 175 |

176 |

177 | Size: -- 178 |

179 |

180 | Downloads: -- 181 |

182 | 183 | 184 |
185 |
187 | 188 | 189 |
190 | 191 | 199 |
200 |
201 | 202 |
204 | 205 | 206 |
207 | 208 | 216 |
217 |
218 |
219 | 220 | 221 |
222 | 224 | View 225 | 226 | 228 | History 229 | 230 | 232 | Download 233 | 234 |
238 | 239 | 240 |
241 |
242 |
243 |
244 |
245 | 246 |
247 |
248 |
249 | 250 | 273 | 274 | 275 | 276 | 277 | 278 | -------------------------------------------------------------------------------- /src/main/resources/templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Error 8 | 9 | 10 | 44 | 45 | 46 |
47 |

48 | Oops! 49 |

50 |

51 | Something went wrong. Please try again later. 52 |

53 | 54 | Go Back to Main Page 55 | 56 |
57 | 58 | -------------------------------------------------------------------------------- /src/main/resources/templates/file-history.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Download History 7 | 8 | 36 | 37 | 38 | 39 | 58 | 59 | 60 |
61 |

History

62 | 63 | 64 |
65 |

File Name

66 |

File Description

67 |
68 | 69 | 70 |
71 |
72 | File Details 73 |
74 |
75 |
76 |
77 | 78 | Size 79 |
80 |
81 | 82 | Upload Date 83 |
84 |
85 | 86 | Total Downloads 87 |
88 |
89 |
90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
DateActionIP AddressUser Agent
01.12.2024 20:12:22Action127.0.0.1Mozilla/5.0 (Windows NT 10.0; Win64; x64)
111 |
112 | 113 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /src/main/resources/templates/file-password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Enter 7 | Password 8 | 10 | 12 | 15 | 16 | 17 | 18 | 56 | 57 | 58 |
59 |

60 | Enter 61 | Password

62 |
63 |
64 |
68 | 71 | 74 |
75 | 77 | 84 |
85 | 89 |
90 |
91 |
92 |
93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /src/main/resources/templates/file-share-view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Shared File View 6 | 7 | 8 | 9 | 10 |
11 |

Shared File

12 |
13 |
14 |
15 |
16 |
File Name
17 | 18 |

Description

19 | 20 |
21 |
Uploaded At:
22 |

23 |
24 | 25 |
26 |
File Size:
27 |

28 |
29 | 30 |
31 | 35 | Download 36 | 37 |
38 |
39 |
40 |
41 |
42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/main/resources/templates/fileView.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | File View 7 | 8 | 11 | 12 | 13 | 20 | 21 | 22 | 23 | 24 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 |

File View

66 |
67 |
68 |
69 |
70 |
File Name
71 |
72 |

73 |
74 | 75 | 76 |
77 |
79 |

80 |
81 | 82 | Files are kept only for 30 days after this date. 83 | 84 | 85 |
86 |
Keep Indefinitely:
87 |
89 | 90 | 91 |
92 | 101 |
102 |
103 |
104 | 105 |
106 |
Hide File From List:
107 |
109 | 110 | 111 |
112 | 120 |
121 |
122 |
123 | 124 |
125 |
File Size:
126 |

127 |
128 | 129 | 135 | 136 |
137 | 142 | Download 143 | 144 | 145 |
149 | 150 | 151 |
152 | 153 |
156 | 157 | 158 |
159 | 160 | 163 |
164 |
165 |
166 |
167 |
168 |
169 | 170 | 171 | 252 | 253 | 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /src/main/resources/templates/invalid-share-link.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Share Link Invalid 7 | 8 | 9 | 34 | 35 | 36 |
37 |
38 |
39 |

Link Expired

40 |

41 | This share link is no longer valid. The file you are trying to access has expired or the link has been 42 | used. 43 |

44 | Return to Homepage 45 |
46 |
47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /src/main/resources/templates/listFiles.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | All 7 | Files 8 | 10 | 12 | 15 | 16 | 17 | 18 | 50 | 51 | 52 | 53 |
54 |

55 | All 56 | Files

57 | 58 |
59 |
60 |
62 |
63 | 69 | 74 |
75 |
76 |
77 |
78 |
79 |
80 |

No files have been uploaded yet.

81 |

Start by uploading a file.

82 |
83 |
84 | 85 |
86 |
88 |
89 |
90 |
92 | File 93 | Name
94 |

97 |

99 | Keep 100 | Indefinitely

101 |

103 | Password 104 | Protected

105 |

107 |
108 | 112 |
113 |
114 |
115 |
116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/main/resources/templates/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Admin Settings 6 | 7 | 8 | 9 | 10 | 29 | 30 |
31 |

Admin Settings

32 | 33 |
34 | 35 | 36 |
37 |
38 |
File Settings
39 |
40 |
41 | 42 |
43 | 44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 |
52 | 53 | 54 |
55 | 56 | 57 |
58 |
59 |
60 | 61 | 62 |
63 |
64 |
System Settings
65 |
66 |
67 | 68 |
69 | 70 | 71 |
72 | 73 | 74 |
75 | 76 | this will impact how long file and admin sessions are kept for 77 | 78 |
79 | 80 | 81 |
82 | 84 | 85 |
86 | 87 | 88 |
89 | 91 | 93 |
94 | The Admin dashboard will still be available at /admin/dashboard 95 |
96 |
97 |
98 | 99 | 100 |
101 |
102 |
Security Settings
103 |
104 |
105 | 106 |
107 | 109 | 110 |
111 | 112 | Protect the whole app with a password 113 | 114 |
115 | 116 | 117 | 121 | 122 | 123 |
124 | 126 | 127 |
128 | 129 | If checked, files will not be encrypted even if the file is password protected is enabled. 130 | 131 |
132 |
133 |
134 | 135 | 136 |
137 | 138 |
139 |
140 | 141 |
142 |
143 |
About QuickDrop
144 |
145 |
146 |

147 | Version: 148 | 1.0.0 149 |

150 |

151 | Database: 152 | SQLite 153 | Unknown 154 |

155 |

156 | Java Version: 157 | N/A 158 |

159 |

160 | OS Info: 161 | N/A 162 |

163 |
164 |
165 |
166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /src/main/resources/templates/upload.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Upload File 6 | 7 | 10 | 11 | 12 | 13 | 14 | 44 | 45 | 46 |
47 |

Upload a File

48 | 49 | 50 |

51 | Max file size: 52 | 1GB 53 |

54 |

55 | Files are deleted after 56 | 30 57 | days unless “Keep indefinitely” is selected. 58 |

59 | 60 |
61 |
62 | 63 |
70 | 71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
81 | 82 | 90 | 91 | 99 |
100 | 101 | 102 |
103 | 104 | 105 |
106 | 107 | 108 |
109 |
110 | 116 | 119 |
120 | 121 | If checked, this file will not be auto-deleted after 122 | 30 days. 123 | 124 |
125 | 126 | 127 |
128 |
129 | 135 | 138 |
139 | 140 | If checked, this file won’t appear on the “View Files” page. 141 | 142 |
143 | 144 | 145 |
146 | 147 | 148 |
149 | 150 | 151 | 154 |
155 |
156 |
157 | 158 | 159 | 173 | 174 |
175 |

176 | All password-protected files are also encrypted for additional security. 177 |

178 |
179 |
180 | 181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /src/main/resources/templates/welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Welcome to QuickDrop 7 | 8 | 22 | 23 | 24 |
25 |
26 |

Welcome to QuickDrop

27 |
28 |
29 |

30 | Thank you for setting up QuickDrop! Before you get started, please set an admin password for the dashboard. 31 |

32 |
33 |
34 | 35 | 37 |
38 |
39 | 40 | 42 |
43 | 44 | 45 |
46 |
47 |
48 | 59 | 60 | -------------------------------------------------------------------------------- /src/test/resources/application-test.properties: -------------------------------------------------------------------------------- 1 | logging.file.path=quickdrop.log 2 | spring.datasource.url=jdbc:sqlite:quickdrop.db 3 | file.save.path=test-path -------------------------------------------------------------------------------- /src/test/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.profiles.active=test 2 | spring.application.name=quickdrop 3 | spring.datasource.driver-class-name=org.sqlite.JDBC 4 | spring.jpa.database-platform=org.hibernate.community.dialect.SQLiteDialect 5 | spring.datasource.url=jdbc:sqlite:db/quickdrop.db 6 | spring.jpa.show-sql=true 7 | spring.jpa.hibernate.ddl-auto=update 8 | spring.thymeleaf.prefix=classpath:/templates/ 9 | spring.thymeleaf.suffix=.html 10 | spring.thymeleaf.cache=false 11 | server.tomcat.connection-timeout=60000 12 | logging.file.name=log/quickdrop.log 13 | #logging.level.org.springframework=DEBUG 14 | #logging.level.org.hibernate=DEBUG --------------------------------------------------------------------------------