🧱 Grpc service basis

This commit is contained in:
2025-07-12 15:19:31 +08:00
parent 0318364bcf
commit 33f56c4ef5
28 changed files with 1620 additions and 28 deletions

View File

@ -18,7 +18,7 @@ public class Account : ModelBase
public Instant? ActivatedAt { get; set; } public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false; public bool IsSuperuser { get; set; } = false;
public Profile Profile { get; set; } = null!; public AccountProfile Profile { get; set; } = null!;
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>(); public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>(); public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
@ -53,7 +53,7 @@ public abstract class Leveling
]; ];
} }
public class Profile : ModelBase public class AccountProfile : ModelBase
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
[MaxLength(256)] public string? FirstName { get; set; } [MaxLength(256)] public string? FirstName { get; set; }

View File

@ -72,7 +72,7 @@ public class AccountCurrentController(
} }
[HttpPatch("profile")] [HttpPatch("profile")]
public async Task<ActionResult<Profile>> UpdateProfile([FromBody] ProfileRequest request) public async Task<ActionResult<AccountProfile>> UpdateProfile([FromBody] ProfileRequest request)
{ {
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var userId = currentUser.Id; var userId = currentUser.Id;

View File

@ -115,7 +115,7 @@ public class AccountService(
}.HashSecret() }.HashSecret()
} }
: [], : [],
Profile = new Profile() Profile = new AccountProfile()
}; };
if (isActivated) if (isActivated)
@ -648,7 +648,7 @@ public class AccountService(
if (missingId.Count != 0) if (missingId.Count != 0)
{ {
var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList(); var newProfiles = missingId.Select(id => new AccountProfile { Id = Guid.NewGuid(), AccountId = id }).ToList();
await db.BulkInsertAsync(newProfiles); await db.BulkInsertAsync(newProfiles);
} }
} }

View File

