Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/BiSheng.Server/Pages/Account.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@
<p class="page-subtitle">@Model.Username · 修改密码与两步验证</p>
</div>

@if (Model.IsRateLimited)
{
<div class="alert" style="background:rgba(234,179,8,0.1); color:#b45309; border:1px solid rgba(234,179,8,0.3);">
@AccountModel.RateLimitMessage
</div>
}

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success">@Model.SuccessMessage</div>
Expand Down
8 changes: 8 additions & 0 deletions src/BiSheng.Server/Pages/Account.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
using BiSheng.Server.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;

namespace BiSheng.Server.Pages;

/// <summary>账号安全:改密、重绑 TOTP</summary>
[AdminPanelAuthorize]
[EnableRateLimiting("login")]
public class AccountModel : PageModel
{
private readonly AppDbContext _db;
Expand Down Expand Up @@ -53,6 +55,12 @@ public AccountModel(
/// <summary>错误提示</summary>
public string ErrorMessage { get; set; } = string.Empty;

/// <summary>是否因 POST 过频被限流</summary>
public bool IsRateLimited => Request.Query.ContainsKey("rateLimited");

/// <summary>限流提示文案(与登录页同策略:5 次/分钟/IP)</summary>
public const string RateLimitMessage = "操作过于频繁,请约 1 分钟后再试";

/// <summary>改密:当前密码</summary>
[BindProperty]
public string CurrentPassword { get; set; } = string.Empty;
Expand Down
2 changes: 1 addition & 1 deletion src/BiSheng.Server/Pages/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
</form>
</div>
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px;line-height:1.5;">
仅查询 GitHub Releases,不会自动下载或安装。升级请用 <code style="font-family:var(--font-mono);font-size:12px;">upgrade-bisheng.sh</code>。
仅查询更新源(优先 <code style="font-family:var(--font-mono);font-size:12px;">Update:ManifestUrl</code> 清单,可选 GitHub 回退),不会自动下载或安装。升级请用 <code style="font-family:var(--font-mono);font-size:12px;">upgrade-bisheng.sh</code>。
</p>
@if (Model.UpdateCheck != null)
{
Expand Down
6 changes: 6 additions & 0 deletions src/BiSheng.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@
return;
}

if (httpContext.Request.Path.StartsWithSegments("/admin/account"))
{
httpContext.Response.Redirect("/admin/account?rateLimited=1");
return;
}

httpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
httpContext.Response.ContentType = "application/problem+json";
var problem = ApiProblemResults.RateLimited(httpContext);
Expand Down
215 changes: 179 additions & 36 deletions src/BiSheng.Server/Services/ServerUpdateCheckService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

namespace BiSheng.Server.Services;

/// <summary>检查 GitHub Releases 上是否有更新的服务端包(只读)</summary>
/// <summary>
/// 检查服务端更新(只读):优先拉取 Update:ManifestUrl 清单;
/// 可选回退到 GitHub Releases API。
/// </summary>
public sealed class ServerUpdateCheckService
{
private static readonly JsonSerializerOptions JsonOptions = new()
Expand Down Expand Up @@ -44,7 +47,7 @@ public static string GetCurrentVersion()
return asm.GetName().Version?.ToString(3) ?? "0.0.0";
}

/// <summary>查询最新稳定 Release 并与当前版本比较</summary>
/// <summary>查询最新版本并与当前比较</summary>
public async Task<ServerUpdateCheckResult> CheckAsync(CancellationToken cancellationToken = default)
{
var current = GetCurrentVersion();
Expand All @@ -53,18 +56,110 @@ public async Task<ServerUpdateCheckResult> CheckAsync(CancellationToken cancella
return ServerUpdateCheckResult.Disabled(current);
}

if (string.IsNullOrWhiteSpace(_options.GitHubOwner) || string.IsNullOrWhiteSpace(_options.GitHubRepo))
var hasManifest = !string.IsNullOrWhiteSpace(_options.ManifestUrl);
var canGitHub = _options.AllowGitHubFallback
&& !string.IsNullOrWhiteSpace(_options.GitHubOwner)
&& !string.IsNullOrWhiteSpace(_options.GitHubRepo);

if (!hasManifest && !canGitHub)
{
return ServerUpdateCheckResult.Failed(current, "未配置 GitHub 仓库。");
return ServerUpdateCheckResult.Failed(
current,
"未配置更新源:请设置 Update:ManifestUrl,或启用 AllowGitHubFallback 并配置 GitHub 仓库。");
}

var url =
$"https://api.github.com/repos/{_options.GitHubOwner}/{_options.GitHubRepo}/releases/latest";
if (hasManifest)
{
var fromManifest = await CheckFromManifestAsync(current, cancellationToken).ConfigureAwait(false);
if (fromManifest.Availability != ServerUpdateAvailability.Failed)
{
return fromManifest;
}

if (!canGitHub)
{
return fromManifest;
}

_logger.LogWarning("清单检查失败,回退 GitHub: {Message}", fromManifest.Message);
}

return await CheckFromGitHubAsync(current, cancellationToken).ConfigureAwait(false);
}

/// <summary>从清单 URL 解析 server 段</summary>
internal async Task<ServerUpdateCheckResult> CheckFromManifestAsync(
string current,
CancellationToken cancellationToken)
{
var url = _options.ManifestUrl!.Trim();
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("BiSheng.Server-UpdateCheck");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return ServerUpdateCheckResult.Failed(
current,
$"清单返回 {(int)response.StatusCode},请检查 Update:ManifestUrl。");
}

await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
var manifest = await JsonSerializer.DeserializeAsync<UpdateManifestDto>(
stream, JsonOptions, cancellationToken).ConfigureAwait(false);

var server = manifest?.Server;
if (server == null || string.IsNullOrWhiteSpace(server.Version))
{
return ServerUpdateCheckResult.Failed(current, "清单缺少 server.version 字段。");
}

var expectedRid = string.IsNullOrWhiteSpace(_options.ServerRuntime)
? "linux-x64"
: _options.ServerRuntime.Trim();
if (!string.IsNullOrWhiteSpace(server.Rid)
&& !string.Equals(server.Rid, expectedRid, StringComparison.OrdinalIgnoreCase))
{
return ServerUpdateCheckResult.Failed(
current,
$"清单 rid={server.Rid} 与配置 ServerRuntime={expectedRid} 不一致。");
}

return BuildComparisonResult(
current,
NormalizeVersion(server.Version),
releaseUrl: server.ReleaseNotesUrl,
downloadUrl: server.DownloadUrl,
assetName: server.PackageFile,
sourceHint: "清单");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "读取更新清单失败: {Url}", url);
return ServerUpdateCheckResult.Failed(current, $"读取清单失败:{ex.Message}");
}
}

