Added magic spell page

This commit is contained in:
2025-07-17 20:28:49 +08:00
parent 4e2a7ebbce
commit 651820e384
11 changed files with 254 additions and 84 deletions

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Pass.Account;
@@ -16,4 +17,48 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
await sp.NotifyMagicSpell(spell, true);
return Ok();
}
[HttpGet("{spellWord}")]
public async Task<ActionResult> GetMagicSpell(string spellWord)
{
var word = Uri.UnescapeDataString(spellWord);
var spell = await db.MagicSpells
.Where(x => x.Spell == word)
.Include(x => x.Account)
.ThenInclude(x => x.Profile)
.FirstOrDefaultAsync();
if (spell is null)
return NotFound();
return Ok(spell);
}
public record class MagicSpellApplyRequest
{
public string? NewPassword { get; set; }
}
[HttpPost("{spellWord}/apply")]
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest request)
{
var word = Uri.UnescapeDataString(spellWord);
var spell = await db.MagicSpells
.Where(x => x.Spell == word)
.Include(x => x.Account)
.ThenInclude(x => x.Profile)
.FirstOrDefaultAsync();
if (spell is null)
return NotFound();
try
{
if (spell.Type == MagicSpellType.AuthPasswordReset && request.NewPassword is not null)
await sp.ApplyPasswordReset(spell, request.NewPassword);
else
await sp.ApplyMagicSpell(spell);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
return Ok();
}
}

View File

