Files
WebDrop/server/src/WebDrop.Api/Hosting/CapCaptchaVerifier.cs
T
justme 72a5e5b839 feat(server): split file storage into a separate file service with tickets, receipts and WebDAV backup
- new projects WebDrop.FileApi (file service), WebDrop.FileProtocol (shared tickets and receipts) and WebDrop.Sync (WebDAV)
- file content of any size goes to the file service; chat, avatars and space structure stay on the main backend
- tickets issued by the main backend, receipts by the file service, deletion queue and daily reconciliation
- daily database snapshot and file sync to external storage (OpenList/WebDAV) with a 30 day retention
- reorganize Core, Storage and Api into feature folders (namespaces unchanged)
- web client uploads file frames to the file service and reads them with tickets
2026-10-17 15:25:16 +08:00

76 lines
3.2 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using WebDrop.Core;
namespace WebDrop.Api;
public sealed class SiteverifyRequest
{
[JsonPropertyName("secret")] public required string Secret { get; init; }
[JsonPropertyName("response")] public required string Response { get; init; }
}
public sealed class SiteverifyResponse
{
[JsonPropertyName("success")] public bool Success { get; init; }
}
// Backend-to-Cap DTOs are not part of the browser API contract.
[JsonSerializable(typeof(SiteverifyRequest))]
[JsonSerializable(typeof(SiteverifyResponse))]
public sealed partial class CaptchaJson : JsonSerializerContext;
/// <summary>
/// 用 Cap(https://github.com/tiagozip/cap)校验创建频道的验证码。
/// 工作量证明的出题、求解、验证全部由 Cap 完成,本项目不实现任何 PoW。
///
/// 流程:浏览器上的 Cap widget 直接向 Cap 取挑战、求解、换得 token,随创建请求放进 X-WD-Cap;
/// 这里只做一件事:把 token 交给 Cap 的 siteverify(与 reCAPTCHA / hCaptcha 兼容的接口)确认。
/// </summary>
public sealed class CapCaptchaVerifier(HttpClient http, CaptchaOptions options, ILogger<CapCaptchaVerifier> logger) : ICaptchaVerifier
{
// token 来自客户端,先限制长度,避免把任意大的内容转发给第三方
private const int MaxTokenLength = 2048;
public bool Enabled => true;
public async Task VerifyAsync(string? token, CancellationToken ct)
{
if (string.IsNullOrEmpty(token) || token.Length > MaxTokenLength)
throw new WebDropException(ErrorCode.CaptchaRequired);
var body = JsonSerializer.Serialize(
new SiteverifyRequest { Secret = options.Secret, Response = token }, CaptchaJson.Default.SiteverifyRequest);
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, options.SiteverifyUrl)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
using var response = await http.SendAsync(request, ct).ConfigureAwait(false);
// 非 2xx(站点密钥写错、Cap 出错等)是我们这边的配置或依赖问题,不是用户没通过验证
if (!response.IsSuccessStatusCode)
{
logger.LogError("Cap siteverify 返回了 {Status}", (int)response.StatusCode);
throw new WebDropException(ErrorCode.CaptchaUnavailable);
}
var result = await JsonSerializer.DeserializeAsync(
await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false),
CaptchaJson.Default.SiteverifyResponse, ct).ConfigureAwait(false);
if (result is not { Success: true }) throw new WebDropException(ErrorCode.CaptchaRequired);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or InvalidOperationException
&& !ct.IsCancellationRequested)
{
// 只记录异常类型:不记录 token、密钥或响应内容
logger.LogError("Cap siteverify 调用失败:{Type}", e.GetType().Name);
throw new WebDropException(ErrorCode.CaptchaUnavailable);
}
}
}