/// <summary>GitHub Releases 回退路径</summary>
private async Task<ServerUpdateCheckResult> CheckFromGitHubAsync(
string current,
CancellationToken cancellationToken)
{
var apiUrl =
$"https://api.github.com/repos/{_options.GitHubOwner}/{_options.GitHubRepo}/releases/latest";

try
{
using var request = new HttpRequestMessage(HttpMethod.Get, apiUrl);
request.Headers.UserAgent.ParseAdd("BiSheng.Server-UpdateCheck");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));

using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
Expand All @@ -82,7 +177,7 @@ public async Task<ServerUpdateCheckResult> CheckAsync(CancellationToken cancella
body.Length > 200 ? body[..200] : body);
return ServerUpdateCheckResult.Failed(
current,
$"GitHub 返回 {(int)response.StatusCode},请稍后重试。");
$"GitHub 返回 {(int)response.StatusCode},请稍后重试或改用 ManifestUrl。");
}

await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken)
Expand All @@ -109,45 +204,66 @@ public async Task<ServerUpdateCheckResult> CheckAsync(CancellationToken cancella
? $"https://github.com/{_options.GitHubOwner}/{_options.GitHubRepo}/releases/tag/{release.TagName}"
: release.HtmlUrl;

var comparison = CompareVersions(current, latest);
if (comparison >= 0)
{
return new ServerUpdateCheckResult
{
Availability = ServerUpdateAvailability.UpToDate,
CurrentVersion = current,
LatestVersion = latest,
ReleaseUrl = releaseUrl,
DownloadUrl = asset?.BrowserDownloadUrl,
AssetName = asset?.Name,
Message = $"已是最新({current})。"
};
}

return new ServerUpdateCheckResult
{
Availability = ServerUpdateAvailability.UpdateAvailable,
CurrentVersion = current,
LatestVersion = latest,
ReleaseUrl = releaseUrl,
DownloadUrl = asset?.BrowserDownloadUrl,
AssetName = asset?.Name,
Message = asset == null
? $"发现新版本 {latest},但未找到 {runtime} 安装包,请打开 Release 页手动下载。"
: $"发现新版本 {latest}。请下载后用 upgrade-bisheng.sh 升级(管理页不会自动安装)。"
};
return BuildComparisonResult(
current,
latest,
releaseUrl,
asset?.BrowserDownloadUrl,
asset?.Name,
sourceHint: "GitHub",
missingAssetMessage: $"发现新版本 {latest},但未找到 {runtime} 安装包,请打开 Release 页手动下载。");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "检查服务端更新异常");
_logger.LogWarning(ex, "检查服务端更新异常(GitHub)");
return ServerUpdateCheckResult.Failed(current, $"检查失败:{ex.Message}");
}
}

