fix: harden security and tune task performance
This commit is contained in:
@@ -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 兼容开关。
|
||||
|
||||
## 公共能力
|
||||
|
||||
|
||||
@@ -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[*]` 中。
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0F172A" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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(/\/+$/, '')
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<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 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, TaskInfo> tasks = new ConcurrentHashMap<String, TaskInfo>();
|
||||
private final Map<String, Future<?>> futures = new ConcurrentHashMap<String, Future<?>>();
|
||||
private final Map<String, CopyOnWriteArrayList<SseEmitter>> 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<TaskInfo> filtered = getTasks().stream()
|
||||
final List<TaskInfo> 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<String, Object>() : 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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.setCreatedAt(Instant.parse("2026-03-01T10:00: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");
|
||||
|
||||
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);
|
||||
Assertions.assertEquals(1, loaded.size());
|
||||
Assertions.assertEquals("task-1", loaded.get(0).getTaskId());
|
||||
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("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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user