pc 프로그램 다운로드 기능 적용
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
package com.goldenchart.controller;
|
||||
|
||||
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
|
||||
import com.goldenchart.service.DesktopAppReleaseService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/desktop-app")
|
||||
@RequiredArgsConstructor
|
||||
public class DesktopAppReleaseController {
|
||||
|
||||
private final DesktopAppReleaseService releaseService;
|
||||
|
||||
@GetMapping("/info")
|
||||
public DesktopAppReleaseInfoDto info(HttpServletRequest request) {
|
||||
return releaseService.getInfo(resolvePublicBaseUrl(request));
|
||||
}
|
||||
|
||||
private static String resolvePublicBaseUrl(HttpServletRequest request) {
|
||||
String forwardedProto = request.getHeader("X-Forwarded-Proto");
|
||||
String forwardedHost = request.getHeader("X-Forwarded-Host");
|
||||
if (forwardedHost != null && !forwardedHost.isBlank()) {
|
||||
String scheme = forwardedProto != null && !forwardedProto.isBlank() ? forwardedProto : "https";
|
||||
return scheme + "://" + forwardedHost.split(",")[0].trim();
|
||||
}
|
||||
String scheme = request.getScheme();
|
||||
int port = request.getServerPort();
|
||||
String host = request.getServerName();
|
||||
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
|
||||
return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class DesktopAppPlatformReleaseDto {
|
||||
private boolean available;
|
||||
private String fileName;
|
||||
private String version;
|
||||
private Long sizeBytes;
|
||||
private String updatedAt;
|
||||
/** 브라우저 다운로드용 절대 URL */
|
||||
private String downloadUrl;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.goldenchart.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class DesktopAppReleaseInfoDto {
|
||||
private String version;
|
||||
private String updatedAt;
|
||||
private DesktopAppPlatformReleaseDto mac;
|
||||
private DesktopAppPlatformReleaseDto windows;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.goldenchart.service;
|
||||
|
||||
import com.goldenchart.dto.DesktopAppPlatformReleaseDto;
|
||||
import com.goldenchart.dto.DesktopAppReleaseInfoDto;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class DesktopAppReleaseService {
|
||||
|
||||
private static final DateTimeFormatter DT_FMT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.of("Asia/Seoul"));
|
||||
|
||||
@Value("${goldenchart.desktop-app.release-dir:data/desktop-releases}")
|
||||
private String releaseDir;
|
||||
|
||||
@Value("${goldenchart.desktop-app.version:}")
|
||||
private String configuredVersion;
|
||||
|
||||
@Value("${goldenchart.desktop-app.public-base-url:}")
|
||||
private String configuredPublicBaseUrl;
|
||||
|
||||
public DesktopAppReleaseInfoDto getInfo(String requestBaseUrl) {
|
||||
String publicBaseUrl = resolvePublicBaseUrl(requestBaseUrl);
|
||||
String base = normalizeBase(publicBaseUrl);
|
||||
String staticBase = base + "/desktop/updates";
|
||||
|
||||
Optional<Path> macFile = resolveMacInstaller();
|
||||
Optional<Path> winFile = resolveWindowsInstaller();
|
||||
|
||||
String version = configuredVersion != null && !configuredVersion.isBlank()
|
||||
? configuredVersion.trim()
|
||||
: resolveVersion(macFile, winFile);
|
||||
|
||||
Instant latestUpdated = Stream.of(macFile, winFile)
|
||||
.flatMap(Optional::stream)
|
||||
.map(this::lastModified)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(Instant.EPOCH);
|
||||
|
||||
return DesktopAppReleaseInfoDto.builder()
|
||||
.version(version)
|
||||
.updatedAt(latestUpdated.equals(Instant.EPOCH) ? null : DT_FMT.format(latestUpdated))
|
||||
.mac(buildPlatform(macFile, staticBase, version))
|
||||
.windows(buildPlatform(winFile, staticBase, version))
|
||||
.build();
|
||||
}
|
||||
|
||||
private DesktopAppPlatformReleaseDto buildPlatform(
|
||||
Optional<Path> file,
|
||||
String staticBase,
|
||||
String version
|
||||
) {
|
||||
if (file.isEmpty()) {
|
||||
return DesktopAppPlatformReleaseDto.builder().available(false).build();
|
||||
}
|
||||
Path path = file.get();
|
||||
try {
|
||||
long size = Files.size(path);
|
||||
Instant updated = Files.getLastModifiedTime(path).toInstant();
|
||||
String fileName = path.getFileName().toString();
|
||||
return DesktopAppPlatformReleaseDto.builder()
|
||||
.available(true)
|
||||
.fileName(fileName)
|
||||
.version(version)
|
||||
.sizeBytes(size)
|
||||
.updatedAt(DT_FMT.format(updated))
|
||||
.downloadUrl(staticBase + "/" + encodeFileName(fileName))
|
||||
.build();
|
||||
} catch (IOException e) {
|
||||
log.warn("[desktop-app] stat failed: {}", e.getMessage());
|
||||
return DesktopAppPlatformReleaseDto.builder().available(false).build();
|
||||
}
|
||||
}
|
||||
|
||||
private Instant lastModified(Path path) {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toInstant();
|
||||
} catch (IOException e) {
|
||||
return Instant.EPOCH;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveVersion(Optional<Path> macFile, Optional<Path> winFile) {
|
||||
return Stream.of(macFile, winFile)
|
||||
.flatMap(Optional::stream)
|
||||
.map(p -> p.getFileName().toString())
|
||||
.map(this::extractVersionFromFileName)
|
||||
.filter(v -> v != null && !v.isBlank())
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String extractVersionFromFileName(String fileName) {
|
||||
// GoldenChart_0.1.0_aarch64.dmg, GoldenChart_0.1.0_x64-setup.exe
|
||||
var m = java.util.regex.Pattern
|
||||
.compile("GoldenChart[_-](\\d+\\.\\d+\\.\\d+)", java.util.regex.Pattern.CASE_INSENSITIVE)
|
||||
.matcher(fileName);
|
||||
return m.find() ? m.group(1) : null;
|
||||
}
|
||||
|
||||
private Optional<Path> resolveMacInstaller() {
|
||||
Path dir = releaseDirectory();
|
||||
if (dir == null) return Optional.empty();
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
return stream
|
||||
.filter(p -> Files.isRegularFile(p) && p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".dmg"))
|
||||
.max(macComparator());
|
||||
} catch (IOException e) {
|
||||
log.warn("[desktop-app] list dir failed: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<Path> resolveWindowsInstaller() {
|
||||
Path dir = releaseDirectory();
|
||||
if (dir == null) return Optional.empty();
|
||||
try (Stream<Path> stream = Files.list(dir)) {
|
||||
return stream
|
||||
.filter(p -> {
|
||||
String n = p.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
return Files.isRegularFile(p) && n.endsWith(".exe")
|
||||
&& (n.contains("setup") || n.contains("nsis") || n.startsWith("goldenchart"));
|
||||
})
|
||||
.max(windowsComparator());
|
||||
} catch (IOException e) {
|
||||
log.warn("[desktop-app] list dir failed: {}", e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private Comparator<Path> macComparator() {
|
||||
return Comparator
|
||||
.comparingInt((Path p) -> scoreMac(p.getFileName().toString()))
|
||||
.thenComparing(p -> lastModified(p).toEpochMilli());
|
||||
}
|
||||
|
||||
private Comparator<Path> windowsComparator() {
|
||||
return Comparator
|
||||
.comparingInt((Path p) -> scoreWindows(p.getFileName().toString()))
|
||||
.thenComparing(p -> lastModified(p).toEpochMilli());
|
||||
}
|
||||
|
||||
private static int scoreMac(String name) {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("aarch64") || lower.contains("arm64")) return 3;
|
||||
if (lower.contains("universal")) return 2;
|
||||
if (lower.contains("x64") || lower.contains("x86_64")) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int scoreWindows(String name) {
|
||||
String lower = name.toLowerCase(Locale.ROOT);
|
||||
if (lower.contains("setup")) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
private Path releaseDirectory() {
|
||||
Path dir = Paths.get(releaseDir).toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(dir)) {
|
||||
log.debug("[desktop-app] release dir missing: {}", dir);
|
||||
return null;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
private static String encodeFileName(String fileName) {
|
||||
return fileName.replace(" ", "%20");
|
||||
}
|
||||
|
||||
private String resolvePublicBaseUrl(String requestBaseUrl) {
|
||||
if (configuredPublicBaseUrl != null && !configuredPublicBaseUrl.isBlank()) {
|
||||
return configuredPublicBaseUrl.trim();
|
||||
}
|
||||
if (requestBaseUrl != null && !requestBaseUrl.isBlank()) {
|
||||
return requestBaseUrl.trim();
|
||||
}
|
||||
return "http://exdev.co.kr";
|
||||
}
|
||||
|
||||
private static String normalizeBase(String publicBaseUrl) {
|
||||
if (publicBaseUrl == null || publicBaseUrl.isBlank()) {
|
||||
return "http://exdev.co.kr";
|
||||
}
|
||||
return publicBaseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user