🧱 Grpc service basis
This commit is contained in:
@ -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; }
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
303
DysonNetwork.Pass/Account/AccountServiceGrpc.cs
Normal file
303
DysonNetwork.Pass/Account/AccountServiceGrpc.cs
Normal 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...
|
||||||
|
}
|
@ -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; }
|
||||||
|
@ -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"/>
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
@DysonNetwork.Pass_HostAddress = http://localhost:5216
|
|
||||||
|
|
||||||
GET {{DysonNetwork.Pass_HostAddress}}/weatherforecast/
|
|
||||||
Accept: application/json
|
|
||||||
|
|
||||||
###
|
|
@ -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();
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>();
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
42
DysonNetwork.Pusher/Connection/ClientTypeMiddleware.cs
Normal file
42
DysonNetwork.Pusher/Connection/ClientTypeMiddleware.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
108
DysonNetwork.Pusher/Connection/WebSocketController.cs
Normal file
108
DysonNetwork.Pusher/Connection/WebSocketController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
72
DysonNetwork.Pusher/Connection/WebSocketPacket.cs
Normal file
72
DysonNetwork.Pusher/Connection/WebSocketPacket.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
129
DysonNetwork.Pusher/Connection/WebSocketService.cs
Normal file
129
DysonNetwork.Pusher/Connection/WebSocketService.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
23
DysonNetwork.Pusher/Dockerfile
Normal file
23
DysonNetwork.Pusher/Dockerfile
Normal 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"]
|
21
DysonNetwork.Pusher/DysonNetwork.Pusher.csproj
Normal file
21
DysonNetwork.Pusher/DysonNetwork.Pusher.csproj
Normal 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>
|
23
DysonNetwork.Pusher/Program.cs
Normal file
23
DysonNetwork.Pusher/Program.cs
Normal 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();
|
23
DysonNetwork.Pusher/Properties/launchSettings.json
Normal file
23
DysonNetwork.Pusher/Properties/launchSettings.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
129
DysonNetwork.Pusher/appsettings.json
Normal file
129
DysonNetwork.Pusher/appsettings.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
@ -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="Google.Api.CommonProtos" Version="2.17.0"/>
|
||||||
|
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||||
|
<PackageReference Include="Google.Protobuf.Tools" Version="3.31.1" />
|
||||||
|
<PackageReference Include="Grpc" Version="2.46.6" />
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
|
||||||
|
<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="MaxMind.GeoIP2" Version="5.3.0"/>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/>
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.7"/>
|
||||||
<PackageReference Include="NetTopologySuite" Version="2.6.0"/>
|
<PackageReference Include="NetTopologySuite" Version="2.6.0"/>
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
||||||
<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="StackExchange.Redis" Version="2.8.41"/>
|
<PackageReference Include="StackExchange.Redis" Version="2.8.41"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Protobuf Include="Proto\*.proto" ProtoRoot="Proto" GrpcServices="Both" AdditionalFileExtensions="Proto\"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
427
DysonNetwork.Shared/Proto/account.proto
Normal file
427
DysonNetwork.Shared/Proto/account.proto
Normal 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
|
||||||
|
}
|
||||||
|
|
87
DysonNetwork.Shared/Proto/file.proto
Normal file
87
DysonNetwork.Shared/Proto/file.proto
Normal 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;
|
||||||
|
}
|
@ -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" />
|
||||||
|
@ -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
|
||||||
|
Reference in New Issue
Block a user