♻️ I have no idea what am I doing. Might be mixing stuff

This commit is contained in:
2025-07-14 19:55:28 +08:00
parent ef9175d27d
commit cbfdb4aa60
232 changed files with 990 additions and 115807 deletions

View File

@@ -9,6 +9,7 @@ namespace DysonNetwork.Pass.Account;
public class AccountServiceGrpc(
AppDatabase db,
RelationshipService relationships,
IClock clock,
ILogger<AccountServiceGrpc> logger
)
@@ -19,7 +20,7 @@ public class AccountServiceGrpc(
private readonly ILogger<AccountServiceGrpc>
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var accountId))
@@ -36,7 +37,8 @@ public class AccountServiceGrpc(
return account.ToProtoValue();
}
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request, ServerCallContext context)
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
ServerCallContext context)
{
var accountIds = request.Id
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
@@ -245,7 +247,8 @@ public class AccountServiceGrpc(
return new Empty();
}
public override async Task<ListContactsResponse> ListContacts(ListContactsRequest request, ServerCallContext context)
public override async Task<ListContactsResponse> ListContacts(ListContactsRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
@@ -263,7 +266,8 @@ public class AccountServiceGrpc(
return response;
}
public override async Task<Shared.Proto.AccountContact> VerifyContact(VerifyContactRequest request, ServerCallContext context)
public override async Task<Shared.Proto.AccountContact> VerifyContact(VerifyContactRequest request,
ServerCallContext context)
{
// This is a placeholder implementation. In a real-world scenario, you would
// have a more robust verification mechanism (e.g., sending a code to the
@@ -343,7 +347,8 @@ public class AccountServiceGrpc(
return response;
}
public override async Task<Shared.Proto.AccountProfile> SetActiveBadge(SetActiveBadgeRequest request, ServerCallContext context)
public override async Task<Shared.Proto.AccountProfile> SetActiveBadge(SetActiveBadgeRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
@@ -359,4 +364,55 @@ public class AccountServiceGrpc(
return profile.ToProtoValue();
}
public override async Task<ListUserRelationshipSimpleResponse> ListFriends(
ListUserRelationshipSimpleRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var relationship = await relationships.ListAccountFriends(accountId);
var resp = new ListUserRelationshipSimpleResponse();
resp.AccountsId.AddRange(relationship.Select(x => x.ToString()));
return resp;
}
public override async Task<ListUserRelationshipSimpleResponse> ListBlocked(
ListUserRelationshipSimpleRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var relationship = await relationships.ListAccountBlocked(accountId);
var resp = new ListUserRelationshipSimpleResponse();
resp.AccountsId.AddRange(relationship.Select(x => x.ToString()));
return resp;
}
public override async Task<GetRelationshipResponse> GetRelationship(GetRelationshipRequest request,
ServerCallContext context)
{
var relationship = await relationships.GetRelationship(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId),
status: (RelationshipStatus?)request.Status
);
return new GetRelationshipResponse
{
Relationship = relationship?.ToProtoValue()
};
}
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
{
var hasRelationship = false;
if (!request.HasStatus)
hasRelationship = await relationships.HasExistingRelationship(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId)
);
else
hasRelationship = await relationships.HasRelationshipWithStatus(
Guid.Parse(request.AccountId),
Guid.Parse(request.RelatedId),
(RelationshipStatus)request.Status
);
return new BoolValue { Value = hasRelationship };
}
}

View File

@@ -1,5 +1,7 @@
using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Proto;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Account;
@@ -20,4 +22,15 @@ public class Relationship : ModelBase
public Instant? ExpiredAt { get; set; }
public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending;
public Shared.Proto.Relationship ToProtoValue() => new()
{
AccountId = AccountId.ToString(),
RelatedId = RelatedId.ToString(),
Account = Account.ToProtoValue(),
Related = Related.ToProtoValue(),
Type = (int)Status,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
}

View File

@@ -154,13 +154,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
public async Task<List<Guid>> ListAccountFriends(Account account)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
return await ListAccountFriends(account.Id);
}
public async Task<List<Guid>> ListAccountFriends(Guid accountId)
{
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
if (friends == null)
{
friends = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.RelatedId == accountId)
.Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId)
.ToListAsync();
@@ -173,13 +178,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
public async Task<List<Guid>> ListAccountBlocked(Account account)
{
var cacheKey = $"{UserBlockedCacheKeyPrefix}{account.Id}";
return await ListAccountBlocked(account.Id);
}
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
{
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
if (blocked == null)
{
blocked = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.RelatedId == accountId)
.Where(r => r.Status == RelationshipStatus.Blocked)
.Select(r => r.AccountId)
.ToListAsync();

View File

@@ -1,50 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Pass.Account;
/// <summary>
/// The verification info of a resource
/// stands, for it is really an individual or organization or a company in the real world.
/// Besides, it can also be use for mark parody or fake.
/// </summary>
public class VerificationMark
{
public VerificationMarkType Type { get; set; }
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(8192)] public string? Description { get; set; }
[MaxLength(1024)] public string? VerifiedBy { get; set; }
public Shared.Proto.VerificationMark ToProtoValue()
{
var proto = new Shared.Proto.VerificationMark
{
Type = Type switch
{
VerificationMarkType.Official => Shared.Proto.VerificationMarkType.Official,
VerificationMarkType.Individual => Shared.Proto.VerificationMarkType.Individual,
VerificationMarkType.Organization => Shared.Proto.VerificationMarkType.Organization,
VerificationMarkType.Government => Shared.Proto.VerificationMarkType.Government,
VerificationMarkType.Creator => Shared.Proto.VerificationMarkType.Creator,
VerificationMarkType.Developer => Shared.Proto.VerificationMarkType.Developer,
VerificationMarkType.Parody => Shared.Proto.VerificationMarkType.Parody,
_ => Shared.Proto.VerificationMarkType.Unspecified
},
Title = Title ?? string.Empty,
Description = Description ?? string.Empty,
VerifiedBy = VerifiedBy ?? string.Empty
};
return proto;
}
}
public enum VerificationMarkType
{
Official,
Individual,
Organization,
Government,
Creator,
Developer,
Parody
}