@ -0,0 +1,303 @@
using DysonNetwork.Shared.Proto;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using NodaTime.Serialization.Protobuf;
namespace DysonNetwork.Pass.Account;
public class AccountServiceGrpc(
AppDatabase db,
IClock clock,
ILogger<AccountServiceGrpc> logger
)
: Shared.Proto.AccountService.AccountServiceBase
{
private readonly AppDatabase _db = db ?? throw new ArgumentNullException(nameof(db));
private readonly IClock _clock = clock ?? throw new ArgumentNullException(nameof(clock));
private readonly ILogger<AccountServiceGrpc>
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Helper methods for conversion between protobuf and domain models
private static Shared.Proto.Account ToProtoAccount(Account account) => new()
{
Id = account.Id.ToString(),
Name = account.Name,
Nick = account.Nick,
Language = account.Language,
ActivatedAt = account.ActivatedAt?.ToTimestamp(),
IsSuperuser = account.IsSuperuser,
Profile = ToProtoProfile(account.Profile)
// Note: Collections are not included by default to avoid large payloads
// They should be loaded on demand via specific methods
};
private static Shared.Proto.AccountProfile ToProtoProfile(AccountProfile profile) => new()
{
Id = profile.Id.ToString(),
FirstName = profile.FirstName,
MiddleName = profile.MiddleName,
LastName = profile.LastName,
Bio = profile.Bio,
Gender = profile.Gender,
Pronouns = profile.Pronouns,
TimeZone = profile.TimeZone,
Location = profile.Location,
Birthday = profile.Birthday?.ToTimestamp(),
LastSeenAt = profile.LastSeenAt?.ToTimestamp(),
Experience = profile.Experience,
Level = profile.Level,
LevelingProgress = profile.LevelingProgress,
AccountId = profile.AccountId.ToString(),
PictureId = profile.PictureId,
BackgroundId = profile.BackgroundId,
Picture = profile.Picture?.ToProtoValue(),
Background = profile.Background?.ToProtoValue()
};
private static Shared.Proto.AccountContact ToProtoContact(AccountContact contact) => new()
{
Id = contact.Id.ToString(),
Type = contact.Type switch
{
AccountContactType.Address => Shared.Proto.AccountContactType.Address,
AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber,
AccountContactType.Email => Shared.Proto.AccountContactType.Email,
_ => Shared.Proto.AccountContactType.Unspecified
},
VerifiedAt = contact.VerifiedAt?.ToTimestamp(),
IsPrimary = contact.IsPrimary,
Content = contact.Content,
AccountId = contact.AccountId.ToString()
};
private static Shared.Proto.AccountBadge ToProtoBadge(AccountBadge badge) => new()
{
Id = badge.Id.ToString(),
Type = badge.Type,
Label = badge.Label,
Caption = badge.Caption,
ActivatedAt = badge.ActivatedAt?.ToTimestamp(),
ExpiredAt = badge.ExpiredAt?.ToTimestamp(),
AccountId = badge.AccountId.ToString()
};
// Implementation of gRPC service methods
public override async Task<Shared.Proto.Account> GetAccount(GetAccountRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var account = await _db.Accounts
.AsNoTracking()
.Include(a => a.Profile)
.FirstOrDefaultAsync(a => a.Id == accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
return ToProtoAccount(account);
}
public override async Task<Shared.Proto.Account> CreateAccount(CreateAccountRequest request,
ServerCallContext context)
{
// Map protobuf request to domain model
var account = new Account
{
Name = request.Name,
Nick = request.Nick,
Language = request.Language,
IsSuperuser = request.IsSuperuser,
ActivatedAt = request.Profile != null ? null : _clock.GetCurrentInstant(),
Profile = new AccountProfile
{
FirstName = request.Profile?.FirstName,
LastName = request.Profile?.LastName,
// Initialize other profile fields as needed
}
};
// Add to database
_db.Accounts.Add(account);
await _db.SaveChangesAsync();
_logger.LogInformation("Created new account with ID {AccountId}", account.Id);
return ToProtoAccount(account);
}
public override async Task<Shared.Proto.Account> UpdateAccount(UpdateAccountRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var account = await _db.Accounts.FindAsync(accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
// Update fields if they are provided in the request
if (request.Name != null) account.Name = request.Name;
if (request.Nick != null) account.Nick = request.Nick;
if (request.Language != null) account.Language = request.Language;
if (request.IsSuperuser != null) account.IsSuperuser = request.IsSuperuser.Value;
await _db.SaveChangesAsync();
return ToProtoAccount(account);
}
public override async Task<Empty> DeleteAccount(DeleteAccountRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.Id, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var account = await _db.Accounts.FindAsync(accountId);
if (account == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, $"Account {request.Id} not found"));
_db.Accounts.Remove(account);
await _db.SaveChangesAsync();
return new Empty();
}
public override async Task<ListAccountsResponse> ListAccounts(ListAccountsRequest request,
ServerCallContext context)
{
var query = _db.Accounts.AsNoTracking();
// Apply filters if provided
if (!string.IsNullOrEmpty(request.Filter))
{
// Implement filtering logic based on request.Filter
// This is a simplified example
query = query.Where(a => a.Name.Contains(request.Filter) || a.Nick.Contains(request.Filter));
}
// Apply ordering
query = request.OrderBy switch
{
"name" => query.OrderBy(a => a.Name),
"name_desc" => query.OrderByDescending(a => a.Name),
_ => query.OrderBy(a => a.Id)
};
// Get total count for pagination
var totalCount = await query.CountAsync();
// Apply pagination
var accounts = await query
.Skip(request.PageSize * (request.PageToken != null ? int.Parse(request.PageToken) : 0))
.Take(request.PageSize)
.Include(a => a.Profile)
.ToListAsync();
var response = new ListAccountsResponse
{
TotalSize = totalCount,
NextPageToken = (accounts.Count == request.PageSize)
? ((request.PageToken != null ? int.Parse(request.PageToken) : 0) + 1).ToString()
: ""
};
response.Accounts.AddRange(accounts.Select(ToProtoAccount));
return response;
}
// Implement other service methods following the same pattern...
// Profile operations
public override async Task<Shared.Proto.AccountProfile> GetProfile(GetProfileRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var profile = await _db.AccountProfiles
.AsNoTracking()
.FirstOrDefaultAsync(p => p.AccountId == accountId);
if (profile == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound,
$"Profile for account {request.AccountId} not found"));
return ToProtoProfile(profile);
}
public override async Task<Shared.Proto.AccountProfile> UpdateProfile(UpdateProfileRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var profile = await _db.AccountProfiles
.FirstOrDefaultAsync(p => p.AccountId == accountId);
if (profile == null)
throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound,
$"Profile for account {request.AccountId} not found"));
// Update only the fields specified in the field mask
if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("first_name"))
profile.FirstName = request.Profile.FirstName;
if (request.UpdateMask == null || request.UpdateMask.Paths.Contains("last_name"))
profile.LastName = request.Profile.LastName;
// Update other fields similarly...
await _db.SaveChangesAsync();
return ToProtoProfile(profile);
}
// Contact operations
public override async Task<Shared.Proto.AccountContact> AddContact(AddContactRequest request,
ServerCallContext context)
{
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var contact = new AccountContact
{
AccountId = accountId,
Type = (AccountContactType)request.Type,
Content = request.Content,
IsPrimary = request.IsPrimary,
VerifiedAt = null
};
_db.AccountContacts.Add(contact);
await _db.SaveChangesAsync();
return ToProtoContact(contact);
}
// Implement other contact operations...
// Badge operations
public override async Task<Shared.Proto.AccountBadge> AddBadge(AddBadgeRequest request, ServerCallContext context)
{
if (!Guid.TryParse(request.AccountId, out var accountId))
throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Invalid account ID format"));
var badge = new AccountBadge
{
AccountId = accountId,
Type = request.Type,
Label = request.Label,
Caption = request.Caption,
ActivatedAt = _clock.GetCurrentInstant(),
ExpiredAt = request.ExpiredAt?.ToInstant(),
Meta = request.Meta.ToDictionary(kvp => kvp.Key, kvp => (object)kvp.Value)
};
_db.Badges.Add(badge);
await _db.SaveChangesAsync();
return ToProtoBadge(badge);
}
// Implement other badge operations...
}

View File

@ -25,7 +25,7 @@ public class AppDatabase(
public DbSet<MagicSpell> MagicSpells { get; set; } public DbSet<MagicSpell> MagicSpells { get; set; }
public DbSet<Account.Account> Accounts { get; set; } public DbSet<Account.Account> Accounts { get; set; }
public DbSet<AccountConnection> AccountConnections { get; set; } public DbSet<AccountConnection> AccountConnections { get; set; }
public DbSet<Profile> AccountProfiles { get; set; } public DbSet<AccountProfile> AccountProfiles { get; set; }
public DbSet<AccountContact> AccountContacts { get; set; } public DbSet<AccountContact> AccountContacts { get; set; }
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; } public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
public DbSet<Relationship> AccountRelationships { get; set; } public DbSet<Relationship> AccountRelationships { get; set; }

View File

