diff --git a/DysonNetwork.Pass/Account/Account.cs b/DysonNetwork.Pass/Account/Account.cs index 027e127..a4183aa 100644 --- a/DysonNetwork.Pass/Account/Account.cs +++ b/DysonNetwork.Pass/Account/Account.cs @@ -18,7 +18,7 @@ public class Account : ModelBase public Instant? ActivatedAt { get; set; } public bool IsSuperuser { get; set; } = false; - public Profile Profile { get; set; } = null!; + public AccountProfile Profile { get; set; } = null!; public ICollection Contacts { get; set; } = new List(); public ICollection Badges { get; set; } = new List(); @@ -53,7 +53,7 @@ public abstract class Leveling ]; } -public class Profile : ModelBase +public class AccountProfile : ModelBase { public Guid Id { get; set; } [MaxLength(256)] public string? FirstName { get; set; } diff --git a/DysonNetwork.Pass/Account/AccountCurrentController.cs b/DysonNetwork.Pass/Account/AccountCurrentController.cs index 037935d..f5e830a 100644 --- a/DysonNetwork.Pass/Account/AccountCurrentController.cs +++ b/DysonNetwork.Pass/Account/AccountCurrentController.cs @@ -72,7 +72,7 @@ public class AccountCurrentController( } [HttpPatch("profile")] - public async Task> UpdateProfile([FromBody] ProfileRequest request) + public async Task> UpdateProfile([FromBody] ProfileRequest request) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); var userId = currentUser.Id; diff --git a/DysonNetwork.Pass/Account/AccountService.cs b/DysonNetwork.Pass/Account/AccountService.cs index 6ba2919..f376468 100644 --- a/DysonNetwork.Pass/Account/AccountService.cs +++ b/DysonNetwork.Pass/Account/AccountService.cs @@ -115,7 +115,7 @@ public class AccountService( }.HashSecret() } : [], - Profile = new Profile() + Profile = new AccountProfile() }; if (isActivated) @@ -648,7 +648,7 @@ public class AccountService( 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); } } diff --git a/DysonNetwork.Pass/Account/AccountServiceGrpc.cs b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs new file mode 100644 index 0000000..9ccbac7 --- /dev/null +++ b/DysonNetwork.Pass/Account/AccountServiceGrpc.cs @@ -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 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 + _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 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 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 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 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 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 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 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 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 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... +} \ No newline at end of file diff --git a/DysonNetwork.Pass/AppDatabase.cs b/DysonNetwork.Pass/AppDatabase.cs index 04b8a39..f666e8f 100644 --- a/DysonNetwork.Pass/AppDatabase.cs +++ b/DysonNetwork.Pass/AppDatabase.cs @@ -25,7 +25,7 @@ public class AppDatabase( public DbSet MagicSpells { get; set; } public DbSet Accounts { get; set; } public DbSet AccountConnections { get; set; } - public DbSet AccountProfiles { get; set; } + public DbSet AccountProfiles { get; set; } public DbSet AccountContacts { get; set; } public DbSet AccountAuthFactors { get; set; } public DbSet AccountRelationships { get; set; } diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.csproj b/DysonNetwork.Pass/DysonNetwork.Pass.csproj index 3548976..3b281ce 100644 --- a/DysonNetwork.Pass/DysonNetwork.Pass.csproj +++ b/DysonNetwork.Pass/DysonNetwork.Pass.csproj @@ -7,9 +7,11 @@ + + diff --git a/DysonNetwork.Pass/DysonNetwork.Pass.http b/DysonNetwork.Pass/DysonNetwork.Pass.http deleted file mode 100644 index 067647c..0000000 --- a/DysonNetwork.Pass/DysonNetwork.Pass.http +++ /dev/null @@ -1,6 +0,0 @@ -@DysonNetwork.Pass_HostAddress = http://localhost:5216 - -GET {{DysonNetwork.Pass_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/DysonNetwork.Pass/Program.cs b/DysonNetwork.Pass/Program.cs index 9e20295..e52a3c3 100644 --- a/DysonNetwork.Pass/Program.cs +++ b/DysonNetwork.Pass/Program.cs @@ -1,4 +1,5 @@ using DysonNetwork.Pass; +using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Startup; using Microsoft.EntityFrameworkCore; @@ -37,4 +38,7 @@ using (var scope = app.Services.CreateScope()) // Configure application middleware pipeline app.ConfigureAppMiddleware(builder.Configuration); +// Configure gRPC +app.ConfigureGrpcServices(); + app.Run(); \ No newline at end of file diff --git a/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs b/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs index 7895bdf..d997ce4 100644 --- a/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs +++ b/DysonNetwork.Pass/Startup/ApplicationConfiguration.cs @@ -1,4 +1,5 @@ using System.Net; +using DysonNetwork.Pass.Account; using DysonNetwork.Pass.Permission; using Microsoft.AspNetCore.HttpOverrides; using Prometheus; @@ -63,4 +64,11 @@ public static class ApplicationConfiguration app.UseForwardedHeaders(forwardedHeadersOptions); } + + public static WebApplication ConfigureGrpcServices(this WebApplication app) + { + app.MapGrpcService(); + + return app; + } } diff --git a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs index 512b18d..eec3b8a 100644 --- a/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Pass/Startup/ServiceCollectionExtensions.cs @@ -40,6 +40,20 @@ public static class ServiceCollectionExtensions 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(); + // Register OIDC services services.AddScoped(); services.AddScoped(); diff --git a/DysonNetwork.Pass/appsettings.Development.json b/DysonNetwork.Pass/appsettings.Development.json deleted file mode 100644 index 0c208ae..0000000 --- a/DysonNetwork.Pass/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/DysonNetwork.Pass/appsettings.json b/DysonNetwork.Pass/appsettings.json index 10f68b8..82c1088 100644 --- a/DysonNetwork.Pass/appsettings.json +++ b/DysonNetwork.Pass/appsettings.json @@ -1,9 +1,129 @@ { + "Debug": true, + "BaseUrl": "http://localhost:5071", "Logging": { "LogLevel": { "Default": "Information", "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": "" + }, + "Subscriptions": { + "Afdian": { + "7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary", + "7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova", + "141713ee3d6211f085b352540025c377": "solian.stellar.supernova" + } + } + }, + "KnownProxies": [ + "127.0.0.1", + "::1" + ] } diff --git a/DysonNetwork.Pusher/Connection/ClientTypeMiddleware.cs b/DysonNetwork.Pusher/Connection/ClientTypeMiddleware.cs new file mode 100644 index 0000000..4e26d43 --- /dev/null +++ b/DysonNetwork.Pusher/Connection/ClientTypeMiddleware.cs @@ -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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs b/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs new file mode 100644 index 0000000..0d5c591 --- /dev/null +++ b/DysonNetwork.Pusher/Connection/IWebSocketPacketHandler.cs @@ -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); +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Connection/WebSocketController.cs b/DysonNetwork.Pusher/Connection/WebSocketController.cs new file mode 100644 index 0000000..8f81f04 --- /dev/null +++ b/DysonNetwork.Pusher/Connection/WebSocketController.cs @@ -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 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(buffer), + cancellationToken + ); + while (!receiveResult.CloseStatus.HasValue) + { + receiveResult = await webSocket.ReceiveAsync( + new ArraySegment(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); + } + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Connection/WebSocketPacket.cs b/DysonNetwork.Pusher/Connection/WebSocketPacket.cs new file mode 100644 index 0000000..745a961 --- /dev/null +++ b/DysonNetwork.Pusher/Connection/WebSocketPacket.cs @@ -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; } + + /// + /// Creates a WebSocketPacket from raw WebSocket message bytes + /// + /// Raw WebSocket message bytes + /// Deserialized WebSocketPacket + 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(json, jsonOpts) ?? + throw new JsonException("Failed to deserialize WebSocketPacket"); + } + + /// + /// Deserializes the Data property to the specified type T + /// + /// Target type to deserialize to + /// Deserialized data of type T + public T? GetData() + { + if (Data is T typedData) + return typedData; + + var jsonOpts = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + return JsonSerializer.Deserialize( + JsonSerializer.Serialize(Data, jsonOpts), + jsonOpts + ); + } + + /// + /// Serializes this WebSocketPacket to a byte array for sending over WebSocket + /// + /// Byte array representation of the packet + 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); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Connection/WebSocketService.cs b/DysonNetwork.Pusher/Connection/WebSocketService.cs new file mode 100644 index 0000000..948db1d --- /dev/null +++ b/DysonNetwork.Pusher/Connection/WebSocketService.cs @@ -0,0 +1,129 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; + +namespace DysonNetwork.Pusher.Connection; + +public class WebSocketService +{ + private readonly IDictionary _handlerMap; + + public WebSocketService(IEnumerable 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 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(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(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(new WebSocketPacket + { + Type = WebSocketPacketType.Error, + ErrorMessage = $"Unprocessable packet: {packet.Type}" + }.ToBytes()), + WebSocketMessageType.Binary, + true, + CancellationToken.None + ); + } +} \ No newline at end of file diff --git a/DysonNetwork.Pusher/Dockerfile b/DysonNetwork.Pusher/Dockerfile new file mode 100644 index 0000000..4a6845b --- /dev/null +++ b/DysonNetwork.Pusher/Dockerfile @@ -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"] diff --git a/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj b/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj new file mode 100644 index 0000000..f3fcc72 --- /dev/null +++ b/DysonNetwork.Pusher/DysonNetwork.Pusher.csproj @@ -0,0 +1,21 @@ + + + + net9.0 + enable + enable + Linux + + + + + + + + + + .dockerignore + + + + diff --git a/DysonNetwork.Pusher/Program.cs b/DysonNetwork.Pusher/Program.cs new file mode 100644 index 0000000..7688653 --- /dev/null +++ b/DysonNetwork.Pusher/Program.cs @@ -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(); \ No newline at end of file diff --git a/DysonNetwork.Pusher/Properties/launchSettings.json b/DysonNetwork.Pusher/Properties/launchSettings.json new file mode 100644 index 0000000..982a366 --- /dev/null +++ b/DysonNetwork.Pusher/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/DysonNetwork.Pusher/appsettings.json b/DysonNetwork.Pusher/appsettings.json new file mode 100644 index 0000000..82c1088 --- /dev/null +++ b/DysonNetwork.Pusher/appsettings.json @@ -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": "" + }, + "Subscriptions": { + "Afdian": { + "7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary", + "7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova", + "141713ee3d6211f085b352540025c377": "solian.stellar.supernova" + } + } + }, + "KnownProxies": [ + "127.0.0.1", + "::1" + ] +} diff --git a/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs b/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs index ae4054e..37c0eab 100644 --- a/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs +++ b/DysonNetwork.Shared/Data/CloudFileReferenceObject.cs @@ -1,3 +1,5 @@ +using Google.Protobuf.WellKnownTypes; + namespace DysonNetwork.Shared.Data; /// @@ -14,4 +16,43 @@ public class CloudFileReferenceObject : ModelBase, ICloudFile public string? Hash { get; set; } public long Size { get; set; } public bool HasCompression { get; set; } = false; + + /// + /// Converts the current object to its protobuf representation + /// + 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; + } } \ No newline at end of file diff --git a/DysonNetwork.Shared/DysonNetwork.Shared.csproj b/DysonNetwork.Shared/DysonNetwork.Shared.csproj index 6092e6f..364b03b 100644 --- a/DysonNetwork.Shared/DysonNetwork.Shared.csproj +++ b/DysonNetwork.Shared/DysonNetwork.Shared.csproj @@ -1,4 +1,4 @@ - + net9.0 @@ -7,12 +7,26 @@ - - - - - - + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/DysonNetwork.Shared/Proto/account.proto b/DysonNetwork.Shared/Proto/account.proto new file mode 100644 index 0000000..444cb49 --- /dev/null +++ b/DysonNetwork.Shared/Proto/account.proto @@ -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 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 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 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 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 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 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 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 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 +} + diff --git a/DysonNetwork.Shared/Proto/file.proto b/DysonNetwork.Shared/Proto/file.proto new file mode 100644 index 0000000..593e2b7 --- /dev/null +++ b/DysonNetwork.Shared/Proto/file.proto @@ -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 file_meta = 3; + + // User-defined metadata + map 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 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; +} diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index 72aa5d7..7532645 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -23,6 +23,7 @@ + diff --git a/DysonNetwork.sln b/DysonNetwork.sln index 7ee589d..4b2f2be 100644 --- a/DysonNetwork.sln +++ b/DysonNetwork.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonN EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Shared", "DysonNetwork.Shared\DysonNetwork.Shared.csproj", "{DB46D1A6-79B4-43FC-A9A9-115CDA26947A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pusher", "DysonNetwork.Pusher\DysonNetwork.Pusher.csproj", "{D5DAFB0D-487E-48EF-BA2F-C581C846F63B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution 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}.Release|Any CPU.ActiveCfg = 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 EndGlobal