🎉 Initial Commit
This commit is contained in:
commit
c39fdceeb6
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
/packages/
|
||||||
|
riderModule.iml
|
||||||
|
/_ReSharper.Caches/
|
||||||
|
.idea
|
60
DysonNetwork.Sphere/Account/Account.cs
Normal file
60
DysonNetwork.Sphere/Account/Account.cs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Text;
|
||||||
|
using NodaTime;
|
||||||
|
using Org.BouncyCastle.Crypto.Generators;
|
||||||
|
using Org.BouncyCastle.Security;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Account;
|
||||||
|
|
||||||
|
public class Account : BaseModel
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
[MaxLength(256)] public string Name { get; set; } = string.Empty;
|
||||||
|
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public ICollection<AccountContact> Contacts { get; set; } = new List<AccountContact>();
|
||||||
|
public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountContact : BaseModel
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public AccountContactType Type { get; set; }
|
||||||
|
public Instant? VerifiedAt { get; set; }
|
||||||
|
[MaxLength(1024)] public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AccountContactType
|
||||||
|
{
|
||||||
|
Email, PhoneNumber, Address
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountAuthFactor : BaseModel
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
public AccountAuthFactorType Type { get; set; }
|
||||||
|
public string? Secret { get; set; } = null;
|
||||||
|
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public AccountAuthFactor HashSecret(int cost = 12)
|
||||||
|
{
|
||||||
|
if(Secret == null) return this;
|
||||||
|
|
||||||
|
var passwordBytes = Encoding.UTF8.GetBytes(Secret);
|
||||||
|
var random = new SecureRandom();
|
||||||
|
var salt = new byte[16];
|
||||||
|
random.NextBytes(salt);
|
||||||
|
var hashed = BCrypt.Generate(passwordBytes, salt, cost);
|
||||||
|
Secret = Convert.ToBase64String(hashed);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AccountAuthFactorType
|
||||||
|
{
|
||||||
|
Password, EmailCode, InAppCode, TimedCode
|
||||||
|
}
|
57
DysonNetwork.Sphere/Account/AccountController.cs
Normal file
57
DysonNetwork.Sphere/Account/AccountController.cs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Account;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/accounts")]
|
||||||
|
public class AccountController(AppDatabase db)
|
||||||
|
{
|
||||||
|
[HttpGet("{name}")]
|
||||||
|
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<ActionResult<Account?>> GetByName(string name)
|
||||||
|
{
|
||||||
|
var account = await db.Accounts.FindAsync(name);
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AccountCreateRequest
|
||||||
|
{
|
||||||
|
[Required] [MaxLength(256)] public string Name { get; set; } = string.Empty;
|
||||||
|
[Required] [MaxLength(256)] public string Nick { get; set; } = string.Empty;
|
||||||
|
[Required] [MaxLength(1024)] public string Email { get; set; } = string.Empty;
|
||||||
|
[Required] [MinLength(4)] [MaxLength(128)] public string Password { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
|
||||||
|
{
|
||||||
|
var account = new Account
|
||||||
|
{
|
||||||
|
Name = request.Name,
|
||||||
|
Nick = request.Nick,
|
||||||
|
Contacts = new List<AccountContact>()
|
||||||
|
{
|
||||||
|
new AccountContact
|
||||||
|
{
|
||||||
|
Type = AccountContactType.Email,
|
||||||
|
Content = request.Email
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AuthFactors = new List<AccountAuthFactor>
|
||||||
|
{
|
||||||
|
new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.Password,
|
||||||
|
Secret = request.Password
|
||||||
|
}.HashSecret()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.Accounts.AddAsync(account);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
}
|
92
DysonNetwork.Sphere/AppDatabase.cs
Normal file
92
DysonNetwork.Sphere/AppDatabase.cs
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere;
|
||||||
|
|
||||||
|
public abstract class BaseModel
|
||||||
|
{
|
||||||
|
public Instant CreatedAt { get; set; }
|
||||||
|
public Instant UpdatedAt { get; set; }
|
||||||
|
public Instant? DeletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppDatabase(DbContextOptions<AppDatabase> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<Account.Account> Accounts { get; set; }
|
||||||
|
public DbSet<Account.AccountContact> AccountContacts { get; set; }
|
||||||
|
public DbSet<Account.AccountAuthFactor> AccountAuthFactors { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||||
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
if (typeof(BaseModel).IsAssignableFrom(entityType.ClrType))
|
||||||
|
{
|
||||||
|
var method = typeof(AppDatabase)
|
||||||
|
.GetMethod(nameof(SetSoftDeleteFilter),
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(entityType.ClrType);
|
||||||
|
|
||||||
|
method.Invoke(null, [modelBuilder]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||||
|
where TEntity : BaseModel
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
foreach (var entry in ChangeTracker.Entries<BaseModel>())
|
||||||
|
{
|
||||||
|
switch (entry.State)
|
||||||
|
{
|
||||||
|
case EntityState.Added:
|
||||||
|
entry.Entity.CreatedAt = now;
|
||||||
|
entry.Entity.UpdatedAt = now;
|
||||||
|
break;
|
||||||
|
case EntityState.Modified:
|
||||||
|
entry.Entity.UpdatedAt = now;
|
||||||
|
break;
|
||||||
|
case EntityState.Deleted:
|
||||||
|
entry.State = EntityState.Modified;
|
||||||
|
entry.Entity.DeletedAt = now;
|
||||||
|
break;
|
||||||
|
case EntityState.Detached:
|
||||||
|
case EntityState.Unchanged:
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await base.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
|
||||||
|
{
|
||||||
|
public AppDatabase CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddJsonFile("appsettings.json")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
|
||||||
|
optionsBuilder.UseNpgsql(
|
||||||
|
configuration.GetConnectionString("App"),
|
||||||
|
o => o.UseNodaTime()
|
||||||
|
).UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
|
return new AppDatabase(optionsBuilder.Options);
|
||||||
|
}
|
||||||
|
}
|
23
DysonNetwork.Sphere/Dockerfile
Normal file
23
DysonNetwork.Sphere/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.Sphere/DysonNetwork.Sphere.csproj", "DysonNetwork.Sphere/"]
|
||||||
|
RUN dotnet restore "DysonNetwork.Sphere/DysonNetwork.Sphere.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/DysonNetwork.Sphere"
|
||||||
|
RUN dotnet build "DysonNetwork.Sphere.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "DysonNetwork.Sphere.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "DysonNetwork.Sphere.dll"]
|
32
DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
Normal file
32
DysonNetwork.Sphere/DysonNetwork.Sphere.csproj
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.1" />
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.2"/>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
|
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
6
DysonNetwork.Sphere/DysonNetwork.Sphere.http
Normal file
6
DysonNetwork.Sphere/DysonNetwork.Sphere.http
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
@DysonNetwork.Sphere_HostAddress = http://localhost:5071
|
||||||
|
|
||||||
|
GET {{DysonNetwork.Sphere_HostAddress}}/weatherforecast/
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
###
|
190
DysonNetwork.Sphere/Migrations/20250408152422_InitialMigration.Designer.cs
generated
Normal file
190
DysonNetwork.Sphere/Migrations/20250408152422_InitialMigration.Designer.cs
generated
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using DysonNetwork.Sphere;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using NodaTime;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDatabase))]
|
||||||
|
[Migration("20250408152422_InitialMigration")]
|
||||||
|
partial class InitialMigration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.3")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("Nick")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("nick");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_accounts");
|
||||||
|
|
||||||
|
b.ToTable("accounts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<long>("AccountId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Secret")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("secret");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_account_auth_factors");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_account_auth_factors_account_id");
|
||||||
|
|
||||||
|
b.ToTable("account_auth_factors", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<long>("AccountId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("content");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("VerifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("verified_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_account_contacts");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_account_contacts_account_id");
|
||||||
|
|
||||||
|
b.ToTable("account_contacts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
.WithMany("AuthFactors")
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_account_auth_factors_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
.WithMany("Contacts")
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_account_contacts_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AuthFactors");
|
||||||
|
|
||||||
|
b.Navigation("Contacts");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,105 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialMigration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "accounts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
nick = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_accounts", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "account_auth_factors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
secret = table.Column<string>(type: "text", nullable: true),
|
||||||
|
account_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_account_auth_factors", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_auth_factors_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "account_contacts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
verified_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
content = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
account_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_account_contacts", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_contacts_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_auth_factors_account_id",
|
||||||
|
table: "account_auth_factors",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_contacts_account_id",
|
||||||
|
table: "account_contacts",
|
||||||
|
column: "account_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_auth_factors");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_contacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "accounts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
187
DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs
Normal file
187
DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using DysonNetwork.Sphere;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using NodaTime;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDatabase))]
|
||||||
|
partial class AppDatabaseModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.3")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("Nick")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("nick");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_accounts");
|
||||||
|
|
||||||
|
b.ToTable("accounts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<long>("AccountId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<string>("Secret")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("secret");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_account_auth_factors");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_account_auth_factors_account_id");
|
||||||
|
|
||||||
|
b.ToTable("account_auth_factors", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<long>("AccountId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<string>("Content")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("content");
|
||||||
|
|
||||||
|
b.Property<Instant>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("VerifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("verified_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_account_contacts");
|
||||||
|
|
||||||
|
b.HasIndex("AccountId")
|
||||||
|
.HasDatabaseName("ix_account_contacts_account_id");
|
||||||
|
|
||||||
|
b.ToTable("account_contacts", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
.WithMany("AuthFactors")
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_account_auth_factors_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||||
|
.WithMany("Contacts")
|
||||||
|
.HasForeignKey("AccountId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_account_contacts_accounts_account_id");
|
||||||
|
|
||||||
|
b.Navigation("Account");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("AuthFactors");
|
||||||
|
|
||||||
|
b.Navigation("Contacts");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
42
DysonNetwork.Sphere/Program.cs
Normal file
42
DysonNetwork.Sphere/Program.cs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Sphere;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<AppDatabase>(opt =>
|
||||||
|
opt.UseNpgsql(
|
||||||
|
builder.Configuration.GetConnectionString("App"),
|
||||||
|
o => o.UseNodaTime()
|
||||||
|
).UseSnakeCaseNamingConvention()
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.Services.AddControllers().AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
|
||||||
|
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||||
|
});
|
||||||
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment()) app.MapOpenApi();
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
db.Database.Migrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
23
DysonNetwork.Sphere/Properties/launchSettings.json
Normal file
23
DysonNetwork.Sphere/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:5071",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7099;http://localhost:5071",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
DysonNetwork.Sphere/appsettings.json
Normal file
12
DysonNetwork.Sphere/appsettings.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_network;Username=postgres;Password=postgres"
|
||||||
|
}
|
||||||
|
}
|
21
DysonNetwork.sln
Normal file
21
DysonNetwork.sln
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Sphere", "DysonNetwork.Sphere\DysonNetwork.Sphere.csproj", "{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A444D180-5B51-49C3-A35D-AA55832BBC66}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
compose.yaml = compose.yaml
|
||||||
|
EndProjectSection
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
2
DysonNetwork.sln.DotSettings.user
Normal file
2
DysonNetwork.sln.DotSettings.user
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ANotFound_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Ff2c049af93e430aac427e8ff3cc9edd8763d5c9f006d7121ed1c5921585cba_003FNotFound_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary>
|
6
compose.yaml
Normal file
6
compose.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
dysonnetwork.sphere:
|
||||||
|
image: dysonnetwork.sphere
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: DysonNetwork.Sphere/Dockerfile
|
Loading…
x
Reference in New Issue
Block a user