@ -7,9 +7,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
<PackageReference Include="NodaTime" Version="3.2.2"/> <PackageReference Include="NodaTime" Version="3.2.2"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0"/>

View File

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

View File

@ -1,4 +1,5 @@
using DysonNetwork.Pass; using DysonNetwork.Pass;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Startup; using DysonNetwork.Pass.Startup;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -37,4 +38,7 @@ using (var scope = app.Services.CreateScope())
// Configure application middleware pipeline // Configure application middleware pipeline
app.ConfigureAppMiddleware(builder.Configuration); app.ConfigureAppMiddleware(builder.Configuration);
// Configure gRPC
app.ConfigureGrpcServices();
app.Run(); app.Run();

View File

@ -1,4 +1,5 @@
using System.Net; using System.Net;
using DysonNetwork.Pass.Account;
using DysonNetwork.Pass.Permission; using DysonNetwork.Pass.Permission;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Prometheus; using Prometheus;
@ -63,4 +64,11 @@ public static class ApplicationConfiguration
app.UseForwardedHeaders(forwardedHeadersOptions); app.UseForwardedHeaders(forwardedHeadersOptions);
} }
public static WebApplication ConfigureGrpcServices(this WebApplication app)
{
app.MapGrpcService<AccountServiceGrpc>();
return app;
}
} }

View File

@ -40,6 +40,20 @@ public static class ServiceCollectionExtensions
services.AddHttpClient(); services.AddHttpClient();
// Register gRPC services
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true; // Will be adjusted in Program.cs
options.MaxReceiveMessageSize = 16 * 1024 * 1024; // 16MB
options.MaxSendMessageSize = 16 * 1024 * 1024; // 16MB
});
// Register gRPC reflection for service discovery
services.AddGrpc();
// Register gRPC services
services.AddScoped<AccountServiceGrpc>();
// Register OIDC services // Register OIDC services
services.AddScoped<OidcService, GoogleOidcService>(); services.AddScoped<OidcService, GoogleOidcService>();
services.AddScoped<OidcService, AppleOidcService>(); services.AddScoped<OidcService, AppleOidcService>();

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,9 +1,129 @@
{ {
"Debug": true,
"BaseUrl": "http://localhost:5071",
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"FastRetrieve": "localhost:6379"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5071",
"https://localhost:7099"
],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Tus": {
"StorePath": "Uploads"
},
"Storage": {
"PreferredRemote": "minio",
"Remote": [
{
"Id": "minio",
"Label": "Minio",
"Region": "auto",
"Bucket": "solar-network-development",
"Endpoint": "localhost:9000",
"SecretId": "littlesheep",
"SecretKey": "password",
"EnabledSigned": true,
"EnableSsl": false
},
{
"Id": "cloudflare",
"Label": "Cloudflare R2",
"Region": "auto",
"Bucket": "solar-network",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
"EnableSigned": true,
"EnableSsl": true
}
]
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
} }

View File

@ -0,0 +1,42 @@
namespace DysonNetwork.Pusher.Connection;
public class ClientTypeMiddleware(RequestDelegate next)
{
public async Task Invoke(HttpContext context)
{
var headers = context.Request.Headers;
bool isWebPage;
// Priority 1: Check for custom header
if (headers.TryGetValue("X-Client", out var clientType))
{
isWebPage = clientType.ToString().Length == 0;
}
else
{
var userAgent = headers.UserAgent.ToString();
var accept = headers.Accept.ToString();
// Priority 2: Check known app User-Agent (backward compatibility)
if (!string.IsNullOrEmpty(userAgent) && userAgent.Contains("Solian"))
isWebPage = false;
// Priority 3: Accept header can help infer intent
else if (!string.IsNullOrEmpty(accept) && accept.Contains("text/html"))
isWebPage = true;
else if (!string.IsNullOrEmpty(accept) && accept.Contains("application/json"))
isWebPage = false;
else
isWebPage = true;
}
context.Items["IsWebPage"] = isWebPage;
if (!isWebPage && context.Request.Path != "/ws" && !context.Request.Path.StartsWithSegments("/api"))
context.Response.Redirect(
$"/api{context.Request.Path.Value}{context.Request.QueryString.Value}",
permanent: false
);
else
await next(context);
}
}

View File

@ -0,0 +1,9 @@
using System.Net.WebSockets;
namespace DysonNetwork.Pusher.Connection;
public interface IWebSocketPacketHandler
{
string PacketType { get; }
Task HandleAsync(Account currentUser, string deviceId, WebSocketPacket packet, WebSocket socket, WebSocketService srv);
}

View File

