Merge branch 'admin-panel' into dev

This commit is contained in:
Rostislav Raykov
2024-12-08 16:22:36 +02:00
48 changed files with 2067 additions and 330 deletions
+3 -2
View File
@@ -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):
Vendored
+1 -1
View File
@@ -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
# ----------------------------------------------------------------------------
+9
View File
@@ -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;
}
}
@@ -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
+114 -8
View File
@@ -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!");
});
}
+9
View File
@@ -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();
});
+26
View File
@@ -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>
+128 -105
View File
@@ -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>
+12 -15
View File
@@ -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">
+31 -65
View File
@@ -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>
+60
View File
@@ -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