This commit is contained in:
@@ -15,6 +15,7 @@ public class UpdateProperties {
|
||||
private String token = "";
|
||||
private String helperCommand = "";
|
||||
private String stateFile = "./runtime/update-state.json";
|
||||
private String configFile = "";
|
||||
private boolean allowInsecureHttp;
|
||||
private int requestTimeoutSeconds = 15;
|
||||
|
||||
@@ -74,6 +75,14 @@ public class UpdateProperties {
|
||||
this.stateFile = stateFile;
|
||||
}
|
||||
|
||||
public String getConfigFile() {
|
||||
return configFile;
|
||||
}
|
||||
|
||||
public void setConfigFile(String configFile) {
|
||||
this.configFile = configFile;
|
||||
}
|
||||
|
||||
public boolean isAllowInsecureHttp() {
|
||||
return allowInsecureHttp;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.kaidi.oa.service;
|
||||
|
||||
import com.kaidi.oa.common.ApiException;
|
||||
import com.kaidi.oa.config.UpdateProperties;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Persists the administrator-managed updater source without exposing credentials. */
|
||||
@Service
|
||||
public class SystemUpdateConfigService {
|
||||
|
||||
private static final long MAX_CONFIG_BYTES = 1024 * 1024;
|
||||
private static final Pattern REPOSITORY_PATTERN = Pattern.compile(
|
||||
"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$");
|
||||
private static final Pattern TOKEN_PATTERN = Pattern.compile("^[A-Za-z0-9._-]{1,512}$");
|
||||
private static final Set<String> CHANNELS = Set.of("stable", "preview");
|
||||
|
||||
private final UpdateProperties properties;
|
||||
|
||||
public SystemUpdateConfigService(UpdateProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
public synchronized UpdateConfig get() {
|
||||
return new UpdateConfig(
|
||||
properties.isEnabled(),
|
||||
value(properties.getGiteaBaseUrl()),
|
||||
value(properties.getRepository()),
|
||||
normalizedChannel(properties.getChannel()),
|
||||
properties.getToken() != null && !properties.getToken().isBlank(),
|
||||
properties.isAllowInsecureHttp()
|
||||
);
|
||||
}
|
||||
|
||||
public synchronized UpdateConfig save(UpdateConfigRequest request) {
|
||||
if (request == null) {
|
||||
throw new ApiException(400, "更新配置不能为空");
|
||||
}
|
||||
|
||||
String baseUrl = normalizeBaseUrl(request.giteaBaseUrl(), request.allowInsecureHttp(), request.enabled());
|
||||
String repository = normalizeRepository(request.repository());
|
||||
String channel = normalizedChannel(request.channel());
|
||||
String token = value(properties.getToken());
|
||||
String suppliedToken = value(request.token());
|
||||
if (request.clearToken() && !suppliedToken.isBlank()) {
|
||||
throw new ApiException(400, "不能同时清除并设置 Gitea Token");
|
||||
}
|
||||
if (request.clearToken()) {
|
||||
token = "";
|
||||
} else if (!suppliedToken.isBlank()) {
|
||||
if (!TOKEN_PATTERN.matcher(suppliedToken).matches()) {
|
||||
throw new ApiException(400, "Gitea Token 格式无效");
|
||||
}
|
||||
token = suppliedToken;
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> changes = new LinkedHashMap<>();
|
||||
changes.put("OA_UPDATE_ENABLED", Boolean.toString(request.enabled()));
|
||||
changes.put("OA_UPDATE_GITEA_BASE_URL", baseUrl);
|
||||
changes.put("OA_UPDATE_REPOSITORY", repository);
|
||||
changes.put("OA_UPDATE_CHANNEL", channel);
|
||||
changes.put("OA_UPDATE_TOKEN", token);
|
||||
changes.put("OA_UPDATE_ALLOW_INSECURE_HTTP", Boolean.toString(request.allowInsecureHttp()));
|
||||
persist(changes);
|
||||
|
||||
properties.setEnabled(request.enabled());
|
||||
properties.setGiteaBaseUrl(baseUrl);
|
||||
properties.setRepository(repository);
|
||||
properties.setChannel(channel);
|
||||
properties.setToken(token);
|
||||
properties.setAllowInsecureHttp(request.allowInsecureHttp());
|
||||
return get();
|
||||
}
|
||||
|
||||
private void persist(Map<String, String> changes) {
|
||||
String configuredPath = value(properties.getConfigFile());
|
||||
if (configuredPath.isBlank()) {
|
||||
throw new ApiException(503, "当前运行方式未提供可写的 ERP_CONFIG_FILE");
|
||||
}
|
||||
Path target = Path.of(configuredPath).toAbsolutePath().normalize();
|
||||
try {
|
||||
if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || !Files.isWritable(target)) {
|
||||
throw new ApiException(503, "ERP 配置文件不存在或不可写");
|
||||
}
|
||||
if (Files.size(target) > MAX_CONFIG_BYTES) {
|
||||
throw new ApiException(503, "ERP 配置文件异常过大");
|
||||
}
|
||||
BasicFileAttributes before = Files.readAttributes(
|
||||
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
List<String> original = Files.readAllLines(target, StandardCharsets.UTF_8);
|
||||
List<String> updated = replaceWhitelistedSettings(original, changes);
|
||||
byte[] content = (String.join("\n", updated) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
atomicWrite(target, content, before);
|
||||
} catch (ApiException exception) {
|
||||
throw exception;
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
throw new ApiException(503, "无法保存更新配置,请检查 ERP 配置文件权限");
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> replaceWhitelistedSettings(List<String> original, Map<String, String> changes) {
|
||||
List<String> result = new ArrayList<>(original.size() + changes.size());
|
||||
Set<String> written = new java.util.HashSet<>();
|
||||
for (String line : original) {
|
||||
String matched = null;
|
||||
for (String key : changes.keySet()) {
|
||||
if (line.startsWith(key + "=")) {
|
||||
matched = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched == null) {
|
||||
result.add(line);
|
||||
} else if (written.add(matched)) {
|
||||
result.add(setting(matched, changes.get(matched)));
|
||||
}
|
||||
}
|
||||
for (Map.Entry<String, String> entry : changes.entrySet()) {
|
||||
if (written.add(entry.getKey())) {
|
||||
result.add(setting(entry.getKey(), entry.getValue()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void atomicWrite(Path target, byte[] content, BasicFileAttributes before) throws IOException {
|
||||
Path parent = target.getParent();
|
||||
if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("configuration parent is unavailable");
|
||||
}
|
||||
Path temporary = Files.createTempFile(parent, ".erp.env-", ".tmp");
|
||||
try {
|
||||
try {
|
||||
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(
|
||||
target, LinkOption.NOFOLLOW_LINKS);
|
||||
Files.setPosixFilePermissions(temporary, permissions);
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Production targets POSIX systems; keep local non-POSIX tests portable.
|
||||
}
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
temporary, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
channel.write(ByteBuffer.wrap(content));
|
||||
channel.force(true);
|
||||
}
|
||||
BasicFileAttributes current = Files.readAttributes(
|
||||
target, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
|
||||
if (current.size() != before.size() || !current.lastModifiedTime().equals(before.lastModifiedTime())) {
|
||||
throw new IOException("configuration changed concurrently");
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, target,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
throw new IOException("atomic configuration replacement is unavailable", exception);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String input, boolean allowInsecureHttp, boolean required) {
|
||||
String value = value(input).replaceAll("/+$", "");
|
||||
if (value.isBlank()) {
|
||||
if (required) {
|
||||
throw new ApiException(400, "启用在线更新时必须填写 Gitea 地址");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(value);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ApiException(400, "Gitea 地址格式无效");
|
||||
}
|
||||
String scheme = value(uri.getScheme()).toLowerCase(Locale.ROOT);
|
||||
if (!("https".equals(scheme) || (allowInsecureHttp && "http".equals(scheme)))) {
|
||||
throw new ApiException(400, "Gitea 地址必须使用 HTTPS");
|
||||
}
|
||||
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|
||||
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
|
||||
throw new ApiException(400, "Gitea 地址格式无效");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String normalizeRepository(String input) {
|
||||
String repository = value(input);
|
||||
if (!REPOSITORY_PATTERN.matcher(repository).matches()) {
|
||||
throw new ApiException(400, "仓库格式必须为 owner/repository");
|
||||
}
|
||||
return repository;
|
||||
}
|
||||
|
||||
private static String normalizedChannel(String input) {
|
||||
String channel = value(input).toLowerCase(Locale.ROOT);
|
||||
if (channel.isBlank()) {
|
||||
return "stable";
|
||||
}
|
||||
if (!CHANNELS.contains(channel)) {
|
||||
throw new ApiException(400, "更新通道只支持 stable 或 preview");
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
private static String setting(String key, String value) {
|
||||
if (value.indexOf('\0') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) {
|
||||
throw new ApiException(400, "更新配置不能包含换行符");
|
||||
}
|
||||
return key + "='" + value.replace("'", "'\\''") + "'";
|
||||
}
|
||||
|
||||
private static String value(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
public record UpdateConfig(
|
||||
boolean enabled,
|
||||
String giteaBaseUrl,
|
||||
String repository,
|
||||
String channel,
|
||||
boolean tokenConfigured,
|
||||
boolean allowInsecureHttp
|
||||
) {
|
||||
}
|
||||
|
||||
public record UpdateConfigRequest(
|
||||
boolean enabled,
|
||||
String giteaBaseUrl,
|
||||
String repository,
|
||||
String channel,
|
||||
String token,
|
||||
boolean clearToken,
|
||||
boolean allowInsecureHttp
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -21,13 +21,16 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.regex.Matcher;
|
||||
@@ -43,6 +46,10 @@ public class SystemUpdateService {
|
||||
+ "(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?"
|
||||
+ "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$");
|
||||
private static final long MAX_STATE_BYTES = 64 * 1024;
|
||||
private static final int MAX_RELEASE_NOTES_CHARS = 32_000;
|
||||
private static final int MAX_RELEASE_ASSETS = 32;
|
||||
private static final int MAX_ASSET_NAME_CHARS = 255;
|
||||
private static final int MAX_ASSET_URL_CHARS = 4_096;
|
||||
|
||||
private final UpdateProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -76,12 +83,33 @@ public class SystemUpdateService {
|
||||
|
||||
public UpdateStatus status() {
|
||||
UpdateStatus persisted = readHelperState();
|
||||
return persisted == null ? state.get() : persisted;
|
||||
if (persisted != null) {
|
||||
state.set(persisted);
|
||||
return persisted;
|
||||
}
|
||||
return state.get();
|
||||
}
|
||||
|
||||
public void ensureConfigurationMutable() {
|
||||
if (isActivePhase(status().phase())) {
|
||||
throw new ApiException(409, "更新进行中,暂时不能修改更新源");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void configurationChanged() {
|
||||
UpdateStatus idle = UpdateStatus.idle(isConfigured(), currentVersion);
|
||||
state.set(idle);
|
||||
persistState(idle);
|
||||
}
|
||||
|
||||
public synchronized UpdateStatus check() {
|
||||
requireConfigured();
|
||||
state.set(state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release"));
|
||||
if (isActivePhase(status().phase())) {
|
||||
throw new ApiException(409, "更新任务正在执行");
|
||||
}
|
||||
UpdateStatus checking = state.get().withPhase(UpdatePhase.CHECKING, 5, "正在检查 Gitea Release");
|
||||
state.set(checking);
|
||||
persistState(checking);
|
||||
try {
|
||||
ReleaseInfo release = fetchLatestRelease();
|
||||
boolean available = compareVersions(release.version(), currentVersion) > 0;
|
||||
@@ -100,9 +128,12 @@ public class SystemUpdateService {
|
||||
null
|
||||
);
|
||||
state.set(checked);
|
||||
persistState(checked);
|
||||
return checked;
|
||||
} catch (RuntimeException e) {
|
||||
state.set(state.get().failed(safeMessage(e)));
|
||||
UpdateStatus failed = state.get().failed(safeMessage(e));
|
||||
state.set(failed);
|
||||
persistState(failed);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +181,7 @@ public class SystemUpdateService {
|
||||
|
||||
UpdateStatus starting = checked.withPhase(UpdatePhase.STARTING, 1, "更新助手已启动");
|
||||
state.set(starting);
|
||||
persistState(starting);
|
||||
process.onExit().thenAccept(completed -> {
|
||||
installRunning.set(false);
|
||||
if (completed.exitValue() != 0) {
|
||||
@@ -182,6 +214,7 @@ public class SystemUpdateService {
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(builder.build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
requireSameOrigin(response.uri(), configuredBaseUri(), "Gitea Release API");
|
||||
if (response.statusCode() == 404) {
|
||||
throw new ApiException(404, "Gitea 尚未发布 Release");
|
||||
}
|
||||
@@ -213,11 +246,21 @@ public class SystemUpdateService {
|
||||
&& "stable".equalsIgnoreCase(properties.getChannel())) {
|
||||
throw new ApiException(502, "稳定频道拒绝预发布版本");
|
||||
}
|
||||
if (!root.path("assets").isArray() || root.path("assets").size() > MAX_RELEASE_ASSETS) {
|
||||
throw new ApiException(502, "Release 文件列表无效");
|
||||
}
|
||||
List<ReleaseAsset> assets = new ArrayList<>();
|
||||
for (JsonNode node : root.path("assets")) {
|
||||
String name = text(node, "name");
|
||||
String downloadUrl = text(node, "browser_download_url");
|
||||
if (name.isBlank() || name.length() > MAX_ASSET_NAME_CHARS
|
||||
|| downloadUrl.isBlank() || downloadUrl.length() > MAX_ASSET_URL_CHARS) {
|
||||
throw new ApiException(502, "Release 文件信息无效");
|
||||
}
|
||||
validateAssetUrl(downloadUrl);
|
||||
assets.add(new ReleaseAsset(
|
||||
text(node, "name"),
|
||||
text(node, "browser_download_url"),
|
||||
name,
|
||||
downloadUrl,
|
||||
node.path("size").asLong(0)
|
||||
));
|
||||
}
|
||||
@@ -234,7 +277,11 @@ public class SystemUpdateService {
|
||||
// An invalid optional timestamp must not hide an otherwise valid release.
|
||||
}
|
||||
}
|
||||
return new ReleaseInfo(version, text(root, "body"), publishedAt, List.copyOf(assets));
|
||||
String notes = text(root, "body");
|
||||
if (notes.length() > MAX_RELEASE_NOTES_CHARS) {
|
||||
notes = notes.substring(0, MAX_RELEASE_NOTES_CHARS) + "\n\n[发布说明过长,已截断]";
|
||||
}
|
||||
return new ReleaseInfo(version, notes, publishedAt, List.copyOf(assets));
|
||||
}
|
||||
|
||||
private void requireConfigured() {
|
||||
@@ -244,16 +291,15 @@ public class SystemUpdateService {
|
||||
if (properties.getGiteaBaseUrl() == null || properties.getGiteaBaseUrl().isBlank()) {
|
||||
throw new ApiException(503, "尚未配置 Gitea 地址");
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(properties.getGiteaBaseUrl().trim());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ApiException(500, "Gitea 地址格式无效");
|
||||
}
|
||||
URI uri = configuredBaseUri();
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"https".equals(scheme) && !(properties.isAllowInsecureHttp() && "http".equals(scheme))) {
|
||||
throw new ApiException(503, "更新服务器必须使用 HTTPS");
|
||||
}
|
||||
String repository = properties.getRepository() == null ? "" : properties.getRepository().trim();
|
||||
if (!repository.matches("^[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")) {
|
||||
throw new ApiException(500, "更新仓库配置无效");
|
||||
}
|
||||
}
|
||||
|
||||
private UpdateStatus readHelperState() {
|
||||
@@ -272,10 +318,15 @@ public class SystemUpdateService {
|
||||
String helperVersion = blankToNull(text(root, "version"));
|
||||
boolean updateAvailable = helperVersion != null
|
||||
&& phase != UpdatePhase.SUCCEEDED
|
||||
&& phase != UpdatePhase.UP_TO_DATE
|
||||
&& compareVersions(helperVersion, currentVersion) > 0;
|
||||
Instant helperUpdatedAt = parseInstant(text(root, "updatedAt"));
|
||||
Instant publishedAt = parseInstant(text(root, "publishedAt"));
|
||||
String releaseNotes = root.path("releaseNotes").isTextual()
|
||||
? root.path("releaseNotes").asText() : memory.releaseNotes();
|
||||
List<ReleaseAsset> assets = readAssets(root.path("assets"), memory.assets());
|
||||
return new UpdateStatus(
|
||||
properties.isEnabled(),
|
||||
isConfigured(),
|
||||
currentVersion,
|
||||
helperVersion,
|
||||
updateAvailable,
|
||||
@@ -283,9 +334,9 @@ public class SystemUpdateService {
|
||||
Math.max(0, Math.min(100, root.path("progress").asInt(0))),
|
||||
text(root, "message"),
|
||||
helperUpdatedAt == null ? memory.checkedAt() : helperUpdatedAt,
|
||||
memory.publishedAt(),
|
||||
memory.releaseNotes(),
|
||||
memory.assets(),
|
||||
publishedAt == null ? memory.publishedAt() : publishedAt,
|
||||
releaseNotes,
|
||||
assets,
|
||||
blankToNull(text(root, "error"))
|
||||
);
|
||||
} catch (Exception e) {
|
||||
@@ -300,6 +351,137 @@ public class SystemUpdateService {
|
||||
.toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
private void persistState(UpdateStatus status) {
|
||||
Path target = statePath();
|
||||
Path parent = target.getParent();
|
||||
Path temporary = null;
|
||||
try {
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
byte[] content = objectMapper.writeValueAsBytes(java.util.Map.ofEntries(
|
||||
java.util.Map.entry("phase", status.phase().name()),
|
||||
java.util.Map.entry("progress", status.progress()),
|
||||
java.util.Map.entry("message", Objects.requireNonNullElse(status.message(), "")),
|
||||
java.util.Map.entry("version", Objects.requireNonNullElse(status.latestVersion(), "")),
|
||||
java.util.Map.entry("error", Objects.requireNonNullElse(status.error(), "")),
|
||||
java.util.Map.entry("updatedAt", Instant.now().toString()),
|
||||
java.util.Map.entry("publishedAt", status.publishedAt() == null ? "" : status.publishedAt().toString()),
|
||||
java.util.Map.entry("releaseNotes", Objects.requireNonNullElse(status.releaseNotes(), "")),
|
||||
java.util.Map.entry("assets", Objects.requireNonNullElse(status.assets(), List.of()))
|
||||
));
|
||||
if (content.length > MAX_STATE_BYTES) {
|
||||
throw new IOException("update state is too large");
|
||||
}
|
||||
Path directory = parent == null ? Path.of(".").toAbsolutePath().normalize() : parent;
|
||||
temporary = Files.createTempFile(directory, ".update-state-", ".tmp");
|
||||
Files.write(temporary, content, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
try {
|
||||
Files.setPosixFilePermissions(temporary, Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.GROUP_READ));
|
||||
} catch (UnsupportedOperationException ignored) {
|
||||
// Keep local non-POSIX tests portable.
|
||||
}
|
||||
Files.move(temporary, target,
|
||||
StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
temporary = null;
|
||||
} catch (Exception exception) {
|
||||
log.warn("Unable to persist update state {}: {}", target, exception.getMessage());
|
||||
} finally {
|
||||
if (temporary != null) {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException ignored) {
|
||||
// Best effort cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<ReleaseAsset> readAssets(JsonNode node, List<ReleaseAsset> fallback) {
|
||||
if (!node.isArray()) {
|
||||
return fallback == null ? List.of() : fallback;
|
||||
}
|
||||
if (node.size() > MAX_RELEASE_ASSETS) {
|
||||
return List.of();
|
||||
}
|
||||
List<ReleaseAsset> assets = new ArrayList<>();
|
||||
for (JsonNode asset : node) {
|
||||
String name = text(asset, "name");
|
||||
String downloadUrl = text(asset, "downloadUrl");
|
||||
if (name.isBlank() || downloadUrl.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
validateAssetUrl(downloadUrl);
|
||||
assets.add(new ReleaseAsset(name, downloadUrl, asset.path("size").asLong(0)));
|
||||
}
|
||||
return List.copyOf(assets);
|
||||
}
|
||||
|
||||
private URI configuredBaseUri() {
|
||||
URI uri;
|
||||
try {
|
||||
uri = URI.create(Objects.requireNonNullElse(properties.getGiteaBaseUrl(), "").trim());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ApiException(500, "Gitea 地址格式无效");
|
||||
}
|
||||
if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null
|
||||
|| uri.getRawFragment() != null || uri.getPort() > 65535) {
|
||||
throw new ApiException(500, "Gitea 地址格式无效");
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private void validateAssetUrl(String value) {
|
||||
URI asset;
|
||||
try {
|
||||
asset = URI.create(value);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new ApiException(502, "Release 文件地址无效");
|
||||
}
|
||||
if (asset.getUserInfo() != null || asset.getRawFragment() != null) {
|
||||
throw new ApiException(502, "Release 文件地址无效");
|
||||
}
|
||||
requireSameOrigin(asset, configuredBaseUri(), "Release 文件");
|
||||
}
|
||||
|
||||
private static void requireSameOrigin(URI actual, URI expected, String label) {
|
||||
String actualScheme = Objects.requireNonNullElse(actual.getScheme(), "").toLowerCase(Locale.ROOT);
|
||||
String expectedScheme = Objects.requireNonNullElse(expected.getScheme(), "").toLowerCase(Locale.ROOT);
|
||||
String actualHost = Objects.requireNonNullElse(actual.getHost(), "").toLowerCase(Locale.ROOT);
|
||||
String expectedHost = Objects.requireNonNullElse(expected.getHost(), "").toLowerCase(Locale.ROOT);
|
||||
if (!actualScheme.equals(expectedScheme) || !actualHost.equals(expectedHost)
|
||||
|| effectivePort(actual) != effectivePort(expected)) {
|
||||
throw new ApiException(502, label + "跳转到了未受信任的服务器");
|
||||
}
|
||||
}
|
||||
|
||||
private static int effectivePort(URI uri) {
|
||||
if (uri.getPort() >= 0) {
|
||||
return uri.getPort();
|
||||
}
|
||||
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
|
||||
private boolean isConfigured() {
|
||||
return properties.isEnabled()
|
||||
&& properties.getGiteaBaseUrl() != null
|
||||
&& !properties.getGiteaBaseUrl().isBlank()
|
||||
&& properties.getRepository() != null
|
||||
&& !properties.getRepository().isBlank();
|
||||
}
|
||||
|
||||
private static boolean isActivePhase(UpdatePhase phase) {
|
||||
return phase == UpdatePhase.STARTING
|
||||
|| phase == UpdatePhase.DOWNLOADING
|
||||
|| phase == UpdatePhase.VERIFYING
|
||||
|| phase == UpdatePhase.INSTALLING
|
||||
|| phase == UpdatePhase.RESTARTING
|
||||
|| phase == UpdatePhase.ROLLING_BACK;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode value = node.path(field);
|
||||
return value.isTextual() ? value.asText().trim() : "";
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.kaidi.oa.common.ApiException;
|
||||
import com.kaidi.oa.common.ApiResp;
|
||||
import com.kaidi.oa.domain.SysUser;
|
||||
import com.kaidi.oa.service.AuthService;
|
||||
import com.kaidi.oa.service.AuthorizationService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* Authentication endpoints. Issues an opaque token on login; the frontend sends
|
||||
@@ -25,10 +27,13 @@ public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final CurrentUserResolver currentUser;
|
||||
private final AuthorizationService authorizationService;
|
||||
|
||||
public AuthController(AuthService authService, CurrentUserResolver currentUser) {
|
||||
public AuthController(AuthService authService, CurrentUserResolver currentUser,
|
||||
AuthorizationService authorizationService) {
|
||||
this.authService = authService;
|
||||
this.currentUser = currentUser;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
public record LoginRequest(@NotBlank String loginName, @NotBlank String password) {
|
||||
@@ -65,6 +70,7 @@ public class AuthController {
|
||||
map.put("deptId", user.getDeptId());
|
||||
map.put("title", user.getTitle());
|
||||
map.put("email", user.getEmail());
|
||||
map.put("roles", new TreeSet<>(authorizationService.roleCodesOf(user.getId())));
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package com.kaidi.oa.web;
|
||||
|
||||
import com.kaidi.oa.common.ApiResp;
|
||||
import com.kaidi.oa.service.SystemUpdateConfigService;
|
||||
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfig;
|
||||
import com.kaidi.oa.service.SystemUpdateConfigService.UpdateConfigRequest;
|
||||
import com.kaidi.oa.service.SystemUpdateService;
|
||||
import com.kaidi.oa.service.SystemUpdateService.UpdateStatus;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -17,9 +21,24 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class SystemUpdateController {
|
||||
|
||||
private final SystemUpdateService updateService;
|
||||
private final SystemUpdateConfigService configService;
|
||||
|
||||
public SystemUpdateController(SystemUpdateService updateService) {
|
||||
public SystemUpdateController(SystemUpdateService updateService, SystemUpdateConfigService configService) {
|
||||
this.updateService = updateService;
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public ApiResp<UpdateConfig> config() {
|
||||
return ApiResp.ok(configService.get());
|
||||
}
|
||||
|
||||
@PutMapping("/config")
|
||||
public ApiResp<UpdateConfig> saveConfig(@Valid @RequestBody UpdateConfigRequest request) {
|
||||
updateService.ensureConfigurationMutable();
|
||||
UpdateConfig saved = configService.save(request);
|
||||
updateService.configurationChanged();
|
||||
return ApiResp.ok(saved);
|
||||
}
|
||||
|
||||
@GetMapping("/status")
|
||||
|
||||
Reference in New Issue
Block a user