Compare commits

...

3 Commits

Author SHA1 Message Date
609e30b67b 🔨 Add the build workflow 2025-07-20 02:19:32 +08:00
d22394230b 🗑️ Remove no longer used files 2025-07-20 02:14:27 +08:00
fc63a76eb2 ♻️ To use CorePush to no longer depends on gorush 2025-07-20 02:11:33 +08:00
28 changed files with 284 additions and 4199 deletions

View File

@@ -1,4 +1,4 @@
name: Build and Push Dyson Sphere
name: Build and Push Microservices
on:
push:
@@ -7,23 +7,19 @@ on:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest # x86_64 (default), avoids arm64 native module issues
build-sphere:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push Docker image
- name: Build and push DysonNetwork.Sphere Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Sphere/Dockerfile
@@ -31,3 +27,87 @@ jobs:
push: true
tags: xsheep2010/dyson-sphere:latest
platforms: linux/amd64
build-pass:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Pass Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pass/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-pass:latest
platforms: linux/amd64
build-pusher:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Pusher Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Pusher/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-pusher:latest
platforms: linux/amd64
build-drive:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Drive Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Drive/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-drive:latest
platforms: linux/amd64
build-gateway:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
- name: Build and push DysonNetwork.Gateway Docker image
uses: docker/build-push-action@v6
with:
file: DysonNetwork.Gateway/Dockerfile
context: .
push: true
tags: xsheep2010/dyson-gateway:latest
platforms: linux/amd64

View File

@@ -91,7 +91,7 @@ public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
{
{ "destination1", new DestinationConfig { Address = serviceUrl } }
},
HttpRequest = new ForwarderRequestConfig()
HttpRequest = new ForwarderRequestConfig
{
ActivityTimeout = directRoute.IsWebsocket ? TimeSpan.FromHours(24) : TimeSpan.FromMinutes(2)
}

View File

@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CorePush" Version="4.3.0" />
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />

View File

