diff --git a/README.md b/README.md index 5a893d1..db83ef3 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/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/pom.xml b/pom.xml index 8306494..5fdc905 100644 --- a/pom.xml +++ b/pom.xml @@ -185,6 +185,15 @@ 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..0b5a109 100644 --- a/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java +++ b/src/main/java/org/rostislav/quickdrop/QuickdropApplication.java @@ -1,22 +1,26 @@ package org.rostislav.quickdrop; -import java.nio.file.Files; -import java.nio.file.Path; - 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; +import java.nio.file.Files; +import java.nio.file.Path; + @SpringBootApplication @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/MultipartConfig.java b/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java index 6a61137..c669b29 100644 --- a/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java +++ b/src/main/java/org/rostislav/quickdrop/config/MultipartConfig.java @@ -1,8 +1,8 @@ package org.rostislav.quickdrop.config; import jakarta.servlet.MultipartConfigElement; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.servlet.MultipartConfigFactory; +import org.springframework.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 +10,15 @@ import org.springframework.util.unit.DataSize; @Configuration public class MultipartConfig { private final long ADDITIONAL_REQUEST_SIZE = 1024L * 1024L * 10L; // 10 MB - @Value("${max-upload-file-size}") - private String maxUploadFileSize; @Bean - public MultipartConfigElement multipartConfigElement() { + @RefreshScope + public MultipartConfigElement multipartConfigElement(MultipartProperties multipartProperties) { 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/config/SecurityConfig.java b/src/main/java/org/rostislav/quickdrop/config/SecurityConfig.java index 167b771..b59efdd 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,19 +22,19 @@ 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() + .requestMatchers("/password/login", "/favicon.ico", "/error", "/file/share/**", "/api/file/download/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form @@ -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/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 new file mode 100644 index 0000000..9ce57e3 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/controller/AdminViewController.java @@ -0,0 +1,108 @@ +package org.rostislav.quickdrop.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; +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.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; + +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; + private final AnalyticsService analyticsService; + private final FileService fileService; + + public AdminViewController(ApplicationSettingsService applicationSettingsService, AnalyticsService analyticsService, FileService fileService) { + this.applicationSettingsService = applicationSettingsService; + this.analyticsService = analyticsService; + this.fileService = fileService; + } + + @GetMapping("/dashboard") + public String getDashboardPage(Model model, HttpServletRequest request) { + if (!applicationSettingsService.checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } + + List files = fileService.getAllFilesWithDownloadCounts(); + model.addAttribute("files", files); + + AnalyticsDataView analytics = analyticsService.getAnalytics(); + model.addAttribute("analytics", analytics); + + 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, HttpServletRequest request) { + if (!applicationSettingsService.checkForAdminPassword(request)) { + return "redirect:/admin/password"; + } + + ApplicationSettingsEntity settings = applicationSettingsService.getApplicationSettings(); + + ApplicationSettingsViewModel applicationSettingsViewModel = new ApplicationSettingsViewModel(settings); + applicationSettingsViewModel.setMaxFileSize(bytesToMegabytes(settings.getMaxFileSize())); + + model.addAttribute("settings", applicationSettingsViewModel); + return "admin/settings"; + } + + @PostMapping("/save") + public String saveSettings(ApplicationSettingsViewModel settings, HttpServletRequest request) { + if (!applicationSettingsService.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"; + } +} diff --git a/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java b/src/main/java/org/rostislav/quickdrop/controller/FileRestController.java index 5d3b456..660ab53 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 org.rostislav.quickdrop.model.FileEntity; +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") @@ -21,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); @@ -32,4 +36,46 @@ 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, HttpServletRequest request) { + try { + StreamingResponseBody responseBody = fileService.streamFileAndInvalidateToken(uuid, token, request); + 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 fdde2c5..7e53119 100644 --- a/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java +++ b/src/main/java/org/rostislav/quickdrop/controller/FileViewController.java @@ -1,17 +1,18 @@ 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.beans.factory.annotation.Value; 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; @@ -22,25 +23,27 @@ 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; - @Value("${file.max.age}") - private String maxFileLifeTime; + private final ApplicationSettingsService applicationSettingsService; + private final DownloadLogRepository downloadLogRepository; + private final AnalyticsService analyticsService; - public FileViewController(FileService fileService) { + public FileViewController(FileService fileService, ApplicationSettingsService applicationSettingsService, DownloadLogRepository downloadLogRepository, AnalyticsService analyticsService) { this.fileService = fileService; + this.applicationSettingsService = applicationSettingsService; + this.downloadLogRepository = downloadLogRepository; + this.analyticsService = analyticsService; } @GetMapping("/upload") public String showUploadFile(Model model) { - model.addAttribute("maxFileSize", maxFileSize); - model.addAttribute("maxFileLifeTime", maxFileLifeTime); + model.addAttribute("maxFileSize", applicationSettingsService.getFormattedMaxFileSize()); + model.addAttribute("maxFileLifeTime", applicationSettingsService.getMaxFileLifeTime()); return "upload"; } @GetMapping("/list") public String listFiles(Model model) { - List files = fileService.getFiles(); + List files = fileService.getNotHiddenFiles(); model.addAttribute("files", files); return "listFiles"; } @@ -48,13 +51,13 @@ 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 && (password == null || !fileService.checkPassword(uuid, password))) { model.addAttribute("uuid", uuid); - return "filePassword"; + return "file-password"; } populateModelAttributes(fileEntity, model, request); @@ -62,6 +65,23 @@ public class FileViewController { return "fileView"; } + @GetMapping("/history/{id}") + 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); + + model.addAttribute("file", new FileEntityView(file, 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)) { @@ -69,7 +89,7 @@ public class FileViewController { return "redirect:/file/" + uuid; } else { model.addAttribute("uuid", uuid); - return "filePassword"; + return "file-password"; } } @@ -85,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}") @@ -108,8 +128,67 @@ 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)) { + action.run(); + return "redirect:" + referer; + } + + // 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"; + } + } + + @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/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/entity/ApplicationSettingsEntity.java b/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java new file mode 100644 index 0000000..7f6c08d --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/entity/ApplicationSettingsEntity.java @@ -0,0 +1,108 @@ +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 { + @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 ApplicationSettingsEntity() { + } + + 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() { + 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/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 76% rename from src/main/java/org/rostislav/quickdrop/model/FileEntity.java rename to src/main/java/org/rostislav/quickdrop/entity/FileEntity.java index c11cef3..835c4b4 100644 --- a/src/main/java/org/rostislav/quickdrop/model/FileEntity.java +++ b/src/main/java/org/rostislav/quickdrop/entity/FileEntity.java @@ -1,13 +1,9 @@ -package org.rostislav.quickdrop.model; +package org.rostislav.quickdrop.entity; + +import jakarta.persistence.*; import java.time.LocalDate; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; - @Entity public class FileEntity { @Id @@ -21,6 +17,13 @@ public class FileEntity { public boolean keepIndefinitely; public LocalDate uploadDate; 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/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/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 new file mode 100644 index 0000000..899ac40 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/ApplicationSettingsViewModel.java @@ -0,0 +1,92 @@ +package org.rostislav.quickdrop.model; + +import org.rostislav.quickdrop.entity.ApplicationSettingsEntity; + +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/model/FileEntityView.java b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java new file mode 100644 index 0000000..3c56416 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/model/FileEntityView.java @@ -0,0 +1,34 @@ +package org.rostislav.quickdrop.model; + +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; + public String uuid; + public String description; + public String size; + public boolean keepIndefinitely; + public LocalDate uploadDate; + public long totalDownloads; + public boolean hidden; + + public FileEntityView() { + } + + public FileEntityView(FileEntity fileEntity, long totalDownloads) { + this.id = fileEntity.id; + this.name = fileEntity.name; + this.uuid = fileEntity.uuid; + this.description = fileEntity.description; + this.size = formatFileSize(fileEntity.size); + this.keepIndefinitely = fileEntity.keepIndefinitely; + this.uploadDate = fileEntity.uploadDate; + this.totalDownloads = totalDownloads; + this.hidden = fileEntity.hidden; + } +} 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/repository/ApplicationSettingsRepository.java b/src/main/java/org/rostislav/quickdrop/repository/ApplicationSettingsRepository.java new file mode 100644 index 0000000..6311cb1 --- /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.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 053a1fc..5a9213f 100644 --- a/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java +++ b/src/main/java/org/rostislav/quickdrop/repository/FileRepository.java @@ -1,14 +1,15 @@ 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; + 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; - public interface FileRepository extends JpaRepository { @Query("SELECT f FROM FileEntity f WHERE f.uuid = :uuid") Optional findByUUID(@Param("uuid") String uuid); @@ -16,6 +17,26 @@ 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); + + @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/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 new file mode 100644 index 0000000..9b36698 --- /dev/null +++ b/src/main/java/org/rostislav/quickdrop/service/ApplicationSettingsService.java @@ -0,0 +1,119 @@ +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; +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; + +@Service +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, ScheduleService scheduleService) { + this.contextRefresher = contextRefresher; + this.applicationSettingsRepository = applicationSettingsRepository; + this.scheduleService = scheduleService; + + 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); + scheduleService.updateSchedule(settings.getFileDeletionCron(), settings.getMaxFileLifeTime()); + return settings; + }); + } + + public ApplicationSettingsEntity getApplicationSettings() { + return applicationSettings; + } + + public void updateApplicationSettings(ApplicationSettingsViewModel settings, String appPassword) { + 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()); + + if (settings.isAppPasswordEnabled()) { + applicationSettingsEntity.setAppPasswordHash(BCrypt.hashpw(appPassword, BCrypt.gensalt())); + } + + + applicationSettingsRepository.save(applicationSettingsEntity); + this.applicationSettings = applicationSettingsEntity; + + scheduleService.updateSchedule(applicationSettingsEntity.getFileDeletionCron(), applicationSettingsEntity.getMaxFileLifeTime()); + contextRefresher.refresh(); + } + + public long getMaxFileSize() { + return applicationSettings.getMaxFileSize(); + } + + public String getFormattedMaxFileSize() { + return formatFileSize(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(); + } + + 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; + } + + 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 95c2e6c..e672fa5 100644 --- a/src/main/java/org/rostislav/quickdrop/service/FileService.java +++ b/src/main/java/org/rostislav/quickdrop/service/FileService.java @@ -1,11 +1,15 @@ 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.FileEntityView; 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; -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; @@ -18,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; @@ -27,21 +32,25 @@ 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; @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; + private final ApplicationSettingsService applicationSettingsService; + private final DownloadLogRepository downloadLogRepository; - public FileService(FileRepository fileRepository, PasswordEncoder passwordEncoder) { + @Lazy + 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) { @@ -75,7 +84,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)) { @@ -93,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); @@ -154,14 +164,14 @@ 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); 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 { @@ -183,11 +193,12 @@ 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") - .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()); @@ -195,6 +206,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); } @@ -216,7 +234,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); @@ -251,4 +269,118 @@ public class FileService { public List searchFiles(String query) { return fileRepository.searchFiles(query); } + + public List searchNotHiddenFiles(String query) { + return fileRepository.searchNotHiddenFiles(query); + } + + public long calculateTotalSpaceUsed() { + return nullToZero(fileRepository.totalFileSizeForAllFiles()); + } + + public void updateKeepIndefinitely(Long id, boolean keepIndefinitely) { + Optional referenceById = fileRepository.findById(id); + if (referenceById.isEmpty()) { + return; + } + + if (!keepIndefinitely) { + extendFile(id); + } + + FileEntity fileEntity = referenceById.get(); + fileEntity.keepIndefinitely = keepIndefinitely; + 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(); + } + + public List getAllFilesWithDownloadCounts() { + 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, HttpServletRequest request) { + Optional optionalFile = fileRepository.findByUUID(uuid); + + if (optionalFile.isEmpty() || !validateShareToken(uuid, token)) { + return null; + } + + FileEntity fileEntity = optionalFile.get(); + logDownload(fileEntity, request); + + 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/service/ScheduleService.java b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java index 14b1d2d..6ae6cf5 100644 --- a/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java +++ b/src/main/java/org/rostislav/quickdrop/service/ScheduleService.java @@ -1,33 +1,46 @@ package org.rostislav.quickdrop.service; -import java.time.LocalDate; -import java.util.List; - -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; -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/java/org/rostislav/quickdrop/util/FileUtils.java b/src/main/java/org/rostislav/quickdrop/util/FileUtils.java index 81bdfbc..f9d8f68 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 { @@ -28,9 +28,13 @@ public class FileUtils { return scheme + "://" + 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", 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 ade4f5b..b399c0c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -9,12 +9,6 @@ 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 -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/main/resources/static/js/fileView.js b/src/main/resources/static/js/fileView.js index 17021f3..2766829 100644 --- a/src/main/resources/static/js/fileView.js +++ b/src/main/resources/static/js/fileView.js @@ -1,14 +1,120 @@ -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 + + 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 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"]'); + if (hiddenField) { + hiddenField.value = checkbox.checked; + } + + 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/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/static/js/upload.js b/src/main/resources/static/js/upload.js index 7d8b793..1dc8338 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]; @@ -102,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/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/admin/dashboard.html b/src/main/resources/templates/admin/dashboard.html new file mode 100644 index 0000000..8f6eff7 --- /dev/null +++ b/src/main/resources/templates/admin/dashboard.html @@ -0,0 +1,166 @@ + + + + + + QuickDrop Admin + + + + + + + +
+

Admin Dashboard

+ + +
+
+

Analytics

+
+
+
+
+
Total Downloads
+

0

+
+
+
Total Space Used
+

0 MB

+
+
+
+
+ + +
+
+

Files

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameUpload DateSizeDownloadsKeep IndefinitelyHiddenActions
+
+ + +
+ +
+
+
+
+ + +
+ +
+
+
+ View File + 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 new file mode 100644 index 0000000..7c7b9d2 --- /dev/null +++ b/src/main/resources/templates/admin/settings.html @@ -0,0 +1,104 @@ + + + + + Admin Settings + + + + + + +
+

Admin Settings

+ +
+ + +
+
+
File Settings
+
+
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+
+ + +
+
+
System Settings
+
+
+ +
+ + +
+
+
+ + +
+
+
Security Settings
+
+
+ +
+ + +
+ + + +
+
+ + +
+ +
+
+
+ + + 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/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 3bc9374..19b93e9 100644 --- a/src/main/resources/templates/fileView.html +++ b/src/main/resources/templates/fileView.html @@ -2,135 +2,144 @@ - - File - View - - - + + File View + + + + + + + + +
-

- File - View

+

File View

-
- File - Name
- +
File Name
-

+

+
-

+

- + Files are kept only for 30 days after this date. +
-
- Keep - Indefinitely:
-

+
Keep Indefinitely:
+
+ + +
+ +
+
-
-
- File - Size:
-

+
+
Hide File From List:
+
+ + +
+ +
+
-
- Link
-
+
+
File Size:
+

+
+ + +
Link
+
+ readonly + style="height: 38px;"/> +
-
+ + + + 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 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..7fdbd73 100644 --- a/src/main/resources/templates/upload.html +++ b/src/main/resources/templates/upload.html @@ -17,38 +17,35 @@ +

@@ -88,23 +85,17 @@ id="uploadForm" method="post" th:action="@{/file/upload}" + onsubmit="return validateKeepIndefinitely()" > - + - +
- + -