fix: harden security and tune task performance

This commit is contained in:
liumangmang
2026-06-10 20:42:17 +08:00
parent 42214b33e3
commit 409c5a81e4
16 changed files with 302 additions and 95 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
- `com.svnlog.SVNLogFetcher` -> `com.svnlog.core.svn.SVNLogFetcher` - `com.svnlog.SVNLogFetcher` -> `com.svnlog.core.svn.SVNLogFetcher`
- `com.svnlog.LogEntry` -> `com.svnlog.core.svn.LogEntry` - `com.svnlog.LogEntry` -> `com.svnlog.core.svn.LogEntry`
- `com.svnlog.TrustAllSSLContext` -> `com.svnlog.core.svn.TrustAllSSLContext` - `com.svnlog.TrustAllSSLContext` 已移除;如需兼容旧内网 SVN,请使用显式 SSL 兼容开关。
## 公共能力 ## 公共能力
+20
View File
@@ -83,6 +83,26 @@ http://localhost:18088
`GET /api/settings` 不会回显 `openaiApiKey``svnPassword` 明文,前端通过 `openaiApiKeyConfigured``svnCredentialsConfigured` 展示配置状态。 `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 预设来源与调用方式
- SVN 地址统一维护在 `application.properties``svn.presets[*]` 中。 - SVN 地址统一维护在 `application.properties``svn.presets[*]` 中。
-3
View File
@@ -6,9 +6,6 @@
<meta name="color-scheme" content="dark" /> <meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0F172A" /> <meta name="theme-color" content="#0F172A" />
<title>SVN 日志工作台 v2</title> <title>SVN 日志工作台 v2</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;400;500;600;700&family=Fira+Code:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+15 -2
View File
@@ -29,8 +29,10 @@ const HTTP_ERRORS = {
} }
export function useApi() { export function useApi() {
const apiBaseUrl = normalizeApiBaseUrl(import.meta.env.VITE_API_BASE_URL || '')
async function apiFetch(url, options = {}) { async function apiFetch(url, options = {}) {
const res = await fetch(url, { const res = await fetch(buildApiUrl(url), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
...options, ...options,
}) })
@@ -44,7 +46,7 @@ export function useApi() {
} }
function buildDownloadUrl(path) { function buildDownloadUrl(path) {
return `/api/files/download?path=${encodeURIComponent(path || '')}` return buildApiUrl(`/api/files/download?path=${encodeURIComponent(path || '')}`)
} }
async function downloadFile(path) { async function downloadFile(path) {
@@ -72,5 +74,16 @@ export function useApi() {
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
function buildApiUrl(path) {
if (/^https?:\/\//i.test(path)) return path
return `${apiBaseUrl}${path.startsWith('/') ? path : `/${path}`}`
}
return { apiFetch, buildDownloadUrl, downloadFile } return { apiFetch, buildDownloadUrl, downloadFile }
} }
function normalizeApiBaseUrl(value) {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
return trimmed.replace(/\/+$/, '')
}
+3 -3
View File
@@ -2,7 +2,7 @@
SVN Log Tool v2 — OLED Dark Theme SVN Log Tool v2 — OLED Dark Theme
Design System: Dark Mode (OLED) by UI/UX Pro Max Design System: Dark Mode (OLED) by UI/UX Pro Max
Colors: #0F172A bg, #1E293B surface, #22C55E accent Colors: #0F172A bg, #1E293B surface, #22C55E accent
Fonts: Fira Sans (body), Fira Code (heading/data) Fonts: system UI stack with monospace data display
============================================= */ ============================================= */
:root { :root {
@@ -29,8 +29,8 @@
--c-info-bg: rgba(59, 130, 246, 0.12); --c-info-bg: rgba(59, 130, 246, 0.12);
--c-code-bg: #0C1929; --c-code-bg: #0C1929;
--font-sans: 'Fira Sans', system-ui, -apple-system, sans-serif; --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'Fira Code', 'JetBrains Mono', 'Consolas', monospace; --font-mono: 'SFMono-Regular', 'Cascadia Code', 'Consolas', monospace;
--space-xs: 4px; --space-xs: 4px;
--space-sm: 8px; --space-sm: 8px;
+9 -2
View File
@@ -88,6 +88,7 @@ const { toast } = useToast()
const tasks = ref([]) const tasks = ref([])
const files = ref([]) const files = ref([])
const health = ref(null) const health = ref(null)
const pollIntervalMs = resolvePollInterval()
let timer = null let timer = null
const stats = computed(() => ({ const stats = computed(() => ({
@@ -153,7 +154,7 @@ async function cancelTask(taskId) {
onMounted(() => { onMounted(() => {
refresh() refresh()
timer = setInterval(refresh, 8000) timer = setInterval(refresh, pollIntervalMs)
document.addEventListener('visibilitychange', onVisibilityChange) document.addEventListener('visibilitychange', onVisibilityChange)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -166,7 +167,13 @@ function onVisibilityChange() {
if (timer) { clearInterval(timer); timer = null } if (timer) { clearInterval(timer); timer = null }
} else { } else {
refresh() 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)
}
</script> </script>
+7
View File
@@ -22,5 +22,12 @@ export default defineConfig({
build: { build: {
outDir: resolve(__dirname, '..', 'src', 'main', 'resources', 'static', 'v2'), outDir: resolve(__dirname, '..', 'src', 'main', 'resources', 'static', 'v2'),
emptyOutDir: true, emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router'],
},
},
},
}, },
}) })
@@ -27,15 +27,28 @@ public class SVNLogFetcher {
private static final TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai"); private static final TimeZone RANGE_TIME_ZONE = TimeZone.getTimeZone("Asia/Shanghai");
private static final long DEFAULT_BOUNDARY_PADDING = 50L; private static final long DEFAULT_BOUNDARY_PADDING = 50L;
private static final long FALLBACK_SCAN_PADDING = 2000L; 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<SimpleDateFormat> DATE_FORMAT = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
private final SVNRepository repository; private final SVNRepository repository;
private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
static { static {
// 初始化 SVNKit 工厂(必须在创建 repository 之前调用) // 初始化 SVNKit 工厂(必须在创建 repository 之前调用)
DAVRepositoryFactory.setup(); DAVRepositoryFactory.setup();
SVNRepositoryFactoryImpl.setup(); SVNRepositoryFactoryImpl.setup();
if (isEnabled(PROP_ALLOW_LEGACY_TLS, ENV_ALLOW_LEGACY_TLS)) {
System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); 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 { public SVNLogFetcher(String url, String username, String password) throws SVNException {
@@ -47,13 +60,13 @@ public class SVNLogFetcher {
password.toCharArray() password.toCharArray()
); );
// 配置认证管理器接受所有 SSL 证书 if (isEnabled(PROP_ALLOW_INSECURE_SSL, ENV_ALLOW_INSECURE_SSL)
if (authManager instanceof org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) { && authManager instanceof org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) {
org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager defaultAuthManager = org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager defaultAuthManager =
(org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) authManager; (org.tmatesoft.svn.core.internal.wc.DefaultSVNAuthenticationManager) authManager;
// 设置为接受所有 SSL 证书
defaultAuthManager.setAuthenticationForced(true); defaultAuthManager.setAuthenticationForced(true);
LOGGER.warn("SVN insecure SSL authentication mode is enabled by explicit configuration");
} }
repository.setAuthenticationManager(authManager); repository.setAuthenticationManager(authManager);
@@ -119,7 +132,7 @@ public class SVNLogFetcher {
} }
public String formatDate(Date date) { public String formatDate(Date date) {
return dateFormat.format(date); return DATE_FORMAT.get().format(date);
} }
public void testConnection() throws SVNException { public void testConnection() throws SVNException {
@@ -283,4 +296,13 @@ public class SVNLogFetcher {
} }
return new long[]{minRevision, maxRevision}; 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());
}
} }
@@ -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;
}
}
@@ -1,12 +1,13 @@
package com.svnlog.web; package com.svnlog.web;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager; import javax.net.ssl.X509TrustManager;
import java.security.cert.X509Certificate;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -17,11 +18,29 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
public class WebApplication { public class WebApplication {
private static final Logger LOGGER = LoggerFactory.getLogger(WebApplication.class); 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 { 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 { try {
// 移除 TLSv1 和 TLSv1.1 的禁用限制
String disabledAlgorithms = java.security.Security.getProperty("jdk.tls.disabledAlgorithms"); String disabledAlgorithms = java.security.Security.getProperty("jdk.tls.disabledAlgorithms");
if (disabledAlgorithms != null && (disabledAlgorithms.contains("TLSv1") || disabledAlgorithms.contains("TLSv1.1"))) { if (disabledAlgorithms != null && (disabledAlgorithms.contains("TLSv1") || disabledAlgorithms.contains("TLSv1.1"))) {
disabledAlgorithms = disabledAlgorithms disabledAlgorithms = disabledAlgorithms
@@ -32,8 +51,22 @@ public class WebApplication {
java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms); java.security.Security.setProperty("jdk.tls.disabledAlgorithms", disabledAlgorithms);
LOGGER.info("TLS configuration updated: {}", 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[]{ TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() { new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() { public X509Certificate[] getAcceptedIssuers() {
@@ -49,7 +82,6 @@ public class WebApplication {
SSLContext sslContext = SSLContext.getInstance("TLS"); SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom()); sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
// 设置为默认 SSL 上下文
SSLContext.setDefault(sslContext); SSLContext.setDefault(sslContext);
HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory()); HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());
HttpsURLConnection.setDefaultHostnameVerifier(new HostnameVerifier() { 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) { } catch (Exception e) {
LOGGER.warn("Failed to configure SSL context: {}", e.getMessage()); LOGGER.warn("Failed to configure insecure SSL mode: {}", e.getMessage());
}
} }
// 配置 TLS 协议版本 private static boolean isEnabled(String propertyName, String envName) {
System.setProperty("https.protocols", "TLSv1.2,TLSv1.1,TLSv1"); final String propertyValue = System.getProperty(propertyName);
System.setProperty("jdk.tls.client.protocols", "TLSv1.2,TLSv1.1,TLSv1"); if (propertyValue != null && !propertyValue.trim().isEmpty()) {
System.setProperty("svnkit.http.sslProtocols", "TLSv1.2,TLSv1.1,TLSv1"); return Boolean.parseBoolean(propertyValue.trim());
} }
final String envValue = System.getenv(envName);
public static void main(String[] args) { return envValue != null && Boolean.parseBoolean(envValue.trim());
SpringApplication.run(WebApplication.class, args);
} }
} }
@@ -76,8 +76,6 @@ public class TaskPersistenceService {
info.progress = task.getProgress(); info.progress = task.getProgress();
info.message = task.getMessage(); info.message = task.getMessage();
info.error = task.getError(); info.error = task.getError();
info.aiReasoningText = task.getAiReasoningText();
info.aiAnswerText = task.getAiAnswerText();
info.aiStreamStatus = task.getAiStreamStatus(); info.aiStreamStatus = task.getAiStreamStatus();
info.createdAt = toString(task.getCreatedAt()); info.createdAt = toString(task.getCreatedAt());
info.updatedAt = toString(task.getUpdatedAt()); info.updatedAt = toString(task.getUpdatedAt());
@@ -93,8 +91,8 @@ public class TaskPersistenceService {
task.setProgress(persisted.progress); task.setProgress(persisted.progress);
task.setMessage(persisted.message); task.setMessage(persisted.message);
task.setError(persisted.error); task.setError(persisted.error);
task.setAiReasoningText(persisted.aiReasoningText); task.setAiReasoningText("");
task.setAiAnswerText(persisted.aiAnswerText); task.setAiAnswerText("");
task.setAiStreamStatus(persisted.aiStreamStatus); task.setAiStreamStatus(persisted.aiStreamStatus);
task.setCreatedAt(parseInstant(persisted.createdAt)); task.setCreatedAt(parseInstant(persisted.createdAt));
task.setUpdatedAt(parseInstant(persisted.updatedAt)); task.setUpdatedAt(parseInstant(persisted.updatedAt));
@@ -137,8 +135,6 @@ public class TaskPersistenceService {
private int progress; private int progress;
private String message; private String message;
private String error; private String error;
private String aiReasoningText;
private String aiAnswerText;
private String aiStreamStatus; private String aiStreamStatus;
private String createdAt; private String createdAt;
private String updatedAt; private String updatedAt;
@@ -36,7 +36,10 @@ public class TaskService {
TaskResult run(TaskContext context) throws Exception; 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<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>(); private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>(); private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
private final Map<String, CopyOnWriteArrayList<SseEmitter>> taskEmitters = private final Map<String, CopyOnWriteArrayList<SseEmitter>> taskEmitters =
@@ -99,10 +102,11 @@ public class TaskService {
final int safePage = Math.max(page, 1); final int safePage = Math.max(page, 1);
final int safeSize = Math.max(1, Math.min(size, 200)); final int safeSize = Math.max(1, Math.min(size, 200));
final List<TaskInfo> filtered = getTasks().stream() final List<TaskInfo> filtered = tasks.values().stream()
.filter(task -> matchStatus(task, status)) .filter(task -> matchStatus(task, status))
.filter(task -> matchType(task, type)) .filter(task -> matchType(task, type))
.filter(task -> matchKeyword(task, keyword)) .filter(task -> matchKeyword(task, keyword))
.sorted(Comparator.comparing(TaskInfo::getCreatedAt).reversed())
.collect(Collectors.toList()); .collect(Collectors.toList());
int fromIndex = (safePage - 1) * safeSize; int fromIndex = (safePage - 1) * safeSize;
@@ -254,7 +258,7 @@ public class TaskService {
persistenceService.save(buildStorePath(), tasks.values()); persistenceService.save(buildStorePath(), tasks.values());
} catch (Exception e) { } catch (Exception e) {
LOGGER.warn("Failed to persist task history; in-memory state intact. Reason: {}", 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 { try {
emitter.send(SseEmitter.event().name(eventName).data(payload == null ? new HashMap<String, Object>() : payload)); emitter.send(SseEmitter.event().name(eventName).data(payload == null ? new HashMap<String, Object>() : payload));
} catch (Exception sendException) { } catch (Exception sendException) {
LOGGER.debug("SSE event send failed: taskId={} event={}", taskId, eventName, sendException);
removeEmitter(taskId, emitter); removeEmitter(taskId, emitter);
} }
} }
@@ -338,8 +343,8 @@ public class TaskService {
for (SseEmitter emitter : emitters) { for (SseEmitter emitter : emitters) {
try { try {
emitter.complete(); emitter.complete();
} catch (Exception ignored) { } catch (Exception completionException) {
// ignore completion failures LOGGER.debug("SSE stream completion failed: taskId={}", taskId, completionException);
} }
} }
} }
@@ -387,4 +392,29 @@ public class TaskService {
public void destroy() { public void destroy() {
executor.shutdownNow(); 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;
}
}
} }
@@ -1,8 +1,15 @@
package com.svnlog.web.util; package com.svnlog.web.util;
import java.io.IOException;
import java.nio.charset.StandardCharsets; 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.security.SecureRandom;
import java.util.Base64; import java.util.Base64;
import java.util.EnumSet;
import java.util.Set;
import javax.crypto.Cipher; import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.IvParameterSpec;
@@ -13,7 +20,12 @@ public final class CryptoUtils {
private static final String ALGORITHM = "AES"; private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding"; private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
private static final int IV_LENGTH = 16; 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() { private CryptoUtils() {
} }
@@ -23,10 +35,10 @@ public final class CryptoUtils {
return ""; return "";
} }
try { try {
final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64); final byte[] keyBytes = resolveKeyBytes();
final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM); final SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
final byte[] iv = new byte[IV_LENGTH]; final byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv); SECURE_RANDOM.nextBytes(iv);
final IvParameterSpec ivSpec = new IvParameterSpec(iv); final IvParameterSpec ivSpec = new IvParameterSpec(iv);
final Cipher cipher = Cipher.getInstance(TRANSFORMATION); final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
@@ -53,14 +65,84 @@ public final class CryptoUtils {
final byte[] encrypted = new byte[combined.length - IV_LENGTH]; final byte[] encrypted = new byte[combined.length - IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, IV_LENGTH); System.arraycopy(combined, 0, iv, 0, IV_LENGTH);
System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length); System.arraycopy(combined, IV_LENGTH, encrypted, 0, encrypted.length);
final byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY_BASE64); 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 SecretKeySpec keySpec = new SecretKeySpec(keyBytes, ALGORITHM);
final IvParameterSpec ivSpec = new IvParameterSpec(iv); final IvParameterSpec ivSpec = new IvParameterSpec(iv);
final Cipher cipher = Cipher.getInstance(TRANSFORMATION); final Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
} catch (Exception e) { }
throw new IllegalStateException("解密失败: " + e.getMessage(), e);
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<PosixFilePermission> 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 "";
}
} }
File diff suppressed because one or more lines are too long
@@ -29,14 +29,22 @@ public class TaskPersistenceServiceTest {
task.setError(""); task.setError("");
task.setCreatedAt(Instant.parse("2026-03-01T10:00:00Z")); task.setCreatedAt(Instant.parse("2026-03-01T10:00:00Z"));
task.setUpdatedAt(Instant.parse("2026-03-01T10:05:00Z")); task.setUpdatedAt(Instant.parse("2026-03-01T10:05:00Z"));
task.setAiReasoningText("reasoning text should stay out of history");
task.setAiAnswerText("answer text should stay out of history");
task.getFiles().add("md/a.md"); task.getFiles().add("md/a.md");
service.save(storePath, Arrays.asList(task)); service.save(storePath, Arrays.asList(task));
String persistedJson = new String(Files.readAllBytes(storePath), java.nio.charset.StandardCharsets.UTF_8);
Assertions.assertFalse(persistedJson.contains("reasoning text should stay out of history"));
Assertions.assertFalse(persistedJson.contains("answer text should stay out of history"));
List<TaskInfo> loaded = service.load(storePath); List<TaskInfo> loaded = service.load(storePath);
Assertions.assertEquals(1, loaded.size()); Assertions.assertEquals(1, loaded.size());
Assertions.assertEquals("task-1", loaded.get(0).getTaskId()); Assertions.assertEquals("task-1", loaded.get(0).getTaskId());
Assertions.assertEquals(TaskStatus.SUCCESS, loaded.get(0).getStatus()); Assertions.assertEquals(TaskStatus.SUCCESS, loaded.get(0).getStatus());
Assertions.assertEquals("", loaded.get(0).getAiReasoningText());
Assertions.assertEquals("", loaded.get(0).getAiAnswerText());
Assertions.assertEquals(1, loaded.get(0).getFiles().size()); Assertions.assertEquals(1, loaded.get(0).getFiles().size());
Assertions.assertEquals("md/a.md", loaded.get(0).getFiles().get(0)); Assertions.assertEquals("md/a.md", loaded.get(0).getFiles().get(0));
} }
@@ -0,0 +1,32 @@
package com.svnlog.web.util;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
public class CryptoUtilsTest {
@TempDir
Path tempDir;
@AfterEach
public void clearCryptoProperties() {
System.clearProperty("svnlog.crypto.key");
System.clearProperty("svnlog.crypto.keyFile");
}
@Test
public void shouldEncryptAndDecryptWithGeneratedKeyFile() throws Exception {
Path keyFile = tempDir.resolve("crypto.key");
System.setProperty("svnlog.crypto.keyFile", keyFile.toString());
String encrypted = CryptoUtils.encrypt("secret-value");
Assertions.assertNotEquals("secret-value", encrypted);
Assertions.assertTrue(Files.exists(keyFile));
Assertions.assertEquals("secret-value", CryptoUtils.decrypt(encrypted));
}
}