From 13ceaa9f88e502927acb4b28ece12d1c1ed0a01e Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sat, 30 Nov 2024 21:50:58 +0200 Subject: [PATCH 01/19] started work on the admin panel work in progress versions of the AdminViewController, entity, repository and views --- .../controller/AdminViewController.java | 44 +++++++++ .../model/ApplicationSettingsEntity.java | 94 +++++++++++++++++++ .../ApplicationSettingsRepository.java | 9 ++ .../service/ApplicationSettingsService.java | 83 ++++++++++++++++ .../resources/templates/admin/dashboard.html | 54 +++++++++++ .../resources/templates/admin/settings.html | 76 +++++++++++++++ 6 files changed, 360 insertions(+) create mode 100644 src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java create mode 100644 src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java create mode 100644 src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java create mode 100644 src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java create mode 100644 src/main/resources/templates/admin/dashboard.html create mode 100644 src/main/resources/templates/admin/settings.html diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java new file mode 100644 index 0000000..6d5c073 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -0,0 +1,44 @@ +package org.rostislav.quickdrop.controller; + +import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.service.ApplicationSettingsService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import static org.rostislav.quickdrop.util.FileUtils.bytesToMegabytes; +import static org.rostislav.quickdrop.util.FileUtils.megabytesToBytes; + +@Controller +@RequestMapping("/admin") +public class AdminViewController { + private final ApplicationSettingsService applicationSettingsService; + + public AdminViewController(ApplicationSettingsService applicationSettingsService) { + this.applicationSettingsService = applicationSettingsService; + } + + @GetMapping("/dashboard") + public String getDashboardPage() { + return "admin/dashboard"; + } + + @GetMapping("/settings") + public String getSettingsPage(Model model) { + ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); + settings.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); + + model.addAttribute("settings", settings); + return "admin/settings"; + } + + @PostMapping("/save") + public String saveSettings(ApplicationSettingsEntity settings) { + settings.setMaxFileSize(megabytesToBytes(settings.getMaxFileSize())); + + applicationSettingsService.updateApplicationSettings(settings); + return "redirect:/admin/dashboard"; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java new file mode 100644 index 0000000..319f47e --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java @@ -0,0 +1,94 @@ +package org.rostislav.quickdrop.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class ApplicationSettingsEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private long maxFileSize; + private long maxFileLifeTime; + private String fileStoragePath; + private String logStoragePath; + private String fileDeletionCron; + private boolean appPasswordEnabled; + private String appPasswordHash; + private String adminPasswordHash; + + public long getMaxFileSize() { + return maxFileSize; + } + + public void setMaxFileSize(long maxFileSize) { + this.maxFileSize = maxFileSize; + } + + public long getMaxFileLifeTime() { + return maxFileLifeTime; + } + + public void setMaxFileLifeTime(long maxFileLifeTime) { + this.maxFileLifeTime = maxFileLifeTime; + } + + public String getFileStoragePath() { + return fileStoragePath; + } + + public void setFileStoragePath(String fileStoragePath) { + this.fileStoragePath = fileStoragePath; + } + + public String getLogStoragePath() { + return logStoragePath; + } + + public void setLogStoragePath(String logStoragePath) { + this.logStoragePath = logStoragePath; + } + + public String getFileDeletionCron() { + return fileDeletionCron; + } + + public void setFileDeletionCron(String fileDeletionCron) { + this.fileDeletionCron = fileDeletionCron; + } + + public boolean isAppPasswordEnabled() { + return appPasswordEnabled; + } + + public void setAppPasswordEnabled(boolean appPasswordEnabled) { + this.appPasswordEnabled = appPasswordEnabled; + } + + public String getAppPasswordHash() { + return appPasswordHash; + } + + public void setAppPasswordHash(String appPasswordHash) { + this.appPasswordHash = appPasswordHash; + } + + public String getAdminPasswordHash() { + return adminPasswordHash; + } + + public void setAdminPasswordHash(String adminPasswordHash) { + this.adminPasswordHash = adminPasswordHash; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java b/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java new file mode 100644 index 0000000..78987b1 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java @@ -0,0 +1,9 @@ +package org.rostislav.quickdrop.repository; + + +import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ApplicationSettingsRepository extends JpaRepository { + +} diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java new file mode 100644 index 0000000..6ab80cd --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -0,0 +1,83 @@ +package org.rostislav.quickdrop.service; + +import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.stereotype.Service; + +@Service +public class ApplicationSettingsService { + private final ConfigurableApplicationContext applicationContext; + private final ApplicationSettingsRepository applicationSettingsRepository; + private ApplicationSettingsEntity applicationSettings; + + public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, ApplicationContext applicationContext) { + this.applicationContext = (ConfigurableApplicationContext) applicationContext; + this.applicationSettingsRepository = applicationSettingsRepository; + + this.applicationSettings = applicationSettingsRepository.findById(1L).orElseGet(() -> { + ApplicationSettingsEntity settings = new ApplicationSettingsEntity(); + settings.setMaxFileSize(1024L * 1024L * 1024L); + settings.setMaxFileLifeTime(30L); + settings.setFileStoragePath("files"); + settings.setLogStoragePath("logs"); + settings.setFileDeletionCron("0 0 2 * * *"); + settings.setAppPasswordEnabled(false); + settings.setAppPasswordHash(""); + settings.setAdminPasswordHash(""); + settings = applicationSettingsRepository.save(settings); + this.applicationContext.refresh(); + return settings; + }); + } + + public ApplicationSettingsEntity getApplicationSettings() { + return applicationSettings; + } + + public void updateApplicationSettings(ApplicationSettingsEntity settings) { + ApplicationSettingsEntity applicationSettingsEntity = applicationSettingsRepository.findById(1L).orElseThrow(); + applicationSettingsEntity.setMaxFileSize(settings.getMaxFileSize()); + applicationSettingsEntity.setMaxFileLifeTime(settings.getMaxFileLifeTime()); + applicationSettingsEntity.setFileStoragePath(settings.getFileStoragePath()); + applicationSettingsEntity.setLogStoragePath(settings.getLogStoragePath()); + applicationSettingsEntity.setFileDeletionCron(settings.getFileDeletionCron()); + applicationSettingsEntity.setAppPasswordEnabled(settings.isAppPasswordEnabled()); + + + applicationSettingsRepository.save(applicationSettingsEntity); + } + + public long getMaxFileSize() { + return applicationSettings.getMaxFileSize(); + } + + public long getMaxFileLifeTime() { + return applicationSettings.getMaxFileLifeTime(); + } + + public String getFileStoragePath() { + return applicationSettings.getFileStoragePath(); + } + + public String getLogStoragePath() { + return applicationSettings.getLogStoragePath(); + } + + public String getFileDeletionCron() { + return applicationSettings.getFileDeletionCron(); + } + + public boolean isAppPasswordEnabled() { + return applicationSettings.isAppPasswordEnabled(); + } + + public String getAppPasswordHash() { + return applicationSettings.getAppPasswordHash(); + } + + public String getAdminPasswordHash() { + return applicationSettings.getAdminPasswordHash(); + } +} diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 0000000..1638c51 --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,54 @@ + + + + + QuickDrop Admin + + + +
+

Admin Dashboard

+ +
+

Files

+ + + + + + + + + + + + + +
NameUpload DateExpiration DateSizeActions
+
+ +
+

Analytics

+

Total Downloads:

+

Total Space Used:

+
+
+ + + + diff --git a/src/main/resources/templates/admin/settings.html b/src/main/resources/templates/admin/settings.html new file mode 100644 index 0000000..7a29488 --- /dev/null +++ b/src/main/resources/templates/admin/settings.html @@ -0,0 +1,76 @@ + + + + + Admin Settings + + + +
+

Admin Settings

+
+ + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ +
+ + +
+ +
+ + + +
Settings saved successfully!
+
+
+ + \ No newline at end of file From 889108f9fae81f930db940615ce7e2892c6831f8 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sat, 30 Nov 2024 23:32:04 +0200 Subject: [PATCH 02/19] removed the deployment steps from Jenkinsfile --- Jenkinsfile | 49 +++++++++++-------------------------------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 4387d19..0d14a47 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -4,7 +4,6 @@ pipeline { environment { MAVEN_HOME = tool name: 'Maven', type: 'hudson.tasks.Maven$MavenInstallation' DOCKER_IMAGE = "roastslav/quickdrop:latest" - CONTAINER_NAME = "quickdrop-1" DOCKER_CREDENTIALS_ID = 'dockerhub-credentials' } @@ -23,51 +22,25 @@ pipeline { stage('Docker Build') { steps { - sh """ - ls -al ${WORKSPACE}/target/ - docker build -t ${DOCKER_IMAGE} . - """ + sh "docker build -t ${DOCKER_IMAGE} ." } } stage('Push to Docker Hub') { - steps { - script { - withCredentials([usernamePassword(credentialsId: DOCKER_CREDENTIALS_ID, passwordVariable: 'DOCKER_PASS', usernameVariable: 'DOCKER_USER')]) { - sh """ - echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin - docker push ${DOCKER_IMAGE} - docker logout - """ - } - } - } - } - - stage('Stop and Remove Old Container') { + when { + environment name: 'PUSH_TO_DOCKERHUB', value: 'true' + } steps { script { - sh """ - docker ps -q --filter name=${CONTAINER_NAME} | grep -q . && docker stop ${CONTAINER_NAME} || true - docker ps -aq --filter name=${CONTAINER_NAME} | grep -q . && docker rm ${CONTAINER_NAME} || true - """ + withCredentials([usernamePassword(credentialsId: DOCKER_CREDENTIALS_ID, passwordVariable: 'DOCKER_PASS', usernameVariable: 'DOCKER_USER')]) { + sh """ + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + docker push ${DOCKER_IMAGE} + docker logout + """ + } } } } - - stage('Run New Container') { - steps { - script { - sh """ - docker run -d --name ${CONTAINER_NAME} \ - -p 8083:8080 \ - -v /var/lib/quickdrop/db:/app/db \ - -v /var/log/quickdrop:/app/log \ - -v /srv/quickdrop/files:/app/files \ - ${DOCKER_IMAGE} - """ - } - } - } } } From b99e77d6e598ebab6dd7262f337ffa894d35c901 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 1 Dec 2024 18:16:46 +0200 Subject: [PATCH 03/19] a working max file size setting in the admin panel --- README.md | 5 ++- pom.xml | 10 +++++ .../quickdrop/QuickdropApplication.java | 6 +-- .../quickdrop/config/MultipartConfig.java | 13 ++++--- .../quickdrop/config/MultipartProperties.java | 19 +++++++++ .../controller/AdminViewController.java | 6 ++- .../controller/FileViewController.java | 9 +++-- .../model/ApplicationSettingsEntity.java | 15 +++++++ .../rostislav/quickdrop/model/FileEntity.java | 8 +--- .../quickdrop/repository/FileRepository.java | 8 ++-- .../service/ApplicationSettingsService.java | 15 ++++++- .../quickdrop/service/FileService.java | 12 +++--- .../quickdrop/service/ScheduleService.java | 6 +-- src/main/resources/application.properties | 1 - .../rostislav/quickdrop/FileServiceTests.java | 39 ++++++++----------- .../quickdrop/TestDataContainer.java | 4 +- src/test/resources/application.properties | 2 + 17 files changed, 116 insertions(+), 62 deletions(-) create mode 100644 src/main/java/org/rostislav/quickdrop/config/MultipartProperties.java diff --git a/README.md b/README.md index 1e47bce..67aaff9 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,9 @@ java -jar target/quickdrop-0.0.1-SNAPSHOT.jar ``` Using an external application.properties file: - - Create an **application.properties** file in the same directory as the JAR file or specify its location in the - start command. + +- Create an **application.properties** file in the same directory as the JAR file or specify its location in the + start command. - Add your custom settings, for example (Listed below are the default values): diff --git a/pom.xml b/pom.xml index 8306494..2748451 100644 --- a/pom.xml +++ b/pom.xml @@ -185,6 +185,16 @@ test + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.cloud + spring-cloud-starter + 4.1.3 + + diff --git a/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java b/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java index 4a95011..7616e21 100644 --- a/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java +++ b/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java @@ -1,8 +1,5 @@ package org.rostislav.quickdrop; -import java.nio.file.Files; -import java.nio.file.Path; - import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,6 +8,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; +import java.nio.file.Files; +import java.nio.file.Path; + @SpringBootApplication @EnableScheduling public class QuickdropApplication { diff --git a/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java b/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java index 6a61137..b075980 100644 --- a/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java @@ -1,8 +1,9 @@ package org.rostislav.quickdrop.config; import jakarta.servlet.MultipartConfigElement; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.MultipartConfigFactory; +import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.unit.DataSize; @@ -10,16 +11,18 @@ import org.springframework.util.unit.DataSize; @Configuration public class MultipartConfig { private final long ADDITIONAL_REQUEST_SIZE = 1024L * 1024L * 10L; // 10 MB - @Value("${max-upload-file-size}") - private String maxUploadFileSize; + + @Autowired + private MultipartProperties multipartProperties; @Bean + @RefreshScope public MultipartConfigElement multipartConfigElement() { MultipartConfigFactory factory = new MultipartConfigFactory(); - factory.setMaxFileSize(DataSize.parse(maxUploadFileSize)); + factory.setMaxFileSize(DataSize.parse(multipartProperties.getMaxFileSize())); - DataSize maxRequestSize = DataSize.parse(maxUploadFileSize); + DataSize maxRequestSize = DataSize.parse(multipartProperties.getMaxFileSize()); maxRequestSize = DataSize.ofBytes(maxRequestSize.toBytes() + ADDITIONAL_REQUEST_SIZE); factory.setMaxRequestSize(maxRequestSize); diff --git a/src/main/java/org/rostislav/quickdrop/config/MultipartProperties.java b/src/main/java/org/rostislav/quickdrop/config/MultipartProperties.java new file mode 100644 index 0000000..4aa4b4b --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/config/MultipartProperties.java @@ -0,0 +1,19 @@ +package org.rostislav.quickdrop.config; + +import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Component; + +@RefreshScope +@Component +public class MultipartProperties { + private final ApplicationSettingsRepository applicationSettingsRepository; + + public MultipartProperties(ApplicationSettingsRepository applicationSettingsRepository) { + this.applicationSettingsRepository = applicationSettingsRepository; + } + + public String getMaxFileSize() { + return "" + applicationSettingsRepository.findById(1L).orElseThrow().getMaxFileSize(); + } +} diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java index 6d5c073..49bac78 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -28,9 +28,11 @@ public class AdminViewController { @GetMapping("/settings") public String getSettingsPage(Model model) { ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); - settings.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); - model.addAttribute("settings", settings); + ApplicationSettingsEntity applicationSettingsEntity = new ApplicationSettingsEntity(settings); + applicationSettingsEntity.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); + + model.addAttribute("settings", applicationSettingsEntity); return "admin/settings"; } diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index fdde2c5..7e86a65 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -2,6 +2,7 @@ package org.rostislav.quickdrop.controller; import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.rostislav.quickdrop.service.FileService; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -22,18 +23,18 @@ import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes; @RequestMapping("/file") public class FileViewController { private final FileService fileService; - @Value("${max-upload-file-size}") - private String maxFileSize; + private final ApplicationSettingsService applicationSettingsService; @Value("${file.max.age}") private String maxFileLifeTime; - public FileViewController(FileService fileService) { + public FileViewController(FileService fileService, ApplicationSettingsService applicationSettingsService) { this.fileService = fileService; + this.applicationSettingsService = applicationSettingsService; } @GetMapping("/upload") public String showUploadFile(Model model) { - model.addAttribute("maxFileSize", maxFileSize); + model.addAttribute("maxFileSize", applicationSettingsService.getFormattedMaxFileSize()); model.addAttribute("maxFileLifeTime", maxFileLifeTime); return "upload"; } diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java index 319f47e..9d997c2 100644 --- a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java @@ -20,6 +20,21 @@ public class ApplicationSettingsEntity { private String appPasswordHash; private String adminPasswordHash; + public ApplicationSettingsEntity() { + } + + public ApplicationSettingsEntity(ApplicationSettingsEntity settings) { + this.id = settings.id; + this.maxFileSize = settings.maxFileSize; + this.maxFileLifeTime = settings.maxFileLifeTime; + this.fileStoragePath = settings.fileStoragePath; + this.logStoragePath = settings.logStoragePath; + this.fileDeletionCron = settings.fileDeletionCron; + this.appPasswordEnabled = settings.appPasswordEnabled; + this.appPasswordHash = settings.appPasswordHash; + this.adminPasswordHash = settings.adminPasswordHash; + } + public long getMaxFileSize() { return maxFileSize; } diff --git a/src/main/java/org/rostislav/quickdrop/model/FileEntity.java b/src/main/java/org/rostislav/quickdrop/model/FileEntity.java index c11cef3..ab95497 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileEntity.java +++ b/src/main/java/org/rostislav/quickdrop/model/FileEntity.java @@ -1,12 +1,8 @@ package org.rostislav.quickdrop.model; -import java.time.LocalDate; +import jakarta.persistence.*; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; +import java.time.LocalDate; @Entity public class FileEntity { diff --git a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java index 053a1fc..bd089d8 100644 --- a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java +++ b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java @@ -1,14 +1,14 @@ package org.rostislav.quickdrop.repository; -import java.time.LocalDate; -import java.util.List; -import java.util.Optional; - import org.rostislav.quickdrop.model.FileEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + public interface FileRepository extends JpaRepository { @Query("SELECT f FROM FileEntity f WHERE f.uuid = :uuid") Optional findByUUID(@Param("uuid") String uuid); diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 6ab80cd..81842ef 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -2,17 +2,23 @@ package org.rostislav.quickdrop.service; import org.rostislav.quickdrop.model.ApplicationSettingsEntity; import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cloud.context.refresh.ContextRefresher; import org.springframework.context.ApplicationContext; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.stereotype.Service; +import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; + @Service public class ApplicationSettingsService { private final ConfigurableApplicationContext applicationContext; private final ApplicationSettingsRepository applicationSettingsRepository; + private final ContextRefresher contextRefresher; private ApplicationSettingsEntity applicationSettings; - public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, ApplicationContext applicationContext) { + public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, ApplicationContext applicationContext, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher) { + this.contextRefresher = contextRefresher; this.applicationContext = (ConfigurableApplicationContext) applicationContext; this.applicationSettingsRepository = applicationSettingsRepository; @@ -27,7 +33,6 @@ public class ApplicationSettingsService { settings.setAppPasswordHash(""); settings.setAdminPasswordHash(""); settings = applicationSettingsRepository.save(settings); - this.applicationContext.refresh(); return settings; }); } @@ -47,12 +52,18 @@ public class ApplicationSettingsService { applicationSettingsRepository.save(applicationSettingsEntity); + this.applicationSettings = applicationSettingsEntity; + contextRefresher.refresh(); } public long getMaxFileSize() { return applicationSettings.getMaxFileSize(); } + public String getFormattedMaxFileSize() { + return formatFileSize(applicationSettings.getMaxFileSize()); + } + public long getMaxFileLifeTime() { return applicationSettings.getMaxFileLifeTime(); } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index 95c2e6c..48c5791 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -33,11 +33,11 @@ import static org.rostislav.quickdrop.util.FileEncryptionUtils.encryptFile; @Service public class FileService { - @Value("${file.save.path}") - private String fileSavePath; private static final Logger logger = LoggerFactory.getLogger(FileService.class); private final FileRepository fileRepository; private final PasswordEncoder passwordEncoder; + @Value("${file.save.path}") + private String fileSavePath; public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder) { this.fileRepository = fileRepository; @@ -184,10 +184,10 @@ public class FileService { Resource resource = new UrlResource(outputFile.toUri()); logger.info("Sending file: {}", fileEntity); return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileEntity.name, StandardCharsets.UTF_8) + "\"") - .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") - .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength())) - .body(responseBody); + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileEntity.name, StandardCharsets.UTF_8) + "\"") + .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength())) + .body(responseBody); } catch ( Exception e) { logger.error("Error reading file: {}", e.getMessage()); diff --git a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java index 14b1d2d..94c9352 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java @@ -1,8 +1,5 @@ package org.rostislav.quickdrop.service; -import java.time.LocalDate; -import java.util.List; - import org.rostislav.quickdrop.model.FileEntity; import org.rostislav.quickdrop.repository.FileRepository; import org.slf4j.Logger; @@ -11,6 +8,9 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.util.List; + @Service public class ScheduleService { private static final Logger logger = LoggerFactory.getLogger(ScheduleService.class); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ade4f5b..3f0f268 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -15,6 +15,5 @@ logging.file.name=log/quickdrop.log file.deletion.cron=0 0 2 * * * app.basic.password=test app.enable.password=false -max-upload-file-size=1GB #logging.level.org.springframework=DEBUG #logging.level.org.hibernate=DEBUG \ No newline at end of file diff --git a/src/test/java/org/rostislav/quickdrop/FileServiceTests.java b/src/test/java/org/rostislav/quickdrop/FileServiceTests.java index 21fa544..43065da 100644 --- a/src/test/java/org/rostislav/quickdrop/FileServiceTests.java +++ b/src/test/java/org/rostislav/quickdrop/FileServiceTests.java @@ -1,9 +1,5 @@ package org.rostislav.quickdrop; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -20,46 +16,45 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.multipart.MultipartFile; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import static org.rostislav.quickdrop.TestDataContainer.getEmptyFileUploadRequest; -import static org.rostislav.quickdrop.TestDataContainer.getFileEntity; -import static org.rostislav.quickdrop.TestDataContainer.getFileUploadRequest; +import static org.rostislav.quickdrop.TestDataContainer.*; @SpringBootTest @ExtendWith(MockitoExtension.class) public class FileServiceTests { @Nested class SaveFileTests { - @Value("${file.save.path}") - private String fileSavePath; @Autowired FileService fileService; @MockBean FileRepository fileRepository; @MockBean PasswordEncoder passwordEncoder; + @Value("${file.save.path}") + private String fileSavePath; @AfterEach void tearDown() { //Delete the all files in the fileSavePath try { Files.walk(Path.of(fileSavePath)) - .filter(Files::isRegularFile) - .forEach(file -> { - try { - Files.delete(file); - } catch ( - IOException e) { - e.printStackTrace(); - } - }); + .filter(Files::isRegularFile) + .forEach(file -> { + try { + Files.delete(file); + } catch ( + IOException e) { + e.printStackTrace(); + } + }); } catch ( IOException e) { e.printStackTrace(); diff --git a/src/test/java/org/rostislav/quickdrop/TestDataContainer.java b/src/test/java/org/rostislav/quickdrop/TestDataContainer.java index cbdffda..5ed08a2 100644 --- a/src/test/java/org/rostislav/quickdrop/TestDataContainer.java +++ b/src/test/java/org/rostislav/quickdrop/TestDataContainer.java @@ -1,10 +1,10 @@ package org.rostislav.quickdrop; -import java.util.UUID; - import org.rostislav.quickdrop.model.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; +import java.util.UUID; + public class TestDataContainer { public static FileEntity getFileEntity() { diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index b1e6fc2..77b3b37 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -9,6 +9,8 @@ spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 +management.endpoint.refresh.enabled=true +management.endpoints.web.exposure.include=refresh file.save.path=files file.max.age=30 logging.file.name=log/quickdrop.log From b018a6585e47cee20bb49a5af5bac102c3372354 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 1 Dec 2024 18:21:30 +0200 Subject: [PATCH 04/19] cleaning up --- .../org/rostislav/quickdrop/config/MultipartConfig.java | 6 +----- .../quickdrop/service/ApplicationSettingsService.java | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java b/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java index b075980..c669b29 100644 --- a/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java @@ -1,7 +1,6 @@ package org.rostislav.quickdrop.config; import jakarta.servlet.MultipartConfigElement; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.MultipartConfigFactory; import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Bean; @@ -12,12 +11,9 @@ import org.springframework.util.unit.DataSize; public class MultipartConfig { private final long ADDITIONAL_REQUEST_SIZE = 1024L * 1024L * 10L; // 10 MB - @Autowired - private MultipartProperties multipartProperties; - @Bean @RefreshScope - public MultipartConfigElement multipartConfigElement() { + public MultipartConfigElement multipartConfigElement(MultipartProperties multipartProperties) { MultipartConfigFactory factory = new MultipartConfigFactory(); factory.setMaxFileSize(DataSize.parse(multipartProperties.getMaxFileSize())); diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 81842ef..32e0b10 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -4,22 +4,18 @@ import org.rostislav.quickdrop.model.ApplicationSettingsEntity; import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cloud.context.refresh.ContextRefresher; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ConfigurableApplicationContext; import org.springframework.stereotype.Service; import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; @Service public class ApplicationSettingsService { - private final ConfigurableApplicationContext applicationContext; private final ApplicationSettingsRepository applicationSettingsRepository; private final ContextRefresher contextRefresher; private ApplicationSettingsEntity applicationSettings; - public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, ApplicationContext applicationContext, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher) { + public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher) { this.contextRefresher = contextRefresher; - this.applicationContext = (ConfigurableApplicationContext) applicationContext; this.applicationSettingsRepository = applicationSettingsRepository; this.applicationSettings = applicationSettingsRepository.findById(1L).orElseGet(() -> { From 9d5db61d07621f80b3384b023cbf17a0ce7e7d6b Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 1 Dec 2024 19:24:34 +0200 Subject: [PATCH 05/19] Moved all the settings to the new settings page --- .../quickdrop/QuickdropApplication.java | 16 ++- .../quickdrop/config/SecurityConfig.java | 18 +-- .../controller/AdminViewController.java | 12 +- .../controller/FileViewController.java | 7 +- .../model/ApplicationSettingsEntity.java | 18 ++- .../model/ApplicationSettingsViewModel.java | 90 ++++++++++++++ .../service/ApplicationSettingsService.java | 15 ++- .../quickdrop/service/FileService.java | 15 +-- .../quickdrop/service/ScheduleService.java | 27 ++-- src/main/resources/application.properties | 3 - src/main/resources/static/js/settings.js | 9 ++ .../resources/templates/admin/settings.html | 115 ++++++++++-------- src/test/resources/application.properties | 6 - 13 files changed, 238 insertions(+), 113 deletions(-) create mode 100644 src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java create mode 100644 src/main/resources/static/js/settings.js diff --git a/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java b/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java index 7616e21..0b5a109 100644 --- a/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java +++ b/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java @@ -1,9 +1,9 @@ package org.rostislav.quickdrop; import jakarta.annotation.PostConstruct; +import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @@ -15,8 +15,12 @@ import java.nio.file.Path; @EnableScheduling public class QuickdropApplication { private static final Logger logger = LoggerFactory.getLogger(QuickdropApplication.class); - @Value("${file.save.path}") - private String fileSavePath; + + private final ApplicationSettingsService applicationSettingsService; + + public QuickdropApplication(ApplicationSettingsService applicationSettingsService) { + this.applicationSettingsService = applicationSettingsService; + } public static void main(String[] args) { SpringApplication.run(QuickdropApplication.class, args); @@ -25,11 +29,11 @@ public class QuickdropApplication { @PostConstruct public void createFileSavePath() { try { - Files.createDirectories(Path.of(fileSavePath)); - logger.info("File save path created: {}", fileSavePath); + Files.createDirectories(Path.of(applicationSettingsService.getFileStoragePath())); + logger.info("File save path created: {}", applicationSettingsService.getFileStoragePath()); } catch ( Exception e) { - logger.error("Failed to create file save path: {}", fileSavePath); + logger.error("Failed to create file save path: {}", applicationSettingsService.getFileStoragePath()); } } } diff --git a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java index 167b771..46834d0 100644 --- a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java @@ -1,6 +1,7 @@ package org.rostislav.quickdrop.config; -import org.springframework.beans.factory.annotation.Value; +import org.rostislav.quickdrop.service.ApplicationSettingsService; +import org.springframework.cloud.context.config.annotation.RefreshScope; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationProvider; @@ -10,6 +11,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.crypto.bcrypt.BCrypt; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -20,16 +22,16 @@ import java.util.List; @Configuration @EnableWebSecurity public class SecurityConfig { + private final ApplicationSettingsService applicationSettingsService; - @Value("${app.enable.password}") - private boolean enablePassword; - - @Value("${app.basic.password}") - private String appPassword; + public SecurityConfig(ApplicationSettingsService applicationSettingsService) { + this.applicationSettingsService = applicationSettingsService; + } @Bean + @RefreshScope public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - if (enablePassword) { + if (applicationSettingsService.isAppPasswordEnabled()) { http .authorizeHttpRequests(authz -> authz .requestMatchers("/password/login", "/favicon.ico", "/error").permitAll() @@ -64,7 +66,7 @@ public class SecurityConfig { @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String providedPassword = (String) authentication.getCredentials(); - if (appPassword.equals(providedPassword)) { + if (BCrypt.checkpw(providedPassword, applicationSettingsService.getAppPasswordHash())) { return new UsernamePasswordAuthenticationToken(null, providedPassword, List.of()); } else { throw new BadCredentialsException("Invalid password"); diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java index 49bac78..0023a71 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -1,6 +1,7 @@ package org.rostislav.quickdrop.controller; import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -29,18 +30,19 @@ public class AdminViewController { public String getSettingsPage(Model model) { ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); - ApplicationSettingsEntity applicationSettingsEntity = new ApplicationSettingsEntity(settings); - applicationSettingsEntity.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); + ApplicationSettingsViewModel applicationSettingsViewModel = new ApplicationSettingsViewModel(settings); + applicationSettingsViewModel.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); - model.addAttribute("settings", applicationSettingsEntity); + model.addAttribute("settings", applicationSettingsViewModel); return "admin/settings"; } @PostMapping("/save") - public String saveSettings(ApplicationSettingsEntity settings) { + public String saveSettings(ApplicationSettingsViewModel settings) { settings.setMaxFileSize(megabytesToBytes(settings.getMaxFileSize())); - applicationSettingsService.updateApplicationSettings(settings); + + applicationSettingsService.updateApplicationSettings(settings, settings.getAppPassword()); return "redirect:/admin/dashboard"; } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index 7e86a65..5afac86 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -4,7 +4,6 @@ import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.model.FileEntity; import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.rostislav.quickdrop.service.FileService; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; @@ -24,8 +23,6 @@ import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes; public class FileViewController { private final FileService fileService; private final ApplicationSettingsService applicationSettingsService; - @Value("${file.max.age}") - private String maxFileLifeTime; public FileViewController(FileService fileService, ApplicationSettingsService applicationSettingsService) { this.fileService = fileService; @@ -35,7 +32,7 @@ public class FileViewController { @GetMapping("/upload") public String showUploadFile(Model model) { model.addAttribute("maxFileSize", applicationSettingsService.getFormattedMaxFileSize()); - model.addAttribute("maxFileLifeTime", maxFileLifeTime); + model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); return "upload"; } @@ -49,7 +46,7 @@ public class FileViewController { @GetMapping("/{uuid}") public String filePage(@PathVariable String uuid, Model model, HttpServletRequest request) { FileEntity fileEntity = fileService.getFile(uuid); - model.addAttribute("maxFileLifeTime", maxFileLifeTime); + model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); String password = (String) request.getSession().getAttribute("password"); if (fileEntity.passwordHash != null && diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java index 9d997c2..7d487fd 100644 --- a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java @@ -23,16 +23,14 @@ public class ApplicationSettingsEntity { public ApplicationSettingsEntity() { } - public ApplicationSettingsEntity(ApplicationSettingsEntity settings) { - this.id = settings.id; - this.maxFileSize = settings.maxFileSize; - this.maxFileLifeTime = settings.maxFileLifeTime; - this.fileStoragePath = settings.fileStoragePath; - this.logStoragePath = settings.logStoragePath; - this.fileDeletionCron = settings.fileDeletionCron; - this.appPasswordEnabled = settings.appPasswordEnabled; - this.appPasswordHash = settings.appPasswordHash; - this.adminPasswordHash = settings.adminPasswordHash; + public ApplicationSettingsEntity(ApplicationSettingsViewModel settings) { + this.id = settings.getId(); + this.maxFileSize = settings.getMaxFileSize(); + this.maxFileLifeTime = settings.getMaxFileLifeTime(); + this.fileStoragePath = settings.getFileStoragePath(); + this.logStoragePath = settings.getLogStoragePath(); + this.fileDeletionCron = settings.getFileDeletionCron(); + this.appPasswordEnabled = settings.isAppPasswordEnabled(); } public long getMaxFileSize() { diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java new file mode 100644 index 0000000..61287ea --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java @@ -0,0 +1,90 @@ +package org.rostislav.quickdrop.model; + +public class ApplicationSettingsViewModel { + private Long id; + + private long maxFileSize; + private long maxFileLifeTime; + private String fileStoragePath; + private String logStoragePath; + private String fileDeletionCron; + private boolean appPasswordEnabled; + private String appPassword; + + public ApplicationSettingsViewModel() { + } + + public ApplicationSettingsViewModel(ApplicationSettingsEntity settings) { + this.id = settings.getId(); + this.maxFileSize = settings.getMaxFileSize(); + this.maxFileLifeTime = settings.getMaxFileLifeTime(); + this.fileStoragePath = settings.getFileStoragePath(); + this.logStoragePath = settings.getLogStoragePath(); + this.fileDeletionCron = settings.getFileDeletionCron(); + this.appPasswordEnabled = settings.isAppPasswordEnabled(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public long getMaxFileSize() { + return maxFileSize; + } + + public void setMaxFileSize(long maxFileSize) { + this.maxFileSize = maxFileSize; + } + + public long getMaxFileLifeTime() { + return maxFileLifeTime; + } + + public void setMaxFileLifeTime(long maxFileLifeTime) { + this.maxFileLifeTime = maxFileLifeTime; + } + + public String getFileStoragePath() { + return fileStoragePath; + } + + public void setFileStoragePath(String fileStoragePath) { + this.fileStoragePath = fileStoragePath; + } + + public String getLogStoragePath() { + return logStoragePath; + } + + public void setLogStoragePath(String logStoragePath) { + this.logStoragePath = logStoragePath; + } + + public String getFileDeletionCron() { + return fileDeletionCron; + } + + public void setFileDeletionCron(String fileDeletionCron) { + this.fileDeletionCron = fileDeletionCron; + } + + public boolean isAppPasswordEnabled() { + return appPasswordEnabled; + } + + public void setAppPasswordEnabled(boolean appPasswordEnabled) { + this.appPasswordEnabled = appPasswordEnabled; + } + + public String getAppPassword() { + return appPassword; + } + + public void setAppPassword(String appPassword) { + this.appPassword = appPassword; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 32e0b10..131e59e 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -1,9 +1,11 @@ package org.rostislav.quickdrop.service; import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cloud.context.refresh.ContextRefresher; +import org.springframework.security.crypto.bcrypt.BCrypt; import org.springframework.stereotype.Service; import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; @@ -13,10 +15,12 @@ public class ApplicationSettingsService { private final ApplicationSettingsRepository applicationSettingsRepository; private final ContextRefresher contextRefresher; private ApplicationSettingsEntity applicationSettings; + private final ScheduleService scheduleService; - public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher) { + public ApplicationSettingsService(ApplicationSettingsRepository applicationSettingsRepository, @Qualifier("configDataContextRefresher") ContextRefresher contextRefresher, ScheduleService scheduleService) { this.contextRefresher = contextRefresher; this.applicationSettingsRepository = applicationSettingsRepository; + this.scheduleService = scheduleService; this.applicationSettings = applicationSettingsRepository.findById(1L).orElseGet(() -> { ApplicationSettingsEntity settings = new ApplicationSettingsEntity(); @@ -29,6 +33,7 @@ public class ApplicationSettingsService { settings.setAppPasswordHash(""); settings.setAdminPasswordHash(""); settings = applicationSettingsRepository.save(settings); + scheduleService.updateSchedule(settings.getFileDeletionCron(), settings.getMaxFileLifeTime()); return settings; }); } @@ -37,7 +42,7 @@ public class ApplicationSettingsService { return applicationSettings; } - public void updateApplicationSettings(ApplicationSettingsEntity settings) { + public void updateApplicationSettings(ApplicationSettingsViewModel settings, String appPassword) { ApplicationSettingsEntity applicationSettingsEntity = applicationSettingsRepository.findById(1L).orElseThrow(); applicationSettingsEntity.setMaxFileSize(settings.getMaxFileSize()); applicationSettingsEntity.setMaxFileLifeTime(settings.getMaxFileLifeTime()); @@ -46,9 +51,15 @@ public class ApplicationSettingsService { applicationSettingsEntity.setFileDeletionCron(settings.getFileDeletionCron()); applicationSettingsEntity.setAppPasswordEnabled(settings.isAppPasswordEnabled()); + if (settings.isAppPasswordEnabled()) { + applicationSettingsEntity.setAppPasswordHash(BCrypt.hashpw(appPassword, BCrypt.gensalt())); + } + applicationSettingsRepository.save(applicationSettingsEntity); this.applicationSettings = applicationSettingsEntity; + + scheduleService.updateSchedule(applicationSettingsEntity.getFileDeletionCron(), applicationSettingsEntity.getMaxFileLifeTime()); contextRefresher.refresh(); } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index 48c5791..b907f25 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -5,7 +5,7 @@ import org.rostislav.quickdrop.model.FileUploadRequest; import org.rostislav.quickdrop.repository.FileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; @@ -36,12 +36,13 @@ public class FileService { private static final Logger logger = LoggerFactory.getLogger(FileService.class); private final FileRepository fileRepository; private final PasswordEncoder passwordEncoder; - @Value("${file.save.path}") - private String fileSavePath; + private final ApplicationSettingsService applicationSettingsService; - public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder) { + @Lazy + public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder, ApplicationSettingsService applicationSettingsService) { this.fileRepository = fileRepository; this.passwordEncoder = passwordEncoder; + this.applicationSettingsService = applicationSettingsService; } private static StreamingResponseBody getStreamingResponseBody(Path outputFile, FileEntity fileEntity) { @@ -75,7 +76,7 @@ public class FileService { logger.info("File received: {}", file.getOriginalFilename()); String uuid = UUID.randomUUID().toString(); - Path path = Path.of(fileSavePath, uuid); + Path path = Path.of(applicationSettingsService.getFileStoragePath(), uuid); if (fileUploadRequest.password == null || fileUploadRequest.password.isEmpty()) { if (!saveUnencryptedFile(file, path)) { @@ -161,7 +162,7 @@ public class FileService { return ResponseEntity.notFound().build(); } - Path pathOfFile = Path.of(fileSavePath, fileEntity.uuid); + Path pathOfFile = Path.of(applicationSettingsService.getFileStoragePath(), fileEntity.uuid); Path outputFile = null; if (fileEntity.passwordHash != null) { try { @@ -216,7 +217,7 @@ public class FileService { } public boolean deleteFileFromFileSystem(String uuid) { - Path path = Path.of(fileSavePath, uuid); + Path path = Path.of(applicationSettingsService.getFileStoragePath(), uuid); try { Files.delete(path); logger.info("File deleted: {}", path); diff --git a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java index 94c9352..e2e78b1 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java @@ -4,30 +4,43 @@ import org.rostislav.quickdrop.model.FileEntity; import org.rostislav.quickdrop.repository.FileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.support.CronTrigger; import org.springframework.stereotype.Service; import java.time.LocalDate; import java.util.List; +import java.util.concurrent.ScheduledFuture; @Service public class ScheduleService { private static final Logger logger = LoggerFactory.getLogger(ScheduleService.class); private final FileRepository fileRepository; private final FileService fileService; - @Value("${file.max.age}") - private int maxFileAge; + private final ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + private ScheduledFuture scheduledTask; public ScheduleService(FileRepository fileRepository, FileService fileService) { this.fileRepository = fileRepository; this.fileService = fileService; + taskScheduler.setPoolSize(1); + taskScheduler.initialize(); } - @Scheduled(cron = "${file.deletion.cron}") - public void deleteOldFiles() { + public void updateSchedule(String cronExpression, long maxFileLifeTime) { + if (scheduledTask != null) { + scheduledTask.cancel(false); + } + + scheduledTask = taskScheduler.schedule( + () -> deleteOldFiles(maxFileLifeTime), + new CronTrigger(cronExpression) + ); + } + + public void deleteOldFiles(long maxFileLifeTime) { logger.info("Deleting old files"); - LocalDate thresholdDate = LocalDate.now().minusDays(maxFileAge); + LocalDate thresholdDate = LocalDate.now().minusDays(maxFileLifeTime); List filesForDeletion = fileRepository.getFilesForDeletion(thresholdDate); for (FileEntity file : filesForDeletion) { logger.info("Deleting file: {}", file); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3f0f268..e332a80 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,10 +9,7 @@ spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 -file.save.path=files -file.max.age=30 logging.file.name=log/quickdrop.log -file.deletion.cron=0 0 2 * * * app.basic.password=test app.enable.password=false #logging.level.org.springframework=DEBUG diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js new file mode 100644 index 0000000..354b131 --- /dev/null +++ b/src/main/resources/static/js/settings.js @@ -0,0 +1,9 @@ +function togglePasswordField() { + const checkbox = document.getElementById("appPasswordEnabled"); + const passwordField = document.getElementById("passwordInputGroup"); + passwordField.style.display = checkbox.checked ? "block" : "none"; +} + +document.addEventListener("DOMContentLoaded", function () { + togglePasswordField(); +}); \ No newline at end of file diff --git a/src/main/resources/templates/admin/settings.html b/src/main/resources/templates/admin/settings.html index 7a29488..6e8fba4 100644 --- a/src/main/resources/templates/admin/settings.html +++ b/src/main/resources/templates/admin/settings.html @@ -7,70 +7,77 @@
-

Admin Settings

+

Admin Settings

+
- - -
- + +
+
+
File Settings
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
- -
- + +
+
+
System Settings
+
+
+ +
+ + +
+
- -
- -
+ +
+
+
Security Settings
+
+
+ +
+ + +
- -
- -
- - -
- - -
- -
- -
- - -
- + + +
- -
Settings saved successfully!
+
+ +
+ - \ No newline at end of file + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 77b3b37..84130e5 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -9,14 +9,8 @@ spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 -management.endpoint.refresh.enabled=true -management.endpoints.web.exposure.include=refresh -file.save.path=files -file.max.age=30 logging.file.name=log/quickdrop.log -file.deletion.cron=0 0 2 * * * app.basic.password=test app.enable.password=false -max-upload-file-size=1GB #logging.level.org.springframework=DEBUG #logging.level.org.hibernate=DEBUG \ No newline at end of file From c94ec5effc12dba593024adc3dc5ca0cca59710d Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 1 Dec 2024 20:54:18 +0200 Subject: [PATCH 06/19] The admin dashboard and file download history done --- pom.xml | 1 - .../controller/AdminViewController.java | 29 +++- .../controller/FileRestController.java | 2 +- .../controller/FileViewController.java | 28 +++- .../ApplicationSettingsEntity.java | 3 +- .../quickdrop/entity/DownloadLog.java | 77 +++++++++++ .../{model => entity}/FileEntity.java | 2 +- .../quickdrop/model/AnalyticsDataView.java | 22 +++ .../model/ApplicationSettingsViewModel.java | 2 + .../quickdrop/model/FileEntityView.java | 30 ++++ .../ApplicationSettingsRepository.java | 2 +- .../repository/DownloadLogRepository.java | 17 +++ .../quickdrop/repository/FileRepository.java | 5 +- .../quickdrop/service/AnalyticsService.java | 37 +++++ .../service/ApplicationSettingsService.java | 2 +- .../quickdrop/service/FileService.java | 24 +++- .../quickdrop/service/ScheduleService.java | 2 +- .../rostislav/quickdrop/util/FileUtils.java | 4 +- src/main/resources/application.properties | 2 - .../resources/templates/admin/dashboard.html | 129 ++++++++++++------ .../templates/admin/download-history.html | 121 ++++++++++++++++ .../resources/templates/admin/settings.html | 27 +++- .../rostislav/quickdrop/FileServiceTests.java | 2 +- .../quickdrop/TestDataContainer.java | 2 +- src/test/resources/application.properties | 2 - 25 files changed, 506 insertions(+), 68 deletions(-) rename src/main/java/org/rostislav/quickdrop/{model => entity}/ApplicationSettingsEntity.java (96%) create mode 100644 src/main/java/org/rostislav/quickdrop/entity/DownloadLog.java rename src/main/java/org/rostislav/quickdrop/{model => entity}/FileEntity.java (95%) create mode 100644 src/main/java/org/rostislav/quickdrop/model/AnalyticsDataView.java create mode 100644 src/main/java/org/rostislav/quickdrop/model/FileEntityView.java create mode 100644 src/main/java/org/rostislav/quickdrop/repository/DownloadLogRepository.java create mode 100644 src/main/java/org/rostislav/quickdrop/service/AnalyticsService.java create mode 100644 src/main/resources/templates/admin/download-history.html diff --git a/pom.xml b/pom.xml index 2748451..5fdc905 100644 --- a/pom.xml +++ b/pom.xml @@ -194,7 +194,6 @@ spring-cloud-starter 4.1.3 - diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java index 0023a71..dae5a92 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -1,31 +1,50 @@ package org.rostislav.quickdrop.controller; -import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; +import org.rostislav.quickdrop.entity.FileEntity; +import org.rostislav.quickdrop.model.AnalyticsDataView; import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; +import org.rostislav.quickdrop.model.FileEntityView; +import org.rostislav.quickdrop.service.AnalyticsService; import org.rostislav.quickdrop.service.ApplicationSettingsService; +import org.rostislav.quickdrop.service.FileService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; -import static org.rostislav.quickdrop.util.FileUtils.bytesToMegabytes; -import static org.rostislav.quickdrop.util.FileUtils.megabytesToBytes; +import java.util.List; + +import static org.rostislav.quickdrop.util.FileUtils.*; @Controller @RequestMapping("/admin") public class AdminViewController { private final ApplicationSettingsService applicationSettingsService; + private final AnalyticsService analyticsService; + private final FileService fileService; - public AdminViewController(ApplicationSettingsService applicationSettingsService) { + public AdminViewController(ApplicationSettingsService applicationSettingsService, AnalyticsService analyticsService, FileService fileService) { this.applicationSettingsService = applicationSettingsService; + this.analyticsService = analyticsService; + this.fileService = fileService; } @GetMapping("/dashboard") - public String getDashboardPage() { + public String getDashboardPage(Model model) { + List files = fileService.getFiles(); + + model.addAttribute("files", files.stream().map( + file -> new FileEntityView(file, formatFileSize(file.size), analyticsService.getTotalDownloadsByFile(file.id)))); + + AnalyticsDataView analytics = analyticsService.getAnalytics(); + model.addAttribute("analytics", analytics); + return "admin/dashboard"; } + @GetMapping("/settings") public String getSettingsPage(Model model) { ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index 5d3b456..d51029c 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java @@ -1,6 +1,6 @@ package org.rostislav.quickdrop.controller; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; import org.rostislav.quickdrop.service.FileService; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index 5afac86..4ff0ea2 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -1,7 +1,11 @@ package org.rostislav.quickdrop.controller; import jakarta.servlet.http.HttpServletRequest; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.DownloadLog; +import org.rostislav.quickdrop.entity.FileEntity; +import org.rostislav.quickdrop.model.FileEntityView; +import org.rostislav.quickdrop.repository.DownloadLogRepository; +import org.rostislav.quickdrop.service.AnalyticsService; import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.rostislav.quickdrop.service.FileService; import org.springframework.http.HttpStatus; @@ -16,6 +20,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.util.List; +import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes; @Controller @@ -23,10 +28,14 @@ import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes; public class FileViewController { private final FileService fileService; private final ApplicationSettingsService applicationSettingsService; + private final DownloadLogRepository downloadLogRepository; + private final AnalyticsService analyticsService; - public FileViewController(FileService fileService, ApplicationSettingsService applicationSettingsService) { + public FileViewController(FileService fileService, ApplicationSettingsService applicationSettingsService, DownloadLogRepository downloadLogRepository, AnalyticsService analyticsService) { this.fileService = fileService; this.applicationSettingsService = applicationSettingsService; + this.downloadLogRepository = downloadLogRepository; + this.analyticsService = analyticsService; } @GetMapping("/upload") @@ -60,6 +69,19 @@ public class FileViewController { return "fileView"; } + @GetMapping("/history/{id}") + public String viewDownloadHistory(@PathVariable Long id, Model model) { + FileEntity file = fileService.getFile(id); + List downloadHistory = downloadLogRepository.findByFileId(id); + long totalDownloads = analyticsService.getTotalDownloadsByFile(id); + + model.addAttribute("file", new FileEntityView(file, formatFileSize(file.size), totalDownloads)); + model.addAttribute("downloadHistory", downloadHistory); + + return "admin/download-history"; + } + + @PostMapping("/password") public String checkPassword(String uuid, String password, HttpServletRequest request, Model model) { if (fileService.checkPassword(uuid, password)) { @@ -83,7 +105,7 @@ public class FileViewController { } String password = (String) request.getSession().getAttribute("password"); - return fileService.downloadFile(id, password); + return fileService.downloadFile(id, password, request); } @PostMapping("/extend/{id}") diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java b/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java similarity index 96% rename from src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java rename to src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java index 7d487fd..7f6c08d 100644 --- a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java @@ -1,9 +1,10 @@ -package org.rostislav.quickdrop.model; +package org.rostislav.quickdrop.entity; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; @Entity public class ApplicationSettingsEntity { diff --git a/src/main/java/org/rostislav/quickdrop/entity/DownloadLog.java b/src/main/java/org/rostislav/quickdrop/entity/DownloadLog.java new file mode 100644 index 0000000..963665b --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/entity/DownloadLog.java @@ -0,0 +1,77 @@ +package org.rostislav.quickdrop.entity; + +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +public class DownloadLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "file_id", nullable = false) + private FileEntity file; + + @Column(name = "downloader_ip", nullable = false) + private String downloaderIp; + + @Column(name = "download_date", nullable = false) + private LocalDateTime downloadDate; + + @Column(name = "user_agent", nullable = true) + private String userAgent; + + public DownloadLog() { + } + + public DownloadLog(FileEntity file, String downloaderIp, String userAgent) { + this.file = file; + this.downloaderIp = downloaderIp; + this.downloadDate = LocalDateTime.now(); + this.userAgent = userAgent; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public FileEntity getFile() { + return file; + } + + public void setFile(FileEntity file) { + this.file = file; + } + + public String getDownloaderIp() { + return downloaderIp; + } + + public void setDownloaderIp(String downloaderIp) { + this.downloaderIp = downloaderIp; + } + + public LocalDateTime getDownloadDate() { + return downloadDate; + } + + public void setDownloadDate(LocalDateTime downloadDate) { + this.downloadDate = downloadDate; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/model/FileEntity.java b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java similarity index 95% rename from src/main/java/org/rostislav/quickdrop/model/FileEntity.java rename to src/main/java/org/rostislav/quickdrop/entity/FileEntity.java index ab95497..e59d7b6 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java @@ -1,4 +1,4 @@ -package org.rostislav.quickdrop.model; +package org.rostislav.quickdrop.entity; import jakarta.persistence.*; diff --git a/src/main/java/org/rostislav/quickdrop/model/AnalyticsDataView.java b/src/main/java/org/rostislav/quickdrop/model/AnalyticsDataView.java new file mode 100644 index 0000000..21ad028 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/AnalyticsDataView.java @@ -0,0 +1,22 @@ +package org.rostislav.quickdrop.model; + +public class AnalyticsDataView { + private long totalDownloads; + private String totalSpaceUsed; + + public long getTotalDownloads() { + return totalDownloads; + } + + public void setTotalDownloads(long totalDownloads) { + this.totalDownloads = totalDownloads; + } + + public String getTotalSpaceUsed() { + return totalSpaceUsed; + } + + public void setTotalSpaceUsed(String totalSpaceUsed) { + this.totalSpaceUsed = totalSpaceUsed; + } +} \ No newline at end of file diff --git a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java index 61287ea..899ac40 100644 --- a/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java @@ -1,5 +1,7 @@ package org.rostislav.quickdrop.model; +import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; + public class ApplicationSettingsViewModel { private Long id; diff --git a/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java new file mode 100644 index 0000000..5c3f46c --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java @@ -0,0 +1,30 @@ +package org.rostislav.quickdrop.model; + +import org.rostislav.quickdrop.entity.FileEntity; + +import java.time.LocalDate; + +public class FileEntityView { + public Long id; + public String name; + public String uuid; + public String description; + public String size; + public boolean keepIndefinitely; + public LocalDate uploadDate; + public long totalDownloads; + + public FileEntityView() { + } + + public FileEntityView(FileEntity fileEntity, String formatedSize, long totalDownloads) { + this.id = fileEntity.id; + this.name = fileEntity.name; + this.uuid = fileEntity.uuid; + this.description = fileEntity.description; + this.size = formatedSize; + this.keepIndefinitely = fileEntity.keepIndefinitely; + this.uploadDate = fileEntity.uploadDate; + this.totalDownloads = totalDownloads; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java b/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java index 78987b1..6311cb1 100644 --- a/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java +++ b/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java @@ -1,7 +1,7 @@ package org.rostislav.quickdrop.repository; -import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; import org.springframework.data.jpa.repository.JpaRepository; public interface ApplicationSettingsRepository extends JpaRepository { diff --git a/src/main/java/org/rostislav/quickdrop/repository/DownloadLogRepository.java b/src/main/java/org/rostislav/quickdrop/repository/DownloadLogRepository.java new file mode 100644 index 0000000..73f87ea --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/repository/DownloadLogRepository.java @@ -0,0 +1,17 @@ +package org.rostislav.quickdrop.repository; + +import org.rostislav.quickdrop.entity.DownloadLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface DownloadLogRepository extends JpaRepository { + @Query("SELECT COUNT(dl) FROM DownloadLog dl") + long countAllDownloads(); + + @Query("SELECT COUNT(dl) FROM DownloadLog dl WHERE dl.file.id = :fileId") + long countDownloadsByFileId(Long fileId); + + List findByFileId(Long fileId); +} diff --git a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java index bd089d8..6795bcc 100644 --- a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java +++ b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java @@ -1,6 +1,6 @@ package org.rostislav.quickdrop.repository; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.FileEntity; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -18,4 +18,7 @@ public interface FileRepository extends JpaRepository { @Query("SELECT f FROM FileEntity f WHERE f.name LIKE %:searchString% OR f.description LIKE %:searchString% OR f.uuid LIKE %:searchString%") List searchFiles(@Param("searchString") String searchString); + + @Query("SELECT SUM(f.size) FROM FileEntity f") + Long totalFileSizeForAllFiles(); } diff --git a/src/main/java/org/rostislav/quickdrop/service/AnalyticsService.java b/src/main/java/org/rostislav/quickdrop/service/AnalyticsService.java new file mode 100644 index 0000000..b99797b --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/service/AnalyticsService.java @@ -0,0 +1,37 @@ +package org.rostislav.quickdrop.service; + + +import org.rostislav.quickdrop.model.AnalyticsDataView; +import org.rostislav.quickdrop.repository.DownloadLogRepository; +import org.springframework.stereotype.Service; + +import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; + +@Service +public class AnalyticsService { + private final FileService fileService; + private final DownloadLogRepository downloadLogRepository; + + public AnalyticsService(FileService fileService, DownloadLogRepository downloadLogRepository) { + this.fileService = fileService; + this.downloadLogRepository = downloadLogRepository; + } + + public AnalyticsDataView getAnalytics() { + long totalDownloads = downloadLogRepository.countAllDownloads(); + long totalSpaceUsed = fileService.calculateTotalSpaceUsed(); + + AnalyticsDataView analytics = new AnalyticsDataView(); + analytics.setTotalDownloads(totalDownloads); + analytics.setTotalSpaceUsed(formatFileSize(totalSpaceUsed)); + return analytics; + } + + public long getTotalDownloads() { + return downloadLogRepository.countAllDownloads(); + } + + public long getTotalDownloadsByFile(long id) { + return downloadLogRepository.countDownloadsByFileId(id); + } +} \ No newline at end of file diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 131e59e..8b405bb 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -1,6 +1,6 @@ package org.rostislav.quickdrop.service; -import org.rostislav.quickdrop.model.ApplicationSettingsEntity; +import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; import org.springframework.beans.factory.annotation.Qualifier; diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index b907f25..8d6348e 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -1,7 +1,10 @@ package org.rostislav.quickdrop.service; -import org.rostislav.quickdrop.model.FileEntity; +import jakarta.servlet.http.HttpServletRequest; +import org.rostislav.quickdrop.entity.DownloadLog; +import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; +import org.rostislav.quickdrop.repository.DownloadLogRepository; import org.rostislav.quickdrop.repository.FileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,6 +30,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import static org.rostislav.quickdrop.util.DataValidator.nullToZero; import static org.rostislav.quickdrop.util.DataValidator.validateObjects; import static org.rostislav.quickdrop.util.FileEncryptionUtils.decryptFile; import static org.rostislav.quickdrop.util.FileEncryptionUtils.encryptFile; @@ -37,12 +41,14 @@ public class FileService { private final FileRepository fileRepository; private final PasswordEncoder passwordEncoder; private final ApplicationSettingsService applicationSettingsService; + private final DownloadLogRepository downloadLogRepository; @Lazy - public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder, ApplicationSettingsService applicationSettingsService) { + public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder, ApplicationSettingsService applicationSettingsService, DownloadLogRepository downloadLogRepository) { this.fileRepository = fileRepository; this.passwordEncoder = passwordEncoder; this.applicationSettingsService = applicationSettingsService; + this.downloadLogRepository = downloadLogRepository; } private static StreamingResponseBody getStreamingResponseBody(Path outputFile, FileEntity fileEntity) { @@ -155,7 +161,7 @@ public class FileService { return fileRepository.findAll(); } - public ResponseEntity downloadFile(Long id, String password) { + public ResponseEntity downloadFile(Long id, String password, HttpServletRequest request) { FileEntity fileEntity = fileRepository.findById(id).orElse(null); if (fileEntity == null) { logger.info("File not found: {}", id); @@ -184,6 +190,7 @@ public class FileService { try { Resource resource = new UrlResource(outputFile.toUri()); logger.info("Sending file: {}", fileEntity); + logDownload(fileEntity, request); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(fileEntity.name, StandardCharsets.UTF_8) + "\"") .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream") @@ -196,6 +203,13 @@ public class FileService { } } + private void logDownload(FileEntity fileEntity, HttpServletRequest request) { + String downloaderIp = request.getRemoteAddr(); + String userAgent = request.getHeader(HttpHeaders.USER_AGENT); + DownloadLog downloadLog = new DownloadLog(fileEntity, downloaderIp, userAgent); + downloadLogRepository.save(downloadLog); + } + public FileEntity getFile(Long id) { return fileRepository.findById(id).orElse(null); } @@ -252,4 +266,8 @@ public class FileService { public List searchFiles(String query) { return fileRepository.searchFiles(query); } + + public long calculateTotalSpaceUsed() { + return nullToZero(fileRepository.totalFileSizeForAllFiles()); + } } diff --git a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java index e2e78b1..6ae6cf5 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java @@ -1,6 +1,6 @@ package org.rostislav.quickdrop.service; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.repository.FileRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java index 8ae86bc..f0b99c9 100644 --- a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java +++ b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java @@ -1,7 +1,7 @@ package org.rostislav.quickdrop.util; import jakarta.servlet.http.HttpServletRequest; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.FileEntity; import org.springframework.ui.Model; public class FileUtils { @@ -34,7 +34,7 @@ public class FileUtils { public static void populateModelAttributes(FileEntity fileEntity, Model model, HttpServletRequest request) { model.addAttribute("file", fileEntity); - model.addAttribute("fileSize", FileUtils.formatFileSize(fileEntity.size)); + model.addAttribute("fileSize", formatFileSize(fileEntity.size)); model.addAttribute("downloadLink", getDownloadLink(request, fileEntity)); } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e332a80..b399c0c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,7 +10,5 @@ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 logging.file.name=log/quickdrop.log -app.basic.password=test -app.enable.password=false #logging.level.org.springframework=DEBUG #logging.level.org.hibernate=DEBUG \ No newline at end of file diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 1638c51..9275eea 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -2,53 +2,106 @@ + QuickDrop Admin -
-

Admin Dashboard

+ + -
-

Files

- - - - - - - - - - - - - -
NameUpload DateExpiration DateSizeActions
+ +
+

Admin Dashboard

+ + +
+
+

Analytics

+
+
+
+
+
Total Downloads
+

0

+
+
+
Total Space Used
+

0 MB

+
+
+
-
-

Analytics

-

Total Downloads:

-

Total Space Used:

+ +
+
+

Files

+
+
+ + + + + + + + + + + + + + + + + + + +
NameUpload DateSizeDownloadsActions
+ + View File + + + View + History + + + Download + + +
+ + +
+
+
- + + diff --git a/src/main/resources/templates/admin/download-history.html b/src/main/resources/templates/admin/download-history.html new file mode 100644 index 0000000..60c6105 --- /dev/null +++ b/src/main/resources/templates/admin/download-history.html @@ -0,0 +1,121 @@ + + + + + + Download History + + + + + + + + +
+

Download History

+ + +
+

File Name

+

File Description

+
+ + +
+
+ File Details +
+
+
+
+ + Size +
+
+ + Upload Date +
+
+ + Total Downloads +
+
+
+
+ + +
+
+

Download History

+
+
+ + + + + + + + + + + + + + + +
Downloader IPDateUser Agent
127.0.0.101.12.2024 20:12:22Mozilla/5.0 (Windows NT 10.0; Win64; x64)
+
+
+
+ + + + + diff --git a/src/main/resources/templates/admin/settings.html b/src/main/resources/templates/admin/settings.html index 6e8fba4..7c7b9d2 100644 --- a/src/main/resources/templates/admin/settings.html +++ b/src/main/resources/templates/admin/settings.html @@ -6,6 +6,27 @@ + + +

Admin Settings

@@ -13,7 +34,7 @@
-
+
File Settings
@@ -39,7 +60,7 @@
-
+
System Settings
@@ -53,7 +74,7 @@
-
+
Security Settings
diff --git a/src/test/java/org/rostislav/quickdrop/FileServiceTests.java b/src/test/java/org/rostislav/quickdrop/FileServiceTests.java index 43065da..e85d784 100644 --- a/src/test/java/org/rostislav/quickdrop/FileServiceTests.java +++ b/src/test/java/org/rostislav/quickdrop/FileServiceTests.java @@ -5,7 +5,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; import org.rostislav.quickdrop.repository.FileRepository; import org.rostislav.quickdrop.service.FileService; diff --git a/src/test/java/org/rostislav/quickdrop/TestDataContainer.java b/src/test/java/org/rostislav/quickdrop/TestDataContainer.java index 5ed08a2..cdc8bde 100644 --- a/src/test/java/org/rostislav/quickdrop/TestDataContainer.java +++ b/src/test/java/org/rostislav/quickdrop/TestDataContainer.java @@ -1,6 +1,6 @@ package org.rostislav.quickdrop; -import org.rostislav.quickdrop.model.FileEntity; +import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; import java.util.UUID; diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 84130e5..e25d06a 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -10,7 +10,5 @@ spring.thymeleaf.suffix=.html spring.thymeleaf.cache=false server.tomcat.connection-timeout=60000 logging.file.name=log/quickdrop.log -app.basic.password=test -app.enable.password=false #logging.level.org.springframework=DEBUG #logging.level.org.hibernate=DEBUG \ No newline at end of file From 4511465d7caba9a96fce2920cb3deee6faf6ea24 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 1 Dec 2024 22:23:27 +0200 Subject: [PATCH 07/19] the admin pages are now behind an admin password set up at start up --- mvnw | 2 +- .../rostislav/quickdrop/config/WebConfig.java | 25 +++++++ .../controller/AdminViewController.java | 55 +++++++++++++++- .../controller/FileViewController.java | 4 +- .../controller/IndexViewController.java | 6 +- .../controller/PasswordController.java | 7 +- .../interceptor/AdminPasswordInterceptor.java | 32 +++++++++ .../service/ApplicationSettingsService.java | 11 ++++ .../templates/admin/admin-password.html | 65 +++++++++++++++++++ .../{password.html => app-password.html} | 0 .../{filePassword.html => file-password.html} | 0 src/main/resources/templates/fileView.html | 31 ++++----- src/main/resources/templates/listFiles.html | 27 ++++---- src/main/resources/templates/upload.html | 27 ++++---- src/main/resources/templates/welcome.html | 60 +++++++++++++++++ 15 files changed, 293 insertions(+), 59 deletions(-) create mode 100644 src/main/java/org/rostislav/quickdrop/config/WebConfig.java create mode 100644 src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordInterceptor.java create mode 100644 src/main/resources/templates/admin/admin-password.html rename src/main/resources/templates/{password.html => app-password.html} (100%) rename src/main/resources/templates/{filePassword.html => file-password.html} (100%) create mode 100644 src/main/resources/templates/welcome.html diff --git a/mvnw b/mvnw index ce13e23..d60aa14 100644 --- a/mvnw +++ b/mvnw @@ -25,7 +25,7 @@ # ----------------- # JAVA_HOME - location of a JDK home dir, required when download maven via java source # MVNW_REPOURL - repo url base for downloading maven distribution -# MVNW_USERNAME/MVNW_PASSWORD - user and password.html for downloading maven +# MVNW_USERNAME/MVNW_PASSWORD - user and app-password.html for downloading maven # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output # ---------------------------------------------------------------------------- diff --git a/src/main/java/org/rostislav/quickdrop/config/WebConfig.java b/src/main/java/org/rostislav/quickdrop/config/WebConfig.java new file mode 100644 index 0000000..aac4263 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/config/WebConfig.java @@ -0,0 +1,25 @@ +package org.rostislav.quickdrop.config; + +import org.rostislav.quickdrop.interceptor.AdminPasswordInterceptor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AdminPasswordInterceptor adminPasswordInterceptor; + + @Autowired + public WebConfig(AdminPasswordInterceptor adminPasswordInterceptor) { + this.adminPasswordInterceptor = adminPasswordInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminPasswordInterceptor) + .addPathPatterns("/**") + .excludePathPatterns("/admin/setup", "/static/**", "/css/**", "/js/**", "/images/**"); + } +} diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java index dae5a92..6caeb2d 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -1,5 +1,6 @@ package org.rostislav.quickdrop.controller; +import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.AnalyticsDataView; @@ -8,11 +9,13 @@ import org.rostislav.quickdrop.model.FileEntityView; import org.rostislav.quickdrop.service.AnalyticsService; import org.rostislav.quickdrop.service.ApplicationSettingsService; import org.rostislav.quickdrop.service.FileService; +import org.springframework.security.crypto.bcrypt.BCrypt; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import java.util.List; @@ -32,7 +35,11 @@ public class AdminViewController { } @GetMapping("/dashboard") - public String getDashboardPage(Model model) { + public String getDashboardPage(Model model, HttpServletRequest request) { + if (!checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } + List files = fileService.getFiles(); model.addAttribute("files", files.stream().map( @@ -44,9 +51,26 @@ public class AdminViewController { return "admin/dashboard"; } + @GetMapping("/setup") + public String showSetupPage() { + if (applicationSettingsService.isAdminPasswordSet()) { + return "redirect:/admin/dashboard"; + } + return "welcome"; + } + + @PostMapping("/setup") + public String setAdminPassword(String adminPassword) { + applicationSettingsService.setAdminPassword(adminPassword); + return "redirect:/admin/dashboard"; + } @GetMapping("/settings") - public String getSettingsPage(Model model) { + public String getSettingsPage(Model model, HttpServletRequest request) { + if (!checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } + ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); ApplicationSettingsViewModel applicationSettingsViewModel = new ApplicationSettingsViewModel(settings); @@ -57,11 +81,36 @@ public class AdminViewController { } @PostMapping("/save") - public String saveSettings(ApplicationSettingsViewModel settings) { + public String saveSettings(ApplicationSettingsViewModel settings, HttpServletRequest request) { + if (!checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } settings.setMaxFileSize(megabytesToBytes(settings.getMaxFileSize())); applicationSettingsService.updateApplicationSettings(settings, settings.getAppPassword()); return "redirect:/admin/dashboard"; } + + @PostMapping("/password") + public String checkAdminPassword(@RequestParam String password, HttpServletRequest request) { + String adminPasswordHash = applicationSettingsService.getAdminPasswordHash(); + if (BCrypt.checkpw(password, adminPasswordHash)) { + request.getSession().setAttribute("adminPassword", adminPasswordHash); + return "redirect:/admin/dashboard"; + } else { + return "redirect:/admin/password"; + } + } + + @GetMapping("/password") + public String showAdminPasswordPage() { + return "/admin/admin-password"; + } + + private boolean checkForAdminPassword(HttpServletRequest request) { + String password = (String) request.getSession().getAttribute("adminPassword"); + String adminPasswordHash = applicationSettingsService.getAdminPasswordHash(); + return password != null && password.equals(adminPasswordHash); + } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index 4ff0ea2..4b60101 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -61,7 +61,7 @@ public class FileViewController { if (fileEntity.passwordHash != null && (password == null || !fileService.checkPassword(uuid, password))) { model.addAttribute("uuid", uuid); - return "filePassword"; + return "file-password"; } populateModelAttributes(fileEntity, model, request); @@ -89,7 +89,7 @@ public class FileViewController { return "redirect:/file/" + uuid; } else { model.addAttribute("uuid", uuid); - return "filePassword"; + return "file-password"; } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java b/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java index 5fb423b..c927fa6 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/IndexViewController.java @@ -2,13 +2,11 @@ package org.rostislav.quickdrop.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.servlet.view.RedirectView; @Controller public class IndexViewController { - @GetMapping("/") - public RedirectView index() { - return new RedirectView("/file/upload"); + public String getIndexPage() { + return "redirect:/file/upload"; } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/PasswordController.java b/src/main/java/org/rostislav/quickdrop/controller/PasswordController.java index 5707b5c..6ba6e8b 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/PasswordController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/PasswordController.java @@ -9,6 +9,11 @@ import org.springframework.web.bind.annotation.RequestMapping; public class PasswordController { @GetMapping("/login") public String passwordPage() { - return "password"; + return "app-password"; + } + + @GetMapping("/admin") + public String adminPasswordPage() { + return "admin/admin-password"; } } diff --git a/src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordInterceptor.java b/src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordInterceptor.java new file mode 100644 index 0000000..3db8779 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/interceptor/AdminPasswordInterceptor.java @@ -0,0 +1,32 @@ +package org.rostislav.quickdrop.interceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.rostislav.quickdrop.service.ApplicationSettingsService; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminPasswordInterceptor implements HandlerInterceptor { + + private final ApplicationSettingsService applicationSettingsService; + + public AdminPasswordInterceptor(ApplicationSettingsService applicationSettingsService) { + this.applicationSettingsService = applicationSettingsService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String requestURI = request.getRequestURI(); + if (!applicationSettingsService.isAdminPasswordSet() + && !requestURI.startsWith("/admin/setup") + && !requestURI.startsWith("/static/") + && !requestURI.startsWith("/css/") + && !requestURI.startsWith("/js/") + && !requestURI.startsWith("/images/")) { + response.sendRedirect("/admin/setup"); + return false; + } + return true; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 8b405bb..784790a 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -98,4 +98,15 @@ public class ApplicationSettingsService { public String getAdminPasswordHash() { return applicationSettings.getAdminPasswordHash(); } + + public boolean isAdminPasswordSet() { + return !applicationSettings.getAdminPasswordHash().isEmpty(); + } + + public void setAdminPassword(String adminPassword) { + ApplicationSettingsEntity applicationSettingsEntity = applicationSettingsRepository.findById(1L).orElseThrow(); + applicationSettingsEntity.setAdminPasswordHash(BCrypt.hashpw(adminPassword, BCrypt.gensalt())); + applicationSettingsRepository.save(applicationSettingsEntity); + this.applicationSettings = applicationSettingsEntity; + } } diff --git a/src/main/resources/templates/admin/admin-password.html b/src/main/resources/templates/admin/admin-password.html new file mode 100644 index 0000000..2af4f2f --- /dev/null +++ b/src/main/resources/templates/admin/admin-password.html @@ -0,0 +1,65 @@ + + + + + + Enter Admin Password + + + + + + +
+
+
+

+ Admin Password Required

+
+
+
+ + + + +
+ + +
+ + + +
+ + +
+

+
+
+
+
+
+
+ + + + diff --git a/src/main/resources/templates/password.html b/src/main/resources/templates/app-password.html similarity index 100% rename from src/main/resources/templates/password.html rename to src/main/resources/templates/app-password.html diff --git a/src/main/resources/templates/filePassword.html b/src/main/resources/templates/file-password.html similarity index 100% rename from src/main/resources/templates/filePassword.html rename to src/main/resources/templates/file-password.html diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index 3bc9374..28434af 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -17,43 +17,38 @@ +

diff --git a/src/main/resources/templates/listFiles.html b/src/main/resources/templates/listFiles.html index ae42a26..4322535 100644 --- a/src/main/resources/templates/listFiles.html +++ b/src/main/resources/templates/listFiles.html @@ -17,38 +17,35 @@ +

diff --git a/src/main/resources/templates/upload.html b/src/main/resources/templates/upload.html index 226c78d..58101e5 100644 --- a/src/main/resources/templates/upload.html +++ b/src/main/resources/templates/upload.html @@ -17,38 +17,35 @@ +

diff --git a/src/main/resources/templates/welcome.html b/src/main/resources/templates/welcome.html new file mode 100644 index 0000000..7ff7361 --- /dev/null +++ b/src/main/resources/templates/welcome.html @@ -0,0 +1,60 @@ + + + + + + Welcome to QuickDrop + + + + +
+
+

Welcome to QuickDrop

+
+
+

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

+
+
+ + +
+
+ + +
+ + +
+
+
+ + + \ No newline at end of file From 63ed6d4675834a1adb4d8d1649cbb77c77999602 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Mon, 2 Dec 2024 13:31:39 +0200 Subject: [PATCH 08/19] added a checkbox for changing if the file should be kept indefinitely in the dashboard --- .../controller/AdminViewController.java | 12 ++----- .../controller/FileViewController.java | 21 ++++++++++--- .../service/ApplicationSettingsService.java | 7 +++++ .../quickdrop/service/FileService.java | 12 +++++++ .../resources/templates/admin/dashboard.html | 31 +++++++++++++++++++ 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java index 6caeb2d..c100f16 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -36,7 +36,7 @@ public class AdminViewController { @GetMapping("/dashboard") public String getDashboardPage(Model model, HttpServletRequest request) { - if (!checkForAdminPassword(request)) { + if (!applicationSettingsService.checkForAdminPassword(request)) { return "redirect:/admin/password"; } @@ -67,7 +67,7 @@ public class AdminViewController { @GetMapping("/settings") public String getSettingsPage(Model model, HttpServletRequest request) { - if (!checkForAdminPassword(request)) { + if (!applicationSettingsService.checkForAdminPassword(request)) { return "redirect:/admin/password"; } @@ -82,7 +82,7 @@ public class AdminViewController { @PostMapping("/save") public String saveSettings(ApplicationSettingsViewModel settings, HttpServletRequest request) { - if (!checkForAdminPassword(request)) { + if (!applicationSettingsService.checkForAdminPassword(request)) { return "redirect:/admin/password"; } settings.setMaxFileSize(megabytesToBytes(settings.getMaxFileSize())); @@ -107,10 +107,4 @@ public class AdminViewController { public String showAdminPasswordPage() { return "/admin/admin-password"; } - - private boolean checkForAdminPassword(HttpServletRequest request) { - String password = (String) request.getSession().getAttribute("adminPassword"); - String adminPasswordHash = applicationSettingsService.getAdminPasswordHash(); - return password != null && password.equals(adminPasswordHash); - } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index 4b60101..7a9d767 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -12,10 +12,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import java.util.List; @@ -70,7 +67,11 @@ public class FileViewController { } @GetMapping("/history/{id}") - public String viewDownloadHistory(@PathVariable Long id, Model model) { + public String viewDownloadHistory(@PathVariable Long id, Model model, HttpServletRequest request) { + if (!applicationSettingsService.checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } + FileEntity file = fileService.getFile(id); List downloadHistory = downloadLogRepository.findByFileId(id); long totalDownloads = analyticsService.getTotalDownloadsByFile(id); @@ -132,4 +133,14 @@ public class FileViewController { model.addAttribute("files", files); return "listFiles"; } + + @PostMapping("/keep-indefinitely/{id}") + public String updateKeepIndefinitely(@PathVariable Long id, @RequestParam(required = false, defaultValue = "false") boolean keepIndefinitely, HttpServletRequest request) { + if (!applicationSettingsService.checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } + + fileService.updateKeepIndefinitely(id, keepIndefinitely); + return "redirect:/admin/dashboard"; + } } diff --git a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java index 784790a..9b36698 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -1,5 +1,6 @@ package org.rostislav.quickdrop.service; +import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; import org.rostislav.quickdrop.repository.ApplicationSettingsRepository; @@ -109,4 +110,10 @@ public class ApplicationSettingsService { applicationSettingsRepository.save(applicationSettingsEntity); this.applicationSettings = applicationSettingsEntity; } + + public boolean checkForAdminPassword(HttpServletRequest request) { + String password = (String) request.getSession().getAttribute("adminPassword"); + String adminPasswordHash = getAdminPasswordHash(); + return password != null && password.equals(adminPasswordHash); + } } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index 8d6348e..28348e1 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -270,4 +270,16 @@ public class FileService { public long calculateTotalSpaceUsed() { return nullToZero(fileRepository.totalFileSizeForAllFiles()); } + + public void updateKeepIndefinitely(Long id, boolean keepIndefinitely) { + Optional referenceById = fileRepository.findById(id); + if (referenceById.isEmpty()) { + return; + } + + FileEntity fileEntity = referenceById.get(); + fileEntity.keepIndefinitely = keepIndefinitely; + logger.info("File keepIndefinitely updated: {}", fileEntity); + fileRepository.save(fileEntity); + } } diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 9275eea..7f834d5 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -76,6 +76,24 @@ + + +
+ + +
+ Keep Indefinitely +
+ +
+
+
+ View File @@ -101,6 +119,19 @@

+ + From 98636bace53ee40090879c69e79e4e94b3c92489 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Mon, 2 Dec 2024 13:49:33 +0200 Subject: [PATCH 09/19] added a warning if keep indefinitely is checked and a password is not input --- src/main/resources/static/js/upload.js | 15 ++++++ src/main/resources/templates/upload.html | 63 +++++------------------- 2 files changed, 28 insertions(+), 50 deletions(-) diff --git a/src/main/resources/static/js/upload.js b/src/main/resources/static/js/upload.js index 7d8b793..906b8b9 100644 --- a/src/main/resources/static/js/upload.js +++ b/src/main/resources/static/js/upload.js @@ -69,6 +69,21 @@ function isPasswordProtected() { return passwordField && passwordField.value.trim() !== ""; } +function validateKeepIndefinitely() { + const keepIndefinitely = document.getElementById("keepIndefinitely").checked; + const password = document.getElementById("password").value; + + if (keepIndefinitely && !password) { + return confirm( + "You have selected 'Keep indefinitely' but haven't set a password. " + + "This means the file will only be deletable by an admin. " + + "Do you want to proceed?" + ); + } + + return true; // Allow form submission if conditions are not met +} + function validateFileSize() { const maxFileSize = document.getElementsByClassName('maxFileSize')[0].innerText; const file = document.getElementById('file').files[0]; diff --git a/src/main/resources/templates/upload.html b/src/main/resources/templates/upload.html index 58101e5..7e41ec6 100644 --- a/src/main/resources/templates/upload.html +++ b/src/main/resources/templates/upload.html @@ -85,23 +85,17 @@ id="uploadForm" method="post" th:action="@{/file/upload}" + onsubmit="return validateKeepIndefinitely()" > - + - +
- + -
+ + From 9cfa3df03e05c04aedcad535476655b263880e6d Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Mon, 2 Dec 2024 14:41:36 +0200 Subject: [PATCH 11/19] added a checkbox to hide the file from the file list view --- .../controller/FileViewController.java | 60 +++++++++---- .../quickdrop/entity/FileEntity.java | 2 + .../quickdrop/model/FileEntityView.java | 2 + .../quickdrop/repository/FileRepository.java | 8 +- .../quickdrop/service/FileService.java | 20 +++++ src/main/resources/static/js/upload.js | 11 +++ .../resources/templates/admin/dashboard.html | 89 ++++++++++++------- src/main/resources/templates/fileView.html | 19 +++- 8 files changed, 158 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index ce8b4a0..ed9db87 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -44,7 +44,7 @@ public class FileViewController { @GetMapping("/list") public String listFiles(Model model) { - List files = fileService.getFiles(); + List files = fileService.getNotHiddenFiles(); model.addAttribute("files", files); return "listFiles"; } @@ -129,32 +129,54 @@ public class FileViewController { @GetMapping("/search") public String searchFiles(String query, Model model) { - List files = fileService.searchFiles(query); + List files = fileService.searchNotHiddenFiles(query); model.addAttribute("files", files); return "listFiles"; } @PostMapping("/keep-indefinitely/{id}") public String updateKeepIndefinitely(@PathVariable Long id, @RequestParam(required = false, defaultValue = "false") boolean keepIndefinitely, HttpServletRequest request, Model model) { + return handlePasswordValidationAndRedirect(id, request, model, () -> fileService.updateKeepIndefinitely(id, keepIndefinitely)); + } + + + @PostMapping("/toggle-hidden/{id}") + public String toggleHidden(@PathVariable Long id, HttpServletRequest request, Model model) { + return handlePasswordValidationAndRedirect(id, request, model, () -> fileService.toggleHidden(id)); + } + + + private String handlePasswordValidationAndRedirect(Long fileId, HttpServletRequest request, Model model, Runnable action) { + String referer = request.getHeader("Referer"); + // Check for admin password - if (!applicationSettingsService.checkForAdminPassword(request)) { - // Check for file password - String filePassword = (String) request.getSession().getAttribute("password"); - if (filePassword != null) { - FileEntity fileEntity = fileService.getFile(id); - // Check if file password is correct - if (fileEntity.passwordHash != null && !fileService.checkPassword(fileEntity.uuid, filePassword)) { - model.addAttribute("uuid", fileEntity.uuid); - return "file-password"; - } - // Redirect to file page - fileService.updateKeepIndefinitely(id, keepIndefinitely); - return "redirect:/file/" + fileEntity.uuid; - } - return "redirect:/admin/password"; + if (applicationSettingsService.checkForAdminPassword(request)) { + action.run(); + return "redirect:" + referer; } - fileService.updateKeepIndefinitely(id, keepIndefinitely); - return "redirect:/admin/dashboard"; + // Check for file password in the session + String filePassword = (String) request.getSession().getAttribute("password"); + if (filePassword != null) { + FileEntity fileEntity = fileService.getFile(fileId); + // Validate file password if the file is password-protected + if (fileEntity.passwordHash != null && !fileService.checkPassword(fileEntity.uuid, filePassword)) { + model.addAttribute("uuid", fileEntity.uuid); + return "file-password"; // Redirect to file password page if the password is incorrect + } + + action.run(); + return "redirect:" + referer; + } + + // No valid password found, determine the redirect destination + if (referer != null && referer.contains("/admin/dashboard")) { + return "redirect:/admin/password"; // Redirect to admin password page + } else { + // Get the file for adding the UUID to the model for the file password page + FileEntity fileEntity = fileService.getFile(fileId); + model.addAttribute("uuid", fileEntity.uuid); + return "file-password"; + } } } diff --git a/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java index e59d7b6..2c55bbb 100644 --- a/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java @@ -17,6 +17,8 @@ public class FileEntity { public boolean keepIndefinitely; public LocalDate uploadDate; public String passwordHash; + @Column(columnDefinition = "boolean default false") + public boolean hidden; @PrePersist public void prePersist() { diff --git a/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java index 5c3f46c..ad689a0 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java +++ b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java @@ -13,6 +13,7 @@ public class FileEntityView { public boolean keepIndefinitely; public LocalDate uploadDate; public long totalDownloads; + public boolean hidden; public FileEntityView() { } @@ -26,5 +27,6 @@ public class FileEntityView { this.keepIndefinitely = fileEntity.keepIndefinitely; this.uploadDate = fileEntity.uploadDate; this.totalDownloads = totalDownloads; + this.hidden = fileEntity.hidden; } } diff --git a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java index 6795bcc..8f8aa9e 100644 --- a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java +++ b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java @@ -16,9 +16,15 @@ public interface FileRepository extends JpaRepository { @Query("SELECT f FROM FileEntity f WHERE f.keepIndefinitely = false AND f.uploadDate < :thresholdDate") List getFilesForDeletion(@Param("thresholdDate") LocalDate thresholdDate); - @Query("SELECT f FROM FileEntity f WHERE f.name LIKE %:searchString% OR f.description LIKE %:searchString% OR f.uuid LIKE %:searchString%") + @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, '%')))") List searchFiles(@Param("searchString") String searchString); + @Query("SELECT f FROM FileEntity f WHERE f.hidden = false") + List findAllNotHiddenFiles(); + @Query("SELECT SUM(f.size) FROM FileEntity f") Long totalFileSizeForAllFiles(); + + @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, '%')))") + List searchNotHiddenFiles(@Param("searchString") String query); } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index db6c184..5def7c1 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -267,6 +267,10 @@ public class FileService { return fileRepository.searchFiles(query); } + public List searchNotHiddenFiles(String query) { + return fileRepository.searchNotHiddenFiles(query); + } + public long calculateTotalSpaceUsed() { return nullToZero(fileRepository.totalFileSizeForAllFiles()); } @@ -286,4 +290,20 @@ public class FileService { logger.info("File keepIndefinitely updated: {}", fileEntity); fileRepository.save(fileEntity); } + + public void toggleHidden(Long id) { + Optional referenceById = fileRepository.findById(id); + if (referenceById.isEmpty()) { + return; + } + + FileEntity fileEntity = referenceById.get(); + fileEntity.hidden = !fileEntity.hidden; + logger.info("File hidden updated: {}", fileEntity); + fileRepository.save(fileEntity); + } + + public List getNotHiddenFiles() { + return fileRepository.findAllNotHiddenFiles(); + } } diff --git a/src/main/resources/static/js/upload.js b/src/main/resources/static/js/upload.js index 906b8b9..1dc8338 100644 --- a/src/main/resources/static/js/upload.js +++ b/src/main/resources/static/js/upload.js @@ -117,4 +117,15 @@ function parseSize(size) { const value = parseFloat(valueMatch[0]); return value * (units[unit] || 1); +} + +function updateHiddenState(event, checkbox) { + event.preventDefault(); + const hiddenField = checkbox.form.querySelector('input[name="hidden"][type="hidden"]'); + if (hiddenField) { + hiddenField.value = checkbox.checked; + } + + console.log('Submitting hidden state form...'); + checkbox.form.submit(); } \ No newline at end of file diff --git a/src/main/resources/templates/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html index 7f834d5..1a6ba6e 100644 --- a/src/main/resources/templates/admin/dashboard.html +++ b/src/main/resources/templates/admin/dashboard.html @@ -63,53 +63,64 @@ - - - - - + + + + + + + - - - - - + + + + + + - - - + @@ -132,6 +143,20 @@ } + + + diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index 795dbbc..2f5e981 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -96,8 +96,25 @@ +
+
Hide File From List:
+
+ + +
+ +
+ +
-
+
File Size:
From 7c006b22b6e2f872ee800d373856cd148be83b84 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Mon, 2 Dec 2024 14:46:03 +0200 Subject: [PATCH 12/19] optimized the db query for the admin dashboard --- .../quickdrop/controller/AdminViewController.java | 10 ++++------ .../quickdrop/controller/FileViewController.java | 3 +-- .../rostislav/quickdrop/model/FileEntityView.java | 6 ++++-- .../quickdrop/repository/FileRepository.java | 12 ++++++++++++ .../org/rostislav/quickdrop/service/FileService.java | 6 ++++++ 5 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java index c100f16..9ce57e3 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -2,7 +2,6 @@ package org.rostislav.quickdrop.controller; import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; -import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.AnalyticsDataView; import org.rostislav.quickdrop.model.ApplicationSettingsViewModel; import org.rostislav.quickdrop.model.FileEntityView; @@ -19,7 +18,8 @@ import org.springframework.web.bind.annotation.RequestParam; import java.util.List; -import static org.rostislav.quickdrop.util.FileUtils.*; +import static org.rostislav.quickdrop.util.FileUtils.bytesToMegabytes; +import static org.rostislav.quickdrop.util.FileUtils.megabytesToBytes; @Controller @RequestMapping("/admin") @@ -40,10 +40,8 @@ public class AdminViewController { return "redirect:/admin/password"; } - List files = fileService.getFiles(); - - model.addAttribute("files", files.stream().map( - file -> new FileEntityView(file, formatFileSize(file.size), analyticsService.getTotalDownloadsByFile(file.id)))); + List files = fileService.getAllFilesWithDownloadCounts(); + model.addAttribute("files", files); AnalyticsDataView analytics = analyticsService.getAnalytics(); model.addAttribute("analytics", analytics); diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index ed9db87..64a81ff 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -17,7 +17,6 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.util.List; -import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; import static org.rostislav.quickdrop.util.FileUtils.populateModelAttributes; @Controller @@ -76,7 +75,7 @@ public class FileViewController { List downloadHistory = downloadLogRepository.findByFileId(id); long totalDownloads = analyticsService.getTotalDownloadsByFile(id); - model.addAttribute("file", new FileEntityView(file, formatFileSize(file.size), totalDownloads)); + model.addAttribute("file", new FileEntityView(file, totalDownloads)); model.addAttribute("downloadHistory", downloadHistory); return "admin/download-history"; diff --git a/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java index ad689a0..3c56416 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java +++ b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java @@ -4,6 +4,8 @@ import org.rostislav.quickdrop.entity.FileEntity; import java.time.LocalDate; +import static org.rostislav.quickdrop.util.FileUtils.formatFileSize; + public class FileEntityView { public Long id; public String name; @@ -18,12 +20,12 @@ public class FileEntityView { public FileEntityView() { } - public FileEntityView(FileEntity fileEntity, String formatedSize, long totalDownloads) { + public FileEntityView(FileEntity fileEntity, long totalDownloads) { this.id = fileEntity.id; this.name = fileEntity.name; this.uuid = fileEntity.uuid; this.description = fileEntity.description; - this.size = formatedSize; + this.size = formatFileSize(fileEntity.size); this.keepIndefinitely = fileEntity.keepIndefinitely; this.uploadDate = fileEntity.uploadDate; this.totalDownloads = totalDownloads; diff --git a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java index 8f8aa9e..5a9213f 100644 --- a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java +++ b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java @@ -1,6 +1,7 @@ package org.rostislav.quickdrop.repository; import org.rostislav.quickdrop.entity.FileEntity; +import org.rostislav.quickdrop.model.FileEntityView; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -27,4 +28,15 @@ public interface FileRepository extends JpaRepository { @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, '%')))") List searchNotHiddenFiles(@Param("searchString") String query); + + @Query(""" + SELECT new org.rostislav.quickdrop.model.FileEntityView( + f, + CAST(SUM(CASE WHEN dl.id IS NOT NULL THEN 1 ELSE 0 END) AS long) + ) + FROM FileEntity f + LEFT JOIN DownloadLog dl ON dl.file.id = f.id + GROUP BY f + """) + List findAllFilesWithDownloadCounts(); } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index 5def7c1..acb2e3c 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -3,6 +3,7 @@ package org.rostislav.quickdrop.service; import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.entity.DownloadLog; import org.rostislav.quickdrop.entity.FileEntity; +import org.rostislav.quickdrop.model.FileEntityView; import org.rostislav.quickdrop.model.FileUploadRequest; import org.rostislav.quickdrop.repository.DownloadLogRepository; import org.rostislav.quickdrop.repository.FileRepository; @@ -306,4 +307,9 @@ public class FileService { public List getNotHiddenFiles() { return fileRepository.findAllNotHiddenFiles(); } + + public List getAllFilesWithDownloadCounts() { + return fileRepository.findAllFilesWithDownloadCounts(); + } + } From 245168ece325f54a9cabbb2383aa9c24cc2f057b Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Mon, 2 Dec 2024 15:29:24 +0200 Subject: [PATCH 13/19] added a qr code to the link in the file view --- src/main/resources/static/js/fileView.js | 62 +++++++++++++++++++--- src/main/resources/templates/fileView.html | 35 ++++++------ 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/src/main/resources/static/js/fileView.js b/src/main/resources/static/js/fileView.js index 17021f3..bfd5876 100644 --- a/src/main/resources/static/js/fileView.js +++ b/src/main/resources/static/js/fileView.js @@ -1,14 +1,60 @@ -function copyToClipboard() { +function copyToClipboard(button) { const copyText = document.getElementById("downloadLink"); - const copyButton = document.querySelector(".copyButton"); - navigator.clipboard.writeText(copyText.value).then(function () { - copyButton.innerText = "Copied!"; - copyButton.classList.add("btn-success"); - }).catch(function (err) { - console.error("Could not copy text: ", err); - }); + + navigator.clipboard.writeText(copyText.value) + .then(() => { + button.innerText = "Copied!"; + button.classList.add("copied"); + + // Revert back after 2 seconds + setTimeout(() => { + button.innerText = "Copy Link"; + button.classList.remove("copied"); + }, 2000); + }) + .catch((err) => { + console.error("Could not copy text: ", err); + button.innerText = "Failed!"; + button.classList.add("btn-danger"); + + setTimeout(() => { + button.innerText = "Copy Link"; + button.classList.remove("btn-danger"); + }, 2000); + }); } function showPreparingMessage() { document.getElementById('preparingMessage').style.display = 'block'; +} + +document.addEventListener("DOMContentLoaded", function () { + const downloadLink = document.getElementById("downloadLink").value; // Get the file download link + const qrCodeContainer = document.getElementById("qrCodeContainer"); // Container for the QR code + + console.log("Download link:", downloadLink); // Debugging log + + if (downloadLink) { + QRCode.toCanvas(qrCodeContainer, encodeURI(downloadLink), { + width: 100, // Size of the QR Code + margin: 2 // Margin around the QR Code + }, function (error) { + if (error) { + console.error("QR Code generation failed:", error); + } + }); + } else { + console.error("Download link is empty or undefined."); + } +}); + +function updateCheckboxState(event, checkbox) { + event.preventDefault(); + const hiddenField = checkbox.form.querySelector('input[name="keepIndefinitely"][type="hidden"]'); + if (hiddenField) { + hiddenField.value = checkbox.checked; + } + + console.log('Submitting form...'); + checkbox.form.submit(); } \ No newline at end of file diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index 2f5e981..4cf63de 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -12,6 +12,14 @@ + + @@ -123,23 +131,24 @@
- Link
-
- - diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index 4cf63de..f0833ef 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -174,34 +174,15 @@ > Download -
- - + + + -
- - + +
From 788209e44278e2b5b6c22d1067faf5170f273ff4 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 8 Dec 2024 14:40:45 +0200 Subject: [PATCH 15/19] added delete confirmations --- src/main/resources/static/js/fileView.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/resources/static/js/fileView.js b/src/main/resources/static/js/fileView.js index bfd5876..1feacf5 100644 --- a/src/main/resources/static/js/fileView.js +++ b/src/main/resources/static/js/fileView.js @@ -48,6 +48,10 @@ document.addEventListener("DOMContentLoaded", function () { } }); +function confirmDelete() { + return confirm("Are you sure you want to delete this file? This action cannot be undone."); +} + function updateCheckboxState(event, checkbox) { event.preventDefault(); const hiddenField = checkbox.form.querySelector('input[name="keepIndefinitely"][type="hidden"]'); From 7659a2483621ed699af21398a29b9c2ecc295ead Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 8 Dec 2024 16:12:16 +0200 Subject: [PATCH 16/19] added a share button that will work once without password protection --- .../quickdrop/config/SecurityConfig.java | 2 +- .../controller/FileRestController.java | 52 +++++- .../controller/FileViewController.java | 13 ++ .../quickdrop/entity/FileEntity.java | 5 + .../quickdrop/service/FileService.java | 69 ++++++++ .../rostislav/quickdrop/util/FileUtils.java | 4 + src/main/resources/static/js/fileView.js | 60 ++++++- .../resources/templates/file-share-view.html | 44 +++++ src/main/resources/templates/fileView.html | 153 +++++++++--------- .../templates/invalid-share-link.html | 50 ++++++ 10 files changed, 373 insertions(+), 79 deletions(-) create mode 100644 src/main/resources/templates/file-share-view.html create mode 100644 src/main/resources/templates/invalid-share-link.html diff --git a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java index 46834d0..25cfd07 100644 --- a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java @@ -34,7 +34,7 @@ public class SecurityConfig { if (applicationSettingsService.isAppPasswordEnabled()) { http .authorizeHttpRequests(authz -> authz - .requestMatchers("/password/login", "/favicon.ico", "/error").permitAll() + .requestMatchers("/password/login", "/favicon.ico", "/error", "/file/share/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index d51029c..4a32270 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java @@ -1,14 +1,17 @@ package org.rostislav.quickdrop.controller; +import jakarta.servlet.http.HttpServletRequest; import org.rostislav.quickdrop.entity.FileEntity; import org.rostislav.quickdrop.model.FileUploadRequest; import org.rostislav.quickdrop.service.FileService; +import org.rostislav.quickdrop.util.FileUtils; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.time.LocalDate; @RestController @RequestMapping("/api/file") @@ -32,4 +35,45 @@ public class FileRestController { return ResponseEntity.badRequest().build(); } } + + @PostMapping("/share/{id}") + public ResponseEntity generateShareableLink(@PathVariable Long id, HttpServletRequest request) { + FileEntity fileEntity = fileService.getFile(id); + if (fileEntity == null) { + return ResponseEntity.badRequest().body("File not found."); + } + + String password = (String) request.getSession().getAttribute("password"); + if (fileEntity.passwordHash != null) { + if (password == null || !fileService.checkPassword(fileEntity.uuid, password)) { + System.out.println("Invalid or missing password."); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Invalid or missing password in session."); + } + } + + String token = fileService.generateShareToken(id, LocalDate.now().plusDays(30)); + String shareLink = FileUtils.getShareLink(request, fileEntity, token); + return ResponseEntity.ok(shareLink); + } + + @GetMapping("/download/{uuid}/{token}") + public ResponseEntity downloadFile(@PathVariable String uuid, @PathVariable String token) { + try { + StreamingResponseBody responseBody = fileService.streamFileAndInvalidateToken(uuid, token); + if (responseBody == null) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + FileEntity fileEntity = fileService.getFile(uuid); + + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + fileEntity.name + "\"") + .header("Content-Length", String.valueOf(fileEntity.size)) + .body(responseBody); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } } diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java index 64a81ff..7e53119 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -178,4 +178,17 @@ public class FileViewController { return "file-password"; } } + + @GetMapping("/share/{uuid}/{token}") + public String viewSharedFile(@PathVariable String uuid, @PathVariable String token, Model model) { + if (!fileService.validateShareToken(uuid, token)) { + return "invalid-share-link"; + } + + FileEntity file = fileService.getFile(uuid); + model.addAttribute("file", new FileEntityView(file, analyticsService.getTotalDownloadsByFile(file.id))); + model.addAttribute("downloadLink", "/api/file/download/" + file.uuid + "/" + token); + + return "file-share-view"; + } } diff --git a/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java index 2c55bbb..835c4b4 100644 --- a/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java @@ -19,6 +19,11 @@ public class FileEntity { public String passwordHash; @Column(columnDefinition = "boolean default false") public boolean hidden; + @Column(nullable = true) + public String shareToken; + + @Column(nullable = true) + public LocalDate tokenExpirationDate; @PrePersist public void prePersist() { diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index acb2e3c..3fd7e7b 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -22,6 +22,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.io.File; import java.io.FileInputStream; +import java.io.OutputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -312,4 +313,72 @@ public class FileService { return fileRepository.findAllFilesWithDownloadCounts(); } + public String generateShareToken(Long fileId, LocalDate tokenExpirationDate) { + Optional optionalFile = fileRepository.findById(fileId); + if (optionalFile.isEmpty()) { + throw new IllegalArgumentException("File not found"); + } + + FileEntity file = optionalFile.get(); + String token = UUID.randomUUID().toString(); // Generate a unique token + file.shareToken = token; + file.tokenExpirationDate = tokenExpirationDate; + fileRepository.save(file); + + logger.info("Share token generated for file: {}", file.name); + return token; + } + + public boolean validateShareToken(String uuid, String token) { + Optional optionalFile = fileRepository.findByUUID(uuid); + if (optionalFile.isEmpty()) { + return false; + } + + FileEntity file = optionalFile.get(); + if (!token.equals(file.shareToken)) { + return false; + } + + return file.tokenExpirationDate == null || !LocalDate.now().isAfter(file.tokenExpirationDate); + } + + private void writeFileToStream(String uuid, OutputStream outputStream) { + Path path = Path.of(applicationSettingsService.getFileStoragePath(), uuid); + try (FileInputStream inputStream = new FileInputStream(path.toFile())) { + byte[] buffer = new byte[1024]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + outputStream.flush(); + } catch ( + Exception e) { + logger.error("Error writing file to stream: {}", e.getMessage()); + } + } + + public StreamingResponseBody streamFileAndInvalidateToken(String uuid, String token) { + Optional optionalFile = fileRepository.findByUUID(uuid); + + if (optionalFile.isEmpty() || !validateShareToken(uuid, token)) { + return null; + } + + FileEntity fileEntity = optionalFile.get(); + + return outputStream -> { + try { + writeFileToStream(uuid, outputStream); + + fileEntity.shareToken = null; + fileEntity.tokenExpirationDate = null; + fileRepository.save(fileEntity); + + logger.info("Share token invalidated and file streamed successfully: {}", fileEntity.name); + } catch (Exception e) { + logger.error("Error streaming file or invalidating token for UUID: {}", uuid, e); + } + }; + } } diff --git a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java index f0b99c9..96fe09c 100644 --- a/src/main/java/org/rostislav/quickdrop/util/FileUtils.java +++ b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java @@ -32,6 +32,10 @@ public class FileUtils { return request.getScheme() + "://" + request.getServerName() + "/file/" + fileEntity.uuid; } + public static String getShareLink(HttpServletRequest request, FileEntity fileEntity, String token) { + return request.getScheme() + "://" + request.getServerName() + "/file/share/" + fileEntity.uuid + "/" + token; + } + public static void populateModelAttributes(FileEntity fileEntity, Model model, HttpServletRequest request) { model.addAttribute("file", fileEntity); model.addAttribute("fileSize", formatFileSize(fileEntity.size)); diff --git a/src/main/resources/static/js/fileView.js b/src/main/resources/static/js/fileView.js index 1feacf5..2766829 100644 --- a/src/main/resources/static/js/fileView.js +++ b/src/main/resources/static/js/fileView.js @@ -32,8 +32,6 @@ document.addEventListener("DOMContentLoaded", function () { const downloadLink = document.getElementById("downloadLink").value; // Get the file download link const qrCodeContainer = document.getElementById("qrCodeContainer"); // Container for the QR code - console.log("Download link:", downloadLink); // Debugging log - if (downloadLink) { QRCode.toCanvas(qrCodeContainer, encodeURI(downloadLink), { width: 100, // Size of the QR Code @@ -61,4 +59,62 @@ function updateCheckboxState(event, checkbox) { console.log('Submitting form...'); checkbox.form.submit(); +} + +function openShareModal() { + const fileId = document.getElementById("fileId").textContent.trim(); + const filePasswordInput = document.getElementById("filePassword"); + const password = filePasswordInput ? filePasswordInput.value : ""; + + generateShareLink(fileId, password) + .then(link => { + const shareLinkInput = document.getElementById("shareLink"); + shareLinkInput.value = link; + + // Generate QR code for the share link + const shareQRCode = document.getElementById("shareQRCode"); + QRCode.toCanvas(shareQRCode, encodeURI(link), { + width: 150, + margin: 2 + }, function (error) { + if (error) { + console.error("QR Code generation failed:", error); + } + }); + + // Show the modal + const shareModal = new bootstrap.Modal(document.getElementById('shareModal')); + shareModal.show(); + }) + .catch(error => { + console.error(error); + alert("Error generating share link."); + }); +} + + +function generateShareLink(fileId) { + const csrfToken = document.querySelector('meta[name="_csrf"]').content; // Retrieve CSRF token + return fetch(`/api/file/share/${fileId}`, { + method: 'POST', + credentials: 'same-origin', // Ensures cookies are sent for session + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': csrfToken // Include CSRF token in request headers + }, + body: JSON.stringify({}), + }) + .then((response) => { + if (!response.ok) throw new Error("Failed to generate share link"); + return response.text(); + }); +} + +function copyShareLink() { + const shareLink = document.getElementById("shareLink"); + shareLink.select(); + shareLink.setSelectionRange(0, 99999); // For mobile devices + navigator.clipboard.writeText(shareLink.value).then(() => { + alert("Share link copied to clipboard!"); + }); } \ No newline at end of file diff --git a/src/main/resources/templates/file-share-view.html b/src/main/resources/templates/file-share-view.html new file mode 100644 index 0000000..5c6e624 --- /dev/null +++ b/src/main/resources/templates/file-share-view.html @@ -0,0 +1,44 @@ + + + + + Shared File View + + + + +
+

Shared File

+
+
+
+
+
File Name
+ +

Description

+ +
+
Uploaded At:
+

+
+ +
+
File Size:
+

+
+ +
+ + Download + +
+
+
+
+
+ + + diff --git a/src/main/resources/templates/fileView.html b/src/main/resources/templates/fileView.html index f0833ef..19b93e9 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -2,16 +2,13 @@ - - File - View - - - + + File View + + + + + +
-

- File - View

+

File View

-
- File - Name
- +
File Name
-

+

+
-

+

- + Files are kept only for 30 days after this date. +
Keep Indefinitely:
@@ -94,16 +81,17 @@
+ value="true">
+
Hide File From List:
@@ -112,57 +100,46 @@
+ th:checked="${file.hidden}" + value="true">
-
- File - Size:
-

+
File Size:
+

-
- Link -
+ +
Link
-
@@ -191,6 +177,29 @@
+ + + diff --git a/src/main/resources/templates/invalid-share-link.html b/src/main/resources/templates/invalid-share-link.html new file mode 100644 index 0000000..137b993 --- /dev/null +++ b/src/main/resources/templates/invalid-share-link.html @@ -0,0 +1,50 @@ + + + + + + Share Link Invalid + + + + + +
+
+
+

Link Expired

+

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

+ Return to Homepage +
+
+
+ + + \ No newline at end of file From 96ba310699aef0432025ce564095eb8dbd0ed178 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 8 Dec 2024 16:16:13 +0200 Subject: [PATCH 17/19] added a checkbox to hide the file when uploading --- .../quickdrop/controller/FileRestController.java | 8 +++++--- .../org/rostislav/quickdrop/model/FileUploadRequest.java | 4 +++- .../java/org/rostislav/quickdrop/service/FileService.java | 1 + src/main/resources/templates/upload.html | 6 ++++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index 4a32270..9a4e6df 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java @@ -24,10 +24,11 @@ public class FileRestController { @PostMapping("/upload") public ResponseEntity saveFile(@RequestParam("file") MultipartFile file, - @RequestParam(value = "description") String description, + @RequestParam(value = "description", required = false) String description, @RequestParam(value = "keepIndefinitely", defaultValue = "false") boolean keepIndefinitely, - @RequestParam(value = "password", required = false) String password) { - FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password); + @RequestParam(value = "password", required = false) String password, + @RequestParam(value = "hidden", defaultValue = "false") boolean hidden) { + FileUploadRequest fileUploadRequest = new FileUploadRequest(description, keepIndefinitely, password, hidden); FileEntity fileEntity = fileService.saveFile(file, fileUploadRequest); if (fileEntity != null) { return ResponseEntity.ok(fileEntity); @@ -36,6 +37,7 @@ public class FileRestController { } } + @PostMapping("/share/{id}") public ResponseEntity generateShareableLink(@PathVariable Long id, HttpServletRequest request) { FileEntity fileEntity = fileService.getFile(id); diff --git a/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java b/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java index a20e84e..653aa51 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java +++ b/src/main/java/org/rostislav/quickdrop/model/FileUploadRequest.java @@ -4,13 +4,15 @@ public class FileUploadRequest { public String description; public boolean keepIndefinitely; public String password; + public boolean hidden; public FileUploadRequest() { } - public FileUploadRequest(String description, boolean keepIndefinitely, String password) { + public FileUploadRequest(String description, boolean keepIndefinitely, String password, boolean hidden) { this.description = description; this.keepIndefinitely = keepIndefinitely; this.password = password; + this.hidden = hidden; } } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index 3fd7e7b..52df3cf 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -102,6 +102,7 @@ public class FileService { fileEntity.description = fileUploadRequest.description; fileEntity.size = file.getSize(); fileEntity.keepIndefinitely = fileUploadRequest.keepIndefinitely; + fileEntity.hidden = fileUploadRequest.hidden; if (fileUploadRequest.password != null && !fileUploadRequest.password.isEmpty()) { fileEntity.passwordHash = passwordEncoder.encode(fileUploadRequest.password); diff --git a/src/main/resources/templates/upload.html b/src/main/resources/templates/upload.html index 7e41ec6..7fdbd73 100644 --- a/src/main/resources/templates/upload.html +++ b/src/main/resources/templates/upload.html @@ -123,6 +123,12 @@
+ +
+ + +
+
From e256e5d58f04f5de3b3df25c0b8d617957c285b5 Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 8 Dec 2024 16:19:12 +0200 Subject: [PATCH 18/19] logging the downloads from share links --- .../rostislav/quickdrop/controller/FileRestController.java | 4 ++-- .../java/org/rostislav/quickdrop/service/FileService.java | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index 9a4e6df..660ab53 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java @@ -59,9 +59,9 @@ public class FileRestController { } @GetMapping("/download/{uuid}/{token}") - public ResponseEntity downloadFile(@PathVariable String uuid, @PathVariable String token) { + public ResponseEntity downloadFile(@PathVariable String uuid, @PathVariable String token, HttpServletRequest request) { try { - StreamingResponseBody responseBody = fileService.streamFileAndInvalidateToken(uuid, token); + StreamingResponseBody responseBody = fileService.streamFileAndInvalidateToken(uuid, token, request); if (responseBody == null) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } diff --git a/src/main/java/org/rostislav/quickdrop/service/FileService.java b/src/main/java/org/rostislav/quickdrop/service/FileService.java index 52df3cf..e672fa5 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -359,7 +359,7 @@ public class FileService { } } - public StreamingResponseBody streamFileAndInvalidateToken(String uuid, String token) { + public StreamingResponseBody streamFileAndInvalidateToken(String uuid, String token, HttpServletRequest request) { Optional optionalFile = fileRepository.findByUUID(uuid); if (optionalFile.isEmpty() || !validateShareToken(uuid, token)) { @@ -367,6 +367,7 @@ public class FileService { } FileEntity fileEntity = optionalFile.get(); + logDownload(fileEntity, request); return outputStream -> { try { From b4b431682c12f292f803fbaa2f63f097b51e637a Mon Sep 17 00:00:00 2001 From: Rostislav Raykov Date: Sun, 8 Dec 2024 16:21:17 +0200 Subject: [PATCH 19/19] added the api share link download endpoint to the security filter chain --- .../java/org/rostislav/quickdrop/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java index 25cfd07..b59efdd 100644 --- a/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java @@ -34,7 +34,7 @@ public class SecurityConfig { if (applicationSettingsService.isAppPasswordEnabled()) { http .authorizeHttpRequests(authz -> authz - .requestMatchers("/password/login", "/favicon.ico", "/error", "/file/share/**").permitAll() + .requestMatchers("/password/login", "/favicon.ico", "/error", "/file/share/**", "/api/file/download/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form
NameUpload DateSizeDownloadsActionsNameUpload DateSizeDownloadsKeep IndefinitelyHiddenActions
- +
-
- Keep Indefinitely -
- -
+
+
- - View File - - View - History + + +
+ + +
+ +
+
+
+ View File + History + Download - -
- +