View File

@@ -20,7 +20,7 @@ public class AuthServiceGrpc(
{
if (!authService.ValidateToken(request.Token, out var sessionId))
return new AuthenticateResponse { Valid = false, Message = "Invalid token." };
var session = await cache.GetAsync<AuthSession>($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}");
if (session is not null)
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
@@ -36,7 +36,7 @@ public class AuthServiceGrpc(
var now = SystemClock.Instance.GetCurrentInstant();
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
return new AuthenticateResponse { Valid = false, Message = "Session has been expired." };
await cache.SetWithGroupsAsync(
$"auth:{sessionId}",
session,
@@ -46,4 +46,17 @@ public class AuthServiceGrpc(
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
}
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
{
var accountId = Guid.Parse(request.AccountId);
var valid = await authService.ValidatePinCode(accountId, request.Pin);
return new ValidateResponse { Valid = valid };
}
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
{
var valid = await authService.ValidateCaptcha(request.Token);
return new ValidateResponse { Valid = valid };
}
}

View File

@@ -34,7 +34,7 @@ public class CustomApp : ModelBase, IIdentifiedResource
// TODO: Publisher
[NotMapped] public string ResourceIdentifier => "custom-app/" + Id;
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
}
public class CustomAppLinks

View File

@@ -100,12 +100,16 @@
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Pages\Checkpoint\CheckpointPage.cshtml" />
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
<AdditionalFiles Include="Pages\Shared\_Layout.cshtml" />
<AdditionalFiles Include="Pages\Shared\_ValidationScriptsPartial.cshtml" />
<AdditionalFiles Include="Pages\Spell\MagicSpellPage.cshtml" />
<AdditionalFiles Include="Resources\Localization\AccountEventResource.resx" />
<AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx" />
<AdditionalFiles Include="Resources\Localization\EmailResource.resx" />

View File

@@ -0,0 +1,110 @@
@page "/auth/captcha"
@model DysonNetwork.Pass.Pages.Checkpoint.CheckpointPage
@{
ViewData["Title"] = "Security Checkpoint";
var cfg = ViewData.Model.Configuration;
var provider = cfg.GetSection("Captcha")["Provider"]?.ToLower();
var apiKey = cfg.GetSection("Captcha")["ApiKey"];
}
@section Scripts {
@switch (provider)
{
case "recaptcha":
<script src="https://www.recaptcha.net/recaptcha/api.js" async defer></script>
break;
case "cloudflare":
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
break;
case "hcaptcha":
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
break;
}
<script>
function getQueryParam(name) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
}
function onSuccess(token) {
window.parent.postMessage("captcha_tk=" + token, "*");
const redirectUri = getQueryParam("redirect_uri");
if (redirectUri) {
window.location.href = `${redirectUri}?captcha_tk=${encodeURIComponent(token)}`;
}
}
</script>
}
<div class="hero min-h-full bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title">Security Check</h1>
<p>Please complete the contest below to confirm you're not a robot</p>
<div class="flex justify-center my-8">
@switch (provider)
{
case "cloudflare":
<div class="cf-turnstile"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
case "recaptcha":
<div class="g-recaptcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
case "hcaptcha":
<div class="h-captcha"
data-sitekey="@apiKey"
data-callback="onSuccess">
</div>
break;
default:
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Captcha provider not configured correctly.</span>
</div>
break;
}
</div>
<div class="text-center text-sm">
<div class="font-semibold mb-1">Solar Network Anti-Robot</div>
<div class="text-base-content/70">
Powered by
@switch (provider)
{
case "cloudflare":
<a href="https://www.cloudflare.com/turnstile/" class="link link-hover">
Cloudflare Turnstile
</a>
break;
case "recaptcha":
<a href="https://www.google.com/recaptcha/" class="link link-hover">
Google reCaptcha
</a>
break;
default:
<span>Nothing</span>
break;
}
<br/>
Hosted by
<a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
DysonNetwork.Pass
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Pass.Pages.Checkpoint;
public class CheckpointPage(IConfiguration configuration) : PageModel
{
[BindProperty] public IConfiguration Configuration { get; set; } = configuration;
public ActionResult OnGet()
{
return Page();
}
}

View File

@@ -0,0 +1,62 @@
@using DysonNetwork.Pass.Auth
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>@ViewData["Title"]</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="~/css/styles.css" asp-append-version="true"/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
rel="stylesheet"
/>
@await RenderSectionAsync("Head", required: false)
</head>
<body class="h-full bg-base-200">
<header class="navbar bg-base-100/35 backdrop-blur-md shadow-xl fixed left-0 right-0 top-0 z-50 px-5">
<div class="flex-1">
<a class="btn btn-ghost text-xl">Solar Network</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal menu-sm px-1">
@if (Context.Request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out _))
{
<li class="tooltip tooltip-bottom" data-tip="Profile">
<a href="//account/profile">
<span class="material-symbols-outlined">account_circle</span>
</a>
</li>
<li class="tooltip tooltip-bottom" data-tip="Logout">
<form method="post" asp-page="/Account/Profile" asp-page-handler="Logout">
<button type="submit">
<span class="material-symbols-outlined">
logout
</span>
</button>
</form>
</li>
}
else
{
<li class="tooltip tooltip-bottom" data-tip="Login">
<a href="//auth/login"><span class="material-symbols-outlined">login</span></a>
</li>
}
</ul>
</div>
</header>
<main class="h-full pt-16">
@RenderBody()
</main>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@@ -0,0 +1,91 @@
@page "/spells/{spellWord}"
@using DysonNetwork.Pass.Account
@model DysonNetwork.Pass.Pages.Spell.MagicSpellPage
@{
ViewData["Title"] = "Magic Spell";
}
<div class="hero min-h-full bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold mb-4">Magic Spell</h1>
@if (Model.IsSuccess)
{
<div class="alert alert-success">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>The spell was applied successfully!</span>
<p>Now you can close this page.</p>
</div>
}
else if (Model.CurrentSpell == null)
{
<div class="alert alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>The spell was expired or does not exist.</span>
</div>
}
else
{
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">
@System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")
</h2>
<p>for @@ @Model.CurrentSpell.Account?.Name</p>
<div class="text-sm opacity-80">
@if (Model.CurrentSpell.ExpiresAt.HasValue)
{
<p>Available until @Model.CurrentSpell.ExpiresAt.Value.ToDateTimeUtc().ToString("g")</p>
}
@if (Model.CurrentSpell.AffectedAt.HasValue)
{
<p>Available after @Model.CurrentSpell.AffectedAt.Value.ToDateTimeUtc().ToString("g")</p>
}
</div>
<p class="text-sm opacity-80">Would you like to apply this spell?</p>
<form method="post" class="mt-4">
<input type="hidden" asp-for="CurrentSpell!.Id"/>
@if (Model.CurrentSpell?.Type == MagicSpellType.AuthPasswordReset)
{
<div class="form-control w-full max-w-xs">
<label class="label" asp-for="NewPassword">
<span class="label-text">New Password</span>
</label>
<input type="password"
asp-for="NewPassword"
required
minlength="8"
placeholder="Your new password"
class="input input-bordered w-full max-w-xs"/>
</div>
}
<div class="card-actions justify-end mt-4">
<button type="submit" class="btn btn-primary">Apply</button>
</div>
</form>
</div>
</div>
}
<div class="mt-8 text-center text-sm">
<div class="font-semibold mb-1">Solar Network</div>
<div class="text-base-content/70">
<a href="https://solsynth.dev" class="link link-hover">
Solsynth LLC
</a>
&copy; @DateTime.Now.Year
<br/>
Powered by
<a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
DysonNetwork.Pass
</a>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,52 @@
using DysonNetwork.Pass.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Pages.Spell;
public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageModel
{
[BindProperty] public MagicSpell? CurrentSpell { get; set; }
[BindProperty] public string? NewPassword { get; set; }
public bool IsSuccess { get; set; }
public async Task<IActionResult> OnGetAsync(string spellWord)
{
spellWord = Uri.UnescapeDataString(spellWord);
var now = SystemClock.Instance.GetCurrentInstant();
CurrentSpell = await db.MagicSpells
.Where(e => e.Spell == spellWord)
.Where(e => e.ExpiresAt == null || now < e.ExpiresAt)
.Where(e => e.AffectedAt == null || now >= e.AffectedAt)
.Include(e => e.Account)
.FirstOrDefaultAsync();
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (CurrentSpell?.Id == null)
return Page();
var now = SystemClock.Instance.GetCurrentInstant();
var spell = await db.MagicSpells
.Where(e => e.Id == CurrentSpell.Id)
.Where(e => e.ExpiresAt == null || now < e.ExpiresAt)
.Where(e => e.AffectedAt == null || now >= e.AffectedAt)
.FirstOrDefaultAsync();
if (spell == null || spell.Type == MagicSpellType.AuthPasswordReset && string.IsNullOrWhiteSpace(NewPassword))
return Page();
if (spell.Type == MagicSpellType.AuthPasswordReset)
await spells.ApplyPasswordReset(spell, NewPassword!);
else
await spells.ApplyMagicSpell(spell);
IsSuccess = true;
return Page();
}
}

View File

@@ -0,0 +1,2 @@
@namespace DysonNetwork.Pass.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}