@@ -1,5 +1,6 @@
using System.Text;
using System.Text.Json;
using CorePush.Apple;
using CorePush.Firebase;
using DysonNetwork.Pusher.Connection;
using DysonNetwork.Shared.Proto;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
@@ -7,14 +8,55 @@ using NodaTime;
namespace DysonNetwork.Pusher.Notification;
public class PushService(IConfiguration config, AppDatabase db, IHttpClientFactory httpFactory)
public class PushService
{
private readonly string _notifyTopic = config["Notifications:Topic"]!;
private readonly Uri _notifyEndpoint = new(config["Notifications:Endpoint"]!);
private readonly AppDatabase _db;
private readonly WebSocketService _ws;
private readonly ILogger<PushService> _logger;
private readonly FirebaseSender? _fcm;
private readonly ApnSender? _apns;
private readonly string? _apnsTopic;
public PushService(
IConfiguration config,
AppDatabase db,
WebSocketService ws,
IHttpClientFactory httpFactory,
ILogger<PushService> logger
)
{
var cfgSection = config.GetSection("Notifications:Push");
// Set up Firebase Cloud Messaging
var fcmConfig = cfgSection.GetValue<string>("Google");
if (fcmConfig != null && File.Exists(fcmConfig))
_fcm = new FirebaseSender(File.ReadAllText(fcmConfig), httpFactory.CreateClient());
// Set up Apple Push Notification Service
var apnsKeyPath = cfgSection.GetValue<string>("Apple:PrivateKey");
if (apnsKeyPath != null && File.Exists(apnsKeyPath))
{
_apns = new ApnSender(new ApnSettings
{
P8PrivateKey = File.ReadAllText(apnsKeyPath),
P8PrivateKeyId = cfgSection.GetValue<string>("Apple:PrivateKeyId"),
TeamId = cfgSection.GetValue<string>("Apple:TeamId"),
AppBundleIdentifier = cfgSection.GetValue<string>("Apple:BundleIdentifier"),
ServerType = cfgSection.GetValue<bool>("Production")
? ApnServerType.Production
: ApnServerType.Development
}, httpFactory.CreateClient());
_apnsTopic = cfgSection.GetValue<string>("Apple:BundleIdentifier");
}
_db = db;
_ws = ws;
_logger = logger;
}
public async Task UnsubscribeDevice(string deviceId)
{
await db.PushSubscriptions
await _db.PushSubscriptions
.Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync();
}
@@ -27,41 +69,40 @@ public class PushService(IConfiguration config, AppDatabase db, IHttpClientFacto
)
{
var now = SystemClock.Instance.GetCurrentInstant();
// First check if a matching subscription exists
var accountId = Guid.Parse(account.Id!);
var existingSubscription = await db.PushSubscriptions
// Check for existing subscription with same device ID or token
var existingSubscription = await _db.PushSubscriptions
.Where(s => s.AccountId == accountId)
.Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
.FirstOrDefaultAsync();
if (existingSubscription is not null)
if (existingSubscription != null)
{
// Update the existing subscription directly in the database
await db.PushSubscriptions
.Where(s => s.Id == existingSubscription.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.DeviceId, deviceId)
.SetProperty(s => s.DeviceToken, deviceToken)
.SetProperty(s => s.UpdatedAt, now));
// Return the updated subscription
// Update existing subscription
existingSubscription.DeviceId = deviceId;
existingSubscription.DeviceToken = deviceToken;
existingSubscription.Provider = provider;
existingSubscription.UpdatedAt = now;
_db.Update(existingSubscription);
await _db.SaveChangesAsync();
return existingSubscription;
}
// Create new subscription
var subscription = new PushSubscription
{
DeviceId = deviceId,
DeviceToken = deviceToken,
Provider = provider,
AccountId = accountId,
CreatedAt = now,
UpdatedAt = now
};
db.PushSubscriptions.Add(subscription);
await db.SaveChangesAsync();
_db.PushSubscriptions.Add(subscription);
await _db.SaveChangesAsync();
return subscription;
}
@@ -94,8 +135,8 @@ public class PushService(IConfiguration config, AppDatabase db, IHttpClientFacto
if (save)
{
db.Add(notification);
await db.SaveChangesAsync();
_db.Add(notification);
await _db.SaveChangesAsync();
}
if (!isSilent) _ = DeliveryNotification(notification);
@@ -104,7 +145,7 @@ public class PushService(IConfiguration config, AppDatabase db, IHttpClientFacto
public async Task DeliveryNotification(Notification notification)
{
// Pushing the notification
var subscribers = await db.PushSubscriptions
var subscribers = await _db.PushSubscriptions
.Where(s => s.AccountId == notification.AccountId)
.ToListAsync();
@@ -117,7 +158,7 @@ public class PushService(IConfiguration config, AppDatabase db, IHttpClientFacto
var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
if (id.Count == 0) return;
await db.Notifications
await _db.Notifications
.Where(n => id.Contains(n.Id))
.ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now)
);
@@ -141,105 +182,113 @@ public class PushService(IConfiguration config, AppDatabase db, IHttpClientFacto
};
return newNotification;
}).ToList();
await db.BulkInsertAsync(notifications);
await _db.BulkInsertAsync(notifications);
}
var subscribers = await db.PushSubscriptions
var subscribers = await _db.PushSubscriptions
.Where(s => accounts.Contains(s.AccountId))
.ToListAsync();
await _PushNotification(notification, subscribers);
}
private List<Dictionary<string, object>> _BuildNotificationPayload(Notification notification,
IEnumerable<PushSubscription> subscriptions)
{
var subDict = subscriptions
.GroupBy(x => x.Provider)
.ToDictionary(x => x.Key, x => x.ToList());
var notifications = subDict.Select(value =>
{
var platformCode = value.Key switch
{
PushProvider.Apple => 1,
PushProvider.Google => 2,
_ => throw new InvalidOperationException($"Unknown push provider: {value.Key}")
};
var tokens = value.Value.Select(x => x.DeviceToken).ToList();
return _BuildNotificationPayload(notification, platformCode, tokens);
}).ToList();
return notifications.ToList();
}
private Dictionary<string, object> _BuildNotificationPayload(Pusher.Notification.Notification notification,
int platformCode,
IEnumerable<string> deviceTokens)
{
var alertDict = new Dictionary<string, object>();
var dict = new Dictionary<string, object>
{
["notif_id"] = notification.Id.ToString(),
["apns_id"] = notification.Id.ToString(),
["topic"] = _notifyTopic,
["tokens"] = deviceTokens,
["data"] = new Dictionary<string, object>
{
["type"] = notification.Topic,
["meta"] = notification.Meta ?? new Dictionary<string, object>(),
},
["mutable_content"] = true,
["priority"] = notification.Priority >= 5 ? "high" : "normal",
};
if (!string.IsNullOrWhiteSpace(notification.Title))
{
dict["title"] = notification.Title;
alertDict["title"] = notification.Title;
}
if (!string.IsNullOrWhiteSpace(notification.Content))
{
dict["message"] = notification.Content;
alertDict["body"] = notification.Content;
}
if (!string.IsNullOrWhiteSpace(notification.Subtitle))
{
dict["message"] = $"{notification.Subtitle}\n{dict["message"]}";
alertDict["subtitle"] = notification.Subtitle;
}
if (notification.Priority >= 5)
dict["name"] = "default";
dict["platform"] = platformCode;
dict["alert"] = alertDict;
return dict;
}
private async Task _PushNotification(
Notification notification,
IEnumerable<PushSubscription> subscriptions
)
{
var subList = subscriptions.ToList();
if (subList.Count == 0) return;
var tasks = subscriptions
.Select(subscription => _PushSingleNotification(notification, subscription))
.ToList();
var requestDict = new Dictionary<string, object>
await Task.WhenAll(tasks);
}
private async Task _PushSingleNotification(Notification notification, PushSubscription subscription)
{
try
{
["notifications"] = _BuildNotificationPayload(notification, subList)
};
_logger.LogDebug(
$"Pushing notification {notification.Topic} #{notification.Id} to device #{subscription.DeviceId}");
var client = httpFactory.CreateClient();
client.BaseAddress = _notifyEndpoint;
var request = await client.PostAsync("/push", new StringContent(
JsonSerializer.Serialize(requestDict),
Encoding.UTF8,
"application/json"
));
request.EnsureSuccessStatusCode();
switch (subscription.Provider)
{
case PushProvider.Google:
if (_fcm == null)
throw new InvalidOperationException("Firebase Cloud Messaging is not initialized.");
var body = string.Empty;
if (!string.IsNullOrEmpty(notification.Subtitle) || !string.IsNullOrEmpty(notification.Content))
{
body = string.Join("\n",
notification.Subtitle ?? string.Empty,
notification.Content ?? string.Empty).Trim();
}
await _fcm.SendAsync(new Dictionary<string, object>
{
["message"] = new Dictionary<string, object>
{
["token"] = subscription.DeviceToken,
["notification"] = new Dictionary<string, object>
{
["title"] = notification.Title ?? string.Empty,
["body"] = body
},
["data"] = new Dictionary<string, object>
{
["id"] = notification.Id,
["topic"] = notification.Topic,
["meta"] = notification.Meta ?? new Dictionary<string, object>()
}
}
});
break;
case PushProvider.Apple:
if (_apns == null)
throw new InvalidOperationException("Apple Push Notification Service is not initialized.");
var alertDict = new Dictionary<string, object>();
if (!string.IsNullOrEmpty(notification.Title))
alertDict["title"] = notification.Title;
if (!string.IsNullOrEmpty(notification.Subtitle))
alertDict["subtitle"] = notification.Subtitle;
if (!string.IsNullOrEmpty(notification.Content))
alertDict["body"] = notification.Content;
var payload = new Dictionary<string, object?>
{
["topic"] = _apnsTopic,
["aps"] = new Dictionary<string, object?>
{
["alert"] = alertDict,
["sound"] = notification.Priority >= 5 ? "default" : null,
["mutable-content"] = 1
},
["meta"] = notification.Meta ?? new Dictionary<string, object>()
};
await _apns.SendAsync(
payload,
deviceToken: subscription.DeviceToken,
apnsId: notification.Id.ToString(),
apnsPriority: notification.Priority,
apnPushType: ApnPushType.Alert
);
break;
default:
throw new InvalidOperationException($"Push provider not supported: {subscription.Provider}");
}
}
catch (Exception ex)
{
_logger.LogError(ex,
$"Failed to push notification #{notification.Id} to device {subscription.DeviceId}. {ex.Message}");
throw new Exception($"Failed to send notification to {subscription.Provider}: {ex.Message}", ex);
}
_logger.LogInformation(
$"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId}");
}
}

