diff --git a/docs/README_Migration.md b/docs/README_Migration.md index 802bd09..c173276 100644 --- a/docs/README_Migration.md +++ b/docs/README_Migration.md @@ -9,7 +9,7 @@ - `com.svnlog.SVNLogFetcher` -> `com.svnlog.core.svn.SVNLogFetcher` - `com.svnlog.LogEntry` -> `com.svnlog.core.svn.LogEntry` -- `com.svnlog.TrustAllSSLContext` -> `com.svnlog.core.svn.TrustAllSSLContext` +- `com.svnlog.TrustAllSSLContext` 已移除;如需兼容旧内网 SVN,请使用显式 SSL 兼容开关。 ## 公共能力 diff --git a/docs/README_Web.md b/docs/README_Web.md index 9e04743..437ad79 100644 --- a/docs/README_Web.md +++ b/docs/README_Web.md @@ -83,6 +83,26 @@ http://localhost:18088 `GET /api/settings` 不会回显 `openaiApiKey` 或 `svnPassword` 明文,前端通过 `openaiApiKeyConfigured` 和 `svnCredentialsConfigured` 展示配置状态。 +## 安全与兼容配置 + +- SVN 密码加密密钥不再写死在源码中: + - 默认生成到 `outputs/.svn-log-tool.key` + - 可用 `SVN_LOG_TOOL_CRYPTO_KEY` 或 `-Dsvnlog.crypto.key=...` 指定 Base64 AES 密钥 + - 可用 `SVN_LOG_TOOL_CRYPTO_KEY_FILE` 或 `-Dsvnlog.crypto.keyFile=...` 指定密钥文件 + - 旧版本固定密钥加密的历史配置需要先用显式密钥启动并重新保存配置完成迁移 +- HTTPS/SVN 默认只启用 TLS 1.2,并保留系统默认的证书校验。 +- 如必须连接旧内网 SVN,可显式启用兼容开关: + - `SVN_LOG_TOOL_ALLOW_LEGACY_TLS=true` 或 `-Dsvnlog.svn.allowLegacyTls=true` + - `SVN_LOG_TOOL_ALLOW_INSECURE_SSL=true` 或 `-Dsvnlog.svn.allowInsecureSsl=true` +- 异步任务线程数可配置: + - `SVN_LOG_TOOL_TASK_THREADS=16` 或 `-Dsvnlog.task.threads=16` + - 未配置时默认取 `max(8, CPU核数*2)`。 + +## 前端构建配置 + +- `VITE_API_BASE_URL`:覆盖前端 API Base URL,默认使用同源 `/api`。 +- `VITE_DASHBOARD_POLL_INTERVAL_MS`:工作台轮询间隔,默认 `8000`,最小 `3000`。 + ## SVN 预设来源与调用方式 - SVN 地址统一维护在 `application.properties` 的 `svn.presets[*]` 中。 diff --git a/frontend-vue/index.html b/frontend-vue/index.html index c58f512..45439fd 100644 --- a/frontend-vue/index.html +++ b/frontend-vue/index.html @@ -6,9 +6,6 @@ SVN 日志工作台 v2 - - -
diff --git a/frontend-vue/src/composables/useApi.js b/frontend-vue/src/composables/useApi.js index 6a3ea1b..7f246fa 100644 --- a/frontend-vue/src/composables/useApi.js +++ b/frontend-vue/src/composables/useApi.js @@ -29,8 +29,10 @@ const HTTP_ERRORS = { } export function useApi() { + const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL || '') + async function apiFetch(url, options = {}) { - const res = await fetch(url, { + const res = await fetch(buildApiUrl(url), { headers: { 'Content-Type': 'application/json' }, ...options, }) @@ -44,7 +46,7 @@ export function useApi() { } function buildDownloadUrl(path) { - return `/api/files/download?path=${encodeURIComponent(path || '')}` + return buildApiUrl(`/api/files/download?path=${encodeURIComponent(path || '')}`) } async function downloadFile(path) { @@ -72,5 +74,16 @@ export function useApi() { URL.revokeObjectURL(blobUrl) } + function buildApiUrl(path) { + if (/^https?:\/\//i.test(path)) return path + return `${apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}` + } + return { apiFetch, buildDownloadUrl, downloadFile } } + +function normalizeApiBaseUrl(value) { + const trimmed = String(value || '').trim() + if (!trimmed) return '' + return trimmed.replace(/\/+$/, '') +} diff --git a/frontend-vue/src/styles/main.css b/frontend-vue/src/styles/main.css index 15100aa..c8b457b 100644 --- a/frontend-vue/src/styles/main.css +++ b/frontend-vue/src/styles/main.css @@ -2,7 +2,7 @@ SVN Log Tool v2 — OLED Dark Theme Design System: Dark Mode (OLED) by UI/UX Pro Max Colors: #0F172A bg, #1E293B surface, #22C55E accent - Fonts: Fira Sans (body), Fira Code (heading/data) + Fonts: system UI stack with monospace data display ============================================= */ :root { @@ -29,8 +29,8 @@ --c-info-bg: rgba(59, 130, 246, 0.12); --c-code-bg: #0C1929; - --font-sans: 'Fira Sans', system-ui, -apple-system, sans-serif; - --font-mono: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace; + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'SFMono-Regular', 'Cascadia Code', 'Consolas', monospace; --space-xs: 4px; --space-sm: 8px; diff --git a/frontend-vue/src/views/DashboardView.vue b/frontend-vue/src/views/DashboardView.vue index f9d460a..a5a605d 100644 --- a/frontend-vue/src/views/DashboardView.vue +++ b/frontend-vue/src/views/DashboardView.vue @@ -88,6 +88,7 @@ const { toast } = useToast() const tasks = ref([]) const files = ref([]) const health = ref(null) +const pollIntervalMs = resolvePollInterval() let timer = null const stats = computed(() => ({ @@ -153,7 +154,7 @@ async function cancelTask(taskId) { onMounted(() => { refresh() - timer = setInterval(refresh, 8000) + timer = setInterval(refresh, pollIntervalMs) document.addEventListener('visibilitychange', onVisibilityChange) }) onUnmounted(() => { @@ -166,7 +167,13 @@ function onVisibilityChange() { if (timer) { clearInterval(timer); timer = null } } else { refresh() - if (!timer) timer = setInterval(refresh, 8000) + if (!timer) timer = setInterval(refresh, pollIntervalMs) } } + +function resolvePollInterval() { + const value = Number(import.meta.env.VITE_DASHBOARD_POLL_INTERVAL_MS || 8000) + if (!Number.isFinite(value)) return 8000 + return Math.max(3000, value) +} diff --git a/frontend-vue/vite.config.js b/frontend-vue/vite.config.js index 4475926..5df01db 100644 --- a/frontend-vue/vite.config.js +++ b/frontend-vue/vite.config.js @@ -22,5 +22,12 @@ export default defineConfig({ build: { outDir: resolve(__dirname, '..', 'src', 'main', 'resources', 'static', 'v2'), emptyOutDir: true, + rollupOptions: { + output: { + manualChunks: { + vendor: ['vue', 'vue-router'], + }, + }, + }, }, }) diff --git a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java b/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java index 2192a53..c70f54b 100644 --- a/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java +++ b/src/main/java/com/svnlog/core/svn/SVNLogFetcher.java @@ -27,15 +27,28 @@ public class SVNLogFetcher { private static final TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai"); private static final long DEFAULT_BOUNDARY_PADDING = 50L; private static final long FALLBACK_SCAN_PADDING = 2000L; + private static final String PROP_ALLOW_INSECURE_SSL = "svnlog.svn.allowInsecureSsl"; + private static final String PROP_ALLOW_LEGACY_TLS = "svnlog.svn.allowLegacyTls"; + private static final String ENV_ALLOW_INSECURE_SSL = "SVN_LOG_TOOL_ALLOW_INSECURE_SSL"; + private static final String ENV_ALLOW_LEGACY_TLS = "SVN_LOG_TOOL_ALLOW_LEGACY_TLS"; + private static final ThreadLocal DATE_FORMAT = new ThreadLocal() { + @Override + protected SimpleDateFormat initialValue() { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + } + }; private final SVNRepository repository; - private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); static { // 初始化 SVNKit 工厂(必须在创建 repository 之前调用) DAVRepositoryFactory.setup(); SVNRepositoryFactoryImpl.setup(); - System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); + if (isEnabled(PROP_ALLOW_LEGACY_TLS, ENV_ALLOW_LEGACY_TLS)) { + System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); + } else { + System.setProperty("svnkit.http.sslProtocols", "TLSv1.2"); + } } public SVNLogFetcher(String url, String username, String password) throws SVNException { @@ -47,13 +60,13 @@ public class SVNLogFetcher { password.toCharArray() ); - // 配置认证管理器接受所有 SSL 证书 - if (authManager instanceof org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) { + if (isEnabled(PROP_ALLOW_INSECURE_SSL, ENV_ALLOW_INSECURE_SSL) + && authManager instanceof org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) { org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager defaultAuthManager = (org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) authManager; - // 设置为接受所有 SSL 证书 defaultAuthManager.setAuthenticationForced(true); + LOGGER.warn("SVN insecure SSL authentication mode is enabled by explicit configuration"); } repository.setAuthenticationManager(authManager); @@ -119,7 +132,7 @@ public class SVNLogFetcher { } public String formatDate(Date date) { - return dateFormat.format(date); + return DATE_FORMAT.get().format(date); } public void testConnection() throws SVNException { @@ -283,4 +296,13 @@ public class SVNLogFetcher { } return new long[]{minRevision, maxRevision}; } + + private static boolean isEnabled(String propertyName, String envName) { + final String propertyValue = System.getProperty(propertyName); + if (propertyValue != null && !propertyValue.trim().isEmpty()) { + return Boolean.parseBoolean(propertyValue.trim()); + } + final String envValue = System.getenv(envName); + return envValue != null && Boolean.parseBoolean(envValue.trim()); + } } diff --git a/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java b/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java deleted file mode 100644 index 3a0a6ae..0000000 --- a/src/main/java/com/svnlog/core/svn/TrustAllSSLContext.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.svnlog.core.svn; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.security.cert.X509Certificate; - -/** - * 提供信任所有证书的 SSLContext(仅用于内网 SVN 服务器) - */ -public class TrustAllSSLContext { - - private static SSLContext sslContext; - - static { - try { - TrustManager[] trustAllCerts = new TrustManager[]{ - new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - public void checkClientTrusted(X509Certificate[] certs, String authType) { - } - public void checkServerTrusted(X509Certificate[] certs, String authType) { - } - } - }; - - sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - } catch (Exception e) { - throw new RuntimeException("Failed to initialize TrustAll SSLContext", e); - } - } - - public static SSLContext getInstance() { - return sslContext; - } -} diff --git a/src/main/java/com/svnlog/web/WebApplication.java b/src/main/java/com/svnlog/web/WebApplication.java index dc48156..f661f6b 100644 --- a/src/main/java/com/svnlog/web/WebApplication.java +++ b/src/main/java/com/svnlog/web/WebApplication.java @@ -1,12 +1,13 @@ package com.svnlog.web; +import java.security.cert.X509Certificate; + import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSession; import javax.net.ssl.TrustManager; import javax.net.ssl.X509TrustManager; -import java.security.cert.X509Certificate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -17,23 +18,55 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; public class WebApplication { private static final Logger LOGGER = LoggerFactory.getLogger(WebApplication.class); + private static final String PROP_ALLOW_INSECURE_SSL = "svnlog.svn.allowInsecureSsl"; + private static final String PROP_ALLOW_LEGACY_TLS = "svnlog.svn.allowLegacyTls"; + private static final String ENV_ALLOW_INSECURE_SSL = "SVN_LOG_TOOL_ALLOW_INSECURE_SSL"; + private static final String ENV_ALLOW_LEGACY_TLS = "SVN_LOG_TOOL_ALLOW_LEGACY_TLS"; static { - // 配置 Java 全局 SSL 上下文(用于内网 SVN 服务器) + configureTlsProtocols(); + configureInsecureSslIfEnabled(); + } + + public static void main(String[] args) { + SpringApplication.run(WebApplication.class, args); + } + + private static void configureTlsProtocols() { + if (!isEnabled(PROP_ALLOW_LEGACY_TLS, ENV_ALLOW_LEGACY_TLS)) { + System.setProperty("https.protocols", "TLSv1.2"); + System.setProperty("jdk.tls.client.protocols", "TLSv1.2"); + System.setProperty("svnkit.http.sslProtocols", "TLSv1.2"); + return; + } + try { - // 移除 TLSv1 和 TLSv1.1 的禁用限制 String disabledAlgorithms = java.security.Security.getProperty("jdk.tls.disabledAlgorithms"); if (disabledAlgorithms != null && (disabledAlgorithms.contains("TLSv1") || disabledAlgorithms.contains("TLSv1.1"))) { disabledAlgorithms = disabledAlgorithms - .replaceAll("TLSv1\\.1,\\s*", "") - .replaceAll("TLSv1,\\s*", "") - .replaceAll(",\\s*TLSv1\\.1", "") - .replaceAll(",\\s*TLSv1", ""); + .replaceAll("TLSv1\\.1,\\s*", "") + .replaceAll("TLSv1,\\s*", "") + .replaceAll(",\\s*TLSv1\\.1", "") + .replaceAll(",\\s*TLSv1", ""); java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms); LOGGER.info("TLS configuration updated: {}", disabledAlgorithms); } + } catch (Exception e) { + LOGGER.warn("Failed to update TLS disabled algorithms: {}", e.getMessage()); + } - // 配置信任所有证书的 SSL 上下文 + System.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1"); + System.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1"); + System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); + LOGGER.warn("Legacy TLS protocols enabled by explicit configuration"); + } + + private static void configureInsecureSslIfEnabled() { + if (!isEnabled(PROP_ALLOW_INSECURE_SSL, ENV_ALLOW_INSECURE_SSL)) { + return; + } + + try { TrustManager[] trustAllCerts = new TrustManager[]{ new X509TrustManager() { public X509Certificate[] getAcceptedIssuers() { @@ -49,7 +82,6 @@ public class WebApplication { SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); - // 设置为默认 SSL 上下文 SSLContext.setDefault(sslContext); HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() { @@ -58,18 +90,18 @@ public class WebApplication { } }); - LOGGER.info("SSL context configured to trust all certificates"); + LOGGER.warn("Insecure SSL trust-all mode is enabled by explicit configuration"); } catch (Exception e) { - LOGGER.warn("Failed to configure SSL context: {}", e.getMessage()); + LOGGER.warn("Failed to configure insecure SSL mode: {}", e.getMessage()); } - - // 配置 TLS 协议版本 - System.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1"); - System.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1"); - System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); } - public static void main(String[] args) { - SpringApplication.run(WebApplication.class, args); + private static boolean isEnabled(String propertyName, String envName) { + final String propertyValue = System.getProperty(propertyName); + if (propertyValue != null && !propertyValue.trim().isEmpty()) { + return Boolean.parseBoolean(propertyValue.trim()); + } + final String envValue = System.getenv(envName); + return envValue != null && Boolean.parseBoolean(envValue.trim()); } } diff --git a/src/main/java/com/svnlog/web/service/TaskPersistenceService.java b/src/main/java/com/svnlog/web/service/TaskPersistenceService.java index 39a82a5..6ddff2a 100644 --- a/src/main/java/com/svnlog/web/service/TaskPersistenceService.java +++ b/src/main/java/com/svnlog/web/service/TaskPersistenceService.java @@ -76,8 +76,6 @@ public class TaskPersistenceService { info.progress = task.getProgress(); info.message = task.getMessage(); info.error = task.getError(); - info.aiReasoningText = task.getAiReasoningText(); - info.aiAnswerText = task.getAiAnswerText(); info.aiStreamStatus = task.getAiStreamStatus(); info.createdAt = toString(task.getCreatedAt()); info.updatedAt = toString(task.getUpdatedAt()); @@ -93,8 +91,8 @@ public class TaskPersistenceService { task.setProgress(persisted.progress); task.setMessage(persisted.message); task.setError(persisted.error); - task.setAiReasoningText(persisted.aiReasoningText); - task.setAiAnswerText(persisted.aiAnswerText); + task.setAiReasoningText(""); + task.setAiAnswerText(""); task.setAiStreamStatus(persisted.aiStreamStatus); task.setCreatedAt(parseInstant(persisted.createdAt)); task.setUpdatedAt(parseInstant(persisted.updatedAt)); @@ -137,8 +135,6 @@ public class TaskPersistenceService { private int progress; private String message; private String error; - private String aiReasoningText; - private String aiAnswerText; private String aiStreamStatus; private String createdAt; private String updatedAt; diff --git a/src/main/java/com/svnlog/web/service/TaskService.java b/src/main/java/com/svnlog/web/service/TaskService.java index f729f20..92cb3d6 100644 --- a/src/main/java/com/svnlog/web/service/TaskService.java +++ b/src/main/java/com/svnlog/web/service/TaskService.java @@ -36,7 +36,10 @@ public class TaskService { TaskResult run(TaskContext context) throws Exception; } - private final ExecutorService executor = Executors.newFixedThreadPool(4); + private static final String EXECUTOR_THREADS_PROPERTY = "svnlog.task.threads"; + private static final String EXECUTOR_THREADS_ENV = "SVN_LOG_TOOL_TASK_THREADS"; + + private final ExecutorService executor = Executors.newFixedThreadPool(resolveExecutorThreads()); private final Map tasks = new ConcurrentHashMap(); private final Map> futures = new ConcurrentHashMap>(); private final Map> taskEmitters = @@ -99,10 +102,11 @@ public class TaskService { final int safePage = Math.max(page, 1); final int safeSize = Math.max(1, Math.min(size, 200)); - final List filtered = getTasks().stream() + final List filtered = tasks.values().stream() .filter(task -> matchStatus(task, status)) .filter(task -> matchType(task, type)) .filter(task -> matchKeyword(task, keyword)) + .sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed()) .collect(Collectors.toList()); int fromIndex = (safePage - 1) * safeSize; @@ -254,7 +258,7 @@ public class TaskService { persistenceService.save(buildStorePath(), tasks.values()); } catch (Exception e) { LOGGER.warn("Failed to persist task history; in-memory state intact. Reason: {}", - e.getMessage()); + e.getMessage(), e); } } @@ -325,6 +329,7 @@ public class TaskService { try { emitter.send(SseEmitter.event().name(eventName).data(payload == null ? new HashMap() : payload)); } catch (Exception sendException) { + LOGGER.debug("SSE event send failed: taskId={} event={}", taskId, eventName, sendException); removeEmitter(taskId, emitter); } } @@ -338,8 +343,8 @@ public class TaskService { for (SseEmitter emitter : emitters) { try { emitter.complete(); - } catch (Exception ignored) { - // ignore completion failures + } catch (Exception completionException) { + LOGGER.debug("SSE stream completion failed: taskId={}", taskId, completionException); } } } @@ -387,4 +392,29 @@ public class TaskService { public void destroy() { executor.shutdownNow(); } + + private static int resolveExecutorThreads() { + final int configured = parsePositiveInt(System.getProperty(EXECUTOR_THREADS_PROPERTY)); + if (configured > 0) { + return configured; + } + final int fromEnv = parsePositiveInt(System.getenv(EXECUTOR_THREADS_ENV)); + if (fromEnv > 0) { + return fromEnv; + } + final int processors = Runtime.getRuntime().availableProcessors(); + return Math.max(8, processors * 2); + } + + private static int parsePositiveInt(String value) { + if (value == null || value.trim().isEmpty()) { + return 0; + } + try { + final int parsed = Integer.parseInt(value.trim()); + return parsed > 0 ? parsed : 0; + } catch (NumberFormatException e) { + return 0; + } + } } diff --git a/src/main/java/com/svnlog/web/util/CryptoUtils.java b/src/main/java/com/svnlog/web/util/CryptoUtils.java index 6b81de2..db9d473 100644 --- a/src/main/java/com/svnlog/web/util/CryptoUtils.java +++ b/src/main/java/com/svnlog/web/util/CryptoUtils.java @@ -1,8 +1,15 @@ package com.svnlog.web.util; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.PosixFilePermission; import java.security.SecureRandom; import java.util.Base64; +import java.util.EnumSet; +import java.util.Set; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; @@ -13,7 +20,12 @@ public final class CryptoUtils { private static final String ALGORITHM = "AES"; private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final int IV_LENGTH = 16; - private static final String SECRET_KEY_BASE64 = "U3ZuTG9nVG9vbFNlY3JldA=="; + private static final int KEY_LENGTH = 32; + private static final String KEY_ENV = "SVN_LOG_TOOL_CRYPTO_KEY"; + private static final String KEY_FILE_ENV = "SVN_LOG_TOOL_CRYPTO_KEY_FILE"; + private static final String KEY_PROPERTY = "svnlog.crypto.key"; + private static final String KEY_FILE_PROPERTY = "svnlog.crypto.keyFile"; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private CryptoUtils() { } @@ -23,10 +35,10 @@ public final class CryptoUtils { return ""; } try { - final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64); + final byte[] keyBytes = resolveKeyBytes(); final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); final byte[] iv = new byte[IV_LENGTH]; - new SecureRandom().nextBytes(iv); + SECURE_RANDOM.nextBytes(iv); final IvParameterSpec ivSpec = new IvParameterSpec(iv); final Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); @@ -53,14 +65,84 @@ public final class CryptoUtils { final byte[] encrypted = new byte[combined.length - IV_LENGTH]; System.arraycopy(combined, 0, iv, 0, IV_LENGTH); System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length); - final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64); - final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); - final IvParameterSpec ivSpec = new IvParameterSpec(iv); - final Cipher cipher = Cipher.getInstance(TRANSFORMATION); - cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); - return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + return decryptWithKey(resolveKeyBytes(), iv, encrypted); } catch (Exception e) { throw new IllegalStateException("解密失败: " + e.getMessage(), e); } } + + private static String decryptWithKey(byte[] keyBytes, byte[] iv, byte[] encrypted) throws Exception { + final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); + final IvParameterSpec ivSpec = new IvParameterSpec(iv); + final Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } + + private static byte[] resolveKeyBytes() throws IOException { + final String configuredKey = firstNonBlank(System.getProperty(KEY_PROPERTY), System.getenv(KEY_ENV)); + if (!configuredKey.isEmpty()) { + return decodeAndValidateKey(configuredKey); + } + + final Path keyFile = resolveKeyFile(); + if (Files.exists(keyFile) && Files.isRegularFile(keyFile)) { + return decodeAndValidateKey(new String(Files.readAllBytes(keyFile), StandardCharsets.UTF_8)); + } + + final byte[] keyBytes = new byte[KEY_LENGTH]; + SECURE_RANDOM.nextBytes(keyBytes); + writeKeyFile(keyFile, Base64.getEncoder().encodeToString(keyBytes)); + return keyBytes; + } + + private static Path resolveKeyFile() { + final String configuredPath = firstNonBlank(System.getProperty(KEY_FILE_PROPERTY), System.getenv(KEY_FILE_ENV)); + if (!configuredPath.isEmpty()) { + return Paths.get(configuredPath).toAbsolutePath().normalize(); + } + return Paths.get(System.getProperty("user.dir"), "outputs", ".svn-log-tool.key") + .toAbsolutePath() + .normalize(); + } + + private static void writeKeyFile(Path keyFile, String encodedKey) throws IOException { + if (keyFile.getParent() != null) { + Files.createDirectories(keyFile.getParent()); + } + Files.write(keyFile, encodedKey.getBytes(StandardCharsets.UTF_8)); + restrictOwnerOnly(keyFile); + } + + private static void restrictOwnerOnly(Path keyFile) { + try { + final Set permissions = EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE + ); + Files.setPosixFilePermissions(keyFile, permissions); + } catch (UnsupportedOperationException e) { + // Non-POSIX file systems do not support Unix permissions. + } catch (IOException e) { + throw new IllegalStateException("设置密钥文件权限失败: " + e.getMessage(), e); + } + } + + private static byte[] decodeAndValidateKey(String encodedKey) { + final byte[] keyBytes = Base64.getDecoder().decode(encodedKey.trim()); + if (keyBytes.length != 16 && keyBytes.length != 24 && keyBytes.length != 32) { + throw new IllegalStateException("AES 密钥长度必须为 16、24 或 32 字节"); + } + return keyBytes; + } + + private static String firstNonBlank(String first, String second) { + if (first != null && !first.trim().isEmpty()) { + return first.trim(); + } + if (second != null && !second.trim().isEmpty()) { + return second.trim(); + } + return ""; + } } diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index 02e47be..d223629 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -1,4 +1,4 @@ -SVN 日志工作台

工作台

查看系统状态与最近产物

任务总数 0
执行中 0
失败任务 0
系统状态 -
健康检查详情 加载中...

最近任务

    最近文件

      SVN 批量抓取参数

      默认已填充 3 个常用项目路径,可选择月份自动填充版本号,或手动填写。

      智能版本号辅助

      项目 1:PRS-7050 场站智慧管控

      项目 2:PRS-7950 在线巡视

      项目 3:PRS-7950 在线巡视电科院测试版

      执行进度面板

      AI 思考过程

      等待思考输出...

      最终分析输出

      等待答案输出...

      系统控制台

      等待任务开始...

      任务列表

      输出文件归档

      系统配置