using JiDangJia.Server;
using JiDangJia.Contracts;
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
var builder=WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o=>o.Limits.MaxRequestBodySize=32768);
builder.Services.AddSingleton(new ServerStore(builder.Configuration["DataDirectory"]??PrivateStorage.DefaultDirectory));
#if COMMUNITY_APP
builder.Services.AddSingleton<CommunityStore>();
builder.Services.AddSingleton<CommunityMediaStore>();
builder.Services.AddSingleton<MediaWatermark>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CommunityChatRetention>();
builder.Services.AddHostedService<CommunityChatRetentionWorker>();
#endif
builder.Services.AddSingleton<ContentStore>();
#if COMMUNITY_APP
builder.Services.AddSingleton<IdentityVault>();
builder.Services.AddSingleton<IIdentityVerificationProvider,ManualIdentityVerificationProvider>();
#endif
builder.Services.AddSingleton<IPhoneCodeSender,TencentPhoneCodeSender>();
builder.Services.AddSingleton<PhoneVerification>();
#if !COMMUNITY_APP
builder.Services.AddSingleton<AlipayGateway>();
builder.Services.AddSingleton<PurchaseStore>();
#endif
builder.Services.AddSingleton<AppReleaseStore>();
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o=>o.MultipartBodyLengthLimit=AppUpdateRules.MaxApkSize+65536);
// Content and private messages publish directly. A future cloud moderation integration is not enabled.
builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(options=>
{
options.ForwardedHeaders=Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor|Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
foreach(var address in (builder.Configuration["JDJ_TRUSTED_PROXIES"]??"").Split(',',StringSplitOptions.RemoveEmptyEntries))options.KnownProxies.Add(System.Net.IPAddress.Parse(address.Trim()));
});
builder.Services.AddRateLimiter(options=>
{
options.RejectionStatusCode=429;
options.AddPolicy("auth",context=>RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString()??"unknown",_=>new FixedWindowRateLimiterOptions {PermitLimit=20,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
options.AddPolicy("activate",context=>RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString()??"unknown",_=>new FixedWindowRateLimiterOptions {PermitLimit=12,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
options.AddPolicy("community",context=>RateLimitPartition.GetFixedWindowLimiter(ServerStore.Hash(AuthFilter.Token(context)),_=>new FixedWindowRateLimiterOptions {PermitLimit=60,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
options.AddPolicy("private",context=>RateLimitPartition.GetFixedWindowLimiter(ServerStore.Hash(AuthFilter.Token(context)),_=>new FixedWindowRateLimiterOptions {PermitLimit=30,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
});
var app=builder.Build();
if(!string.IsNullOrWhiteSpace(builder.Configuration["JDJ_TRUSTED_PROXIES"]))app.UseForwardedHeaders();
#if COMMUNITY_APP
app.Services.GetRequiredService<CommunityStore>();
#endif
app.Services.GetRequiredService<ContentStore>();
#if COMMUNITY_APP
app.Services.GetRequiredService<IdentityVault>();
#endif
app.Services.GetRequiredService<PhoneVerification>();
#if !COMMUNITY_APP
app.Services.GetRequiredService<PurchaseStore>();
#endif
app.Services.GetRequiredService<AppReleaseStore>();
app.Use(async(context,next)=>
{
if(context.Request.Path=="/api/admin/updates"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=AppUpdateRules.MaxApkSize+65536;}
if((context.Request.Path=="/api/admin/content" || context.Request.Path=="/api/admin/support") && context.Request.Method=="POST")
{
var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=6*1024*1024;
}
if(context.Request.Path=="/api/admin/about"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=64*1024;}
#if COMMUNITY_APP
if(context.Request.Path=="/api/community/media"&&context.Request.Method=="POST")
{
var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=CommunityMediaStore.MaxSize;
}
if(context.Request.Path.StartsWithSegments("/api/admin/community/recommendations")||context.Request.Path=="/api/admin/community/media")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=null;}
if(context.Request.Path=="/api/safety/documents"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=IdentityVault.MaxDocumentSize;}
if(context.Request.Path=="/api/admin/safety/governance"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=65536;}
#endif
if(context.Request.Path.StartsWithSegments("/api/admin/legal")&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=512*1024;}
context.Response.Headers["X-Content-Type-Options"]="nosniff";context.Response.Headers["X-Frame-Options"]="DENY";context.Response.Headers["Referrer-Policy"]="no-referrer";
context.Response.Headers["Content-Security-Policy"]="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
if(context.Request.Path.StartsWithSegments("/api"))context.Response.Headers.CacheControl="no-store";
if(context.Request.Method is "POST" or "PATCH" or "DELETE" && context.Request.Headers.Origin.Count>0)
{
var origin=context.Request.Headers.Origin.ToString();var own=context.Request.Scheme+"://"+context.Request.Host;
if(!string.Equals(origin,own,StringComparison.OrdinalIgnoreCase)){context.Response.StatusCode=403;await context.Response.WriteAsJsonAsync(new{error="来源验证失败"});return;}
}
try{await next();}catch(CommunityException ex){context.Response.StatusCode=ex.Status;await context.Response.WriteAsJsonAsync(new{error=ex.Message});}
catch(Exception ex)when(ex is ArgumentException or UnauthorizedAccessException or InvalidOperationException or System.Text.Json.JsonException or InvalidDataException)
{context.Response.StatusCode=ex is UnauthorizedAccessException?401:400;await context.Response.WriteAsJsonAsync(new{error=ex.Message});}
});
app.UseRateLimiter();
app.MapGet("/",()=>Results.Ok(new{app=ServerProduct.Name,product=ServerProduct.Id,message="管理员后台位于 APP 内,使用管理员账号登录后在设置中进入。"}));
app.MapGet("/api/health",()=>new{status="ok",app=ServerProduct.Name,product=ServerProduct.Id,version=ServerProduct.Version,utc=DateTime.UtcNow});
app.MapGet("/api/updates/latest",(int? versionCode,AppReleaseStore s)=>new AppUpdateOffer(s.Latest(versionCode??0)));
app.MapGet("/api/updates/{id}/apk",(string id,AppReleaseStore s)=>Results.File(s.Download(id),"application/vnd.android.package-archive",enableRangeProcessing:true));
app.MapGet("/api/legal",(ServerStore store)=>store.Legal());
app.MapPost("/api/auth/register",(RegisterRequest r,ServerStore store)=>{var a=store.Register(r.Username,r.Password,r.Name,r.Consent??throw new CommunityException(428,"请先阅读并勾选同意注册登录条款"));return Results.Ok(new{account=a});}).RequireRateLimiting("auth");
app.MapPost("/api/auth/login",(LoginRequest r,HttpContext context,ServerStore store)=>
{
var (account,token)=store.Login(r.Username,r.Password,r.Consent??throw new CommunityException(428,"请先阅读并勾选同意注册登录条款"));
if(r.Admin)
{if(account.Role!="admin"){store.Logout(token);return Results.Json(new{error="此账号没有管理员权限"},statusCode:403);}context.Response.Cookies.Append(ServerProduct.Cookie,token,new CookieOptions {HttpOnly=true,Secure=context.Request.IsHttps,SameSite=SameSiteMode.Strict,MaxAge=TimeSpan.FromDays(7),Path="/"});return Results.Ok(new{account});}
return Results.Ok(new{account,token,expiresAt=DateTime.UtcNow.AddDays(7)});
}).RequireRateLimiting("auth");
var users=app.MapGroup("/api/user").AddEndpointFilter(new AuthFilter(false));
users.MapGet("/me",(HttpContext c)=>Results.Ok(new{account=(Account)c.Items["account"]!}));
users.MapGet("/legal",(HttpContext c,ServerStore store)=>store.LegalStatus((Account)c.Items["account"]!));
users.MapPost("/password",(PasswordRequest r,HttpContext c,ServerStore store)=>{store.ChangePassword((Account)c.Items["account"]!,r.OldPassword,r.Password);c.Response.Cookies.Delete(ServerProduct.Cookie);return Results.Ok(new{ok=true});});
users.MapPost("/logout",(HttpContext c,ServerStore store)=>{store.Logout(AuthFilter.Token(c));c.Response.Cookies.Delete(ServerProduct.Cookie);return Results.Ok(new{ok=true});});
#if !COMMUNITY_APP
users.MapGet("/license",(string deviceId,HttpContext c,ServerStore store)=>store.License((Account)c.Items["account"]!,deviceId));
users.MapPost("/activate",(ActivateRequest r,HttpContext c,ServerStore store)=>store.Activate((Account)c.Items["account"]!,r.DeviceId,r.Code)).RequireRateLimiting("activate");
#endif
users.MapPost("/password/verify",(PasswordVerificationRequest r,HttpContext c,ServerStore s)=>{s.VerifyOwnPassword((Account)c.Items["account"]!,r.Password);return new OperationResult(true);}).RequireRateLimiting("auth");
#if COMMUNITY_APP
users.MapPost("/data-clear",(ClearMyDataRequest r,HttpContext c,ServerStore s,IdentityVault vault)=>{var account=(Account)c.Items["account"]!;var result=s.ClearOwnChat(account,r);vault.CancelPending(account);return result;}).RequireRateLimiting("activate");
#else
users.MapPost("/data-clear",(ClearMyDataRequest r,HttpContext c,ServerStore s)=>s.ConfirmLocalClear((Account)c.Items["account"]!,r)).RequireRateLimiting("activate");
#endif
users.MapGet("/phone",(HttpContext c,PhoneVerification s)=>s.Status((Account)c.Items["account"]!));
users.MapPost("/phone/code",async(PhoneCodeRequest r,HttpContext c,PhoneVerification s)=>await s.Send((Account)c.Items["account"]!,r,c.RequestAborted)).RequireRateLimiting("activate");
users.MapPost("/phone/bind",(PhoneBindRequest r,HttpContext c,PhoneVerification s)=>s.Bind((Account)c.Items["account"]!,r)).RequireRateLimiting("activate");
#if !COMMUNITY_APP
app.MapGet("/api/purchase",(PurchaseStore s)=>s.Offer());
app.MapPost("/api/purchase/orders",(CreatePurchaseRequest r,PurchaseStore s)=>s.Create(r)).RequireRateLimiting("activate");
app.MapGet("/api/purchase/orders/{id}",(string id,HttpContext c,PurchaseStore s)=>s.Get(id,c.Request.Headers["X-Purchase-Key"].ToString())).RequireRateLimiting("auth");
app.MapPost("/api/purchase/orders/{id}/checkout",(string id,HttpContext c,PurchaseStore s)=>s.Checkout(id,c.Request.Headers["X-Purchase-Key"].ToString())).RequireRateLimiting("activate");
app.MapPost("/api/purchase/alipay/notify",async(HttpContext c,PurchaseStore s)=>
{
if(c.Request.ContentType is null||!c.Request.ContentType.StartsWith("application/x-www-form-urlencoded",StringComparison.OrdinalIgnoreCase))return Results.Text("failure");
try
{
var form=await c.Request.ReadFormAsync(c.RequestAborted);if(form.Any(p=>p.Value.Count!=1))return Results.Text("failure");
return Results.Text(s.Notify(form.ToDictionary(p=>p.Key,p=>p.Value.ToString(),StringComparer.Ordinal))?"success":"failure");
}
catch(Exception ex)when(ex is Microsoft.Data.Sqlite.SqliteException or System.Security.Cryptography.CryptographicException or InvalidDataException){return Results.Text("failure");}
});
app.MapGet("/purchase/return",()=>Results.Content("<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>"+ServerProduct.Name+"购买订单</title><h2>请返回"+ServerProduct.Name+" APP 查询订单</h2><p>付款结果以支付宝通知确认为准。在购买页点击“查询付款结果”,确认成功后领取卡密。如已付款,请勿重复支付。</p></html>","text/html; charset=utf-8"));
#endif
var admin=app.MapGroup("/api/admin").AddEndpointFilter(new AuthFilter(true));
admin.MapGet("/legal",(ServerStore store)=>store.Legal());
admin.MapPost("/legal/{key}",(string key,PublishLegalRequest r,HttpContext c,ServerStore store)=>store.PublishLegal((Account)c.Items["account"]!,key,r)).RequireRateLimiting("private");
admin.MapGet("/legal/{key}/versions",(string key,long? before,HttpContext c,ServerStore store)=>store.LegalHistory((Account)c.Items["account"]!,key,before??0));
admin.MapGet("/legal/{key}/versions/{version:long}",(string key,long version,HttpContext c,ServerStore store)=>store.LegalVersion((Account)c.Items["account"]!,key,version));
admin.MapGet("/updates",(HttpContext c,AppReleaseStore s)=>s.Releases((Account)c.Items["account"]!));
admin.MapPost("/updates",async(HttpContext c,AppReleaseStore s)=>
{
if(!c.Request.HasFormContentType)throw new ArgumentException("请选择 APK 并填写更新说明");
var form=await c.Request.ReadFormAsync(c.RequestAborted);if(form.Files.Count!=1||form["metadata"].Count!=1)throw new ArgumentException("请上传一个 APK 安装包");
var request=System.Text.Json.JsonSerializer.Deserialize<PublishReleaseRequest>(form["metadata"].ToString(),new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))??throw new ArgumentException("版本设置无效");
await using var stream=form.Files[0].OpenReadStream();return await s.Publish((Account)c.Items["account"]!,stream,request,c.RequestAborted);
}).RequireRateLimiting("private");
admin.MapPost("/updates/{id}/withdraw",(string id,HttpContext c,AppReleaseStore s)=>{s.Withdraw((Account)c.Items["account"]!,id);return new OperationResult(true);});
admin.MapPost("/updates/clean",(HttpContext c,AppReleaseStore s)=>new{removed=s.CleanOld((Account)c.Items["account"]!)});
#if !COMMUNITY_APP
admin.MapGet("/purchase",(string? q,HttpContext c,PurchaseStore s)=>s.Admin((Account)c.Items["account"]!,q));
admin.MapGet("/purchase/prices",(HttpContext c,PurchaseStore s)=>s.AdminPrices((Account)c.Items["account"]!));
admin.MapPost("/purchase/prices",(UpdatePurchasePricesRequest r,HttpContext c,PurchaseStore s)=>s.UpdatePrices((Account)c.Items["account"]!,r));
#endif
app.MapGet("/api/content",(HttpContext c,ContentStore store)=>store.Get((Account)c.Items["account"]!)).AddEndpointFilter(new AuthFilter(false));
app.MapGet("/api/public/content",(ContentStore store)=>store.Public());
admin.MapGet("/content",(HttpContext c,ContentStore store)=>store.Get((Account)c.Items["account"]!,true));
admin.MapPost("/content",(UpdateContentRequest r,HttpContext c,ContentStore store)=>store.Update((Account)c.Items["account"]!,r));
admin.MapPost("/support",(UpdateSupportRequest r,HttpContext c,ContentStore store)=>store.UpdateSupport((Account)c.Items["account"]!,r));
admin.MapPost("/about",(UpdateAboutRequest r,HttpContext c,ContentStore store)=>store.UpdateAbout((Account)c.Items["account"]!,r));
#if COMMUNITY_APP
admin.MapGet("/community/settings",(HttpContext c,CommunityStore s)=>s.Settings((Account)c.Items["account"]!));
admin.MapPost("/community/settings",(UpdateCommunitySettingsRequest r,HttpContext c,CommunityStore s)=>s.UpdateSettings((Account)c.Items["account"]!,r));
admin.MapGet("/community/labels",(string? q,HttpContext c,CommunityStore s)=>s.UserLabels((Account)c.Items["account"]!,q));
admin.MapPost("/community/labels/{id}",(string id,UpdateUserLabelRequest r,HttpContext c,CommunityStore s)=>s.SetUserLabel((Account)c.Items["account"]!,id,r));
admin.MapPost("/community/posts/{id:long}/pin",(long id,PinRequest r,HttpContext c,CommunityStore s)=>s.Pin((Account)c.Items["account"]!,id,r.Pinned));
admin.MapPost("/community/media",async(HttpContext c,CommunityMediaStore s)=>await s.UploadRecommendation((Account)c.Items["account"]!,c.Request,c.RequestAborted));
admin.MapPost("/community/recommendations",async(HttpContext c,CommunityStore s)=>
{var r=await c.Request.ReadFromJsonAsync<PublishPostRequest>(c.RequestAborted)??throw new ArgumentException("请填写推荐内容");if(r.Kind!="RECOMMEND")throw new ArgumentException("仅支持官方推荐");return s.Publish((Account)c.Items["account"]!,r);});
admin.MapPost("/community/recommendations/{id:long}/edit",async(long id,HttpContext c,CommunityStore s)=>
{var r=await c.Request.ReadFromJsonAsync<PublishPostRequest>(c.RequestAborted)??throw new ArgumentException("请填写推荐内容");if(r.Kind!="RECOMMEND")throw new ArgumentException("仅支持官方推荐");return s.EditPost((Account)c.Items["account"]!,id,r);});
#endif
#if COMMUNITY_APP
admin.MapGet("/overview",(ServerStore store)=>new{accounts=store.Accounts(),audit=store.Audits()});
#else
admin.MapGet("/overview",(ServerStore store)=>new{accounts=store.Accounts(),cards=store.Cards(),licenses=store.Licenses(),audit=store.Audits()});
admin.MapPost("/cards",(IssueRequest r,HttpContext c,ServerStore store)=>Results.Ok(new{cards=store.Issue((Account)c.Items["account"]!,r.Count,r.Days,r.Batch,r.ValidDays,r.Permanent,r.Until)}));
admin.MapPost("/cards/{id}/status",(string id,StatusRequest r,HttpContext c,ServerStore store)=>{store.SetCard((Account)c.Items["account"]!,id,r.Active);return Results.Ok(new{ok=true});});
#endif
admin.MapPost("/accounts/{id}/status",(string id,StatusRequest r,HttpContext c,ServerStore store)=>{store.SetAccount((Account)c.Items["account"]!,id,r.Active);return Results.Ok(new{ok=true});});
admin.MapPost("/accounts/{id}/reset-password",(string id,ResetRequest r,HttpContext c,ServerStore store)=>{store.ResetUser((Account)c.Items["account"]!,id,r.Password);return Results.Ok(new{ok=true});});
#if !COMMUNITY_APP
admin.MapPost("/licenses/{id}/status",(string id,StatusRequest r,HttpContext c,ServerStore store)=>{store.SetLicense((Account)c.Items["account"]!,id,r.Active);return Results.Ok(new{ok=true});});
#endif
#if COMMUNITY_APP
var community=app.MapGroup("/api/community").AddEndpointFilter(new AuthFilter(false)).AddEndpointFilter(new CommunityDeviceFilter());
Account Viewer(HttpContext c)=>(Account)c.Items["account"]!;
community.MapGet("/access",(HttpContext c,ServerStore s)=>s.Access(Viewer(c)));
community.MapGet("/agreement",(ServerStore s)=>s.Agreement());
community.MapPost("/agreement/accept",(AcceptAgreementRequest r,HttpContext c,ServerStore s)=>s.AcceptAgreement(Viewer(c),r));
var safety=app.MapGroup("/api/safety").AddEndpointFilter(new AuthFilter(false)).AddEndpointFilter(new CommunityDeviceFilter()).AddEndpointFilter(new PrivateTransportFilter());
safety.MapPost("/documents",async(HttpContext c,IdentityVault s)=>await s.Upload(Viewer(c),c.Request,c.RequestAborted)).RequireRateLimiting("community");
safety.MapGet("/documents/{id}",(string id,HttpContext c,IdentityVault s)=>{var file=s.Download(Viewer(c),id);return Results.File(file.Bytes,file.Mime);});
safety.MapGet("/identity",(HttpContext c,IdentityVault s)=>s.OwnApplication(Viewer(c)));
safety.MapPost("/identity",(IdentityForm r,HttpContext c,IdentityVault s)=>s.Submit(Viewer(c),r)).RequireRateLimiting("private");
safety.MapGet("/reports",(HttpContext c,IdentityVault s)=>s.Reports(Viewer(c),false));
safety.MapPost("/reports",(ReportRequest r,HttpContext c,IdentityVault s)=>s.Report(Viewer(c),r)).RequireRateLimiting("private");
safety.MapPost("/exchanges",(ExchangeRequest r,HttpContext c,IdentityVault s)=>s.RequestExchange(Viewer(c),r)).RequireRateLimiting("private");
safety.MapGet("/exchanges/{id}",(string id,HttpContext c,IdentityVault s)=>s.Exchange(Viewer(c),id));
safety.MapPost("/exchanges/{id}",(string id,ExchangeDecision r,HttpContext c,IdentityVault s)=>s.DecideExchange(Viewer(c),id,r)).RequireRateLimiting("private");
admin.MapGet("/safety/governance",(HttpContext c,ServerStore s)=>s.Governance(Viewer(c)));
admin.MapPost("/safety/governance",(UpdateGovernanceRequest r,HttpContext c,ServerStore s)=>s.UpdateGovernance(Viewer(c),r));
admin.MapPost("/accounts/{id}/community-ban",(string id,CommunityBanRequest r,HttpContext c,ServerStore s)=>{s.SetCommunityBan(Viewer(c),id,r);return new OperationResult(true);});
var privateAdmin=admin.MapGroup("/safety").AddEndpointFilter(new PrivateTransportFilter());
privateAdmin.MapGet("/identities",(HttpContext c,IdentityVault s)=>s.Applications(Viewer(c)));
privateAdmin.MapGet("/identities/{id}",(string id,HttpContext c,IdentityVault s)=>s.ReviewApplication(Viewer(c),id));
privateAdmin.MapPost("/identities/{id}",(string id,IdentityDecision r,HttpContext c,IdentityVault s)=>s.Decide(Viewer(c),id,r));
privateAdmin.MapPost("/identities/{id}/provider-check",(string id,IdentityProviderCheckRequest r,HttpContext c,IdentityVault s)=>s.ProviderCheck(Viewer(c),id,r.Revision,c.RequestAborted)).RequireRateLimiting("private");
privateAdmin.MapGet("/reports",(HttpContext c,IdentityVault s)=>s.Reports(Viewer(c),true));
privateAdmin.MapPost("/reports/{id}",(string id,ReportDecision r,HttpContext c,IdentityVault s)=>s.DecideReport(Viewer(c),id,r));
community.MapGet("/contacts",(string? q,HttpContext c,CommunityStore s)=>s.Contacts(Viewer(c),q));
community.MapGet("/follows",(string? q,HttpContext c,CommunityStore s)=>s.Followed(Viewer(c),q));
community.MapPost("/follows/{peerId}",(string peerId,FollowRequest r,HttpContext c,CommunityStore s)=>s.Follow(Viewer(c),peerId,r)).RequireRateLimiting("community");
community.MapGet("/settings",(HttpContext c,CommunityStore s)=>s.Settings(Viewer(c)));
community.MapGet("/supply/quota",(HttpContext c,CommunityStore s)=>s.SupplyQuota(Viewer(c)));
community.MapPost("/posts/{id:long}/shelf",(long id,ShelfRequest r,HttpContext c,CommunityStore s)=>s.Shelf(Viewer(c),id,r.Active)).RequireRateLimiting("community");
community.MapGet("/feed",([AsParameters] FeedFilter filter,HttpContext c,CommunityStore s)=>s.Feed(Viewer(c),filter));
community.MapGet("/posts",(long? before,HttpContext c,CommunityStore s)=>s.Posts(Viewer(c),before??0));
community.MapGet("/posts/{id:long}",(long id,HttpContext c,CommunityStore s)=>s.GetPost(Viewer(c),id));
community.MapPost("/posts",(PublishPostRequest r,HttpContext c,CommunityStore s)=>s.Publish(Viewer(c),r)).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/delete",(long id,HttpContext c,CommunityStore s)=>{s.DeletePost(Viewer(c),id);return new OperationResult(true);}).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/edit",(long id,PublishPostRequest r,HttpContext c,CommunityStore s)=>s.EditPost(Viewer(c),id,r)).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/refresh",(long id,HttpContext c,CommunityStore s)=>s.Bump(Viewer(c),id)).RequireRateLimiting("community");
community.MapPost("/posts/refresh-all",(HttpContext c,CommunityStore s)=>s.BumpAll(Viewer(c))).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/like",(long id,LikeRequest r,HttpContext c,CommunityStore s)=>s.Like(Viewer(c),id,r.Liked)).RequireRateLimiting("community");
community.MapGet("/posts/{id:long}/comments",(long id,long? before,HttpContext c,CommunityStore s)=>s.Comments(Viewer(c),id,before??0));
community.MapPost("/posts/{id:long}/comments",(long id,PublishCommentRequest r,HttpContext c,CommunityStore s)=>s.AddComment(Viewer(c),id,r)).RequireRateLimiting("community");
community.MapPost("/comments/{id:long}/delete",(long id,HttpContext c,CommunityStore s)=>{s.DeleteComment(Viewer(c),id);return new OperationResult(true);}).RequireRateLimiting("community");
community.MapGet("/chat/conversations",(HttpContext c,CommunityStore s)=>s.Conversations(Viewer(c)));
community.MapGet("/chat/reminders",(long? after,HttpContext c,CommunityStore s)=>s.Inbox(Viewer(c),after??0));
community.MapGet("/chat/messages",(string peerId,long? after,long? before,HttpContext c,CommunityStore s)=>s.Messages(Viewer(c),peerId,after??0,before??0));
community.MapGet("/chat/sync",(long? after,HttpContext c,CommunityStore s)=>s.SyncMessages(Viewer(c),after??0));
community.MapPost("/chat/messages",(SendMessageRequest r,HttpContext c,CommunityStore s)=>s.Send(Viewer(c),r)).RequireRateLimiting("community");
community.MapPost("/chat/read",(MarkReadRequest r,HttpContext c,CommunityStore s)=>{s.MarkRead(Viewer(c),r);return new OperationResult(true);});
community.MapPost("/media",async(HttpContext c,CommunityMediaStore s)=>await s.Upload(Viewer(c),c.Request,c.RequestAborted)).RequireRateLimiting("community");
community.MapGet("/media/{id}",(string id,HttpContext c,CommunityMediaStore s)=>{var file=s.Download(Viewer(c),id);return Results.File(file.Path,file.Mime,enableRangeProcessing:true);});
// Older clients receive a clear retirement response without creating calls or issuing relay credentials.
community.MapMethods("/calls/{**path}",[HttpMethods.Get,HttpMethods.Post],()=>Results.Json(new{error="实时语音、视频通话已取消,请使用私信消息。"},statusCode:StatusCodes.Status410Gone));
#endif
app.Run();
public sealed record LoginRequest(string Username,string Password,bool Admin=false,LegalConsent? Consent=null);
public sealed record RegisterRequest(string Username,string Password,string Name,LegalConsent? Consent=null);
#if !COMMUNITY_APP
public sealed record ActivateRequest(string DeviceId,string Code);
public sealed record IssueRequest(int Count,int Days,string Batch,int ValidDays=365,bool Permanent=false,DateTimeOffset? Until=null);
#endif
public sealed record StatusRequest(bool Active);
public sealed record ResetRequest(string Password);
public sealed record PasswordRequest(string OldPassword,string Password);
#if COMMUNITY_APP
public sealed class CommunityDeviceFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,EndpointFilterDelegate next)
{
var c=context.HttpContext;
var device=c.Request.Headers["X-Device-Id"].ToString();
ServerStore.ValidateDevice(device);
return await next(context);
}
}
#endif
public sealed class PrivateTransportFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,EndpointFilterDelegate next)
{
var c=context.HttpContext;if(!c.Request.IsHttps&&!c.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("PrivateHttpTrial"))return Results.Json(new{error="私密资料接口需要 HTTPS;本机 HTTP 试用需由服务器代码配置启用"},statusCode:426);
return await next(context);
}
}
public sealed class AuthFilter(bool admin) : IEndpointFilter
{
public static string Token(HttpContext c) {var header=c.Request.Headers.Authorization.ToString();return header.StartsWith("Bearer ",StringComparison.Ordinal)?header[7..]:c.Request.Cookies[ServerProduct.Cookie]??"";}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,EndpointFilterDelegate next)
{
var c=context.HttpContext;var account=c.RequestServices.GetRequiredService<ServerStore>().Session(Token(c));
if(account is null)return Results.Json(new{error="登录已过期,请重新登录"},statusCode:401);
if(admin&&account.Role!="admin")return Results.Json(new{error="需要管理员权限"},statusCode:403);
c.Items["account"]=account;return await next(context);
}
}
using JiDangJia.Server;
using JiDangJia.Contracts;
using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;
var builder=WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o=>o.Limits.MaxRequestBodySize=32768);
builder.Services.AddSingleton(new ServerStore(builder.Configuration["DataDirectory"]??PrivateStorage.DefaultDirectory));
#if COMMUNITY_APP
builder.Services.AddSingleton<CommunityStore>();
builder.Services.AddSingleton<CommunityMediaStore>();
builder.Services.AddSingleton<MediaWatermark>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CommunityChatRetention>();
builder.Services.AddHostedService<CommunityChatRetentionWorker>();
#endif
builder.Services.AddSingleton<ContentStore>();
#if COMMUNITY_APP
builder.Services.AddSingleton<IdentityVault>();
builder.Services.AddSingleton<IIdentityVerificationProvider,ManualIdentityVerificationProvider>();
#endif
builder.Services.AddSingleton<IPhoneCodeSender,TencentPhoneCodeSender>();
builder.Services.AddSingleton<PhoneVerification>();
#if !COMMUNITY_APP
builder.Services.AddSingleton<AlipayGateway>();
builder.Services.AddSingleton<PurchaseStore>();
#endif
builder.Services.AddSingleton<AppReleaseStore>();
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o=>o.MultipartBodyLengthLimit=AppUpdateRules.MaxApkSize+65536);
// Content and private messages publish directly. A future cloud moderation integration is not enabled.
builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(options=>
{
options.ForwardedHeaders=Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor|Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
foreach(var address in (builder.Configuration["JDJ_TRUSTED_PROXIES"]??"").Split(',',StringSplitOptions.RemoveEmptyEntries))options.KnownProxies.Add(System.Net.IPAddress.Parse(address.Trim()));
});
builder.Services.AddRateLimiter(options=>
{
options.RejectionStatusCode=429;
options.AddPolicy("auth",context=>RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString()??"unknown",_=>new FixedWindowRateLimiterOptions {PermitLimit=20,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
options.AddPolicy("activate",context=>RateLimitPartition.GetFixedWindowLimiter(context.Connection.RemoteIpAddress?.ToString()??"unknown",_=>new FixedWindowRateLimiterOptions {PermitLimit=12,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
options.AddPolicy("community",context=>RateLimitPartition.GetFixedWindowLimiter(ServerStore.Hash(AuthFilter.Token(context)),_=>new FixedWindowRateLimiterOptions {PermitLimit=60,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
options.AddPolicy("private",context=>RateLimitPartition.GetFixedWindowLimiter(ServerStore.Hash(AuthFilter.Token(context)),_=>new FixedWindowRateLimiterOptions {PermitLimit=30,Window=TimeSpan.FromMinutes(1),QueueLimit=0}));
});
var app=builder.Build();
if(!string.IsNullOrWhiteSpace(builder.Configuration["JDJ_TRUSTED_PROXIES"]))app.UseForwardedHeaders();
#if COMMUNITY_APP
app.Services.GetRequiredService<CommunityStore>();
#endif
app.Services.GetRequiredService<ContentStore>();
#if COMMUNITY_APP
app.Services.GetRequiredService<IdentityVault>();
#endif
app.Services.GetRequiredService<PhoneVerification>();
#if !COMMUNITY_APP
app.Services.GetRequiredService<PurchaseStore>();
#endif
app.Services.GetRequiredService<AppReleaseStore>();
app.Use(async(context,next)=>
{
if(context.Request.Path=="/api/admin/updates"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=AppUpdateRules.MaxApkSize+65536;}
if((context.Request.Path=="/api/admin/content" || context.Request.Path=="/api/admin/support") && context.Request.Method=="POST")
{
var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();
if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=6*1024*1024;
}
if(context.Request.Path=="/api/admin/about"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=64*1024;}
#if COMMUNITY_APP
if(context.Request.Path=="/api/community/media"&&context.Request.Method=="POST")
{
var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=CommunityMediaStore.MaxSize;
}
if(context.Request.Path.StartsWithSegments("/api/admin/community/recommendations")||context.Request.Path=="/api/admin/community/media")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=null;}
if(context.Request.Path=="/api/safety/documents"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=IdentityVault.MaxDocumentSize;}
if(context.Request.Path=="/api/admin/safety/governance"&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=65536;}
#endif
if(context.Request.Path.StartsWithSegments("/api/admin/legal")&&context.Request.Method=="POST")
{var limit=context.Features.Get<Microsoft.AspNetCore.Http.Features.IHttpMaxRequestBodySizeFeature>();if(limit is {IsReadOnly:false})limit.MaxRequestBodySize=512*1024;}
context.Response.Headers["X-Content-Type-Options"]="nosniff";context.Response.Headers["X-Frame-Options"]="DENY";context.Response.Headers["Referrer-Policy"]="no-referrer";
context.Response.Headers["Content-Security-Policy"]="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
if(context.Request.Path.StartsWithSegments("/api"))context.Response.Headers.CacheControl="no-store";
if(context.Request.Method is "POST" or "PATCH" or "DELETE" && context.Request.Headers.Origin.Count>0)
{
var origin=context.Request.Headers.Origin.ToString();var own=context.Request.Scheme+"://"+context.Request.Host;
if(!string.Equals(origin,own,StringComparison.OrdinalIgnoreCase)){context.Response.StatusCode=403;await context.Response.WriteAsJsonAsync(new{error="来源验证失败"});return;}
}
try{await next();}catch(CommunityException ex){context.Response.StatusCode=ex.Status;await context.Response.WriteAsJsonAsync(new{error=ex.Message});}
catch(Exception ex)when(ex is ArgumentException or UnauthorizedAccessException or InvalidOperationException or System.Text.Json.JsonException or InvalidDataException)
{context.Response.StatusCode=ex is UnauthorizedAccessException?401:400;await context.Response.WriteAsJsonAsync(new{error=ex.Message});}
});
app.UseRateLimiter();
app.MapGet("/",()=>Results.Ok(new{app=ServerProduct.Name,product=ServerProduct.Id,message="管理员后台位于 APP 内,使用管理员账号登录后在设置中进入。"}));
app.MapGet("/api/health",()=>new{status="ok",app=ServerProduct.Name,product=ServerProduct.Id,version=ServerProduct.Version,utc=DateTime.UtcNow});
app.MapGet("/api/updates/latest",(int? versionCode,AppReleaseStore s)=>new AppUpdateOffer(s.Latest(versionCode??0)));
app.MapGet("/api/updates/{id}/apk",(string id,AppReleaseStore s)=>Results.File(s.Download(id),"application/vnd.android.package-archive",enableRangeProcessing:true));
app.MapGet("/api/legal",(ServerStore store)=>store.Legal());
app.MapPost("/api/auth/register",(RegisterRequest r,ServerStore store)=>{var a=store.Register(r.Username,r.Password,r.Name,r.Consent??throw new CommunityException(428,"请先阅读并勾选同意注册登录条款"));return Results.Ok(new{account=a});}).RequireRateLimiting("auth");
app.MapPost("/api/auth/login",(LoginRequest r,HttpContext context,ServerStore store)=>
{
var (account,token)=store.Login(r.Username,r.Password,r.Consent??throw new CommunityException(428,"请先阅读并勾选同意注册登录条款"));
if(r.Admin)
{if(account.Role!="admin"){store.Logout(token);return Results.Json(new{error="此账号没有管理员权限"},statusCode:403);}context.Response.Cookies.Append(ServerProduct.Cookie,token,new CookieOptions {HttpOnly=true,Secure=context.Request.IsHttps,SameSite=SameSiteMode.Strict,MaxAge=TimeSpan.FromDays(7),Path="/"});return Results.Ok(new{account});}
return Results.Ok(new{account,token,expiresAt=DateTime.UtcNow.AddDays(7)});
}).RequireRateLimiting("auth");
var users=app.MapGroup("/api/user").AddEndpointFilter(new AuthFilter(false));
users.MapGet("/me",(HttpContext c)=>Results.Ok(new{account=(Account)c.Items["account"]!}));
users.MapGet("/legal",(HttpContext c,ServerStore store)=>store.LegalStatus((Account)c.Items["account"]!));
users.MapPost("/password",(PasswordRequest r,HttpContext c,ServerStore store)=>{store.ChangePassword((Account)c.Items["account"]!,r.OldPassword,r.Password);c.Response.Cookies.Delete(ServerProduct.Cookie);return Results.Ok(new{ok=true});});
users.MapPost("/logout",(HttpContext c,ServerStore store)=>{store.Logout(AuthFilter.Token(c));c.Response.Cookies.Delete(ServerProduct.Cookie);return Results.Ok(new{ok=true});});
#if !COMMUNITY_APP
users.MapGet("/license",(string deviceId,HttpContext c,ServerStore store)=>store.License((Account)c.Items["account"]!,deviceId));
users.MapPost("/activate",(ActivateRequest r,HttpContext c,ServerStore store)=>store.Activate((Account)c.Items["account"]!,r.DeviceId,r.Code)).RequireRateLimiting("activate");
#endif
users.MapPost("/password/verify",(PasswordVerificationRequest r,HttpContext c,ServerStore s)=>{s.VerifyOwnPassword((Account)c.Items["account"]!,r.Password);return new OperationResult(true);}).RequireRateLimiting("auth");
#if COMMUNITY_APP
users.MapPost("/data-clear",(ClearMyDataRequest r,HttpContext c,ServerStore s,IdentityVault vault)=>{var account=(Account)c.Items["account"]!;var result=s.ClearOwnChat(account,r);vault.CancelPending(account);return result;}).RequireRateLimiting("activate");
#else
users.MapPost("/data-clear",(ClearMyDataRequest r,HttpContext c,ServerStore s)=>s.ConfirmLocalClear((Account)c.Items["account"]!,r)).RequireRateLimiting("activate");
#endif
users.MapGet("/phone",(HttpContext c,PhoneVerification s)=>s.Status((Account)c.Items["account"]!));
users.MapPost("/phone/code",async(PhoneCodeRequest r,HttpContext c,PhoneVerification s)=>await s.Send((Account)c.Items["account"]!,r,c.RequestAborted)).RequireRateLimiting("activate");
users.MapPost("/phone/bind",(PhoneBindRequest r,HttpContext c,PhoneVerification s)=>s.Bind((Account)c.Items["account"]!,r)).RequireRateLimiting("activate");
#if !COMMUNITY_APP
app.MapGet("/api/purchase",(PurchaseStore s)=>s.Offer());
app.MapPost("/api/purchase/orders",(CreatePurchaseRequest r,PurchaseStore s)=>s.Create(r)).RequireRateLimiting("activate");
app.MapGet("/api/purchase/orders/{id}",(string id,HttpContext c,PurchaseStore s)=>s.Get(id,c.Request.Headers["X-Purchase-Key"].ToString())).RequireRateLimiting("auth");
app.MapPost("/api/purchase/orders/{id}/checkout",(string id,HttpContext c,PurchaseStore s)=>s.Checkout(id,c.Request.Headers["X-Purchase-Key"].ToString())).RequireRateLimiting("activate");
app.MapPost("/api/purchase/alipay/notify",async(HttpContext c,PurchaseStore s)=>
{
if(c.Request.ContentType is null||!c.Request.ContentType.StartsWith("application/x-www-form-urlencoded",StringComparison.OrdinalIgnoreCase))return Results.Text("failure");
try
{
var form=await c.Request.ReadFormAsync(c.RequestAborted);if(form.Any(p=>p.Value.Count!=1))return Results.Text("failure");
return Results.Text(s.Notify(form.ToDictionary(p=>p.Key,p=>p.Value.ToString(),StringComparer.Ordinal))?"success":"failure");
}
catch(Exception ex)when(ex is Microsoft.Data.Sqlite.SqliteException or System.Security.Cryptography.CryptographicException or InvalidDataException){return Results.Text("failure");}
});
app.MapGet("/purchase/return",()=>Results.Content("<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>"+ServerProduct.Name+"购买订单</title><h2>请返回"+ServerProduct.Name+" APP 查询订单</h2><p>付款结果以支付宝通知确认为准。在购买页点击“查询付款结果”,确认成功后领取卡密。如已付款,请勿重复支付。</p></html>","text/html; charset=utf-8"));
#endif
var admin=app.MapGroup("/api/admin").AddEndpointFilter(new AuthFilter(true));
admin.MapGet("/legal",(ServerStore store)=>store.Legal());
admin.MapPost("/legal/{key}",(string key,PublishLegalRequest r,HttpContext c,ServerStore store)=>store.PublishLegal((Account)c.Items["account"]!,key,r)).RequireRateLimiting("private");
admin.MapGet("/legal/{key}/versions",(string key,long? before,HttpContext c,ServerStore store)=>store.LegalHistory((Account)c.Items["account"]!,key,before??0));
admin.MapGet("/legal/{key}/versions/{version:long}",(string key,long version,HttpContext c,ServerStore store)=>store.LegalVersion((Account)c.Items["account"]!,key,version));
admin.MapGet("/updates",(HttpContext c,AppReleaseStore s)=>s.Releases((Account)c.Items["account"]!));
admin.MapPost("/updates",async(HttpContext c,AppReleaseStore s)=>
{
if(!c.Request.HasFormContentType)throw new ArgumentException("请选择 APK 并填写更新说明");
var form=await c.Request.ReadFormAsync(c.RequestAborted);if(form.Files.Count!=1||form["metadata"].Count!=1)throw new ArgumentException("请上传一个 APK 安装包");
var request=System.Text.Json.JsonSerializer.Deserialize<PublishReleaseRequest>(form["metadata"].ToString(),new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))??throw new ArgumentException("版本设置无效");
await using var stream=form.Files[0].OpenReadStream();return await s.Publish((Account)c.Items["account"]!,stream,request,c.RequestAborted);
}).RequireRateLimiting("private");
admin.MapPost("/updates/{id}/withdraw",(string id,HttpContext c,AppReleaseStore s)=>{s.Withdraw((Account)c.Items["account"]!,id);return new OperationResult(true);});
admin.MapPost("/updates/clean",(HttpContext c,AppReleaseStore s)=>new{removed=s.CleanOld((Account)c.Items["account"]!)});
#if !COMMUNITY_APP
admin.MapGet("/purchase",(string? q,HttpContext c,PurchaseStore s)=>s.Admin((Account)c.Items["account"]!,q));
admin.MapGet("/purchase/prices",(HttpContext c,PurchaseStore s)=>s.AdminPrices((Account)c.Items["account"]!));
admin.MapPost("/purchase/prices",(UpdatePurchasePricesRequest r,HttpContext c,PurchaseStore s)=>s.UpdatePrices((Account)c.Items["account"]!,r));
#endif
app.MapGet("/api/content",(HttpContext c,ContentStore store)=>store.Get((Account)c.Items["account"]!)).AddEndpointFilter(new AuthFilter(false));
app.MapGet("/api/public/content",(ContentStore store)=>store.Public());
admin.MapGet("/content",(HttpContext c,ContentStore store)=>store.Get((Account)c.Items["account"]!,true));
admin.MapPost("/content",(UpdateContentRequest r,HttpContext c,ContentStore store)=>store.Update((Account)c.Items["account"]!,r));
admin.MapPost("/support",(UpdateSupportRequest r,HttpContext c,ContentStore store)=>store.UpdateSupport((Account)c.Items["account"]!,r));
admin.MapPost("/about",(UpdateAboutRequest r,HttpContext c,ContentStore store)=>store.UpdateAbout((Account)c.Items["account"]!,r));
#if COMMUNITY_APP
admin.MapGet("/community/settings",(HttpContext c,CommunityStore s)=>s.Settings((Account)c.Items["account"]!));
admin.MapPost("/community/settings",(UpdateCommunitySettingsRequest r,HttpContext c,CommunityStore s)=>s.UpdateSettings((Account)c.Items["account"]!,r));
admin.MapGet("/community/labels",(string? q,HttpContext c,CommunityStore s)=>s.UserLabels((Account)c.Items["account"]!,q));
admin.MapPost("/community/labels/{id}",(string id,UpdateUserLabelRequest r,HttpContext c,CommunityStore s)=>s.SetUserLabel((Account)c.Items["account"]!,id,r));
admin.MapPost("/community/posts/{id:long}/pin",(long id,PinRequest r,HttpContext c,CommunityStore s)=>s.Pin((Account)c.Items["account"]!,id,r.Pinned));
admin.MapPost("/community/media",async(HttpContext c,CommunityMediaStore s)=>await s.UploadRecommendation((Account)c.Items["account"]!,c.Request,c.RequestAborted));
admin.MapPost("/community/recommendations",async(HttpContext c,CommunityStore s)=>
{var r=await c.Request.ReadFromJsonAsync<PublishPostRequest>(c.RequestAborted)??throw new ArgumentException("请填写推荐内容");if(r.Kind!="RECOMMEND")throw new ArgumentException("仅支持官方推荐");return s.Publish((Account)c.Items["account"]!,r);});
admin.MapPost("/community/recommendations/{id:long}/edit",async(long id,HttpContext c,CommunityStore s)=>
{var r=await c.Request.ReadFromJsonAsync<PublishPostRequest>(c.RequestAborted)??throw new ArgumentException("请填写推荐内容");if(r.Kind!="RECOMMEND")throw new ArgumentException("仅支持官方推荐");return s.EditPost((Account)c.Items["account"]!,id,r);});
#endif
#if COMMUNITY_APP
admin.MapGet("/overview",(ServerStore store)=>new{accounts=store.Accounts(),audit=store.Audits()});
#else
admin.MapGet("/overview",(ServerStore store)=>new{accounts=store.Accounts(),cards=store.Cards(),licenses=store.Licenses(),audit=store.Audits()});
admin.MapPost("/cards",(IssueRequest r,HttpContext c,ServerStore store)=>Results.Ok(new{cards=store.Issue((Account)c.Items["account"]!,r.Count,r.Days,r.Batch,r.ValidDays,r.Permanent,r.Until)}));
admin.MapPost("/cards/{id}/status",(string id,StatusRequest r,HttpContext c,ServerStore store)=>{store.SetCard((Account)c.Items["account"]!,id,r.Active);return Results.Ok(new{ok=true});});
#endif
admin.MapPost("/accounts/{id}/status",(string id,StatusRequest r,HttpContext c,ServerStore store)=>{store.SetAccount((Account)c.Items["account"]!,id,r.Active);return Results.Ok(new{ok=true});});
admin.MapPost("/accounts/{id}/reset-password",(string id,ResetRequest r,HttpContext c,ServerStore store)=>{store.ResetUser((Account)c.Items["account"]!,id,r.Password);return Results.Ok(new{ok=true});});
#if !COMMUNITY_APP
admin.MapPost("/licenses/{id}/status",(string id,StatusRequest r,HttpContext c,ServerStore store)=>{store.SetLicense((Account)c.Items["account"]!,id,r.Active);return Results.Ok(new{ok=true});});
#endif
#if COMMUNITY_APP
var community=app.MapGroup("/api/community").AddEndpointFilter(new AuthFilter(false)).AddEndpointFilter(new CommunityDeviceFilter());
Account Viewer(HttpContext c)=>(Account)c.Items["account"]!;
community.MapGet("/access",(HttpContext c,ServerStore s)=>s.Access(Viewer(c)));
community.MapGet("/agreement",(ServerStore s)=>s.Agreement());
community.MapPost("/agreement/accept",(AcceptAgreementRequest r,HttpContext c,ServerStore s)=>s.AcceptAgreement(Viewer(c),r));
var safety=app.MapGroup("/api/safety").AddEndpointFilter(new AuthFilter(false)).AddEndpointFilter(new CommunityDeviceFilter()).AddEndpointFilter(new PrivateTransportFilter());
safety.MapPost("/documents",async(HttpContext c,IdentityVault s)=>await s.Upload(Viewer(c),c.Request,c.RequestAborted)).RequireRateLimiting("community");
safety.MapGet("/documents/{id}",(string id,HttpContext c,IdentityVault s)=>{var file=s.Download(Viewer(c),id);return Results.File(file.Bytes,file.Mime);});
safety.MapGet("/identity",(HttpContext c,IdentityVault s)=>s.OwnApplication(Viewer(c)));
safety.MapPost("/identity",(IdentityForm r,HttpContext c,IdentityVault s)=>s.Submit(Viewer(c),r)).RequireRateLimiting("private");
safety.MapGet("/reports",(HttpContext c,IdentityVault s)=>s.Reports(Viewer(c),false));
safety.MapPost("/reports",(ReportRequest r,HttpContext c,IdentityVault s)=>s.Report(Viewer(c),r)).RequireRateLimiting("private");
safety.MapPost("/exchanges",(ExchangeRequest r,HttpContext c,IdentityVault s)=>s.RequestExchange(Viewer(c),r)).RequireRateLimiting("private");
safety.MapGet("/exchanges/{id}",(string id,HttpContext c,IdentityVault s)=>s.Exchange(Viewer(c),id));
safety.MapPost("/exchanges/{id}",(string id,ExchangeDecision r,HttpContext c,IdentityVault s)=>s.DecideExchange(Viewer(c),id,r)).RequireRateLimiting("private");
admin.MapGet("/safety/governance",(HttpContext c,ServerStore s)=>s.Governance(Viewer(c)));
admin.MapPost("/safety/governance",(UpdateGovernanceRequest r,HttpContext c,ServerStore s)=>s.UpdateGovernance(Viewer(c),r));
admin.MapPost("/accounts/{id}/community-ban",(string id,CommunityBanRequest r,HttpContext c,ServerStore s)=>{s.SetCommunityBan(Viewer(c),id,r);return new OperationResult(true);});
var privateAdmin=admin.MapGroup("/safety").AddEndpointFilter(new PrivateTransportFilter());
privateAdmin.MapGet("/identities",(HttpContext c,IdentityVault s)=>s.Applications(Viewer(c)));
privateAdmin.MapGet("/identities/{id}",(string id,HttpContext c,IdentityVault s)=>s.ReviewApplication(Viewer(c),id));
privateAdmin.MapPost("/identities/{id}",(string id,IdentityDecision r,HttpContext c,IdentityVault s)=>s.Decide(Viewer(c),id,r));
privateAdmin.MapPost("/identities/{id}/provider-check",(string id,IdentityProviderCheckRequest r,HttpContext c,IdentityVault s)=>s.ProviderCheck(Viewer(c),id,r.Revision,c.RequestAborted)).RequireRateLimiting("private");
privateAdmin.MapGet("/reports",(HttpContext c,IdentityVault s)=>s.Reports(Viewer(c),true));
privateAdmin.MapPost("/reports/{id}",(string id,ReportDecision r,HttpContext c,IdentityVault s)=>s.DecideReport(Viewer(c),id,r));
community.MapGet("/contacts",(string? q,HttpContext c,CommunityStore s)=>s.Contacts(Viewer(c),q));
community.MapGet("/follows",(string? q,HttpContext c,CommunityStore s)=>s.Followed(Viewer(c),q));
community.MapPost("/follows/{peerId}",(string peerId,FollowRequest r,HttpContext c,CommunityStore s)=>s.Follow(Viewer(c),peerId,r)).RequireRateLimiting("community");
community.MapGet("/settings",(HttpContext c,CommunityStore s)=>s.Settings(Viewer(c)));
community.MapGet("/supply/quota",(HttpContext c,CommunityStore s)=>s.SupplyQuota(Viewer(c)));
community.MapPost("/posts/{id:long}/shelf",(long id,ShelfRequest r,HttpContext c,CommunityStore s)=>s.Shelf(Viewer(c),id,r.Active)).RequireRateLimiting("community");
community.MapGet("/feed",([AsParameters] FeedFilter filter,HttpContext c,CommunityStore s)=>s.Feed(Viewer(c),filter));
community.MapGet("/posts",(long? before,HttpContext c,CommunityStore s)=>s.Posts(Viewer(c),before??0));
community.MapGet("/posts/{id:long}",(long id,HttpContext c,CommunityStore s)=>s.GetPost(Viewer(c),id));
community.MapPost("/posts",(PublishPostRequest r,HttpContext c,CommunityStore s)=>s.Publish(Viewer(c),r)).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/delete",(long id,HttpContext c,CommunityStore s)=>{s.DeletePost(Viewer(c),id);return new OperationResult(true);}).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/edit",(long id,PublishPostRequest r,HttpContext c,CommunityStore s)=>s.EditPost(Viewer(c),id,r)).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/refresh",(long id,HttpContext c,CommunityStore s)=>s.Bump(Viewer(c),id)).RequireRateLimiting("community");
community.MapPost("/posts/refresh-all",(HttpContext c,CommunityStore s)=>s.BumpAll(Viewer(c))).RequireRateLimiting("community");
community.MapPost("/posts/{id:long}/like",(long id,LikeRequest r,HttpContext c,CommunityStore s)=>s.Like(Viewer(c),id,r.Liked)).RequireRateLimiting("community");
community.MapGet("/posts/{id:long}/comments",(long id,long? before,HttpContext c,CommunityStore s)=>s.Comments(Viewer(c),id,before??0));
community.MapPost("/posts/{id:long}/comments",(long id,PublishCommentRequest r,HttpContext c,CommunityStore s)=>s.AddComment(Viewer(c),id,r)).RequireRateLimiting("community");
community.MapPost("/comments/{id:long}/delete",(long id,HttpContext c,CommunityStore s)=>{s.DeleteComment(Viewer(c),id);return new OperationResult(true);}).RequireRateLimiting("community");
community.MapGet("/chat/conversations",(HttpContext c,CommunityStore s)=>s.Conversations(Viewer(c)));
community.MapGet("/chat/reminders",(long? after,HttpContext c,CommunityStore s)=>s.Inbox(Viewer(c),after??0));
community.MapGet("/chat/messages",(string peerId,long? after,long? before,HttpContext c,CommunityStore s)=>s.Messages(Viewer(c),peerId,after??0,before??0));
community.MapGet("/chat/sync",(long? after,HttpContext c,CommunityStore s)=>s.SyncMessages(Viewer(c),after??0));
community.MapPost("/chat/messages",(SendMessageRequest r,HttpContext c,CommunityStore s)=>s.Send(Viewer(c),r)).RequireRateLimiting("community");
community.MapPost("/chat/read",(MarkReadRequest r,HttpContext c,CommunityStore s)=>{s.MarkRead(Viewer(c),r);return new OperationResult(true);});
community.MapPost("/media",async(HttpContext c,CommunityMediaStore s)=>await s.Upload(Viewer(c),c.Request,c.RequestAborted)).RequireRateLimiting("community");
community.MapGet("/media/{id}",(string id,HttpContext c,CommunityMediaStore s)=>{var file=s.Download(Viewer(c),id);return Results.File(file.Path,file.Mime,enableRangeProcessing:true);});
// Older clients receive a clear retirement response without creating calls or issuing relay credentials.
community.MapMethods("/calls/{**path}",[HttpMethods.Get,HttpMethods.Post],()=>Results.Json(new{error="实时语音、视频通话已取消,请使用私信消息。"},statusCode:StatusCodes.Status410Gone));
#endif
app.Run();
public sealed record LoginRequest(string Username,string Password,bool Admin=false,LegalConsent? Consent=null);
public sealed record RegisterRequest(string Username,string Password,string Name,LegalConsent? Consent=null);
#if !COMMUNITY_APP
public sealed record ActivateRequest(string DeviceId,string Code);
public sealed record IssueRequest(int Count,int Days,string Batch,int ValidDays=365,bool Permanent=false,DateTimeOffset? Until=null);
#endif
public sealed record StatusRequest(bool Active);
public sealed record ResetRequest(string Password);
public sealed record PasswordRequest(string OldPassword,string Password);
#if COMMUNITY_APP
public sealed class CommunityDeviceFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,EndpointFilterDelegate next)
{
var c=context.HttpContext;
var device=c.Request.Headers["X-Device-Id"].ToString();
ServerStore.ValidateDevice(device);
return await next(context);
}
}
#endif
public sealed class PrivateTransportFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,EndpointFilterDelegate next)
{
var c=context.HttpContext;if(!c.Request.IsHttps&&!c.RequestServices.GetRequiredService<IConfiguration>().GetValue<bool>("PrivateHttpTrial"))return Results.Json(new{error="私密资料接口需要 HTTPS;本机 HTTP 试用需由服务器代码配置启用"},statusCode:426);
return await next(context);
}
}
public sealed class AuthFilter(bool admin) : IEndpointFilter
{
public static string Token(HttpContext c) {var header=c.Request.Headers.Authorization.ToString();return header.StartsWith("Bearer ",StringComparison.Ordinal)?header[7..]:c.Request.Cookies[ServerProduct.Cookie]??"";}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,EndpointFilterDelegate next)
{
var c=context.HttpContext;var account=c.RequestServices.GetRequiredService<ServerStore>().Session(Token(c));
if(account is null)return Results.Json(new{error="登录已过期,请重新登录"},statusCode:401);
if(admin&&account.Role!="admin")return Results.Json(new{error="需要管理员权限"},statusCode:403);
c.Items["account"]=account;return await next(context);
}
}