From 31559fbdc700cfecd67c0245deba6415ca608d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Florczak?= <84631301+florczaq@users.noreply.github.com> Date: Mon, 22 Sep 2025 21:36:57 +0200 Subject: [PATCH 1/2] Create file upload/download logic; Create apk download endpoint; --- .gitignore | 3 +- docker-compose.yml | 2 + .../java/org/pkwmtt/files/FileController.java | 49 +++++++++++++++ .../java/org/pkwmtt/files/FileService.java | 61 +++++++++++++++++++ .../org/pkwmtt/files/apk/ApkController.java | 41 +++++++++++++ .../java/org/pkwmtt/files/apk/ApkService.java | 53 ++++++++++++++++ .../org/pkwmtt/global/RequestInterceptor.java | 1 - .../org/pkwmtt/global/config/WebConfig.java | 3 +- .../resources/application-prod.properties | 7 ++- src/main/resources/application.properties | 7 ++- 10 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/pkwmtt/files/FileController.java create mode 100644 src/main/java/org/pkwmtt/files/FileService.java create mode 100644 src/main/java/org/pkwmtt/files/apk/ApkController.java create mode 100644 src/main/java/org/pkwmtt/files/apk/ApkService.java diff --git a/.gitignore b/.gitignore index c6a22f5..efd1836 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ logs/* - +uploads/*.* +uploads/apk/*.* ### STS ### .apt_generated .classpath diff --git a/docker-compose.yml b/docker-compose.yml index 87636af..5897749 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: ports: - "8080:8080" restart: always + volumes: + - ./uploads:/app/uploads environment: SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE} db: diff --git a/src/main/java/org/pkwmtt/files/FileController.java b/src/main/java/org/pkwmtt/files/FileController.java new file mode 100644 index 0000000..8a6084b --- /dev/null +++ b/src/main/java/org/pkwmtt/files/FileController.java @@ -0,0 +1,49 @@ +package org.pkwmtt.files; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.FileNotFoundException; +import java.io.IOException; + +@RestController +@RequestMapping("/admin/files") +@RequiredArgsConstructor +public class FileController { + private final FileService service; + + /** + * @param file provided file + * @return 200 if request ok + * @throws IOException when file or directory malformed + */ + @PostMapping(value = "/upload", consumes = MediaType.ALL_VALUE) + public ResponseEntity upload (@RequestParam("file") MultipartFile file) throws IOException { + service.upload(file); + return ResponseEntity.ok().build(); + } + + /** + * @param fileName name of requested file + * @return file + * @throws IOException problem with accessing selected file + */ + @GetMapping(value = "/download/{fileName}") + public ResponseEntity download (@PathVariable String fileName) throws IOException { + try { + UrlResource resource = service.getResourceByFileName(fileName); + return ResponseEntity + .ok() + .contentType(service.getContentNameByFileName(fileName)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"") + .body(resource); + } catch (FileNotFoundException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/src/main/java/org/pkwmtt/files/FileService.java b/src/main/java/org/pkwmtt/files/FileService.java new file mode 100644 index 0000000..7f85045 --- /dev/null +++ b/src/main/java/org/pkwmtt/files/FileService.java @@ -0,0 +1,61 @@ +package org.pkwmtt.files; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.UrlResource; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +public class FileService { + @Value("${app.upload.dir:uploads}") + private String UPLOADS_DIR; + + public void upload (MultipartFile file) throws IOException { + Path projectRoot = Paths.get("").toAbsolutePath(); + Path uploadPath = projectRoot.resolve(UPLOADS_DIR); + + if (!Files.exists(uploadPath)) { + Files.createDirectories(uploadPath); + } + + Path filePath = uploadPath.resolve(Objects.requireNonNull(file.getOriginalFilename())); + file.transferTo(filePath.toFile()); + } + + public UrlResource getResourceByFileName (String fileName) throws IOException { + //Dir: ProjectRoot/uploads/fileName + Path filePath = getFilePathByName(fileName); + + UrlResource resource = new UrlResource(filePath.toUri()); + + if (!resource.exists()) { + throw new FileNotFoundException(); + } + + return resource; + + } + + public MediaType getContentNameByFileName (String fileName) throws IOException { + Path filePath = getFilePathByName(fileName); + String contentType = Files.probeContentType(filePath); + if (contentType == null) { + contentType = "application/octet-stream"; + } + return MediaType.parseMediaType(contentType); + } + + private Path getFilePathByName (String fileName) { + return Paths.get("").toAbsolutePath().resolve(UPLOADS_DIR).resolve(fileName).normalize(); + } +} diff --git a/src/main/java/org/pkwmtt/files/apk/ApkController.java b/src/main/java/org/pkwmtt/files/apk/ApkController.java new file mode 100644 index 0000000..8621257 --- /dev/null +++ b/src/main/java/org/pkwmtt/files/apk/ApkController.java @@ -0,0 +1,41 @@ +package org.pkwmtt.files.apk; + +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.FileNotFoundException; +import java.io.IOException; + +@RequestMapping("${apiPrefix}/apk") +@RestController +@RequiredArgsConstructor +public class ApkController { + + private final ApkService apkService; + + @GetMapping("/download") + public ResponseEntity download () { + try { + return ResponseEntity + .ok() + .contentType(MediaType.parseMediaType("application/vnd.android.package-archive")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=PKWM_App.apk") + .body(apkService.getApkResource()); + } catch (FileNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @GetMapping("/version") + public String getApkVersion () { + return "3.0.0"; + } +} diff --git a/src/main/java/org/pkwmtt/files/apk/ApkService.java b/src/main/java/org/pkwmtt/files/apk/ApkService.java new file mode 100644 index 0000000..ee6c438 --- /dev/null +++ b/src/main/java/org/pkwmtt/files/apk/ApkService.java @@ -0,0 +1,53 @@ +package org.pkwmtt.files.apk; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.files.FileService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Comparator; +import java.util.Optional; +import java.util.stream.Stream; + +@Service +@RequiredArgsConstructor +public class ApkService { + @Value("${app.upload.dir:uploads}") + private String FILES_DIR; + + private final FileService fileService; + + public UrlResource getApkResource () throws IOException { + Path filePath = findApkByExtensionInUploads().orElseThrow(FileNotFoundException::new); + return fileService.getResourceByFileName(filePath.getFileName().toString()); + } + + private Optional findApkByExtensionInUploads () throws IOException { + Path dirPath = Paths.get(FILES_DIR); + + if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { + throw new IllegalArgumentException("Invalid directory: " + dirPath); + } + + Stream stream = Files.list(dirPath); + + try (stream) { + return stream + .filter(Files::isRegularFile) + .filter(file -> file.getFileName().toString().toLowerCase().endsWith(".apk")) + .max(Comparator.comparingLong(file -> { + try { + return Files.getLastModifiedTime(file).toMillis(); + } catch (IOException e) { + throw new RuntimeException(e); //TODO handle + } + })); + } + } +} diff --git a/src/main/java/org/pkwmtt/global/RequestInterceptor.java b/src/main/java/org/pkwmtt/global/RequestInterceptor.java index 45de179..23d42c2 100644 --- a/src/main/java/org/pkwmtt/global/RequestInterceptor.java +++ b/src/main/java/org/pkwmtt/global/RequestInterceptor.java @@ -25,7 +25,6 @@ public class RequestInterceptor implements HandlerInterceptor { @Override public boolean preHandle (@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) { - String headerName = "X-API-KEY"; try { String providedApiKey = request.getHeader(headerName); diff --git a/src/main/java/org/pkwmtt/global/config/WebConfig.java b/src/main/java/org/pkwmtt/global/config/WebConfig.java index e900c37..ac9a028 100644 --- a/src/main/java/org/pkwmtt/global/config/WebConfig.java +++ b/src/main/java/org/pkwmtt/global/config/WebConfig.java @@ -25,7 +25,8 @@ public void addInterceptors (@NonNull InterceptorRegistry registry) { String apiPrefix = environment.getProperty("apiPrefix", ""); requestInterceptor.ifPresent(interceptor -> registry .addInterceptor(interceptor) - .addPathPatterns(apiPrefix + "/**")); + .addPathPatterns(apiPrefix + "/**") + .excludePathPatterns(apiPrefix + "/apk/download")); registry.addInterceptor(adminRequestInterceptor).addPathPatterns("/admin"); } } \ No newline at end of file diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index 7317153..32bebba 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -30,4 +30,9 @@ spring.mail.properties.mail.smtp.starttls.enable=true #Path apiPrefix=/pkwmtt/api/v1 #Swagger https protocol -swagger.url=https://backend.pkwmapp.pl \ No newline at end of file +swagger.url=https://backend.pkwmapp.pl +#Uploads +app.upload.dir="uploads" +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB +spring.servlet.multipart.enabled=true \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 876e8ad..ab49a21 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -28,4 +28,9 @@ spring.mail.password=${EMAIL_PASSWORD:} spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true #Path -apiPrefix=/pkwmtt/api/v1 \ No newline at end of file +apiPrefix=/pkwmtt/api/v1 +#Uploads +app.upload.dir=uploads +spring.servlet.multipart.max-file-size=100MB +spring.servlet.multipart.max-request-size=100MB +spring.servlet.multipart.enabled=true \ No newline at end of file From 51d9ad1ecf2be1b280a5c7a5165366a2f290755e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miko=C5=82aj=20Florczak?= <84631301+florczaq@users.noreply.github.com> Date: Tue, 23 Sep 2025 19:37:20 +0200 Subject: [PATCH 2/2] Add Endpoint: Get version of apk --- .../java/org/pkwmtt/files/FileService.java | 18 ++++++++++--- .../files/FileUploadsExceptionHandler.java | 27 +++++++++++++++++++ .../org/pkwmtt/files/apk/ApkController.java | 23 ++++++---------- .../java/org/pkwmtt/files/apk/ApkService.java | 21 ++++++++++++--- 4 files changed, 67 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java diff --git a/src/main/java/org/pkwmtt/files/FileService.java b/src/main/java/org/pkwmtt/files/FileService.java index 7f85045..fb3a30c 100644 --- a/src/main/java/org/pkwmtt/files/FileService.java +++ b/src/main/java/org/pkwmtt/files/FileService.java @@ -20,42 +20,54 @@ public class FileService { @Value("${app.upload.dir:uploads}") private String UPLOADS_DIR; + /** + * Upload files - admin only + * + * @param file - file to upload + * @throws IOException - when location is malformed + */ public void upload (MultipartFile file) throws IOException { Path projectRoot = Paths.get("").toAbsolutePath(); Path uploadPath = projectRoot.resolve(UPLOADS_DIR); + //Create directory if not exists if (!Files.exists(uploadPath)) { Files.createDirectories(uploadPath); } + //Create file Path filePath = uploadPath.resolve(Objects.requireNonNull(file.getOriginalFilename())); + + //Move content from provided file to recently created one file.transferTo(filePath.toFile()); } public UrlResource getResourceByFileName (String fileName) throws IOException { //Dir: ProjectRoot/uploads/fileName Path filePath = getFilePathByName(fileName); - UrlResource resource = new UrlResource(filePath.toUri()); if (!resource.exists()) { throw new FileNotFoundException(); } - return resource; - } public MediaType getContentNameByFileName (String fileName) throws IOException { Path filePath = getFilePathByName(fileName); + + //Get file content String contentType = Files.probeContentType(filePath); if (contentType == null) { + //Default value contentType = "application/octet-stream"; } + //Parse to Media type return MediaType.parseMediaType(contentType); } private Path getFilePathByName (String fileName) { + //Location of provided file return Paths.get("").toAbsolutePath().resolve(UPLOADS_DIR).resolve(fileName).normalize(); } } diff --git a/src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java b/src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java new file mode 100644 index 0000000..3e7f71f --- /dev/null +++ b/src/main/java/org/pkwmtt/files/FileUploadsExceptionHandler.java @@ -0,0 +1,27 @@ +package org.pkwmtt.files; + +import org.pkwmtt.exceptions.dto.ErrorResponseDTO; +import org.pkwmtt.files.apk.ApkController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.io.IOException; + +@RestControllerAdvice(assignableTypes = {FileController.class, ApkController.class}) +public class FileUploadsExceptionHandler { + + @ExceptionHandler(IOException.class) + public ResponseEntity handleIOException () { + return new ResponseEntity<>( + new ErrorResponseDTO("File or directory not found or is malformed."), + HttpStatus.NOT_FOUND + ); + } + + @ExceptionHandler({IllegalAccessException.class, RuntimeException.class}) + public ResponseEntity handleIllegalArgumentException (Exception e) { + return new ResponseEntity<>(new ErrorResponseDTO(e.getMessage()), HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/org/pkwmtt/files/apk/ApkController.java b/src/main/java/org/pkwmtt/files/apk/ApkController.java index 8621257..9a38620 100644 --- a/src/main/java/org/pkwmtt/files/apk/ApkController.java +++ b/src/main/java/org/pkwmtt/files/apk/ApkController.java @@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.io.FileNotFoundException; import java.io.IOException; @RequestMapping("${apiPrefix}/apk") @@ -20,22 +19,16 @@ public class ApkController { private final ApkService apkService; @GetMapping("/download") - public ResponseEntity download () { - try { - return ResponseEntity - .ok() - .contentType(MediaType.parseMediaType("application/vnd.android.package-archive")) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=PKWM_App.apk") - .body(apkService.getApkResource()); - } catch (FileNotFoundException e) { - return ResponseEntity.notFound().build(); - } catch (IOException e) { - throw new RuntimeException(e); - } + public ResponseEntity download () throws IOException { + return ResponseEntity + .ok() + .contentType(MediaType.parseMediaType("application/vnd.android.package-archive")) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=PKWM_App.apk") + .body(apkService.getApkResource()); } @GetMapping("/version") - public String getApkVersion () { - return "3.0.0"; + public String getApkVersion () throws IOException { + return apkService.getApkVersion(); } } diff --git a/src/main/java/org/pkwmtt/files/apk/ApkService.java b/src/main/java/org/pkwmtt/files/apk/ApkService.java index ee6c438..bc72f22 100644 --- a/src/main/java/org/pkwmtt/files/apk/ApkService.java +++ b/src/main/java/org/pkwmtt/files/apk/ApkService.java @@ -13,6 +13,8 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Stream; @Service @@ -23,12 +25,23 @@ public class ApkService { private final FileService fileService; - public UrlResource getApkResource () throws IOException { - Path filePath = findApkByExtensionInUploads().orElseThrow(FileNotFoundException::new); + public UrlResource getApkResource () throws IOException, IllegalArgumentException { + Path filePath = findNewestApkByExtensionInUploads().orElseThrow(FileNotFoundException::new); return fileService.getResourceByFileName(filePath.getFileName().toString()); } - private Optional findApkByExtensionInUploads () throws IOException { + public String getApkVersion () throws IOException { + Path filePath = findNewestApkByExtensionInUploads().orElseThrow(IOException::new); + String fileName = filePath.getFileName().toString(); + Pattern pattern = Pattern.compile("\\d+(?:\\.\\d+){1,2}"); + Matcher matcher = pattern.matcher(fileName); + if (!matcher.find()) { + return null; + } + return matcher.group(); + } + + private Optional findNewestApkByExtensionInUploads () throws IOException, IllegalArgumentException { Path dirPath = Paths.get(FILES_DIR); if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { @@ -45,7 +58,7 @@ private Optional findApkByExtensionInUploads () throws IOException { try { return Files.getLastModifiedTime(file).toMillis(); } catch (IOException e) { - throw new RuntimeException(e); //TODO handle + throw new RuntimeException("Couldn't locate last modified file"); } })); }