@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Swashbuckle.AspNetCore.Annotations;
namespace DysonNetwork.Pusher.Connection;
[ApiController]
[Route("/ws")]
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
{
[Route("/ws")]
[Authorize]
[SwaggerIgnore]
public async Task TheGateway()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
if (currentUserValue is not Account.Account currentUser ||
currentSessionValue is not Auth.Session currentSession)
{
HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var accountId = currentUser.Id;
var deviceId = currentSession.Challenge.DeviceId;
if (string.IsNullOrEmpty(deviceId))
{
HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
var cts = new CancellationTokenSource();
var connectionKey = (accountId, deviceId);
if (!ws.TryAdd(connectionKey, webSocket, cts))
{
await webSocket.CloseAsync(
WebSocketCloseStatus.InternalServerError,
"Failed to establish connection.",
CancellationToken.None
);
return;
}
logger.LogInformation(
$"Connection established with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
try
{
await _ConnectionEventLoop(deviceId, currentUser, webSocket, cts.Token);
}
catch (Exception ex)
{
Console.WriteLine($"WebSocket Error: {ex.Message}");
}
finally
{
ws.Disconnect(connectionKey);
logger.LogInformation(
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
}
}
private async Task _ConnectionEventLoop(
string deviceId,
Account.Account currentUser,
WebSocket webSocket,
CancellationToken cancellationToken
)
{
var connectionKey = (AccountId: currentUser.Id, DeviceId: deviceId);
var buffer = new byte[1024 * 4];
try
{
var receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
while (!receiveResult.CloseStatus.HasValue)
{
receiveResult = await webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
var packet = WebSocketPacket.FromBytes(buffer[..receiveResult.Count]);
_ = ws.HandlePacket(currentUser, connectionKey.DeviceId, packet, webSocket);
}
}
catch (OperationCanceledException)
{
if (
webSocket.State != WebSocketState.Closed
&& webSocket.State != WebSocketState.Aborted
)
{
ws.Disconnect(connectionKey);
}
}
}
}

View File

@ -0,0 +1,72 @@
using System.Text.Json;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
public class WebSocketPacketType
{
public const string Error = "error";
public const string MessageNew = "messages.new";
public const string MessageUpdate = "messages.update";
public const string MessageDelete = "messages.delete";
public const string CallParticipantsUpdate = "call.participants.update";
}
public class WebSocketPacket
{
public string Type { get; set; } = null!;
public object Data { get; set; } = null!;
public string? ErrorMessage { get; set; }
/// <summary>
/// Creates a WebSocketPacket from raw WebSocket message bytes
/// </summary>
/// <param name="bytes">Raw WebSocket message bytes</param>
/// <returns>Deserialized WebSocketPacket</returns>
public static WebSocketPacket FromBytes(byte[] bytes)
{
var json = System.Text.Encoding.UTF8.GetString(bytes);
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
};
return JsonSerializer.Deserialize<WebSocketPacket>(json, jsonOpts) ??
throw new JsonException("Failed to deserialize WebSocketPacket");
}
/// <summary>
/// Deserializes the Data property to the specified type T
/// </summary>
/// <typeparam name="T">Target type to deserialize to</typeparam>
/// <returns>Deserialized data of type T</returns>
public T? GetData<T>()
{
if (Data is T typedData)
return typedData;
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
};
return JsonSerializer.Deserialize<T>(
JsonSerializer.Serialize(Data, jsonOpts),
jsonOpts
);
}
/// <summary>
/// Serializes this WebSocketPacket to a byte array for sending over WebSocket
/// </summary>
/// <returns>Byte array representation of the packet</returns>
public byte[] ToBytes()
{
var jsonOpts = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower,
}.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
var json = JsonSerializer.Serialize(this, jsonOpts);
return System.Text.Encoding.UTF8.GetBytes(json);
}
}

View File