View File

@@ -23,6 +23,7 @@ builder.Services.AddAppFlushHandlers();
// Add business services
builder.Services.AddAppBusinessServices();
builder.Services.AddPushServices(builder.Configuration);
// Add scheduled jobs
builder.Services.AddAppScheduledJobs();

View File

@@ -1,5 +1,7 @@
using System.Text.Json;
using System.Threading.RateLimiting;
using CorePush.Apple;
using CorePush.Firebase;
using DysonNetwork.Pusher.Connection;
using DysonNetwork.Pusher.Email;
using DysonNetwork.Pusher.Notification;
@@ -137,4 +139,13 @@ public static class ServiceCollectionExtensions
return services;
}
public static void AddPushServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<ApnSettings>(configuration.GetSection("PushNotify:Apple"));
services.AddHttpClient<ApnSender>();
services.Configure<FirebaseSettings>(configuration.GetSection("PushNotify:Firebase"));
services.AddHttpClient<FirebaseSettings>();
}
}

View File

@@ -14,8 +14,16 @@
"Etcd": "etcd.orb.local:2379"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
"Push": {
"Production": true,
"Google": "./Keys/Solian.json",
"Apple": {
"PrivateKey": "./Keys/Solian.p8",
"PrivateKeyId": "4US4KSX4W6",
"TeamId": "W7HPZ53V6B",
"BundleIdentifier": "dev.solsynth.solian"
}
}
},
"Email": {
"Server": "smtp4dev.orb.local",

View File

@@ -10,11 +10,6 @@
<SatelliteResourceLanguages>en-US;zh-Hans</SatelliteResourceLanguages>
</PropertyGroup>
<!-- NPM Configuration -->
<PropertyGroup>
<NpmInstallStampFile>node_modules/.install-stamp</NpmInstallStampFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0"/>
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1"/>
@@ -75,6 +70,7 @@
<ItemGroup>
<Folder Include="Discovery\"/>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
@@ -162,6 +158,17 @@
<_ContentIncludedByDefault Remove="Pages\Checkpoint\CheckpointPage.cshtml"/>
<_ContentIncludedByDefault Remove="Pages\Spell\MagicSpellPage.cshtml"/>
<_ContentIncludedByDefault Remove="Keys\Solian.json"/>
<_ContentIncludedByDefault Remove="Pages\Index.cshtml" />
<_ContentIncludedByDefault Remove="Pages\Posts\PostDetail.cshtml" />
<_ContentIncludedByDefault Remove="Pages\Shared\_Layout.cshtml" />
<_ContentIncludedByDefault Remove="Pages\_ViewImports.cshtml" />
<_ContentIncludedByDefault Remove="Pages\_ViewStart.cshtml" />
<_ContentIncludedByDefault Remove="Pages\Emails\AccountDeletionEmail.razor" />
<_ContentIncludedByDefault Remove="Pages\Emails\ContactVerificationEmail.razor" />
<_ContentIncludedByDefault Remove="Pages\Emails\EmailLayout.razor" />
<_ContentIncludedByDefault Remove="Pages\Emails\LandingEmail.razor" />
<_ContentIncludedByDefault Remove="Pages\Emails\PasswordResetEmail.razor" />
<_ContentIncludedByDefault Remove="Pages\Emails\VerificationEmail.razor" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,6 +0,0 @@
@DysonNetwork.Sphere_HostAddress = http://localhost:5071
GET {{DysonNetwork.Sphere_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,42 +0,0 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
<p>@(Localizer["AccountDeletionPara2"])</p>
<p>@(Localizer["AccountDeletionPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["AccountDeletionButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["AccountDeletionPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@@ -1,43 +0,0 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
<p>@(Localizer["ContactVerificationPara2"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["ContactVerificationButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["ContactVerificationPara3"])</p>
<p>@(Localizer["ContactVerificationPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@@ -1,337 +0,0 @@
@inherits LayoutComponentBase
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style media="all" type="text/css">
body {
font-family: Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
line-height: 1.3;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: Helvetica, sans-serif;
font-size: 16px;
vertical-align: top;
}
body {
background-color: #f4f5f6;
margin: 0;
padding: 0;
}
.body {
background-color: #f4f5f6;
width: 100%;
}
.container {
margin: 0 auto !important;
max-width: 600px;
padding: 0;
padding-top: 24px;
width: 600px;
}
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 600px;
padding: 0;
}
.main {
background: #ffffff;
border: 1px solid #eaebed;
border-radius: 16px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 24px;
}
.footer {
clear: both;
padding-top: 24px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #9a9ea6;
font-size: 16px;
text-align: center;
}
p {
font-family: Helvetica, sans-serif;
font-size: 16px;
font-weight: normal;
margin: 0;
margin-bottom: 16px;
}
a {
color: #0867ec;
text-decoration: underline;
}
.btn {
box-sizing: border-box;
min-width: 100% !important;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 16px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 4px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 2px #0867ec;
border-radius: 4px;
box-sizing: border-box;
color: #0867ec;
cursor: pointer;
display: inline-block;
font-size: 16px;
font-weight: bold;
margin: 0;
padding: 12px 24px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #0867ec;
}
.btn-primary a {
background-color: #0867ec;
border-color: #0867ec;
color: #ffffff;
}
.font-bold {
font-weight: bold;
}
.verification-code
{
font-family: "Courier New", Courier, monospace;
font-size: 24px;
letter-spacing: 0.5em;
}
@@media all {
.btn-primary table td:hover {
background-color: #ec0867 !important;
}
.btn-primary a:hover {
background-color: #ec0867 !important;
border-color: #ec0867 !important;
}
}
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.text-link {
color: #0867ec !important;
text-decoration: underline !important;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
@@media only screen and (max-width: 640px) {
.main p,
.main td,
.main span {
font-size: 16px !important;
}
.wrapper {
padding: 8px !important;
}
.content {
padding: 0 !important;
}
.container {
padding: 0 !important;
padding-top: 8px !important;
width: 100% !important;
}
.main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
.btn table {
max-width: 100% !important;
width: 100% !important;
}
.btn a {
font-size: 16px !important;
max-width: 100% !important;
width: 100% !important;
}
}
@@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
</style>
</head>
<body>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
<!-- START MAIN CONTENT AREA -->
@ChildContent
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
<span class="apple-link">Solar Network</span>
<br> Solsynth LLC © @(DateTime.Now.Year)
</td>
</tr>
<tr>
<td class="content-block powered-by">
Powered by <a href="https://github.com/solsynth/dysonnetwork">Dyson Network</a>
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER --></div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
}

View File

@@ -1,43 +0,0 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["LandingHeader1"])</p>
<p>@(Localizer["LandingPara1"]) @@@Name,</p>
<p>@(Localizer["LandingPara2"])</p>
<p>@(Localizer["LandingPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["LandingButton1"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["LandingPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
}

View File

@@ -1,44 +0,0 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["PasswordResetHeader"])</p>
<p>@(Localizer["PasswordResetPara1"]) @@@Name,</p>
<p>@(Localizer["PasswordResetPara2"])</p>
<p>@(Localizer["PasswordResetPara3"])</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="@Link" target="_blank">
@(Localizer["PasswordResetButton"])
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>@(Localizer["PasswordResetPara4"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Link { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@@ -1,27 +0,0 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
<EmailLayout>
<tr>
<td class="wrapper">
<p class="font-bold">@(Localizer["VerificationHeader1"])</p>
<p>@(Localizer["VerificationPara1"]) @@@Name,</p>
<p>@(Localizer["VerificationPara2"])</p>
<p>@(Localizer["VerificationPara3"])</p>
<p class="verification-code">@Code</p>
<p>@(Localizer["VerificationPara4"])</p>
<p>@(Localizer["VerificationPara5"])</p>
</td>
</tr>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Code { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@@ -1,23 +0,0 @@
@page
@model IndexModel
@{
ViewData["Title"] = "The Solar Network | Solar Network";
}
<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">Solar Network</h1>
<p class="py-6">This Solar Network instance is up and running.</p>
<a href="https://sn.solsynth.dev" target="_blank" class="btn btn-primary">Get started</a>
<div class="flex items-center justify-center gap-x-6 mt-6">
<a href="/swagger" target="_blank" class="btn btn-ghost">
<span aria-hidden="true">λ&nbsp;</span> API Docs
</a>
<a href="https://kb.solsynth.dev" target="_blank" class="btn btn-ghost">
Learn more <span aria-hidden="true">→</span>
</a>
</div>
</div>
</div>
</div>

View File

@@ -1,10 +0,0 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace DysonNetwork.Sphere.Pages;
public class IndexModel : PageModel
{
public void OnGet()
{
}
}

View File

@@ -1,65 +0,0 @@
@page "/posts/{PostId:guid}"
@model DysonNetwork.Sphere.Pages.Posts.PostDetailModel
@using Markdig
@{
ViewData["Title"] = Model.Post?.Title + " | Solar Network";
var imageUrl = Model.Post?.Attachments?.FirstOrDefault(a => a.MimeType.StartsWith("image/"))?.Id;
}
@section Head {
<meta property="og:title" content="@Model.Post?.Title"/>
<meta property="og:type" content="article"/>
@if (imageUrl != null)
{
<meta property="og:image" content="/api/files/@imageUrl"/>
}
<meta property="og:url" content="@Request.Scheme://@Request.Host@Request.Path"/>
<meta property="og:description" content="@Model.Post?.Description"/>
}
<div class="container mx-auto p-4">
@if (Model.Post != null)
{
<h1 class="text-3xl font-bold mb-4">@Model.Post.Title</h1>
<p class="text-gray-600 mb-2">
Created at: @Model.Post.CreatedAt
<span>by <a href="#" class="text-blue-500">@@@Model.Post.Publisher.Name</a></span>
</p>
<div class="prose lg:prose-xl mb-4">
@Html.Raw(Markdown.ToHtml(Model.Post.Content ?? string.Empty))
</div>
@if (Model.Post.Attachments != null && Model.Post.Attachments.Any())
{
<h2 class="text-2xl font-bold mb-2">Attachments</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach (var attachment in Model.Post.Attachments)
{
<div class="border p-2 rounded-md">
@if (attachment.MimeType != null && attachment.MimeType.StartsWith("image/"))
{
<img src="/api/files/@attachment.Id" alt="@attachment.Name"
class="w-full h-auto object-cover mb-2"/>
}
else if (attachment.MimeType != null && attachment.MimeType.StartsWith("video/"))
{
<video controls class="w-full h-auto object-cover mb-2">
<source src="/api/files/@attachment.Id" type="@attachment.MimeType">
Your browser does not support the video tag.
</video>
}
<a href="/api/files/@attachment.Id" target="_blank" class="text-blue-500 hover:underline">
@attachment.Name
</a>
</div>
}
</div>
}
}
else
{
<div class="alert alert-error">
<span>Post not found.</span>
</div>
}
</div>

View File

@@ -1,48 +0,0 @@
using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Pages.Posts;
public class PostDetailModel(
AppDatabase db,
PublisherService pub,
AccountService.AccountServiceClient accounts
) : PageModel
{
[BindProperty(SupportsGet = true)] public Guid PostId { get; set; }
public Post.Post? Post { get; set; }
public async Task<IActionResult> OnGetAsync()
{
if (PostId == Guid.Empty)
return NotFound();
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
var accountId = currentUser is null ? Guid.Empty : Guid.Parse(currentUser.Id);
var userFriends = currentUser is null
? []
: (await accounts.ListFriendsAsync(
new ListRelationshipSimpleRequest { AccountId = currentUser.Id }
)).AccountsId.Select(Guid.Parse).ToList();
var userPublishers = currentUser is null ? [] : await pub.GetUserPublishers(accountId);
Post = await db.Posts
.Where(e => e.Id == PostId)
.Include(e => e.Publisher)
.Include(e => e.Tags)
.Include(e => e.Categories)
.FilterWithVisibility(currentUser, userFriends, userPublishers)
.FirstOrDefaultAsync();
if (Post == null)
return NotFound();
return Page();
}
}

View File

@@ -1,35 +0,0 @@
<!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">
</div>
</header>
<main class="h-full pt-16">
@RenderBody()
</main>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>

View File

@@ -1,4 +0,0 @@
@using DysonNetwork.Sphere
@namespace DysonNetwork.Sphere.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Microsoft.AspNetCore.SpaServices

View File

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

View File

@@ -21,14 +21,11 @@ public static class ApplicationConfiguration
ConfigureForwardedHeaders(app, configuration);
app.UseWebSockets();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<PermissionMiddleware>();
app.MapControllers().RequireRateLimiting("fixed");
app.MapStaticAssets().RequireRateLimiting("fixed");
app.MapRazorPages().RequireRateLimiting("fixed");
app.MapControllers();
// Map gRPC services
app.MapGrpcService<WebSocketHandlerGrpc>();

View File

@@ -1,13 +0,0 @@
{
"name": "@dyson/sphere",
"version": "1.0.0",
"description": "DysonNetwork Sphere Web Application",
"scripts": {
"css:build": "npx @tailwindcss/cli -i ./wwwroot/css/site.css -o ./wwwroot/css/styles.css"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.7",
"@tailwindcss/postcss": "^4.1.7",
"daisyui": "^5.0.46"
}
}

View File

@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
}

View File

@@ -1,11 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./Pages/**/*.cshtml",
"./Views/**/*.cshtml"
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,110 +0,0 @@
@import "tailwindcss";
@plugin "daisyui";
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css" layer(utilities);
@theme {
--font-sans: "Nunito", sans-serif;
--font-mono: "Noto Sans Mono", monospace;
}
@plugin "daisyui/theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(98% 0 0);
--color-base-300: oklch(95% 0 0);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(62% 0.0873 281deg);
--color-primary-content: oklch(93% 0.034 272.788);
--color-secondary: oklch(62% 0.214 259.815);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(77% 0.152 181.912);
--color-accent-content: oklch(38% 0.063 188.416);
--color-neutral: oklch(14% 0.005 285.823);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(82% 0.111 230.318);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(79% 0.209 151.711);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.28125rem;
--size-field: 0.28125rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
@plugin "daisyui/theme" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(0% 0 0);
--color-base-200: oklch(20% 0.016 285.938);
--color-base-300: oklch(25% 0.013 285.805);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(50% 0.0873 281deg);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(62% 0.214 259.815);
--color-secondary-content: oklch(94% 0.028 342.258);
--color-accent: oklch(77% 0.152 181.912);
--color-accent-content: oklch(38% 0.063 188.416);
--color-neutral: oklch(21% 0.006 285.885);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(82% 0.111 230.318);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(79% 0.209 151.711);
--color-success-content: oklch(37% 0.077 168.94);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(64% 0.246 16.439);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.28125rem;
--size-field: 0.28125rem;
--border: 1px;
--depth: 1;
--noise: 1;
}
@layer base {
html, body {
padding: 0;
margin: 0;
box-sizing: border-box;
}
.material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 700, 'GRAD' 0, 'opsz' 48;
}
/* For Firefox. */
* {
scrollbar-width: none;
}
/* For WebKit (Chrome & Safari). */
::-webkit-scrollbar {
display: none;
}
}
.container-default {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}

File diff suppressed because it is too large Load Diff