private static ServerUpdateCheckResult BuildComparisonResult(
string current,
string latest,
string? releaseUrl,
string? downloadUrl,
string? assetName,
string sourceHint,
string? missingAssetMessage = null)
{
var comparison = CompareVersions(current, latest);
if (comparison >= 0)
{
return new ServerUpdateCheckResult
{
Availability = ServerUpdateAvailability.UpToDate,
CurrentVersion = current,
LatestVersion = latest,
ReleaseUrl = releaseUrl,
DownloadUrl = downloadUrl,
AssetName = assetName,
Message = $"已是最新({current},来源:{sourceHint})。"
};
}

var message = string.IsNullOrEmpty(downloadUrl) && missingAssetMessage != null
? missingAssetMessage
: $"发现新版本 {latest}(来源:{sourceHint})。请下载后用 upgrade-bisheng.sh 升级(管理页不会自动安装)。";

return new ServerUpdateCheckResult
{
Availability = ServerUpdateAvailability.UpdateAvailable,
CurrentVersion = current,
LatestVersion = latest,
ReleaseUrl = releaseUrl,
DownloadUrl = downloadUrl,
AssetName = assetName,
Message = message
};
}

/// <summary>去掉 tag 前缀 v/V</summary>
public static string NormalizeVersion(string tagOrVersion)
{
Expand Down Expand Up @@ -184,7 +300,6 @@ private static string PadVersion(string version)
parts[^1] = "0";
}

// Version 最多 4 段;截断预发布后缀
for (var i = 0; i < parts.Length && i < 4; i++)
{
var dash = parts[i].IndexOf('-');
Expand All @@ -197,6 +312,34 @@ private static string PadVersion(string version)
return string.Join('.', parts.Take(4));
}

/// <summary>与设计文档 §2.3 对齐的精简清单(仅消费 server 段)</summary>
internal sealed class UpdateManifestDto
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; set; }

[JsonPropertyName("server")]
public UpdateManifestServerDto? Server { get; set; }
}

internal sealed class UpdateManifestServerDto
{
[JsonPropertyName("version")]
public string? Version { get; set; }

[JsonPropertyName("rid")]
public string? Rid { get; set; }

[JsonPropertyName("packageFile")]
public string? PackageFile { get; set; }

[JsonPropertyName("downloadUrl")]
public string? DownloadUrl { get; set; }

[JsonPropertyName("releaseNotesUrl")]
public string? ReleaseNotesUrl { get; set; }
}

private sealed class GitHubReleaseDto
{
[JsonPropertyName("tag_name")]
Expand Down
18 changes: 15 additions & 3 deletions src/BiSheng.Server/Services/ServerUpdateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@ public sealed class ServerUpdateOptions
/// <summary>是否启用检查(关闭后管理页提示已禁用)</summary>
public bool Enabled { get; set; } = true;

/// <summary>GitHub 仓库 Owner</summary>
/// <summary>
/// 更新清单 URL(优先)。指向稳定托管的 update-manifest.json,
/// 可放在任意 HTTPS 静态站 / 对象存储,不依赖 GitHub API。
/// </summary>
public string? ManifestUrl { get; set; }

/// <summary>
/// 清单不可用时是否回退到 GitHub Releases API。
/// 无外网或希望完全解耦时可设为 false。
/// </summary>
public bool AllowGitHubFallback { get; set; } = true;

/// <summary>GitHub 仓库 Owner(仅回退路径使用)</summary>
public string GitHubOwner { get; set; } = "hillsburg";

/// <summary>GitHub 仓库名</summary>
/// <summary>GitHub 仓库名(仅回退路径使用)</summary>
public string GitHubRepo { get; set; } = "BiSheng";

/// <summary>匹配的 Server 发布包 Runtime(资产名中的一段)</summary>
/// <summary>匹配的 Server 发布包 Runtime / rid(资产名或清单字段)</summary>
public string ServerRuntime { get; set; } = "linux-x64";
}
2 changes: 2 additions & 0 deletions src/BiSheng.Server/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
},
"Update": {
"Enabled": true,
"ManifestUrl": "",
"AllowGitHubFallback": true,
"GitHubOwner": "hillsburg",
"GitHubRepo": "BiSheng",
"ServerRuntime": "linux-x64"
Expand Down
Loading
Loading