@ -0,0 +1,129 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace DysonNetwork.Pusher.Connection;
public class WebSocketService
{
private readonly IDictionary<string, IWebSocketPacketHandler> _handlerMap;
public WebSocketService(IEnumerable<IWebSocketPacketHandler> handlers)
{
_handlerMap = handlers.ToDictionary(h => h.PacketType);
}
private static readonly ConcurrentDictionary<
(Guid AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new();
private static readonly ConcurrentDictionary<string, string> ActiveSubscriptions = new(); // deviceId -> chatRoomId
public void SubscribeToChatRoom(string chatRoomId, string deviceId)
{
ActiveSubscriptions[deviceId] = chatRoomId;
}
public void UnsubscribeFromChatRoom(string deviceId)
{
ActiveSubscriptions.TryRemove(deviceId, out _);
}
public bool IsUserSubscribedToChatRoom(Guid accountId, string chatRoomId)
{
var userDeviceIds = ActiveConnections.Keys.Where(k => k.AccountId == accountId).Select(k => k.DeviceId);
foreach (var deviceId in userDeviceIds)
{
if (ActiveSubscriptions.TryGetValue(deviceId, out var subscribedChatRoomId) && subscribedChatRoomId == chatRoomId)
{
return true;
}
}
return false;
}
public bool TryAdd(
(Guid AccountId, string DeviceId) key,
WebSocket socket,
CancellationTokenSource cts
)
{
if (ActiveConnections.TryGetValue(key, out _))
Disconnect(key,
"Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier
return ActiveConnections.TryAdd(key, (socket, cts));
}
public void Disconnect((Guid AccountId, string DeviceId) key, string? reason = null)
{
if (!ActiveConnections.TryGetValue(key, out var data)) return;
data.Socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
reason ?? "Server just decided to disconnect.",
CancellationToken.None
);
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
UnsubscribeFromChatRoom(key.DeviceId);
}
public bool GetAccountIsConnected(Guid accountId)
{
return ActiveConnections.Any(c => c.Key.AccountId == accountId);
}
public void SendPacketToAccount(Guid userId, WebSocketPacket packet)
{
var connections = ActiveConnections.Where(c => c.Key.AccountId == userId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
{
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}
public void SendPacketToDevice(string deviceId, WebSocketPacket packet)
{
var connections = ActiveConnections.Where(c => c.Key.DeviceId == deviceId);
var packetBytes = packet.ToBytes();
var segment = new ArraySegment<byte>(packetBytes);
foreach (var connection in connections)
{
connection.Value.Socket.SendAsync(
segment,
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}
public async Task HandlePacket(Account.Account currentUser, string deviceId, WebSocketPacket packet,
WebSocket socket)
{
if (_handlerMap.TryGetValue(packet.Type, out var handler))
{
await handler.HandleAsync(currentUser, deviceId, packet, socket, this);
return;
}
await socket.SendAsync(
new ArraySegment<byte>(new WebSocketPacket
{
Type = WebSocketPacketType.Error,
ErrorMessage = $"Unprocessable packet: {packet.Type}"
}.ToBytes()),
WebSocketMessageType.Binary,
true,
CancellationToken.None
);
}
}

View File

@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DysonNetwork.Pusher/DysonNetwork.Pusher.csproj", "DysonNetwork.Pusher/"]
RUN dotnet restore "DysonNetwork.Pusher/DysonNetwork.Pusher.csproj"
COPY . .
WORKDIR "/src/DysonNetwork.Pusher"
RUN dotnet build "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DysonNetwork.Pusher.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DysonNetwork.Pusher.dll"]

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,23 @@
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();

View File

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7259;http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,129 @@
{
"Debug": true,
"BaseUrl": "http://localhost:5071",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
"FastRetrieve": "localhost:6379"
},
"Authentication": {
"Schemes": {
"Bearer": {
"ValidAudiences": [
"http://localhost:5071",
"https://localhost:7099"
],
"ValidIssuer": "solar-network"
}
}
},
"AuthToken": {
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem"
},
"OidcProvider": {
"IssuerUri": "https://nt.solian.app",
"PublicKeyPath": "Keys/PublicKey.pem",
"PrivateKeyPath": "Keys/PrivateKey.pem",
"AccessTokenLifetime": "01:00:00",
"RefreshTokenLifetime": "30.00:00:00",
"AuthorizationCodeLifetime": "00:30:00",
"RequireHttpsMetadata": true
},
"Tus": {
"StorePath": "Uploads"
},
"Storage": {
"PreferredRemote": "minio",
"Remote": [
{
"Id": "minio",
"Label": "Minio",
"Region": "auto",
"Bucket": "solar-network-development",
"Endpoint": "localhost:9000",
"SecretId": "littlesheep",
"SecretKey": "password",
"EnabledSigned": true,
"EnableSsl": false
},
{
"Id": "cloudflare",
"Label": "Cloudflare R2",
"Region": "auto",
"Bucket": "solar-network",
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
"EnableSigned": true,
"EnableSsl": true
}
]
},
"Captcha": {
"Provider": "cloudflare",
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
},
"Notifications": {
"Topic": "dev.solsynth.solian",
"Endpoint": "http://localhost:8088"
},
"Email": {
"Server": "smtp4dev.orb.local",
"Port": 25,
"UseSsl": false,
"Username": "no-reply@mail.solsynth.dev",
"Password": "password",
"FromAddress": "no-reply@mail.solsynth.dev",
"FromName": "Alphabot",
"SubjectPrefix": "Solar Network"
},
"RealtimeChat": {
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
"ApiKey": "APIs6TiL8wj3A4j",
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
},
"GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
},
"Oidc": {
"Google": {
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
"ClientSecret": ""
},
"Apple": {
"ClientId": "dev.solsynth.solian",
"TeamId": "W7HPZ53V6B",
"KeyId": "B668YP4KBG",
"PrivateKeyPath": "./Keys/Solarpass.p8"
},
"Microsoft": {
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
}
},
"Payment": {
"Auth": {
"Afdian": "<token here>"
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
}
}
},
"KnownProxies": [
"127.0.0.1",
"::1"
]
}

View File

@ -1,3 +1,5 @@
using Google.Protobuf.WellKnownTypes;
namespace DysonNetwork.Shared.Data; namespace DysonNetwork.Shared.Data;
/// <summary> /// <summary>
@ -14,4 +16,43 @@ public class CloudFileReferenceObject : ModelBase, ICloudFile
public string? Hash { get; set; } public string? Hash { get; set; }
public long Size { get; set; } public long Size { get; set; }
public bool HasCompression { get; set; } = false; public bool HasCompression { get; set; } = false;
/// <summary>
/// Converts the current object to its protobuf representation
/// </summary>
public global::DysonNetwork.Shared.Proto.CloudFileReferenceObject ToProtoValue()
{
var proto = new global::DysonNetwork.Shared.Proto.CloudFileReferenceObject
{
Id = Id,
Name = Name,
MimeType = MimeType ?? string.Empty,
Hash = Hash ?? string.Empty,
Size = Size,
HasCompression = HasCompression,
// Backward compatibility fields
ContentType = MimeType ?? string.Empty,
Url = string.Empty // This should be set by the caller if needed
};
// Convert file metadata
if (FileMeta != null)
{
foreach (var (key, value) in FileMeta)
{
proto.FileMeta[key] = Value.ForString(value?.ToString() ?? string.Empty);
}
}
// Convert user metadata
if (UserMeta != null)
{
foreach (var (key, value) in UserMeta)
{
proto.UserMeta[key] = Value.ForString(value?.ToString() ?? string.Empty);
}
}
return proto;
}
} }

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
@ -7,12 +7,26 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="Google.Api.CommonProtos" Version="2.17.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7" /> <PackageReference Include="Google.Protobuf" Version="3.31.1" />
<PackageReference Include="NetTopologySuite" Version="2.6.0" /> <PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Grpc" Version="2.46.6" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> <PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" /> <PackageReference Include="Grpc.Tools" Version="2.72.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0"/>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/>
<PackageReference Include="NetTopologySuite" Version="2.6.0"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0"/>
<PackageReference Include="NodaTime.Serialization.Protobuf" Version="2.0.2"/>
<PackageReference Include="StackExchange.Redis" Version="2.8.41"/>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,427 @@
syntax = "proto3";
package proto;
option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/wrappers.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import 'file.proto';
// Account represents a user account in the system
message Account {
string id = 1;
string name = 2;
string nick = 3;
string language = 4;
google.protobuf.Timestamp activated_at = 5;
bool is_superuser = 6;
AccountProfile profile = 7;
repeated AccountContact contacts = 8;
repeated AccountBadge badges = 9;
repeated AccountAuthFactor auth_factors = 10;
repeated AccountConnection connections = 11;
repeated Relationship outgoing_relationships = 12;
repeated Relationship incoming_relationships = 13;
}
// Profile contains detailed information about a user
message AccountProfile {
string id = 1;
google.protobuf.StringValue first_name = 2;
google.protobuf.StringValue middle_name = 3;
google.protobuf.StringValue last_name = 4;
google.protobuf.StringValue bio = 5;
google.protobuf.StringValue gender = 6;
google.protobuf.StringValue pronouns = 7;
google.protobuf.StringValue time_zone = 8;
google.protobuf.StringValue location = 9;
google.protobuf.Timestamp birthday = 10;
google.protobuf.Timestamp last_seen_at = 11;
VerificationMark verification = 12;
BadgeReferenceObject active_badge = 13;
int32 experience = 14;
int32 level = 15;
double leveling_progress = 16;
// Legacy fields
google.protobuf.StringValue picture_id = 17;
google.protobuf.StringValue background_id = 18;
CloudFileReferenceObject picture = 19;
CloudFileReferenceObject background = 20;
string account_id = 21;
}
// AccountContact represents a contact method for an account
message AccountContact {
string id = 1;
AccountContactType type = 2;
google.protobuf.Timestamp verified_at = 3;
bool is_primary = 4;
string content = 5;
string account_id = 6;
}
// Enum for contact types
enum AccountContactType {
ACCOUNT_CONTACT_TYPE_UNSPECIFIED = 0;
EMAIL = 1;
PHONE_NUMBER = 2;
ADDRESS = 3;
}
// AccountAuthFactor represents an authentication factor for an account
message AccountAuthFactor {
string id = 1;
AccountAuthFactorType type = 2;
google.protobuf.StringValue secret = 3; // Omitted from JSON serialization in original
map<string, string> config = 4; // Omitted from JSON serialization in original
int32 trustworthy = 5;
google.protobuf.Timestamp enabled_at = 6;
google.protobuf.Timestamp expired_at = 7;
string account_id = 8;
map<string, string> created_response = 9; // For initial setup
}
// Enum for authentication factor types
enum AccountAuthFactorType {
AUTH_FACTOR_TYPE_UNSPECIFIED = 0;
PASSWORD = 1;
EMAIL_CODE = 2;
IN_APP_CODE = 3;
TIMED_CODE = 4;
PIN_CODE = 5;
}
// AccountBadge represents a badge associated with an account
message AccountBadge {
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, string> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
}
// AccountConnection represents a third-party connection for an account
message AccountConnection {
string id = 1;
string provider = 2;
string provided_identifier = 3;
map<string, string> meta = 4;
google.protobuf.StringValue access_token = 5; // Omitted from JSON serialization
google.protobuf.StringValue refresh_token = 6; // Omitted from JSON serialization
google.protobuf.Timestamp last_used_at = 7;
string account_id = 8;
}
// VerificationMark represents verification status
message VerificationMark {
bool verified = 1;
string method = 2;
google.protobuf.Timestamp verified_at = 3;
string verified_by = 4;
}
// BadgeReferenceObject represents a reference to a badge with minimal information
message BadgeReferenceObject {
string id = 1; // Unique identifier for the badge
string type = 2; // Type/category of the badge
google.protobuf.StringValue label = 3; // Display name of the badge
google.protobuf.StringValue caption = 4; // Optional description of the badge
map<string, string> meta = 5; // Additional metadata for the badge
google.protobuf.Timestamp activated_at = 6; // When the badge was activated
google.protobuf.Timestamp expired_at = 7; // Optional expiration time
string account_id = 8; // ID of the account this badge belongs to
}
// Relationship represents a connection between two accounts
message Relationship {
string id = 1;
string from_account_id = 2;
string to_account_id = 3;
RelationshipType type = 4;
string note = 5;
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
}
// Enum for relationship types
enum RelationshipType {
RELATIONSHIP_TYPE_UNSPECIFIED = 0;
FRIEND = 1;
BLOCKED = 2;
PENDING_INCOMING = 3;
PENDING_OUTGOING = 4;
}
// Leveling information
message LevelingInfo {
int32 current_level = 1;
int32 current_experience = 2;
int32 next_level_experience = 3;
int32 previous_level_experience = 4;
double level_progress = 5;
repeated int32 experience_per_level = 6;
}
// ====================================
// Service Definitions
// ====================================
// AccountService provides CRUD operations for user accounts and related entities
service AccountService {
// Account Operations
rpc GetAccount(GetAccountRequest) returns (Account) {}
rpc CreateAccount(CreateAccountRequest) returns (Account) {}
rpc UpdateAccount(UpdateAccountRequest) returns (Account) {}
rpc DeleteAccount(DeleteAccountRequest) returns (google.protobuf.Empty) {}
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse) {}
// Profile Operations
rpc GetProfile(GetProfileRequest) returns (AccountProfile) {}
rpc UpdateProfile(UpdateProfileRequest) returns (AccountProfile) {}
// Contact Operations
rpc AddContact(AddContactRequest) returns (AccountContact) {}
rpc UpdateContact(UpdateContactRequest) returns (AccountContact) {}
rpc RemoveContact(RemoveContactRequest) returns (google.protobuf.Empty) {}
rpc ListContacts(ListContactsRequest) returns (ListContactsResponse) {}
rpc VerifyContact(VerifyContactRequest) returns (AccountContact) {}
// Badge Operations
rpc AddBadge(AddBadgeRequest) returns (AccountBadge) {}
rpc RemoveBadge(RemoveBadgeRequest) returns (google.protobuf.Empty) {}
rpc ListBadges(ListBadgesRequest) returns (ListBadgesResponse) {}
rpc SetActiveBadge(SetActiveBadgeRequest) returns (AccountProfile) {}
// Authentication Factor Operations
rpc AddAuthFactor(AddAuthFactorRequest) returns (AccountAuthFactor) {}
rpc RemoveAuthFactor(RemoveAuthFactorRequest) returns (google.protobuf.Empty) {}
rpc ListAuthFactors(ListAuthFactorsRequest) returns (ListAuthFactorsResponse) {}
// Connection Operations
rpc AddConnection(AddConnectionRequest) returns (AccountConnection) {}
rpc RemoveConnection(RemoveConnectionRequest) returns (google.protobuf.Empty) {}
rpc ListConnections(ListConnectionsRequest) returns (ListConnectionsResponse) {}
// Relationship Operations
rpc CreateRelationship(CreateRelationshipRequest) returns (Relationship) {}
rpc UpdateRelationship(UpdateRelationshipRequest) returns (Relationship) {}
rpc DeleteRelationship(DeleteRelationshipRequest) returns (google.protobuf.Empty) {}
rpc ListRelationships(ListRelationshipsRequest) returns (ListRelationshipsResponse) {}
}
// ====================================
// Request/Response Messages
// ====================================
// Account Requests/Responses
message GetAccountRequest {
string id = 1; // Account ID to retrieve
}
message CreateAccountRequest {
string name = 1; // Required: Unique username
string nick = 2; // Optional: Display name
string language = 3; // Default language
bool is_superuser = 4; // Admin flag
AccountProfile profile = 5; // Initial profile data
}
message UpdateAccountRequest {
string id = 1; // Account ID to update
google.protobuf.StringValue name = 2; // New username if changing
google.protobuf.StringValue nick = 3; // New display name
google.protobuf.StringValue language = 4; // New language
google.protobuf.BoolValue is_superuser = 5; // Admin status
}
message DeleteAccountRequest {
string id = 1; // Account ID to delete
bool purge = 2; // If true, permanently delete instead of soft delete
}
message ListAccountsRequest {
int32 page_size = 1; // Number of results per page
string page_token = 2; // Token for pagination
string filter = 3; // Filter expression
string order_by = 4; // Sort order
}
message ListAccountsResponse {
repeated Account accounts = 1; // List of accounts
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of accounts
}
// Profile Requests/Responses
message GetProfileRequest {
string account_id = 1; // Account ID to get profile for
}
message UpdateProfileRequest {
string account_id = 1; // Account ID to update profile for
AccountProfile profile = 2; // Profile data to update
google.protobuf.FieldMask update_mask = 3; // Fields to update
}
// Contact Requests/Responses
message AddContactRequest {
string account_id = 1; // Account to add contact to
AccountContactType type = 2; // Type of contact
string content = 3; // Contact content (email, phone, etc.)
bool is_primary = 4; // If this should be the primary contact
}
message UpdateContactRequest {
string id = 1; // Contact ID to update
string account_id = 2; // Account ID (for validation)
google.protobuf.StringValue content = 3; // New contact content
google.protobuf.BoolValue is_primary = 4; // New primary status
}
message RemoveContactRequest {
string id = 1; // Contact ID to remove
string account_id = 2; // Account ID (for validation)
}
message ListContactsRequest {
string account_id = 1; // Account ID to list contacts for
AccountContactType type = 2; // Optional: filter by type
bool verified_only = 3; // Only return verified contacts
}
message ListContactsResponse {
repeated AccountContact contacts = 1; // List of contacts
}
message VerifyContactRequest {
string id = 1; // Contact ID to verify
string account_id = 2; // Account ID (for validation)
string code = 3; // Verification code
}
// Badge Requests/Responses
message AddBadgeRequest {
string account_id = 1; // Account to add badge to
string type = 2; // Type of badge
google.protobuf.StringValue label = 3; // Display label
google.protobuf.StringValue caption = 4; // Description
map<string, string> meta = 5; // Additional metadata
google.protobuf.Timestamp expired_at = 6; // Optional expiration
}
message RemoveBadgeRequest {
string id = 1; // Badge ID to remove
string account_id = 2; // Account ID (for validation)
}
message ListBadgesRequest {
string account_id = 1; // Account to list badges for
string type = 2; // Optional: filter by type
bool active_only = 3; // Only return active (non-expired) badges
}
message ListBadgesResponse {
repeated AccountBadge badges = 1; // List of badges
}
message SetActiveBadgeRequest {
string account_id = 1; // Account to update
string badge_id = 2; // Badge ID to set as active (empty to clear)
}
// Authentication Factor Requests/Responses
message AddAuthFactorRequest {
string account_id = 1; // Account to add factor to
AccountAuthFactorType type = 2; // Type of factor
string secret = 3; // Factor secret (hashed on server)
map<string, string> config = 4; // Configuration
int32 trustworthy = 5; // Trust level
google.protobuf.Timestamp expired_at = 6; // Optional expiration
}
message RemoveAuthFactorRequest {
string id = 1; // Factor ID to remove
string account_id = 2; // Account ID (for validation)
}
message ListAuthFactorsRequest {
string account_id = 1; // Account to list factors for
bool active_only = 2; // Only return active (non-expired) factors
}
message ListAuthFactorsResponse {
repeated AccountAuthFactor factors = 1; // List of auth factors
}
// Connection Requests/Responses
message AddConnectionRequest {
string account_id = 1; // Account to add connection to
string provider = 2; // Provider name (e.g., "google", "github")
string provided_identifier = 3; // Provider's user ID
map<string, string> meta = 4; // Additional metadata
google.protobuf.StringValue access_token = 5; // OAuth access token
google.protobuf.StringValue refresh_token = 6; // OAuth refresh token
}
message RemoveConnectionRequest {
string id = 1; // Connection ID to remove
string account_id = 2; // Account ID (for validation)
}
message ListConnectionsRequest {
string account_id = 1; // Account to list connections for
string provider = 2; // Optional: filter by provider
}
message ListConnectionsResponse {
repeated AccountConnection connections = 1; // List of connections
}
// Relationship Requests/Responses
message CreateRelationshipRequest {
string from_account_id = 1; // Source account ID
string to_account_id = 2; // Target account ID
RelationshipType type = 3; // Type of relationship
string note = 4; // Optional note
}
message UpdateRelationshipRequest {
string id = 1; // Relationship ID to update
string account_id = 2; // Account ID (for validation)
RelationshipType type = 3; // New relationship type
google.protobuf.StringValue note = 4; // New note
}
message DeleteRelationshipRequest {
string id = 1; // Relationship ID to delete
string account_id = 2; // Account ID (for validation)
}
message ListRelationshipsRequest {
string account_id = 1; // Account to list relationships for
RelationshipType type = 2; // Optional: filter by type
bool incoming = 3; // If true, list incoming relationships
bool outgoing = 4; // If true, list outgoing relationships
int32 page_size = 5; // Number of results per page
string page_token = 6; // Token for pagination
}
message ListRelationshipsResponse {
repeated Relationship relationships = 1; // List of relationships
string next_page_token = 2; // Token for next page
int32 total_size = 3; // Total number of relationships
}

