mirror of
https://github.com/RoastSlav/quickdrop.git
synced 2026-05-12 15:29:40 -05:00
Merge branch 'admin-panel' into dev
This commit is contained in:
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -185,6 +185,15 @@
|
||||
test
|
||||
</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter</artifactId>
|
||||
<version>4.1.3</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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/**");
|
||||
}
|
||||
}
|
||||
@@ -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<FileEntityView> 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";
|
||||
}
|
||||
}
|
||||
@@ -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<FileEntity> 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<String> 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<StreamingResponseBody> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FileEntity> files = fileService.getFiles();
|
||||
List<FileEntity> 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<DownloadLog> 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<FileEntity> files = fileService.searchFiles(query);
|
||||
List<FileEntity> 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -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() {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ApplicationSettingsEntity, Long> {
|
||||
|
||||
}
|
||||
@@ -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<DownloadLog, Long> {
|
||||
@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<DownloadLog> findByFileId(Long fileId);
|
||||
}
|
||||
@@ -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<FileEntity, Long> {
|
||||
@Query("SELECT f FROM FileEntity f WHERE f.uuid = :uuid")
|
||||
Optional<FileEntity> findByUUID(@Param("uuid") String uuid);
|
||||
@@ -16,6 +17,26 @@ public interface FileRepository extends JpaRepository<FileEntity, Long> {
|
||||
@Query("SELECT f FROM FileEntity f WHERE f.keepIndefinitely = false AND f.uploadDate < :thresholdDate")
|
||||
List<FileEntity> 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<FileEntity> searchFiles(@Param("searchString") String searchString);
|
||||
|
||||
@Query("SELECT f FROM FileEntity f WHERE f.hidden = false")
|
||||
List<FileEntity> 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<FileEntity> 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<FileEntityView> findAllFilesWithDownloadCounts();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<StreamingResponseBody> downloadFile(Long id, String password) {
|
||||
public ResponseEntity<StreamingResponseBody> 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<FileEntity> searchFiles(String query) {
|
||||
return fileRepository.searchFiles(query);
|
||||
}
|
||||
|
||||
public List<FileEntity> searchNotHiddenFiles(String query) {
|
||||
return fileRepository.searchNotHiddenFiles(query);
|
||||
}
|
||||
|
||||
public long calculateTotalSpaceUsed() {
|
||||
return nullToZero(fileRepository.totalFileSizeForAllFiles());
|
||||
}
|
||||
|
||||
public void updateKeepIndefinitely(Long id, boolean keepIndefinitely) {
|
||||
Optional<FileEntity> 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<FileEntity> 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<FileEntity> getNotHiddenFiles() {
|
||||
return fileRepository.findAllNotHiddenFiles();
|
||||
}
|
||||
|
||||
public List<FileEntityView> getAllFilesWithDownloadCounts() {
|
||||
return fileRepository.findAllFilesWithDownloadCounts();
|
||||
}
|
||||
|
||||
public String generateShareToken(Long fileId, LocalDate tokenExpirationDate) {
|
||||
Optional<FileEntity> 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<FileEntity> 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<FileEntity> 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FileEntity> filesForDeletion = fileRepository.getFilesForDeletion(thresholdDate);
|
||||
for (FileEntity file : filesForDeletion) {
|
||||
logger.info("Deleting file: {}", file);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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!");
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>
|
||||
Enter Admin Password</title>
|
||||
<meta content="width=device-width, initial-scale=1"
|
||||
name="viewport">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet">
|
||||
<link href="/images/favicon.png"
|
||||
rel="icon"
|
||||
type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Main Content -->
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-12 col-md-6 col-lg-4">
|
||||
<h2 class="text-center mb-4">
|
||||
Admin Password Required</h2>
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<form id="adminPasswordForm"
|
||||
method="POST"
|
||||
th:action="@{/admin/password}">
|
||||
<!-- CSRF Token -->
|
||||
<input th:name="${_csrf.parameterName}"
|
||||
th:value="${_csrf.token}"
|
||||
type="hidden"/>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"
|
||||
for="adminPassword">Admin Password:</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="adminPassword"
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button class="btn btn-primary w-100"
|
||||
type="submit">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div th:if="${error}">
|
||||
<p class="text-danger mt-3"
|
||||
th:text="${error}"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,166 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>QuickDrop Admin</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img alt="QuickDrop Logo" class="me-2" height="40" src="/images/favicon.png">
|
||||
QuickDrop Admin
|
||||
</a>
|
||||
<button aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
||||
data-bs-target="#navbarNav" data-bs-toggle="collapse" type="button">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/settings">Settings</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/">Home</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mt-5">
|
||||
<h1 class="text-center mb-4">Admin Dashboard</h1>
|
||||
|
||||
<!-- Analytics Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h2 class="mb-0">Analytics</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h5>Total Downloads</h5>
|
||||
<p class="text-muted" th:text="${analytics.totalDownloads}">0</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h5>Total Space Used</h5>
|
||||
<p class="text-muted" th:text="${analytics.totalSpaceUsed}">0 MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Files Section -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h2 class="mb-0">Files</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%;">Name</th>
|
||||
<th style="width: 15%;">Upload Date</th>
|
||||
<th style="width: 10%;">Size</th>
|
||||
<th style="width: 10%;">Downloads</th>
|
||||
<th style="width: 10%; text-align: center;">Keep Indefinitely</th>
|
||||
<th style="width: 10%; text-align: center;">Hidden</th>
|
||||
<th style="width: 25%; text-align: right;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="align-middle" th:each="file : ${files}">
|
||||
<td th:text="${file.name}"></td>
|
||||
<td th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></td>
|
||||
<td th:text="${file.size}"></td>
|
||||
<td th:text="${file.totalDownloads}"></td>
|
||||
|
||||
<!-- Keep Indefinitely Checkbox -->
|
||||
<td class="text-center">
|
||||
<form class="d-inline" method="post" th:action="@{/file/keep-indefinitely/{id}(id=${file.id})}">
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<input name="keepIndefinitely" type="hidden" value="false">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input"
|
||||
name="keepIndefinitely"
|
||||
onchange="updateCheckboxState(event, this)"
|
||||
th:checked="${file.keepIndefinitely}"
|
||||
type="checkbox"
|
||||
value="true">
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
<!-- Hidden Checkbox -->
|
||||
<td class="text-center">
|
||||
<form class="d-inline" method="post" th:action="@{/file/toggle-hidden/{id}(id=${file.id})}">
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<input name="hidden" type="hidden" value="false">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input"
|
||||
name="hidden"
|
||||
onchange="updateHiddenState(event, this)"
|
||||
th:checked="${file.hidden}"
|
||||
type="checkbox"
|
||||
value="true">
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="text-end">
|
||||
<a class="btn btn-sm btn-info me-1" th:href="@{/file/{uuid}(uuid=${file.uuid})}">View File</a>
|
||||
<a class="btn btn-sm btn-warning me-1"
|
||||
th:href="@{/file/history/{id}(id=${file.id})}">History</a>
|
||||
<a class="btn btn-sm btn-success me-1"
|
||||
th:href="@{/file/download/{id}(id=${file.id})}">Download</a>
|
||||
<form class="d-inline" method="post" onsubmit="return confirmDelete();"
|
||||
th:action="@{/file/delete/{id}(id=${file.id})}">
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<button class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<!-- Bootstrap Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,121 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>Download History</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.file-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.file-info-item {
|
||||
background-color: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file-description {
|
||||
font-size: 1.1rem;
|
||||
color: #6c757d;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img alt="QuickDrop Logo" class="me-2" height="40" src="/images/favicon.png">
|
||||
QuickDrop Admin
|
||||
</a>
|
||||
<button aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
||||
data-bs-target="#navbarNav" data-bs-toggle="collapse" type="button">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/dashboard">Dashboard</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mt-5">
|
||||
<h1 class="text-center mb-4">Download History</h1>
|
||||
|
||||
<!-- File Name and Description -->
|
||||
<div class="text-center">
|
||||
<p class="file-name" th:text="${file.name}">File Name</p>
|
||||
<p class="file-description" th:text="${file.description ?: 'No description provided'}">File Description</p>
|
||||
</div>
|
||||
|
||||
<!-- File Details Grid -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-info text-white text-center">
|
||||
File Details
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="file-info">
|
||||
<div class="file-info-item">
|
||||
<strong th:text="${file.size}"></strong>
|
||||
<span>Size</span>
|
||||
</div>
|
||||
<div class="file-info-item">
|
||||
<strong th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></strong>
|
||||
<span>Upload Date</span>
|
||||
</div>
|
||||
<div class="file-info-item">
|
||||
<strong th:text="${file.totalDownloads}"></strong>
|
||||
<span>Total Downloads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download History Table -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h2 class="mb-0">Download History</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Downloader IP</th>
|
||||
<th>Date</th>
|
||||
<th>User Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="log : ${downloadHistory}">
|
||||
<td th:text="${log.downloaderIp}">127.0.0.1</td>
|
||||
<td th:text="${#temporals.format(log.downloadDate, 'dd.MM.yyyy HH:mm:ss')}">01.12.2024 20:12:22</td>
|
||||
<td th:text="${log.userAgent ?: 'N/A'}">Mozilla/5.0 (Windows NT 10.0; Win64; x64)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap Bundle -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Admin Settings</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img alt="QuickDrop Logo" class="me-2" height="40" src="/images/favicon.png">
|
||||
QuickDrop Admin
|
||||
</a>
|
||||
<button aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
|
||||
data-bs-target="#navbarNav" data-bs-toggle="collapse" type="button">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/admin/dashboard">Dashboard</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-5">
|
||||
<h1 class="mb-4 text-center">Admin Settings</h1>
|
||||
|
||||
<form method="post" th:action="@{/admin/save}" th:object="${settings}">
|
||||
|
||||
<!-- File Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5>File Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Max File Size -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max File Size (MB)</label>
|
||||
<input class="form-control" required th:field="*{maxFileSize}" type="text">
|
||||
</div>
|
||||
|
||||
<!-- Max File Lifetime -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Max File Lifetime (days)</label>
|
||||
<input class="form-control" required th:field="*{maxFileLifeTime}" type="text">
|
||||
</div>
|
||||
|
||||
<!-- File Storage Path -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">File Storage Path</label>
|
||||
<input class="form-control" required th:field="*{fileStoragePath}" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5>System Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- File Deletion Cron -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">File Deletion Cron Expression</label>
|
||||
<input class="form-control" required th:field="*{fileDeletionCron}" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Settings -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5>Security Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- App Password Enabled -->
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" id="appPasswordEnabled" onclick="togglePasswordField()"
|
||||
th:field="*{appPasswordEnabled}" type="checkbox">
|
||||
<label class="form-check-label">Enable Password Protection</label>
|
||||
</div>
|
||||
|
||||
<!-- App Password -->
|
||||
<div class="mb-3" id="passwordInputGroup" style="display: none;">
|
||||
<label class="form-label">App Password</label>
|
||||
<input class="form-control" th:field="*{appPassword}" type="password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="d-flex justify-content-center">
|
||||
<button class="btn btn-primary" type="submit">Save Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script src="/js/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Shared File View</title>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1 class="text-center mb-4">Shared File</h1>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-8 col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center" th:text="${file.name}">File Name</h5>
|
||||
|
||||
<p class="card-text text-center" th:text="${file.description}">Description</p>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center border-top pt-3">
|
||||
<h5 class="card-title mb-0">Uploaded At:</h5>
|
||||
<p class="card-text mb-0" th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-3">
|
||||
<h5 class="card-title">File Size:</h5>
|
||||
<p class="card-text" th:text="${file.size}"></p>
|
||||
</div>
|
||||
|
||||
<h5 class="card-title border-top pt-3"></h5>
|
||||
<a
|
||||
class="btn btn-success w-100"
|
||||
id="downloadButton"
|
||||
th:href="${downloadLink}">
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,135 +2,144 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>
|
||||
File
|
||||
View</title>
|
||||
<meta content="width=device-width, initial-scale=1"
|
||||
name="viewport">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet">
|
||||
<link href="/images/favicon.png"
|
||||
rel="icon"
|
||||
type="image/png">
|
||||
<meta name="_csrf" th:content="${_csrf.token}"/>
|
||||
<title>File View</title>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet">
|
||||
<link href="/images/favicon.png" rel="icon" type="image/png">
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.4.4/build/qrcode.min.js"></script>
|
||||
<style>
|
||||
.copyButton.copied {
|
||||
background-color: #28a745; /* Green background */
|
||||
color: white; /* White text */
|
||||
font-weight: bold; /* Make text bold */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex align-items-center"
|
||||
href="/">
|
||||
<img alt="Website Logo"
|
||||
class="me-2"
|
||||
height="40"
|
||||
src="/images/favicon.png">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<img alt="Website Logo" class="me-2" height="40" src="/images/favicon.png">
|
||||
QuickDrop
|
||||
</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
aria-label="Toggle navigation"
|
||||
data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
class="navbar-toggler"
|
||||
data-bs-target="#navbarNav"
|
||||
data-bs-toggle="collapse"
|
||||
type="button"
|
||||
>
|
||||
data-bs-toggle="collapse">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse"
|
||||
id="navbarNav">
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link"
|
||||
href="/file/list">View
|
||||
Files</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link"
|
||||
href="/file/upload">Upload
|
||||
File</a>
|
||||
</li>
|
||||
<li class="nav-item"><a class="nav-link" href="/file/list">View Files</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/file/upload">Upload File</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/dashboard" onclick="requestAdminPassword()">Admin
|
||||
Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hidden element to retrieve file ID -->
|
||||
<span hidden id="fileId" th:text="${file.id}"></span>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mt-5">
|
||||
<h1 class="text-center mb-4">
|
||||
File
|
||||
View</h1>
|
||||
<h1 class="text-center mb-4">File View</h1>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-md-8 col-lg-6">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-center"
|
||||
th:text="${file.name}">
|
||||
File
|
||||
Name</h5>
|
||||
|
||||
<h5 class="card-title text-center" th:text="${file.name}">File Name</h5>
|
||||
<div th:if="${!#strings.isEmpty(file.description)}">
|
||||
<p class="card-text text-center mb-3"
|
||||
th:text="${file.description}"></p>
|
||||
<p class="card-text text-center mb-3" th:text="${file.description}"></p>
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="d-flex justify-content-between align-items-center border-top pt-3">
|
||||
<h5 class="card-title mb-0"
|
||||
th:text="${file.keepIndefinitely} ? 'Uploaded At:' : 'Uploaded/Renewed At:'"></h5>
|
||||
<p class="card-text mb-0"
|
||||
th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></p>
|
||||
<p class="card-text mb-0" th:text="${#temporals.format(file.uploadDate, 'dd.MM.yyyy')}"></p>
|
||||
</div>
|
||||
<small class="text-muted"
|
||||
th:if="${file.keepIndefinitely == false}">
|
||||
<small class="text-muted" th:if="${file.keepIndefinitely == false}">
|
||||
Files are kept only for <span th:text="${maxFileLifeTime}">30</span> days after this date.
|
||||
</small>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center pt-3">
|
||||
<h5 class="card-title">
|
||||
Keep
|
||||
Indefinitely:</h5>
|
||||
<p class="card-text"
|
||||
th:text="${file.keepIndefinitely} ? 'Yes' : 'No'"></p>
|
||||
<h5 class="card-title">Keep Indefinitely:</h5>
|
||||
<form class="d-inline" method="post" th:action="@{/file/keep-indefinitely/{id}(id=${file.id})}">
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<input name="keepIndefinitely" type="hidden" value="false">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
onchange="updateCheckboxState(event, this)"
|
||||
id="keepIndefinitely"
|
||||
name="keepIndefinitely"
|
||||
type="checkbox"
|
||||
th:checked="${file.keepIndefinitely}"
|
||||
th:disabled="${file.passwordHash == null}"
|
||||
value="true">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">
|
||||
File
|
||||
Size:</h5>
|
||||
<p class="card-text"
|
||||
th:text="${fileSize}"></p>
|
||||
<div class="d-flex justify-content-between align-items-center" th:if="${file.passwordHash != null}">
|
||||
<h5 class="card-title">Hide File From List:</h5>
|
||||
<form class="d-inline" method="post" th:action="@{/file/toggle-hidden/{id}(id=${file.id})}">
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<input name="hidden" type="hidden" value="false">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
onchange="updateCheckboxState(event, this)"
|
||||
id="hidden"
|
||||
name="hidden"
|
||||
type="checkbox"
|
||||
th:checked="${file.hidden}"
|
||||
value="true">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<h5 class="card-title border-top pt-3">
|
||||
Link</h5>
|
||||
<div class="input-group mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center pt-3">
|
||||
<h5 class="card-title">File Size:</h5>
|
||||
<p class="card-text" th:text="${fileSize}"></p>
|
||||
</div>
|
||||
|
||||
<!-- Link and QR -->
|
||||
<h5 class="card-title border-top pt-3">Link</h5>
|
||||
<div class="input-group mb-3 align-items-center">
|
||||
<input
|
||||
th:value="${downloadLink}"
|
||||
class="form-control"
|
||||
id="downloadLink"
|
||||
readonly
|
||||
th:value="${downloadLink}"
|
||||
type="text"
|
||||
/>
|
||||
readonly
|
||||
style="height: 38px;"/>
|
||||
<button
|
||||
class="btn btn-outline-secondary copyButton"
|
||||
onclick="copyToClipboard()"
|
||||
type="button"
|
||||
>
|
||||
Copy
|
||||
Link
|
||||
class="btn btn-outline-secondary copyButton"
|
||||
onclick="copyToClipboard(this)"
|
||||
style="height: 38px;">
|
||||
Copy Link
|
||||
</button>
|
||||
<canvas id="qrCodeContainer" style="width: 100px; height: 100px;"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info"
|
||||
id="preparingMessage"
|
||||
style="display: none;">
|
||||
Your
|
||||
file
|
||||
is
|
||||
being
|
||||
prepared
|
||||
for
|
||||
download.
|
||||
Please
|
||||
wait...
|
||||
<div
|
||||
class="alert alert-info"
|
||||
id="preparingMessage"
|
||||
style="display: none;">
|
||||
Your file is being prepared for download. Please wait...
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-3 border-top pt-3">
|
||||
@@ -138,45 +147,59 @@
|
||||
class="btn btn-success"
|
||||
id="downloadButton"
|
||||
th:href="@{/file/download/{id}(id=${file.id})}"
|
||||
th:onclick="${file.passwordHash != null} ? 'showPreparingMessage()' : ''"
|
||||
>
|
||||
th:onclick="${file.passwordHash != null} ? 'showPreparingMessage()' : ''">
|
||||
Download
|
||||
</a>
|
||||
|
||||
<form method="post"
|
||||
onsubmit="return confirmDelete();"
|
||||
th:action="@{/file/delete/{id}(id=${file.id})}"
|
||||
th:if="${file.passwordHash != null}">
|
||||
<input
|
||||
th:name="${_csrf.parameterName}"
|
||||
th:value="${_csrf.token}"
|
||||
type="hidden"
|
||||
/>
|
||||
<button class="btn btn-danger"
|
||||
type="submit">
|
||||
Delete
|
||||
File
|
||||
</button>
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<button class="btn btn-danger" type="submit">Delete File</button>
|
||||
</form>
|
||||
|
||||
<form method="post"
|
||||
th:action="@{/file/extend/{id}(id=${file.id})}"
|
||||
th:if="${file.keepIndefinitely == false}">
|
||||
<input
|
||||
th:name="${_csrf.parameterName}"
|
||||
th:value="${_csrf.token}"
|
||||
type="hidden"
|
||||
/>
|
||||
<button class="btn btn-primary"
|
||||
type="submit">
|
||||
Renew
|
||||
File
|
||||
Lifetime
|
||||
</button>
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden">
|
||||
<button class="btn btn-primary" type="submit">Renew File Lifetime</button>
|
||||
</form>
|
||||
|
||||
<!-- New Share Button -->
|
||||
<button class="btn btn-secondary" onclick="openShareModal()" th:if="${file.passwordHash != null}"
|
||||
type="button">Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<div aria-hidden="true" aria-labelledby="shareModalLabel" class="modal fade" id="shareModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="shareModalLabel">Share File</h1>
|
||||
<button aria-label="Close" class="btn-close" data-bs-dismiss="modal" type="button"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This link can be used to share the file once without any password protection. It will be valid for 30
|
||||
days.</p>
|
||||
<div class="input-group mb-3">
|
||||
<input class="form-control" id="shareLink" readonly type="text">
|
||||
<button class="btn btn-outline-secondary" onclick="copyShareLink()" type="button">Copy Link</button>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<canvas id="shareQRCode" style="width: 150px; height: 150px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/js/fileView.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>Share Link Invalid</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/images/favicon.png" rel="icon" type="image/png">
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-home {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container d-flex justify-content-center align-items-center">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Link Expired</h1>
|
||||
<p class="card-text text-muted">
|
||||
This share link is no longer valid. The file you are trying to access has expired or the link has been
|
||||
used.
|
||||
</p>
|
||||
<a class="btn btn-primary btn-home" href="/">Return to Homepage</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,38 +17,35 @@
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex align-items-center"
|
||||
href="/">
|
||||
<img alt="Website Logo"
|
||||
class="me-2"
|
||||
height="40"
|
||||
src="/images/favicon.png">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<img alt="Website Logo" class="me-2" height="40" src="/images/favicon.png">
|
||||
QuickDrop
|
||||
</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
class="navbar-toggler"
|
||||
data-bs-target="#navbarNav"
|
||||
data-bs-toggle="collapse"
|
||||
type="button"
|
||||
>
|
||||
data-bs-toggle="collapse">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse"
|
||||
id="navbarNav">
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link"
|
||||
href="/file/upload">Upload
|
||||
File</a>
|
||||
<a class="nav-link" href="/file/upload">Upload File</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<!-- Admin Dashboard Button -->
|
||||
<a class="nav-link" href="/admin/dashboard" onclick="requestAdminPassword()">Admin Dashboard</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container mt-5">
|
||||
<h1 class="text-center mb-4">
|
||||
|
||||
@@ -17,38 +17,35 @@
|
||||
<!-- Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand d-flex align-items-center"
|
||||
href="/">
|
||||
<img alt="Website Logo"
|
||||
class="me-2"
|
||||
height="40"
|
||||
src="/images/favicon.png">
|
||||
<a class="navbar-brand d-flex align-items-center" href="/">
|
||||
<img alt="Website Logo" class="me-2" height="40" src="/images/favicon.png">
|
||||
QuickDrop
|
||||
</a>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
aria-controls="navbarNav"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
class="navbar-toggler"
|
||||
data-bs-target="#navbarNav"
|
||||
data-bs-toggle="collapse"
|
||||
type="button"
|
||||
>
|
||||
data-bs-toggle="collapse">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse"
|
||||
id="navbarNav">
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link"
|
||||
href="/file/list">View
|
||||
Files</a>
|
||||
<a class="nav-link" href="/file/list">View Files</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<!-- Admin Dashboard Button -->
|
||||
<a class="nav-link" href="/admin/dashboard" onclick="requestAdminPassword()">Admin Dashboard</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container">
|
||||
<h1 class="text-center mb-4">
|
||||
@@ -88,23 +85,17 @@
|
||||
id="uploadForm"
|
||||
method="post"
|
||||
th:action="@{/file/upload}"
|
||||
onsubmit="return validateKeepIndefinitely()"
|
||||
>
|
||||
<!-- CSRF Token -->
|
||||
<input th:name="${_csrf.parameterName}"
|
||||
th:value="${_csrf.token}"
|
||||
type="hidden"/>
|
||||
<input th:name="${_csrf.parameterName}" th:value="${_csrf.token}" type="hidden"/>
|
||||
|
||||
<!-- UUID -->
|
||||
<input name="uuid"
|
||||
th:value="${uuid}"
|
||||
type="hidden"/>
|
||||
<input name="uuid" th:value="${uuid}" type="hidden"/>
|
||||
|
||||
<!-- File Input -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"
|
||||
for="file">Select
|
||||
a
|
||||
file:</label>
|
||||
<label class="form-label" for="file">Select a file:</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="file"
|
||||
@@ -116,61 +107,36 @@
|
||||
</div>
|
||||
|
||||
<!-- File Size Alert -->
|
||||
<div class="alert alert-danger"
|
||||
id="fileSizeAlert"
|
||||
role="alert"
|
||||
style="display: none;">
|
||||
File
|
||||
size
|
||||
exceeds
|
||||
the
|
||||
<span th:text="${maxFileSize}">1GB</span>
|
||||
limit.
|
||||
<div class="alert alert-danger" id="fileSizeAlert" role="alert" style="display: none;">
|
||||
File size exceeds the <span th:text="${maxFileSize}">1GB</span> limit.
|
||||
</div>
|
||||
|
||||
<!-- Description Input -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"
|
||||
for="description">Description:</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="description"
|
||||
name="description"
|
||||
type="text"
|
||||
/>
|
||||
<label class="form-label" for="description">Description:</label>
|
||||
<input class="form-control" id="description" name="description" type="text"/>
|
||||
</div>
|
||||
|
||||
<!-- Keep Indefinitely Checkbox -->
|
||||
<div class="form-check mb-3">
|
||||
<input
|
||||
class="form-check-input"
|
||||
id="keepIndefinitely"
|
||||
name="keepIndefinitely"
|
||||
type="checkbox"
|
||||
/>
|
||||
<label class="form-check-label"
|
||||
for="keepIndefinitely">
|
||||
Keep
|
||||
indefinitely
|
||||
</label>
|
||||
<input class="form-check-input" id="keepIndefinitely" name="keepIndefinitely" type="checkbox"/>
|
||||
<label class="form-check-label" for="keepIndefinitely">Keep indefinitely</label>
|
||||
</div>
|
||||
|
||||
<!-- Hidden File Checkbox -->
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" id="hidden" name="hidden" type="checkbox"/>
|
||||
<label class="form-check-label" for="hidden">Hide from file list</label>
|
||||
</div>
|
||||
|
||||
<!-- Password Input -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label"
|
||||
for="password">Password
|
||||
(Optional):</label>
|
||||
<input
|
||||
class="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
/>
|
||||
<label class="form-label" for="password">Password (Optional):</label>
|
||||
<input class="form-control" id="password" name="password" type="password"/>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button class="btn btn-primary w-100"
|
||||
type="submit">
|
||||
<button class="btn btn-primary w-100" type="submit">
|
||||
Upload
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>Welcome to QuickDrop</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card welcome-card shadow">
|
||||
<div class="card-header bg-primary text-white text-center">
|
||||
<h1>Welcome to QuickDrop</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-center">
|
||||
Thank you for setting up QuickDrop! Before you get started, please set an admin password for the dashboard.
|
||||
</p>
|
||||
<form id="setupForm" method="post" th:action="@{/admin/setup}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="adminPassword">Admin Password</label>
|
||||
<input class="form-control" id="adminPassword" minlength="8" name="adminPassword" required
|
||||
type="password">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="confirmPassword">Confirm Password</label>
|
||||
<input class="form-control" id="confirmPassword" minlength="8" name="confirmPassword" required
|
||||
type="password">
|
||||
</div>
|
||||
<div class="text-danger mb-3" id="error-message" style="display: none;">Passwords do not match.</div>
|
||||
<button class="btn btn-primary w-100" type="submit">Set Admin Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('setupForm').addEventListener('submit', function (e) {
|
||||
const password = document.getElementById('adminPassword').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
e.preventDefault();
|
||||
document.getElementById('error-message').style.display = 'block';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +1,11 @@
|
||||
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;
|
||||
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;
|
||||
@@ -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();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.rostislav.quickdrop;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.rostislav.quickdrop.model.FileEntity;
|
||||
import org.rostislav.quickdrop.entity.FileEntity;
|
||||
import org.rostislav.quickdrop.model.FileUploadRequest;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public class TestDataContainer {
|
||||
|
||||
public static FileEntity getFileEntity() {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user