@@ -1,5 +1,7 @@
using System.Security.Cryptography;
using System.Text.Json;
using DysonNetwork.Pass.Email;
using DysonNetwork.Pass.Pages.Emails;
using DysonNetwork.Pass.Permission;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
@@ -12,7 +14,8 @@ public class MagicSpellService(
AppDatabase db,
IConfiguration configuration,
ILogger<MagicSpellService> logger,
IStringLocalizer<EmailResource> localizer
IStringLocalizer<EmailResource> localizer,
EmailService email
)
{
public async Task<MagicSpell> CreateMagicSpell(
@@ -79,61 +82,62 @@ public class MagicSpellService(
try
{
// switch (spell.Type)
// {
// case MagicSpellType.AccountActivation:
// await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
// contact.Account.Nick,
// contact.Content,
// localizer["EmailLandingTitle"],
// new LandingEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// case MagicSpellType.AccountRemoval:
// await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
// contact.Account.Nick,
// contact.Content,
// localizer["EmailAccountDeletionTitle"],
// new AccountDeletionEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// case MagicSpellType.AuthPasswordReset:
// await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
// contact.Account.Nick,
// contact.Content,
// localizer["EmailAccountDeletionTitle"],
// new PasswordResetEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// case MagicSpellType.ContactVerification:
// if (spell.Meta["contact_method"] is not string contactMethod)
// throw new InvalidOperationException("Contact method is not found.");
// await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
// contact.Account.Nick,
// contactMethod!,
// localizer["EmailContactVerificationTitle"],
// new ContactVerificationEmailModel
// {
// Name = contact.Account.Name,
// Link = link
// }
// );
// break;
// default:
// throw new ArgumentOutOfRangeException();
// }
switch (spell.Type)
{
case MagicSpellType.AccountActivation:
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailLandingTitle"],
new LandingEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AuthPasswordReset:
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
contact.Account.Nick,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new PasswordResetEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.ContactVerification:
if (spell.Meta["contact_method"] is not string contactMethod)
throw new InvalidOperationException("Contact method is not found.");
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
contact.Account.Nick,
contactMethod!,
localizer["EmailContactVerificationTitle"],
new ContactVerificationEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;
case MagicSpellType.AccountDeactivation:
default:
throw new ArgumentOutOfRangeException();
}
}
catch (Exception err)
{

View File

@@ -23,6 +23,8 @@ export default defineConfigWithVueTs(
{
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
},
},
skipFormatting,

View File

@@ -1,14 +1,14 @@
<!DOCTYPE html>
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Solarpass</title>
%%APP_DATA%%
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
<app-data />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -12,6 +12,11 @@ const router = createRouter({
path: '/captcha',
name: 'captcha',
component: () => import('../views/captcha.vue'),
},
{
path: '/spells/:word',
name: 'spells',
component: () => import('../views/spells.vue'),
}
],
})

View File

@@ -0,0 +1,99 @@
<template>
<div class="h-full flex items-center justify-center">
<n-card class="max-w-lg" title="Spell">
<n-alert type="success" v-if="done">
The magic spell has been applied successfully. Now you can close this tab and back to the
Solar Network!
</n-alert>
<n-alert type="error" v-else-if="!!error" title="Something went wrong">{{ error }}</n-alert>
<div v-else-if="!!spell">
<p class="mb-2">Magic spell for {{ spellTypes[spell.type] ?? 'unknown' }}</p>
<div class="flex items-center gap-1">
<n-icon size="18"><account-circle-outlined /></n-icon>
<b>@{{ spell.account.name }}</b>
</div>
<div class="flex items-center gap-1">
<n-icon size="18"><play-arrow-filled /></n-icon>
<span>Available at</span>
<b>{{ new Date(spell.created_at ?? spell.affected_at).toLocaleString() }}</b>
</div>
<div class="flex items-center gap-1" v-if="spell.expired_at">
<n-icon size="18"><date-range-filled /></n-icon>
<span>Until</span>
<b>{{ spell.expired_at.toString() }}</b>
</div>
<div class="mt-4">
<n-input v-if="spell.type == 3" v-model:value="newPassword" />
<n-button type="primary" :loading="submitting" @click="applySpell">
<template #icon><check-filled /></template>
Apply
</n-button>
</div>
</div>
<n-spin v-else size="small" />
</n-card>
</div>
</template>
<script setup lang="ts">
import { NCard, NAlert, NSpin, NIcon, NButton, NInput } from 'naive-ui'
import {
AccountCircleOutlined,
PlayArrowFilled,
DateRangeFilled,
CheckFilled,
} from '@vicons/material'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const spellWord: string = route.params.word.toString()
const spell = ref<any>(null)
const error = ref<string | null>(null)
const newPassword = ref<string>()
const submitting = ref(false)
const done = ref(false)
const spellTypes = [
'Account Acivation',
'Account Deactivation',
'Account Deletion',
'Reset Password',
'Contact Method Verification',
]
async function fetchSpell() {
// @ts-ignore
if (window.__APP_DATA__ != null) {
// @ts-ignore
spell.value = window.__APP_DATA__['Spell']
return
}
const resp = await fetch(`/api/spells/${encodeURIComponent(spellWord)}`)
if (resp.status === 200) {
const data = await resp.json()
spell.value = data
} else {
error.value = await resp.text()
}
}
async function applySpell() {
submitting.value = true
const resp = await fetch(`/api/spells/${encodeURIComponent(spellWord)}/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: newPassword.value ? JSON.stringify({ new_password: newPassword.value }) : null,
})
if (resp.status === 200) {
done.value = true
} else {
error.value = await resp.text()
}
}
onMounted(() => fetchSpell())
</script>

View File

@@ -18,8 +18,6 @@ public class EmailService(
string htmlBody
)
{
subject = $"[Solarpass] {subject}";
await pusher.SendEmailAsync(
new SendEmailRequest()
{

View File

@@ -0,0 +1,28 @@
using DysonNetwork.Shared.PageData;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Pass.Pages.Data;
public class SpellPageData(AppDatabase db) : IPageDataProvider
{
public bool CanHandlePath(PathString path) => path.StartsWithSegments("/spells");
public async Task<IDictionary<string, object?>> GetAppDataAsync(HttpContext context)
{
var spellWord = context.Request.Path.Value!.Split('/').Last();
spellWord = Uri.UnescapeDataString(spellWord);
var now = SystemClock.Instance.GetCurrentInstant();
var spell = 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)
.ThenInclude(e => e.Profile)
.FirstOrDefaultAsync();
return new Dictionary<string, object?>
{
["Spell"] = spell
};
}
}

View File

@@ -34,6 +34,7 @@ builder.Services.AddAppScheduledJobs();
builder.Services.AddTransient<IPageDataProvider, VersionPageData>();
builder.Services.AddTransient<IPageDataProvider, CaptchaPageData>();
builder.Services.AddTransient<IPageDataProvider, SpellPageData>();
var app = builder.Build();

View File

@@ -27,13 +27,7 @@ public class EmailService
_logger = logger;
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
{
await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null);
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody,
string? htmlBody)
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string htmlBody)
{
subject = $"[{_configuration.SubjectPrefix}] {subject}";
@@ -42,13 +36,7 @@ public class EmailService
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
emailMessage.Subject = subject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody
};
if (!string.IsNullOrEmpty(htmlBody))
bodyBuilder.HtmlBody = htmlBody;
var bodyBuilder = new BodyBuilder { HtmlBody = htmlBody };
emailMessage.Body = bodyBuilder.ToMessageBody();

View File

@@ -30,7 +30,7 @@ public static class PageStartup
appData[key] = value;
var json = JsonSerializer.Serialize(appData);
html = html.Replace("%%APP_DATA%%", $"<script>window.__APP_DATA__ = {json};</script>");
html = html.Replace("<app-data />", $"<script>window.__APP_DATA__ = {json};</script>");
context.Response.ContentType = "text/html";
await context.Response.WriteAsync(html);