View File

@ -0,0 +1,87 @@
syntax = "proto3";
package proto;
option csharp_namespace = "DysonNetwork.Shared.Proto";
import "google/protobuf/timestamp.proto";
import "google/protobuf/struct.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
// CloudFileReferenceObject represents a reference to a file stored in cloud storage.
// It contains metadata about the file that won't change, helping to reduce database load.
message CloudFileReferenceObject {
// Unique identifier for the file
string id = 1;
// Original name of the file
string name = 2;
// File metadata (e.g., dimensions, duration, etc.)
map<string, google.protobuf.Value> file_meta = 3;
// User-defined metadata
map<string, google.protobuf.Value> user_meta = 4;
// MIME type of the file
string mime_type = 5;
// File content hash (e.g., MD5, SHA-256)
string hash = 6;
// File size in bytes
int64 size = 7;
// Indicates if the file is stored with compression
bool has_compression = 8;
// URL to access the file (kept for backward compatibility)
string url = 9;
// Content type of the file (kept for backward compatibility)
string content_type = 10;
// When the file was uploaded (kept for backward compatibility)
google.protobuf.Timestamp uploaded_at = 11;
// Additional metadata (kept for backward compatibility)
map<string, string> metadata = 12;
}
// Service for file operations
service FileService {
// Get file reference by ID
rpc GetFile(GetFileRequest) returns (CloudFileReferenceObject) {}
// Create a new file reference
rpc CreateFile(CreateFileRequest) returns (CloudFileReferenceObject) {}
// Update an existing file reference
rpc UpdateFile(UpdateFileRequest) returns (CloudFileReferenceObject) {}
// Delete a file reference
rpc DeleteFile(DeleteFileRequest) returns (google.protobuf.Empty) {}
}
// Request message for GetFile
message GetFileRequest {
string id = 1;
}
// Request message for CreateFile
message CreateFileRequest {
CloudFileReferenceObject file = 1;
}
// Request message for UpdateFile
message UpdateFileRequest {
CloudFileReferenceObject file = 1;
google.protobuf.FieldMask update_mask = 2;
}
// Request message for DeleteFile
message DeleteFileRequest {
string id = 1;
bool hard_delete = 2;
}

View File

@ -23,6 +23,7 @@
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" /> <PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
<PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" />
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" /> <PackageReference Include="Livekit.Server.Sdk.Dotnet" Version="1.0.8" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.11.0" />

View File

@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonN
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pusher", "DysonNetwork.Pusher\DysonNetwork.Pusher.csproj", "{D5DAFB0D-487E-48EF-BA2F-C581C846F63B}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -29,5 +31,9 @@ Global
{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Debug|Any CPU.Build.0 = Debug|Any CPU {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Release|Any CPU.ActiveCfg = Release|Any CPU {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Release|Any CPU.Build.0 = Release|Any CPU {DB46D1A6-79B4-43FC-A9A9-115CDA26947A}.Release|Any CPU.Build.0 = Release|Any CPU
{D5DAFB0D-487E-48EF-BA2F-C581C846F63B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D5DAFB0D-487E-48EF-BA2F-C581C846F63B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D5DAFB0D-487E-48EF-BA2F-C581C846F63B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D5DAFB0D-487E-48EF-BA2F-C581C846F63B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal