Compare commits
123 Commits
d1fb0b9b55
...
refactor/s
Author | SHA1 | Date | |
---|---|---|---|
3310487aba | |||
21b42b5b21 | |||
8fbc81cab9 | |||
3c11c4f3be | |||
a03b8d1cac | |||
cbfdb4aa60 | |||
ef9175d27d | |||
06f1cc3ca1 | |||
92ab7a1a2a | |||
28067d18f6 | |||
387246a95c | |||
007da589bf | |||
cde55eb237 | |||
03e26ef93c | |||
afdbde951c | |||
e66abe2e0c | |||
4a7f2e18b3 | |||
e1b47bc7d1 | |||
33f56c4ef5 | |||
0318364bcf | |||
ba49d1c7a7 | |||
e76c80eead | |||
2a3918134f | |||
734e5ca4a0 | |||
ff0789904d | |||
17330fc104 | |||
7c0ad46deb | |||
b8fcd0d94f | |||
fc6edd7378 | |||
1f2cdb146d | |||
be236a27c6 | |||
99c36ae548 | |||
ed2961a5d5 | |||
08b5ffa02f | |||
837a123c3b | |||
ad1166190f | |||
8e8c938132 | |||
8e5b6ace45 | |||
5757526ea5 | |||
6a9cd0905d | |||
082a096470 | |||
3a72347432 | |||
19b1e957dd | |||
6449926334 | |||
fb885e138d | |||
5bdc21ebc5 | |||
f177377fe3 | |||
0df4864888 | |||
29b0ad184e | |||
ad730832db | |||
71fcc26534 | |||
fb8fc69920 | |||
05bf2cd055 | |||
ccb8a4e3f4 | |||
ca5be5a01c | |||
c4ea15097e | |||
cdeed3c318 | |||
a53fcb10dd | |||
c0879d30d4 | |||
0226bf8fa3 | |||
217b434cc4 | |||
f8295c6a18 | |||
d4fa08d320 | |||
8bd0ea0fa1 | |||
9ab31d79ce | |||
ee5d6ef821 | |||
d7b443e678 | |||
98b2eeb13d | |||
ec3961d546 | |||
a5dae37525 | |||
933d762f24 | |||
8251a9ec7d | |||
38243f9eba | |||
b0b7afd6b3 | |||
6237fd6140 | |||
2e8d6a3667 | |||
ac496777ed | |||
19ddc1b363 | |||
661b612537 | |||
8432436fcf | |||
2a28948418 | |||
5dd138949e | |||
f540544a47 | |||
9f8eec792b | |||
0bdd429d87 | |||
b2203fb464 | |||
c5bbd58f5c | |||
35a9dcffbc | |||
1d50f225c1 | |||
b7263b9804 | |||
c63d6e0fbc | |||
cebd1bd65a | |||
da58e10d88 | |||
d492c9ce1f | |||
f170793928 | |||
1a137fbb6a | |||
21cf212d8f | |||
c6cb2a0dc3 | |||
d9747daab9 | |||
d91b705b9a | |||
5ce3598cc9 | |||
1b45f07419 | |||
6bec0a672e | |||
c338512c16 | |||
9444913b72 | |||
50bfec59ee | |||
a97bf15362 | |||
feb612afcd | |||
049a5c9b6f | |||
694bc77921 | |||
be0b48cfd9 | |||
a23338c263 | |||
c5ef9b065b | |||
5990b17b4c | |||
de7a2cea09 | |||
698442ad13 | |||
9fd6016308 | |||
516090a5f8 | |||
6b0e5f919d | |||
c6450757be | |||
38abe16ba6 | |||
bf40b51c41 | |||
f50894a3d1 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
/packages/
|
/packages/
|
||||||
|
/Certificates/
|
||||||
riderModule.iml
|
riderModule.iml
|
||||||
/_ReSharper.Caches/
|
/_ReSharper.Caches/
|
||||||
.idea
|
.idea
|
||||||
|
179
DysonNetwork.Drive/AppDatabase.cs
Normal file
179
DysonNetwork.Drive/AppDatabase.cs
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
|
using NodaTime;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive;
|
||||||
|
|
||||||
|
public class AppDatabase(
|
||||||
|
DbContextOptions<AppDatabase> options,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<CloudFile> Files { get; set; } = null!;
|
||||||
|
public DbSet<CloudFileReference> FileReferences { get; set; } = null!;
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
optionsBuilder.UseNpgsql(
|
||||||
|
configuration.GetConnectionString("App"),
|
||||||
|
opt => opt
|
||||||
|
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||||
|
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||||
|
.UseNetTopologySuite()
|
||||||
|
.UseNodaTime()
|
||||||
|
).UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
|
base.OnConfiguring(optionsBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
|
||||||
|
var method = typeof(AppDatabase)
|
||||||
|
.GetMethod(nameof(SetSoftDeleteFilter),
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(entityType.ClrType);
|
||||||
|
|
||||||
|
method.Invoke(null, [modelBuilder]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||||
|
where TEntity : ModelBase
|
||||||
|
{
|
||||||
|
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<ModelBase>())
|
||||||
|
{
|
||||||
|
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 AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
logger.LogInformation("Deleting soft-deleted records...");
|
||||||
|
|
||||||
|
var threshold = now - Duration.FromDays(7);
|
||||||
|
|
||||||
|
var entityTypes = db.Model.GetEntityTypes()
|
||||||
|
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
|
||||||
|
.Select(t => t.ClrType);
|
||||||
|
|
||||||
|
foreach (var entityType in entityTypes)
|
||||||
|
{
|
||||||
|
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||||
|
.MakeGenericMethod(entityType).Invoke(db, null)!;
|
||||||
|
var parameter = Expression.Parameter(entityType, "e");
|
||||||
|
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
|
||||||
|
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
|
||||||
|
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
|
||||||
|
var finalCondition = Expression.AndAlso(notNull, condition);
|
||||||
|
var lambda = Expression.Lambda(finalCondition, parameter);
|
||||||
|
|
||||||
|
var queryable = set.Provider.CreateQuery(
|
||||||
|
Expression.Call(
|
||||||
|
typeof(Queryable),
|
||||||
|
"Where",
|
||||||
|
[entityType],
|
||||||
|
set.Expression,
|
||||||
|
Expression.Quote(lambda)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
|
||||||
|
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
|
||||||
|
.MakeGenericMethod(entityType);
|
||||||
|
|
||||||
|
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
|
||||||
|
db.RemoveRange(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>();
|
||||||
|
return new AppDatabase(optionsBuilder.Options, configuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OptionalQueryExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<T> If<T>(
|
||||||
|
this IQueryable<T> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IQueryable<T>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<T> If<T, TP>(
|
||||||
|
this IIncludableQueryable<T, TP> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<T> If<T, TP>(
|
||||||
|
this IIncludableQueryable<T, IEnumerable<TP>> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
}
|
23
DysonNetwork.Drive/Dockerfile
Normal file
23
DysonNetwork.Drive/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.Drive/DysonNetwork.Drive.csproj", "DysonNetwork.Drive/"]
|
||||||
|
RUN dotnet restore "DysonNetwork.Drive/DysonNetwork.Drive.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/DysonNetwork.Drive"
|
||||||
|
RUN dotnet build "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./DysonNetwork.Drive.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "DysonNetwork.Drive.dll"]
|
69
DysonNetwork.Drive/DysonNetwork.Drive.csproj
Normal file
69
DysonNetwork.Drive/DysonNetwork.Drive.csproj
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BlurHashSharp.SkiaSharp" Version="1.3.4" />
|
||||||
|
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||||
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="MimeTypes" Version="2.5.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Minio" Version="6.0.5" />
|
||||||
|
<PackageReference Include="NetVips" Version="3.1.0" />
|
||||||
|
<PackageReference Include="NetVips.Native.linux-x64" Version="8.17.1" />
|
||||||
|
<PackageReference Include="NetVips.Native.osx-arm64" Version="8.17.1" />
|
||||||
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
|
<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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
|
||||||
|
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
|
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" />
|
||||||
|
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" />
|
||||||
|
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||||
|
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
|
||||||
|
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="3.119.0" />
|
||||||
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.0" />
|
||||||
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.0" />
|
||||||
|
<PackageReference Include="SkiaSharp.NativeAssets.macOS" Version="3.119.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||||
|
<PackageReference Include="tusdotnet" Version="2.10.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
190
DysonNetwork.Drive/Migrations/20250713121317_InitialMigration.Designer.cs
generated
Normal file
190
DysonNetwork.Drive/Migrations/20250713121317_InitialMigration.Designer.cs
generated
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DysonNetwork.Drive;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
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.Drive.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDatabase))]
|
||||||
|
[Migration("20250713121317_InitialMigration")]
|
||||||
|
partial class InitialMigration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.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>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("FileMeta")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("file_meta");
|
||||||
|
|
||||||
|
b.Property<bool>("HasCompression")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("has_compression");
|
||||||
|
|
||||||
|
b.Property<string>("Hash")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMarkedRecycle")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_marked_recycle");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("mime_type");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("sensitive_marks");
|
||||||
|
|
||||||
|
b.Property<long>("Size")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("size");
|
||||||
|
|
||||||
|
b.Property<string>("StorageId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("storage_id");
|
||||||
|
|
||||||
|
b.Property<string>("StorageUrl")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("storage_url");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("UploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("uploaded_at");
|
||||||
|
|
||||||
|
b.Property<string>("UploadedTo")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("uploaded_to");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("UserMeta")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("user_meta");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_files");
|
||||||
|
|
||||||
|
b.ToTable("files", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("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<Instant?>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<string>("FileId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("file_id");
|
||||||
|
|
||||||
|
b.Property<string>("ResourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("resource_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<string>("Usage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("usage");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_file_references");
|
||||||
|
|
||||||
|
b.HasIndex("FileId")
|
||||||
|
.HasDatabaseName("ix_file_references_file_id");
|
||||||
|
|
||||||
|
b.ToTable("file_references", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_references_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialMigration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "files",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
file_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
|
user_meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
|
sensitive_marks = table.Column<List<ContentSensitiveMark>>(type: "jsonb", nullable: true),
|
||||||
|
mime_type = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
hash = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
size = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
uploaded_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
uploaded_to = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
has_compression = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
is_marked_recycle = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
storage_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
storage_url = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_files", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "file_references",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
file_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
usage = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
resource_id = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
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_file_references", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_file_references_files_file_id",
|
||||||
|
column: x => x.file_id,
|
||||||
|
principalTable: "files",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_file_references_file_id",
|
||||||
|
table: "file_references",
|
||||||
|
column: "file_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "file_references");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
190
DysonNetwork.Drive/Migrations/20250715080004_ReinitalMigration.Designer.cs
generated
Normal file
190
DysonNetwork.Drive/Migrations/20250715080004_ReinitalMigration.Designer.cs
generated
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DysonNetwork.Drive;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
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.Drive.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDatabase))]
|
||||||
|
[Migration("20250715080004_ReinitalMigration")]
|
||||||
|
partial class ReinitalMigration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.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>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("FileMeta")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("file_meta");
|
||||||
|
|
||||||
|
b.Property<bool>("HasCompression")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("has_compression");
|
||||||
|
|
||||||
|
b.Property<string>("Hash")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMarkedRecycle")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_marked_recycle");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("mime_type");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("sensitive_marks");
|
||||||
|
|
||||||
|
b.Property<long>("Size")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("size");
|
||||||
|
|
||||||
|
b.Property<string>("StorageId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("storage_id");
|
||||||
|
|
||||||
|
b.Property<string>("StorageUrl")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("storage_url");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("UploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("uploaded_at");
|
||||||
|
|
||||||
|
b.Property<string>("UploadedTo")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("uploaded_to");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("UserMeta")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("user_meta");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_files");
|
||||||
|
|
||||||
|
b.ToTable("files", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("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<Instant?>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<string>("FileId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("file_id");
|
||||||
|
|
||||||
|
b.Property<string>("ResourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("resource_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<string>("Usage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("usage");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_file_references");
|
||||||
|
|
||||||
|
b.HasIndex("FileId")
|
||||||
|
.HasDatabaseName("ix_file_references_file_id");
|
||||||
|
|
||||||
|
b.ToTable("file_references", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_references_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReinitalMigration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Dictionary<string, object>>(
|
||||||
|
name: "file_meta",
|
||||||
|
table: "files",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(Dictionary<string, object>),
|
||||||
|
oldType: "jsonb",
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<Dictionary<string, object>>(
|
||||||
|
name: "file_meta",
|
||||||
|
table: "files",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(Dictionary<string, object>),
|
||||||
|
oldType: "jsonb");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
187
DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs
Normal file
187
DysonNetwork.Drive/Migrations/AppDatabaseModelSnapshot.cs
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DysonNetwork.Drive;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using NodaTime;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDatabase))]
|
||||||
|
partial class AppDatabaseModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFile", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.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>("Description")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("FileMeta")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("file_meta");
|
||||||
|
|
||||||
|
b.Property<bool>("HasCompression")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("has_compression");
|
||||||
|
|
||||||
|
b.Property<string>("Hash")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMarkedRecycle")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("is_marked_recycle");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)")
|
||||||
|
.HasColumnName("mime_type");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<List<ContentSensitiveMark>>("SensitiveMarks")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("sensitive_marks");
|
||||||
|
|
||||||
|
b.Property<long>("Size")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("size");
|
||||||
|
|
||||||
|
b.Property<string>("StorageId")
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("storage_id");
|
||||||
|
|
||||||
|
b.Property<string>("StorageUrl")
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("storage_url");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<Instant?>("UploadedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("uploaded_at");
|
||||||
|
|
||||||
|
b.Property<string>("UploadedTo")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("character varying(128)")
|
||||||
|
.HasColumnName("uploaded_to");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("UserMeta")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("user_meta");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_files");
|
||||||
|
|
||||||
|
b.ToTable("files", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("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<Instant?>("ExpiredAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expired_at");
|
||||||
|
|
||||||
|
b.Property<string>("FileId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("character varying(32)")
|
||||||
|
.HasColumnName("file_id");
|
||||||
|
|
||||||
|
b.Property<string>("ResourceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("resource_id");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.Property<string>("Usage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)")
|
||||||
|
.HasColumnName("usage");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_file_references");
|
||||||
|
|
||||||
|
b.HasIndex("FileId")
|
||||||
|
.HasDatabaseName("ix_file_references_file_id");
|
||||||
|
|
||||||
|
b.ToTable("file_references", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Drive.Storage.CloudFileReference", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Drive.Storage.CloudFile", "File")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FileId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_file_references_files_file_id");
|
||||||
|
|
||||||
|
b.Navigation("File");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
DysonNetwork.Drive/Program.cs
Normal file
50
DysonNetwork.Drive/Program.cs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
using DysonNetwork.Drive;
|
||||||
|
using DysonNetwork.Drive.Startup;
|
||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Http;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using tusdotnet.Stores;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Configure Kestrel and server options
|
||||||
|
builder.ConfigureAppKestrel();
|
||||||
|
|
||||||
|
// Add application services
|
||||||
|
builder.Services.AddRegistryService(builder.Configuration);
|
||||||
|
builder.Services.AddAppServices(builder.Configuration);
|
||||||
|
builder.Services.AddAppRateLimiting();
|
||||||
|
builder.Services.AddAppAuthentication();
|
||||||
|
builder.Services.AddAppSwagger();
|
||||||
|
builder.Services.AddDysonAuth();
|
||||||
|
|
||||||
|
builder.Services.AddAppFileStorage(builder.Configuration);
|
||||||
|
|
||||||
|
// Add flush handlers and websocket handlers
|
||||||
|
builder.Services.AddAppFlushHandlers();
|
||||||
|
|
||||||
|
// Add business services
|
||||||
|
builder.Services.AddAppBusinessServices();
|
||||||
|
|
||||||
|
// Add scheduled jobs
|
||||||
|
builder.Services.AddAppScheduledJobs();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
// Run database migrations
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tusDiskStore = app.Services.GetRequiredService<TusDiskStore>();
|
||||||
|
|
||||||
|
// Configure application middleware pipeline
|
||||||
|
app.ConfigureAppMiddleware(tusDiskStore);
|
||||||
|
|
||||||
|
// Configure gRPC
|
||||||
|
app.ConfigureGrpcServices();
|
||||||
|
|
||||||
|
app.Run();
|
23
DysonNetwork.Drive/Properties/launchSettings.json
Normal file
23
DysonNetwork.Drive/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:5090",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7092;http://localhost:5090",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
35
DysonNetwork.Drive/Startup/ApplicationBuilderExtensions.cs
Normal file
35
DysonNetwork.Drive/Startup/ApplicationBuilderExtensions.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
using DysonNetwork.Drive.Storage;
|
||||||
|
using tusdotnet;
|
||||||
|
using tusdotnet.Interfaces;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Startup;
|
||||||
|
|
||||||
|
public static class ApplicationBuilderExtensions
|
||||||
|
{
|
||||||
|
public static WebApplication ConfigureAppMiddleware(this WebApplication app, ITusStore tusStore)
|
||||||
|
{
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.MapTus("/tus", _ => Task.FromResult(TusService.BuildConfiguration(tusStore)));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static WebApplication ConfigureGrpcServices(this WebApplication app)
|
||||||
|
{
|
||||||
|
// Map your gRPC services here
|
||||||
|
app.MapGrpcService<FileServiceGrpc>();
|
||||||
|
app.MapGrpcService<FileReferenceServiceGrpc>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
22
DysonNetwork.Drive/Startup/ScheduledJobsConfiguration.cs
Normal file
22
DysonNetwork.Drive/Startup/ScheduledJobsConfiguration.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Startup;
|
||||||
|
|
||||||
|
public static class ScheduledJobsConfiguration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddQuartz(q =>
|
||||||
|
{
|
||||||
|
var appDatabaseRecyclingJob = new JobKey("AppDatabaseRecycling");
|
||||||
|
q.AddJob<AppDatabaseRecyclingJob>(opts => opts.WithIdentity(appDatabaseRecyclingJob));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(appDatabaseRecyclingJob)
|
||||||
|
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||||
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
|
});
|
||||||
|
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
144
DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs
Normal file
144
DysonNetwork.Drive/Startup/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.SystemTextJson;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using tusdotnet.Stores;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Startup;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
|
||||||
|
services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||||
|
{
|
||||||
|
var connection = configuration.GetConnectionString("FastRetrieve")!;
|
||||||
|
return ConnectionMultiplexer.Connect(connection);
|
||||||
|
});
|
||||||
|
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
services.AddControllers().AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseLower;
|
||||||
|
|
||||||
|
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppRateLimiting(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddRateLimiter(o => o.AddFixedWindowLimiter(policyName: "fixed", opts =>
|
||||||
|
{
|
||||||
|
opts.Window = TimeSpan.FromMinutes(1);
|
||||||
|
opts.PermitLimit = 120;
|
||||||
|
opts.QueueLimit = 2;
|
||||||
|
opts.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddCors();
|
||||||
|
services.AddAuthorization();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<FlushBufferService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppSwagger(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen(options =>
|
||||||
|
{
|
||||||
|
options.SwaggerDoc("v1", new OpenApiInfo
|
||||||
|
{
|
||||||
|
Version = "v1",
|
||||||
|
Title = "Dyson Drive",
|
||||||
|
Description =
|
||||||
|
"The file service of the Dyson Network. Mainly handling file storage and sharing. Also provide image processing and media analysis. Powered the Solar Network Drive as well.",
|
||||||
|
TermsOfService = new Uri("https://solsynth.dev/terms"), // Update with actual terms
|
||||||
|
License = new OpenApiLicense
|
||||||
|
{
|
||||||
|
Name = "APGLv3", // Update with actual license
|
||||||
|
Url = new Uri("https://www.gnu.org/licenses/agpl-3.0.html")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
In = ParameterLocation.Header,
|
||||||
|
Description = "Please enter a valid token",
|
||||||
|
Name = "Authorization",
|
||||||
|
Type = SecuritySchemeType.Http,
|
||||||
|
BearerFormat = "JWT",
|
||||||
|
Scheme = "Bearer"
|
||||||
|
});
|
||||||
|
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||||
|
{
|
||||||
|
{
|
||||||
|
new OpenApiSecurityScheme
|
||||||
|
{
|
||||||
|
Reference = new OpenApiReference
|
||||||
|
{
|
||||||
|
Type = ReferenceType.SecurityScheme,
|
||||||
|
Id = "Bearer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppFileStorage(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var tusStorePath = configuration.GetSection("Tus").GetValue<string>("StorePath")!;
|
||||||
|
Directory.CreateDirectory(tusStorePath);
|
||||||
|
var tusDiskStore = new TusDiskStore(tusStorePath);
|
||||||
|
|
||||||
|
services.AddSingleton(tusDiskStore);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<Storage.FileService>();
|
||||||
|
services.AddScoped<Storage.FileReferenceService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,10 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using DysonNetwork.Shared.Data;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
public class RemoteStorageConfig
|
public class RemoteStorageConfig
|
||||||
{
|
{
|
||||||
@ -28,7 +29,7 @@ public class CloudFileReferenceObject : ModelBase, ICloudFile
|
|||||||
{
|
{
|
||||||
public string Id { get; set; } = null!;
|
public string Id { get; set; } = null!;
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public Dictionary<string, object>? FileMeta { get; set; } = null!;
|
public Dictionary<string, object?> FileMeta { get; set; } = null!;
|
||||||
public Dictionary<string, object>? UserMeta { get; set; } = null!;
|
public Dictionary<string, object>? UserMeta { get; set; } = null!;
|
||||||
public string? MimeType { get; set; }
|
public string? MimeType { get; set; }
|
||||||
public string? Hash { get; set; }
|
public string? Hash { get; set; }
|
||||||
@ -44,9 +45,9 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
|
|
||||||
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
[MaxLength(1024)] public string Name { get; set; } = string.Empty;
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? FileMeta { get; set; } = null!;
|
[Column(TypeName = "jsonb")] public Dictionary<string, object?> FileMeta { get; set; } = null!;
|
||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? UserMeta { get; set; } = null!;
|
[Column(TypeName = "jsonb")] public Dictionary<string, object>? UserMeta { get; set; } = null!;
|
||||||
[Column(TypeName = "jsonb")] public List<CloudFileSensitiveMark>? SensitiveMarks { get; set; } = [];
|
[Column(TypeName = "jsonb")] public List<ContentSensitiveMark>? SensitiveMarks { get; set; } = [];
|
||||||
[MaxLength(256)] public string? MimeType { get; set; }
|
[MaxLength(256)] public string? MimeType { get; set; }
|
||||||
[MaxLength(256)] public string? Hash { get; set; }
|
[MaxLength(256)] public string? Hash { get; set; }
|
||||||
public long Size { get; set; }
|
public long Size { get; set; }
|
||||||
@ -74,7 +75,6 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
[MaxLength(4096)]
|
[MaxLength(4096)]
|
||||||
public string? StorageUrl { get; set; }
|
public string? StorageUrl { get; set; }
|
||||||
|
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
public CloudFileReferenceObject ToReferenceObject()
|
public CloudFileReferenceObject ToReferenceObject()
|
||||||
@ -95,24 +95,47 @@ public class CloudFile : ModelBase, ICloudFile, IIdentifiedResource
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ResourceIdentifier => $"file/{Id}";
|
public string ResourceIdentifier => $"file:{Id}";
|
||||||
}
|
|
||||||
|
|
||||||
public enum CloudFileSensitiveMark
|
/// <summary>
|
||||||
{
|
/// Converts the CloudFile to a protobuf message
|
||||||
Language,
|
/// </summary>
|
||||||
SexualContent,
|
/// <returns>The protobuf message representation of this object</returns>
|
||||||
Violence,
|
public Shared.Proto.CloudFile ToProtoValue()
|
||||||
Profanity,
|
{
|
||||||
HateSpeech,
|
var protoFile = new Shared.Proto.CloudFile
|
||||||
Racism,
|
{
|
||||||
AdultContent,
|
Id = Id,
|
||||||
DrugAbuse,
|
Name = Name ?? string.Empty,
|
||||||
AlcoholAbuse,
|
MimeType = MimeType ?? string.Empty,
|
||||||
Gambling,
|
Hash = Hash ?? string.Empty,
|
||||||
SelfHarm,
|
Size = Size,
|
||||||
ChildAbuse,
|
HasCompression = HasCompression,
|
||||||
Other
|
Url = StorageUrl ?? string.Empty,
|
||||||
|
ContentType = MimeType ?? string.Empty,
|
||||||
|
UploadedAt = UploadedAt?.ToTimestamp()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert FileMeta dictionary
|
||||||
|
if (FileMeta != null)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in FileMeta)
|
||||||
|
{
|
||||||
|
protoFile.FileMeta[key] = Google.Protobuf.WellKnownTypes.Value.ForString(value?.ToString() ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert UserMeta dictionary
|
||||||
|
if (UserMeta != null)
|
||||||
|
{
|
||||||
|
foreach (var (key, value) in UserMeta)
|
||||||
|
{
|
||||||
|
protoFile.UserMeta[key] = Google.Protobuf.WellKnownTypes.Value.ForString(value?.ToString() ?? string.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return protoFile;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CloudFileReference : ModelBase
|
public class CloudFileReference : ModelBase
|
||||||
@ -127,4 +150,21 @@ public class CloudFileReference : ModelBase
|
|||||||
/// Optional expiration date for the file reference
|
/// Optional expiration date for the file reference
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts the CloudFileReference to a protobuf message
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The protobuf message representation of this object</returns>
|
||||||
|
public Shared.Proto.CloudFileReference ToProtoValue()
|
||||||
|
{
|
||||||
|
return new Shared.Proto.CloudFileReference
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
FileId = FileId,
|
||||||
|
File = File?.ToProtoValue(),
|
||||||
|
Usage = Usage,
|
||||||
|
ResourceId = ResourceId,
|
||||||
|
ExpiredAt = ExpiredAt?.ToTimestamp()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
public class CloudFileUnusedRecyclingJob(
|
public class CloudFileUnusedRecyclingJob(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
@ -1,29 +1,42 @@
|
|||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Minio.DataModel.Args;
|
using Minio.DataModel.Args;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/files")]
|
[Route("/api/files")]
|
||||||
public class FileController(
|
public class FileController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
FileService fs,
|
FileService fs,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IWebHostEnvironment env,
|
IWebHostEnvironment env
|
||||||
FileReferenceMigrationService rms
|
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public async Task<ActionResult> OpenFile(string id, [FromQuery] bool original = false)
|
public async Task<ActionResult> OpenFile(
|
||||||
|
string id,
|
||||||
|
[FromQuery] bool download = false,
|
||||||
|
[FromQuery] bool original = false,
|
||||||
|
[FromQuery] string? overrideMimeType = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
|
// Support the file extension for client side data recognize
|
||||||
|
string? fileExtension = null;
|
||||||
|
if (id.Contains('.'))
|
||||||
|
{
|
||||||
|
var splitId = id.Split('.');
|
||||||
|
id = splitId.First();
|
||||||
|
fileExtension = splitId.Last();
|
||||||
|
}
|
||||||
|
|
||||||
var file = await fs.GetFileAsync(id);
|
var file = await fs.GetFileAsync(id);
|
||||||
if (file is null) return NotFound();
|
if (file is null) return NotFound();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
if (!string.IsNullOrWhiteSpace(file.StorageUrl)) return Redirect(file.StorageUrl);
|
||||||
|
|
||||||
if (file.UploadedTo is null)
|
if (file.UploadedTo is null)
|
||||||
{
|
{
|
||||||
var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!;
|
var tusStorePath = configuration.GetValue<string>("Tus:StorePath")!;
|
||||||
@ -61,12 +74,33 @@ public class FileController(
|
|||||||
return BadRequest(
|
return BadRequest(
|
||||||
"Failed to configure client for remote destination, file got an invalid storage remote.");
|
"Failed to configure client for remote destination, file got an invalid storage remote.");
|
||||||
|
|
||||||
|
var headers = new Dictionary<string, string>();
|
||||||
|
if (fileExtension is not null)
|
||||||
|
{
|
||||||
|
if (MimeTypes.TryGetMimeType(fileExtension, out var mimeType))
|
||||||
|
headers.Add("Response-Content-Type", mimeType);
|
||||||
|
}
|
||||||
|
else if (overrideMimeType is not null)
|
||||||
|
{
|
||||||
|
headers.Add("Response-Content-Type", overrideMimeType);
|
||||||
|
}
|
||||||
|
else if (file.MimeType is not null && !file.MimeType!.EndsWith("unknown"))
|
||||||
|
{
|
||||||
|
headers.Add("Response-Content-Type", file.MimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download)
|
||||||
|
{
|
||||||
|
headers.Add("Response-Content-Disposition", $"attachment; filename=\"{file.Name}\"");
|
||||||
|
}
|
||||||
|
|
||||||
var bucket = dest.Bucket;
|
var bucket = dest.Bucket;
|
||||||
var openUrl = await client.PresignedGetObjectAsync(
|
var openUrl = await client.PresignedGetObjectAsync(
|
||||||
new PresignedGetObjectArgs()
|
new PresignedGetObjectArgs()
|
||||||
.WithBucket(bucket)
|
.WithBucket(bucket)
|
||||||
.WithObject(fileName)
|
.WithObject(fileName)
|
||||||
.WithExpiry(3600)
|
.WithExpiry(3600)
|
||||||
|
.WithHeaders(headers)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Redirect(openUrl);
|
return Redirect(openUrl);
|
||||||
@ -91,12 +125,12 @@ public class FileController(
|
|||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<ActionResult> DeleteFile(string id)
|
public async Task<ActionResult> DeleteFile(string id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
var userId = currentUser.Id;
|
var userId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
var file = await db.Files
|
var file = await db.Files
|
||||||
.Where(e => e.Id == id)
|
.Where(e => e.Id == id)
|
||||||
.Where(e => e.Account.Id == userId)
|
.Where(e => e.AccountId == userId)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (file is null) return NotFound();
|
if (file is null) return NotFound();
|
||||||
|
|
||||||
@ -107,13 +141,4 @@ public class FileController(
|
|||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/maintenance/migrateReferences")]
|
|
||||||
[Authorize]
|
|
||||||
[RequiredPermission("maintenance", "files.references")]
|
|
||||||
public async Task<ActionResult> MigrateFileReferences()
|
|
||||||
{
|
|
||||||
await rms.ScanAndMigrateReferences();
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Job responsible for cleaning up expired file references
|
/// Job responsible for cleaning up expired file references
|
@ -1,7 +1,9 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using EFCore.BulkExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache)
|
public class FileReferenceService(AppDatabase db, FileService fileService, ICacheService cache)
|
||||||
{
|
{
|
||||||
@ -18,11 +20,12 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
|
/// <param name="duration">Optional duration after which the file expires (alternative to expiredAt)</param>
|
||||||
/// <returns>The created file reference</returns>
|
/// <returns>The created file reference</returns>
|
||||||
public async Task<CloudFileReference> CreateReferenceAsync(
|
public async Task<CloudFileReference> CreateReferenceAsync(
|
||||||
string fileId,
|
string fileId,
|
||||||
string usage,
|
string usage,
|
||||||
string resourceId,
|
string resourceId,
|
||||||
Instant? expiredAt = null,
|
Instant? expiredAt = null,
|
||||||
Duration? duration = null)
|
Duration? duration = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Calculate expiration time if needed
|
// Calculate expiration time if needed
|
||||||
var finalExpiration = expiredAt;
|
var finalExpiration = expiredAt;
|
||||||
@ -45,6 +48,25 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
return reference;
|
return reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<CloudFileReference>> CreateReferencesAsync(
|
||||||
|
List<string> fileId,
|
||||||
|
string usage,
|
||||||
|
string resourceId,
|
||||||
|
Instant? expiredAt = null,
|
||||||
|
Duration? duration = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var data = fileId.Select(id => new CloudFileReference
|
||||||
|
{
|
||||||
|
FileId = id,
|
||||||
|
Usage = usage,
|
||||||
|
ResourceId = resourceId,
|
||||||
|
ExpiredAt = expiredAt ?? SystemClock.Instance.GetCurrentInstant() + duration
|
||||||
|
}).ToList();
|
||||||
|
await db.BulkInsertAsync(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all references to a file
|
/// Gets all references to a file
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -156,6 +178,36 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
return deletedCount;
|
return deletedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes references for a specific resource and usage
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="resourceId">The ID of the resource</param>
|
||||||
|
/// <param name="usage">The usage context</param>
|
||||||
|
/// <returns>The number of deleted references</returns>
|
||||||
|
public async Task<int> DeleteResourceReferencesAsync(string resourceId, string usage)
|
||||||
|
{
|
||||||
|
var references = await db.FileReferences
|
||||||
|
.Where(r => r.ResourceId == resourceId && r.Usage == usage)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (!references.Any())
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileIds = references.Select(r => r.FileId).Distinct().ToList();
|
||||||
|
|
||||||
|
db.FileReferences.RemoveRange(references);
|
||||||
|
var deletedCount = await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Purge caches
|
||||||
|
var tasks = fileIds.Select(fileService._PurgeCacheAsync).ToList();
|
||||||
|
tasks.Add(PurgeCacheForResourceAsync(resourceId));
|
||||||
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
return deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a specific file reference
|
/// Deletes a specific file reference
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -243,8 +295,8 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
|
|
||||||
// Update newly added references with the expiration time
|
// Update newly added references with the expiration time
|
||||||
var referenceIds = await db.FileReferences
|
var referenceIds = await db.FileReferences
|
||||||
.Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) &&
|
.Where(r => toAdd.Select(a => a.FileId).Contains(r.FileId) &&
|
||||||
r.ResourceId == resourceId &&
|
r.ResourceId == resourceId &&
|
||||||
r.Usage == usage)
|
r.Usage == usage)
|
||||||
.Select(r => r.Id)
|
.Select(r => r.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@ -400,4 +452,4 @@ public class FileReferenceService(AppDatabase db, FileService fileService, ICach
|
|||||||
|
|
||||||
return await SetReferenceExpirationAsync(referenceId, expiredAt);
|
return await SetReferenceExpirationAsync(referenceId, expiredAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
163
DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
Normal file
163
DysonNetwork.Drive/Storage/FileReferenceServiceGrpc.cs
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Grpc.Core;
|
||||||
|
using NodaTime;
|
||||||
|
using Duration = NodaTime.Duration;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage
|
||||||
|
{
|
||||||
|
public class FileReferenceServiceGrpc(FileReferenceService fileReferenceService)
|
||||||
|
: Shared.Proto.FileReferenceService.FileReferenceServiceBase
|
||||||
|
{
|
||||||
|
public override async Task<Shared.Proto.CloudFileReference> CreateReference(CreateReferenceRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
else if (request.Duration != null)
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
|
||||||
|
var reference = await fileReferenceService.CreateReferenceAsync(
|
||||||
|
request.FileId,
|
||||||
|
request.Usage,
|
||||||
|
request.ResourceId,
|
||||||
|
expiredAt
|
||||||
|
);
|
||||||
|
return reference.ToProtoValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<CreateReferenceBatchResponse> CreateReferenceBatch(CreateReferenceBatchRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
else if (request.Duration != null)
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
|
||||||
|
var references = await fileReferenceService.CreateReferencesAsync(
|
||||||
|
request.FilesId.ToList(),
|
||||||
|
request.Usage,
|
||||||
|
request.ResourceId,
|
||||||
|
expiredAt
|
||||||
|
);
|
||||||
|
var response = new CreateReferenceBatchResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetReferencesResponse> GetReferences(GetReferencesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var references = await fileReferenceService.GetReferencesAsync(request.FileId);
|
||||||
|
var response = new GetReferencesResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetReferenceCountResponse> GetReferenceCount(GetReferenceCountRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var count = await fileReferenceService.GetReferenceCountAsync(request.FileId);
|
||||||
|
return new GetReferenceCountResponse { Count = count };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetReferencesResponse> GetResourceReferences(GetResourceReferencesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var references = await fileReferenceService.GetResourceReferencesAsync(request.ResourceId, request.Usage);
|
||||||
|
var response = new GetReferencesResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetResourceFilesResponse> GetResourceFiles(GetResourceFilesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var files = await fileReferenceService.GetResourceFilesAsync(request.ResourceId, request.Usage);
|
||||||
|
var response = new GetResourceFilesResponse();
|
||||||
|
response.Files.AddRange(files.Select(f => f.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<DeleteResourceReferencesResponse> DeleteResourceReferences(
|
||||||
|
DeleteResourceReferencesRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var deletedCount = 0;
|
||||||
|
if (request.Usage is null)
|
||||||
|
deletedCount = await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId);
|
||||||
|
else
|
||||||
|
deletedCount =
|
||||||
|
await fileReferenceService.DeleteResourceReferencesAsync(request.ResourceId, request.Usage!);
|
||||||
|
return new DeleteResourceReferencesResponse { DeletedCount = deletedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<DeleteReferenceResponse> DeleteReference(DeleteReferenceRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var success = await fileReferenceService.DeleteReferenceAsync(Guid.Parse(request.ReferenceId));
|
||||||
|
return new DeleteReferenceResponse { Success = success };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<UpdateResourceFilesResponse> UpdateResourceFiles(UpdateResourceFilesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
{
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
}
|
||||||
|
else if (request.Duration != null)
|
||||||
|
{
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
var references = await fileReferenceService.UpdateResourceFilesAsync(
|
||||||
|
request.ResourceId,
|
||||||
|
request.FileIds,
|
||||||
|
request.Usage,
|
||||||
|
expiredAt
|
||||||
|
);
|
||||||
|
var response = new UpdateResourceFilesResponse();
|
||||||
|
response.References.AddRange(references.Select(r => r.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<SetReferenceExpirationResponse> SetReferenceExpiration(
|
||||||
|
SetReferenceExpirationRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
Instant? expiredAt = null;
|
||||||
|
if (request.ExpiredAt != null)
|
||||||
|
{
|
||||||
|
expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
}
|
||||||
|
else if (request.Duration != null)
|
||||||
|
{
|
||||||
|
expiredAt = SystemClock.Instance.GetCurrentInstant() +
|
||||||
|
Duration.FromTimeSpan(request.Duration.ToTimeSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
var success =
|
||||||
|
await fileReferenceService.SetReferenceExpirationAsync(Guid.Parse(request.ReferenceId), expiredAt);
|
||||||
|
return new SetReferenceExpirationResponse { Success = success };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<SetFileReferencesExpirationResponse> SetFileReferencesExpiration(
|
||||||
|
SetFileReferencesExpirationRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var expiredAt = Instant.FromUnixTimeSeconds(request.ExpiredAt.Seconds);
|
||||||
|
var updatedCount = await fileReferenceService.SetFileReferencesExpirationAsync(request.FileId, expiredAt);
|
||||||
|
return new SetFileReferencesExpirationResponse { UpdatedCount = updatedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<HasFileReferencesResponse> HasFileReferences(HasFileReferencesRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var hasReferences = await fileReferenceService.HasFileReferencesAsync(request.FileId);
|
||||||
|
return new HasFileReferencesResponse { HasReferences = hasReferences };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,16 +1,17 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using FFMpegCore;
|
using FFMpegCore;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Minio;
|
using Minio;
|
||||||
using Minio.DataModel.Args;
|
using Minio.DataModel.Args;
|
||||||
using NetVips;
|
using NetVips;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Quartz;
|
|
||||||
using tusdotnet.Stores;
|
using tusdotnet.Stores;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
public class FileService(
|
public class FileService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
@ -41,7 +42,6 @@ public class FileService(
|
|||||||
return cachedFile;
|
return cachedFile;
|
||||||
|
|
||||||
var file = await db.Files
|
var file = await db.Files
|
||||||
.Include(f => f.Account)
|
|
||||||
.Where(f => f.Id == fileId)
|
.Where(f => f.Id == fileId)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
@ -50,14 +50,61 @@ public class FileService(
|
|||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<CloudFile>> GetFilesAsync(List<string> fileIds)
|
||||||
|
{
|
||||||
|
var cachedFiles = new Dictionary<string, CloudFile>();
|
||||||
|
var uncachedIds = new List<string>();
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
foreach (var fileId in fileIds)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{fileId}";
|
||||||
|
var cachedFile = await cache.GetAsync<CloudFile>(cacheKey);
|
||||||
|
|
||||||
|
if (cachedFile != null)
|
||||||
|
{
|
||||||
|
cachedFiles[fileId] = cachedFile;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
uncachedIds.Add(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load uncached files from database
|
||||||
|
if (uncachedIds.Count > 0)
|
||||||
|
{
|
||||||
|
var dbFiles = await db.Files
|
||||||
|
.Where(f => uncachedIds.Contains(f.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Add to cache
|
||||||
|
foreach (var file in dbFiles)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{CacheKeyPrefix}{file.Id}";
|
||||||
|
await cache.SetAsync(cacheKey, file, CacheDuration);
|
||||||
|
cachedFiles[file.Id] = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve original order
|
||||||
|
return fileIds
|
||||||
|
.Select(f => cachedFiles.GetValueOrDefault(f))
|
||||||
|
.Where(f => f != null)
|
||||||
|
.Cast<CloudFile>()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly string TempFilePrefix = "dyn-cloudfile";
|
private static readonly string TempFilePrefix = "dyn-cloudfile";
|
||||||
private static readonly string[] AnimatedImageTypes = new[] { "image/gif", "image/apng", "image/webp", "image/avif" };
|
|
||||||
|
private static readonly string[] AnimatedImageTypes =
|
||||||
|
["image/gif", "image/apng", "image/webp", "image/avif"];
|
||||||
|
|
||||||
// The analysis file method no longer will remove the GPS EXIF data
|
// The analysis file method no longer will remove the GPS EXIF data
|
||||||
// It should be handled on the client side, and for some specific cases it should be keep
|
// It should be handled on the client side, and for some specific cases it should be keep
|
||||||
public async Task<CloudFile> ProcessNewFileAsync(
|
public async Task<CloudFile> ProcessNewFileAsync(
|
||||||
Account.Account account,
|
Account account,
|
||||||
string fileId,
|
string fileId,
|
||||||
Stream stream,
|
Stream stream,
|
||||||
string fileName,
|
string fileName,
|
||||||
@ -78,7 +125,7 @@ public class FileService(
|
|||||||
MimeType = contentType,
|
MimeType = contentType,
|
||||||
Size = fileSize,
|
Size = fileSize,
|
||||||
Hash = hash,
|
Hash = hash,
|
||||||
AccountId = account.Id
|
AccountId = Guid.Parse(account.Id)
|
||||||
};
|
};
|
||||||
|
|
||||||
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == hash);
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Hash == hash);
|
||||||
@ -113,17 +160,27 @@ public class FileService(
|
|||||||
|
|
||||||
// Try to get orientation from exif data
|
// Try to get orientation from exif data
|
||||||
var orientation = 1;
|
var orientation = 1;
|
||||||
|
var meta = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["blur"] = blurhash,
|
||||||
|
["format"] = format,
|
||||||
|
["width"] = width,
|
||||||
|
["height"] = height,
|
||||||
|
["orientation"] = orientation,
|
||||||
|
};
|
||||||
Dictionary<string, object> exif = [];
|
Dictionary<string, object> exif = [];
|
||||||
|
|
||||||
foreach (var field in vipsImage.GetFields())
|
foreach (var field in vipsImage.GetFields())
|
||||||
{
|
{
|
||||||
var value = vipsImage.Get(field);
|
var value = vipsImage.Get(field);
|
||||||
|
|
||||||
// Skip GPS-related EXIF fields to remove location data
|
// Skip GPS-related EXIF fields to remove location data
|
||||||
if (IsGpsExifField(field))
|
if (IsIgnoredField(field))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
exif.Add(field, value);
|
if (field.StartsWith("exif-")) exif[field.Replace("exif-", "")] = value;
|
||||||
|
else meta[field] = value;
|
||||||
|
|
||||||
if (field == "orientation") orientation = (int)value;
|
if (field == "orientation") orientation = (int)value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,16 +189,9 @@ public class FileService(
|
|||||||
|
|
||||||
var aspectRatio = height != 0 ? (double)width / height : 0;
|
var aspectRatio = height != 0 ? (double)width / height : 0;
|
||||||
|
|
||||||
file.FileMeta = new Dictionary<string, object>
|
meta["exif"] = exif;
|
||||||
{
|
meta["ratio"] = aspectRatio;
|
||||||
["blur"] = blurhash,
|
file.FileMeta = meta;
|
||||||
["format"] = format,
|
|
||||||
["width"] = width,
|
|
||||||
["height"] = height,
|
|
||||||
["orientation"] = orientation,
|
|
||||||
["ratio"] = aspectRatio,
|
|
||||||
["exif"] = exif
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@ -150,7 +200,7 @@ public class FileService(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var mediaInfo = await FFProbe.AnalyseAsync(ogFilePath);
|
var mediaInfo = await FFProbe.AnalyseAsync(ogFilePath);
|
||||||
file.FileMeta = new Dictionary<string, object>
|
file.FileMeta = new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["duration"] = mediaInfo.Duration.TotalSeconds,
|
["duration"] = mediaInfo.Duration.TotalSeconds,
|
||||||
["format_name"] = mediaInfo.Format.FormatName,
|
["format_name"] = mediaInfo.Format.FormatName,
|
||||||
@ -160,6 +210,9 @@ public class FileService(
|
|||||||
["tags"] = mediaInfo.Format.Tags ?? [],
|
["tags"] = mediaInfo.Format.Tags ?? [],
|
||||||
["chapters"] = mediaInfo.Chapters,
|
["chapters"] = mediaInfo.Chapters,
|
||||||
};
|
};
|
||||||
|
if (mediaInfo.PrimaryVideoStream is not null)
|
||||||
|
file.FileMeta["ratio"] =
|
||||||
|
mediaInfo.PrimaryVideoStream.Width / mediaInfo.PrimaryVideoStream.Height;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -186,11 +239,12 @@ public class FileService(
|
|||||||
{
|
{
|
||||||
// Skip compression for animated image types
|
// Skip compression for animated image types
|
||||||
var animatedMimeTypes = AnimatedImageTypes;
|
var animatedMimeTypes = AnimatedImageTypes;
|
||||||
if (animatedMimeTypes.Contains(contentType))
|
if (Enumerable.Contains(animatedMimeTypes, contentType))
|
||||||
{
|
{
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId,
|
"File {fileId} is an animated image (MIME: {mime}), skipping WebP conversion.", fileId,
|
||||||
contentType);
|
contentType
|
||||||
|
);
|
||||||
var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
var tempFilePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
||||||
result.Add((tempFilePath, string.Empty));
|
result.Add((tempFilePath, string.Empty));
|
||||||
return;
|
return;
|
||||||
@ -200,9 +254,8 @@ public class FileService(
|
|||||||
|
|
||||||
using var vipsImage = Image.NewFromFile(ogFilePath);
|
using var vipsImage = Image.NewFromFile(ogFilePath);
|
||||||
var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
var imagePath = Path.Join(Path.GetTempPath(), $"{TempFilePrefix}#{file.Id}");
|
||||||
vipsImage.Autorot();
|
vipsImage.Autorot().WriteToFile(imagePath + ".webp",
|
||||||
vipsImage.WriteToFile(imagePath + ".webp",
|
new VOption { { "lossless", true }, { "strip", true } });
|
||||||
new VOption { { "lossless", true } });
|
|
||||||
result.Add((imagePath + ".webp", string.Empty));
|
result.Add((imagePath + ".webp", string.Empty));
|
||||||
|
|
||||||
if (vipsImage.Width * vipsImage.Height >= 1024 * 1024)
|
if (vipsImage.Width * vipsImage.Height >= 1024 * 1024)
|
||||||
@ -213,8 +266,8 @@ public class FileService(
|
|||||||
|
|
||||||
// Create and save image within the same synchronous block to avoid disposal issues
|
// Create and save image within the same synchronous block to avoid disposal issues
|
||||||
using var compressedImage = vipsImage.Resize(scale);
|
using var compressedImage = vipsImage.Resize(scale);
|
||||||
compressedImage.WriteToFile(imageCompressedPath + ".webp",
|
compressedImage.Autorot().WriteToFile(imageCompressedPath + ".webp",
|
||||||
new VOption { { "Q", 80 } });
|
new VOption { { "Q", 80 }, { "strip", true } });
|
||||||
|
|
||||||
result.Add((imageCompressedPath + ".webp", ".compressed"));
|
result.Add((imageCompressedPath + ".webp", ".compressed"));
|
||||||
file.HasCompression = true;
|
file.HasCompression = true;
|
||||||
@ -337,6 +390,68 @@ public class FileService(
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CloudFile> UpdateFileAsync(CloudFile file, FieldMask updateMask)
|
||||||
|
{
|
||||||
|
var existingFile = await db.Files.FirstOrDefaultAsync(f => f.Id == file.Id);
|
||||||
|
if (existingFile == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"File with ID {file.Id} not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var path in updateMask.Paths)
|
||||||
|
{
|
||||||
|
switch (path)
|
||||||
|
{
|
||||||
|
case "name":
|
||||||
|
existingFile.Name = file.Name;
|
||||||
|
break;
|
||||||
|
case "description":
|
||||||
|
existingFile.Description = file.Description;
|
||||||
|
break;
|
||||||
|
case "file_meta":
|
||||||
|
existingFile.FileMeta = file.FileMeta;
|
||||||
|
break;
|
||||||
|
case "user_meta":
|
||||||
|
existingFile.UserMeta = file.UserMeta;
|
||||||
|
break;
|
||||||
|
case "mime_type":
|
||||||
|
existingFile.MimeType = file.MimeType;
|
||||||
|
break;
|
||||||
|
case "hash":
|
||||||
|
existingFile.Hash = file.Hash;
|
||||||
|
break;
|
||||||
|
case "size":
|
||||||
|
existingFile.Size = file.Size;
|
||||||
|
break;
|
||||||
|
case "uploaded_at":
|
||||||
|
existingFile.UploadedAt = file.UploadedAt;
|
||||||
|
break;
|
||||||
|
case "uploaded_to":
|
||||||
|
existingFile.UploadedTo = file.UploadedTo;
|
||||||
|
break;
|
||||||
|
case "has_compression":
|
||||||
|
existingFile.HasCompression = file.HasCompression;
|
||||||
|
break;
|
||||||
|
case "is_marked_recycle":
|
||||||
|
existingFile.IsMarkedRecycle = file.IsMarkedRecycle;
|
||||||
|
break;
|
||||||
|
case "storage_id":
|
||||||
|
existingFile.StorageId = file.StorageId;
|
||||||
|
break;
|
||||||
|
case "storage_url":
|
||||||
|
existingFile.StorageUrl = file.StorageUrl;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.LogWarning("Attempted to update unknown field: {Field}", path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
await _PurgeCacheAsync(file.Id);
|
||||||
|
return existingFile;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteFileAsync(CloudFile file)
|
public async Task DeleteFileAsync(CloudFile file)
|
||||||
{
|
{
|
||||||
await DeleteFileDataAsync(file);
|
await DeleteFileDataAsync(file);
|
||||||
@ -459,7 +574,6 @@ public class FileService(
|
|||||||
if (uncachedIds.Count > 0)
|
if (uncachedIds.Count > 0)
|
||||||
{
|
{
|
||||||
var dbFiles = await db.Files
|
var dbFiles = await db.Files
|
||||||
.Include(f => f.Account)
|
|
||||||
.Where(f => uncachedIds.Contains(f.Id))
|
.Where(f => uncachedIds.Contains(f.Id))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@ -514,7 +628,7 @@ public class FileService(
|
|||||||
var gpsFields = new[]
|
var gpsFields = new[]
|
||||||
{
|
{
|
||||||
"gps-latitude",
|
"gps-latitude",
|
||||||
"gps-longitude",
|
"gps-longitude",
|
||||||
"gps-altitude",
|
"gps-altitude",
|
||||||
"gps-latitude-ref",
|
"gps-latitude-ref",
|
||||||
"gps-longitude-ref",
|
"gps-longitude-ref",
|
||||||
@ -535,8 +649,15 @@ public class FileService(
|
|||||||
"gps-area-information"
|
"gps-area-information"
|
||||||
};
|
};
|
||||||
|
|
||||||
return gpsFields.Any(gpsField =>
|
return gpsFields.Any(gpsField =>
|
||||||
fieldName.Equals(gpsField, StringComparison.OrdinalIgnoreCase) ||
|
fieldName.Equals(gpsField, StringComparison.OrdinalIgnoreCase) ||
|
||||||
fieldName.StartsWith("gps", StringComparison.OrdinalIgnoreCase));
|
fieldName.StartsWith("gps", StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsIgnoredField(string fieldName)
|
||||||
|
{
|
||||||
|
if (IsGpsExifField(fieldName)) return true;
|
||||||
|
if (fieldName.EndsWith("-data")) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
70
DysonNetwork.Drive/Storage/FileServiceGrpc.cs
Normal file
70
DysonNetwork.Drive/Storage/FileServiceGrpc.cs
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Grpc.Core;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Drive.Storage
|
||||||
|
{
|
||||||
|
public class FileServiceGrpc(FileService fileService) : Shared.Proto.FileService.FileServiceBase
|
||||||
|
{
|
||||||
|
public override async Task<Shared.Proto.CloudFile> GetFile(GetFileRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var file = await fileService.GetFileAsync(request.Id);
|
||||||
|
return file?.ToProtoValue() ?? throw new RpcException(new Status(StatusCode.NotFound, "File not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetFileBatchResponse> GetFileBatch(GetFileBatchRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var files = await fileService.GetFilesAsync(request.Ids.ToList());
|
||||||
|
return new GetFileBatchResponse { Files = { files.Select(f => f.ToProtoValue()) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Shared.Proto.CloudFile> UpdateFile(UpdateFileRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var file = await fileService.GetFileAsync(request.File.Id);
|
||||||
|
if (file == null)
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "File not found"));
|
||||||
|
var updatedFile = await fileService.UpdateFileAsync(file, request.UpdateMask);
|
||||||
|
return updatedFile.ToProtoValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Empty> DeleteFile(DeleteFileRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var file = await fileService.GetFileAsync(request.Id);
|
||||||
|
if (file == null)
|
||||||
|
{
|
||||||
|
throw new RpcException(new Status(StatusCode.NotFound, "File not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await fileService.DeleteFileAsync(file);
|
||||||
|
return new Empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<LoadFromReferenceResponse> LoadFromReference(
|
||||||
|
LoadFromReferenceRequest request,
|
||||||
|
ServerCallContext context
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Assuming CloudFileReferenceObject is a simple class/struct that holds an ID
|
||||||
|
// You might need to define this or adjust the LoadFromReference method in FileService
|
||||||
|
var references = request.ReferenceIds.Select(id => new CloudFileReferenceObject { Id = id }).ToList();
|
||||||
|
var files = await fileService.LoadFromReference(references);
|
||||||
|
var response = new LoadFromReferenceResponse();
|
||||||
|
response.Files.AddRange(files.Where(f => f != null).Select(f => f!.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<IsReferencedResponse> IsReferenced(IsReferencedRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var isReferenced = await fileService.IsReferencedAsync(request.FileId);
|
||||||
|
return new IsReferencedResponse { IsReferenced = isReferenced };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<Empty> PurgeCache(PurgeCacheRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
await fileService._PurgeCacheAsync(request.FileId);
|
||||||
|
return new Empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,14 +1,14 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using tusdotnet.Interfaces;
|
using tusdotnet.Interfaces;
|
||||||
using tusdotnet.Models;
|
using tusdotnet.Models;
|
||||||
using tusdotnet.Models.Configuration;
|
using tusdotnet.Models.Configuration;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage;
|
namespace DysonNetwork.Drive.Storage;
|
||||||
|
|
||||||
public abstract class TusService
|
public abstract class TusService
|
||||||
{
|
{
|
||||||
@ -29,7 +29,7 @@ public abstract class TusService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var httpContext = eventContext.HttpContext;
|
var httpContext = eventContext.HttpContext;
|
||||||
if (httpContext.Items["CurrentUser"] is not Account.Account user)
|
if (httpContext.Items["CurrentUser"] is not Account user)
|
||||||
{
|
{
|
||||||
eventContext.FailRequest(HttpStatusCode.Unauthorized);
|
eventContext.FailRequest(HttpStatusCode.Unauthorized);
|
||||||
return;
|
return;
|
||||||
@ -38,9 +38,10 @@ public abstract class TusService
|
|||||||
if (!user.IsSuperuser)
|
if (!user.IsSuperuser)
|
||||||
{
|
{
|
||||||
using var scope = httpContext.RequestServices.CreateScope();
|
using var scope = httpContext.RequestServices.CreateScope();
|
||||||
var pm = scope.ServiceProvider.GetRequiredService<PermissionService>();
|
var pm = scope.ServiceProvider.GetRequiredService<PermissionService.PermissionServiceClient>();
|
||||||
var allowed = await pm.HasPermissionAsync($"user:{user.Id}", "global", "files.create");
|
var allowed = await pm.HasPermissionAsync(new HasPermissionRequest
|
||||||
if (!allowed)
|
{ Actor = $"user:{user.Id}", Area = "global", Key = "files.create" });
|
||||||
|
if (!allowed.HasPermission)
|
||||||
eventContext.FailRequest(HttpStatusCode.Forbidden);
|
eventContext.FailRequest(HttpStatusCode.Forbidden);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -50,7 +51,7 @@ public abstract class TusService
|
|||||||
var services = scope.ServiceProvider;
|
var services = scope.ServiceProvider;
|
||||||
|
|
||||||
var httpContext = eventContext.HttpContext;
|
var httpContext = eventContext.HttpContext;
|
||||||
if (httpContext.Items["CurrentUser"] is not Account.Account user) return;
|
if (httpContext.Items["CurrentUser"] is not Account user) return;
|
||||||
|
|
||||||
var file = await eventContext.GetFileAsync();
|
var file = await eventContext.GetFileAsync();
|
||||||
var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
|
var metadata = await file.GetMetadataAsync(eventContext.CancellationToken);
|
136
DysonNetwork.Drive/appsettings.json
Normal file
136
DysonNetwork.Drive/appsettings.json
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
{
|
||||||
|
"Debug": true,
|
||||||
|
"BaseUrl": "http://localhost:5071",
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60",
|
||||||
|
"FastRetrieve": "localhost:6379",
|
||||||
|
"Etcd": "etcd.orb.local:2379"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"Service": {
|
||||||
|
"Name": "DysonNetwork.Drive",
|
||||||
|
"Url": "https://localhost:7092",
|
||||||
|
"ClientCert": "../Certificates/client.crt",
|
||||||
|
"ClientKey": "../Certificates/client.key"
|
||||||
|
}
|
||||||
|
}
|
47
DysonNetwork.Gateway/Controllers/WellKnownController.cs
Normal file
47
DysonNetwork.Gateway/Controllers/WellKnownController.cs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Yarp.ReverseProxy.Configuration;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Gateway.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/.well-known")]
|
||||||
|
public class WellKnownController(IConfiguration configuration, IProxyConfigProvider proxyConfigProvider)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("domains")]
|
||||||
|
public IActionResult GetDomainMappings()
|
||||||
|
{
|
||||||
|
var domainMappings = configuration.GetSection("DomainMappings").GetChildren()
|
||||||
|
.ToDictionary(x => x.Key, x => x.Value);
|
||||||
|
return Ok(domainMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("routes")]
|
||||||
|
public IActionResult GetProxyRules()
|
||||||
|
{
|
||||||
|
var config = proxyConfigProvider.GetConfig();
|
||||||
|
var rules = config.Routes.Select(r => new
|
||||||
|
{
|
||||||
|
r.RouteId,
|
||||||
|
r.ClusterId,
|
||||||
|
Match = new
|
||||||
|
{
|
||||||
|
r.Match.Path,
|
||||||
|
Hosts = r.Match.Hosts != null ? string.Join(", ", r.Match.Hosts) : null
|
||||||
|
},
|
||||||
|
Transforms = r.Transforms?.Select(t => t.Select(kv => $"{kv.Key}: {kv.Value}").ToList())
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var clusters = config.Clusters.Select(c => new
|
||||||
|
{
|
||||||
|
c.ClusterId,
|
||||||
|
Destinations = c.Destinations?.Select(d => new
|
||||||
|
{
|
||||||
|
d.Key,
|
||||||
|
d.Value.Address
|
||||||
|
}).ToList()
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return Ok(new { Rules = rules, Clusters = clusters });
|
||||||
|
}
|
||||||
|
}
|
23
DysonNetwork.Gateway/Dockerfile
Normal file
23
DysonNetwork.Gateway/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.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"]
|
||||||
|
RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/DysonNetwork.Gateway"
|
||||||
|
RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"]
|
19
DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
Normal file
19
DysonNetwork.Gateway/DysonNetwork.Gateway.csproj
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="dotnet-etcd" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
14
DysonNetwork.Gateway/Program.cs
Normal file
14
DysonNetwork.Gateway/Program.cs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
using DysonNetwork.Gateway.Startup;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
builder.Services.AddGateway(builder.Configuration);
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
app.MapReverseProxy();
|
||||||
|
|
||||||
|
app.Run();
|
23
DysonNetwork.Gateway/Properties/launchSettings.json
Normal file
23
DysonNetwork.Gateway/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:5094",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": false,
|
||||||
|
"applicationUrl": "https://localhost:7034;http://localhost:5094",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
176
DysonNetwork.Gateway/RegistryProxyConfigProvider.cs
Normal file
176
DysonNetwork.Gateway/RegistryProxyConfigProvider.cs
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
using System.Text;
|
||||||
|
using dotnet_etcd.interfaces;
|
||||||
|
using Yarp.ReverseProxy.Configuration;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Gateway;
|
||||||
|
|
||||||
|
public class RegistryProxyConfigProvider : IProxyConfigProvider, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IEtcdClient _etcdClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<RegistryProxyConfigProvider> _logger;
|
||||||
|
private readonly CancellationTokenSource _watchCts = new();
|
||||||
|
private CancellationTokenSource _cts = new();
|
||||||
|
|
||||||
|
public RegistryProxyConfigProvider(IEtcdClient etcdClient, IConfiguration configuration, ILogger<RegistryProxyConfigProvider> logger)
|
||||||
|
{
|
||||||
|
_etcdClient = etcdClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
// Watch for changes in etcd
|
||||||
|
_etcdClient.WatchRange("/services/", _ =>
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Etcd configuration changed. Reloading proxy config.");
|
||||||
|
_cts.Cancel();
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
}, cancellationToken: _watchCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IProxyConfig GetConfig()
|
||||||
|
{
|
||||||
|
// This will be called by YARP when it needs a new config
|
||||||
|
_logger.LogInformation("Generating new proxy config.");
|
||||||
|
var response = _etcdClient.GetRange("/services/");
|
||||||
|
var kvs = response.Kvs;
|
||||||
|
|
||||||
|
var serviceMap = kvs.ToDictionary(
|
||||||
|
kv => Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""),
|
||||||
|
kv => Encoding.UTF8.GetString(kv.Value.ToByteArray())
|
||||||
|
);
|
||||||
|
|
||||||
|
var clusters = new List<ClusterConfig>();
|
||||||
|
var routes = new List<RouteConfig>();
|
||||||
|
|
||||||
|
var domainMappings = _configuration.GetSection("DomainMappings").GetChildren()
|
||||||
|
.ToDictionary(x => x.Key, x => x.Value);
|
||||||
|
|
||||||
|
var pathAliases = _configuration.GetSection("PathAliases").GetChildren()
|
||||||
|
.ToDictionary(x => x.Key, x => x.Value);
|
||||||
|
|
||||||
|
var directRoutes = _configuration.GetSection("DirectRoutes").Get<List<DirectRouteConfig>>() ?? new List<DirectRouteConfig>();
|
||||||
|
|
||||||
|
_logger.LogInformation("Indexing {ServiceCount} services from Etcd.", kvs.Count);
|
||||||
|
|
||||||
|
var gatewayServiceName = _configuration["Service:Name"];
|
||||||
|
|
||||||
|
// Add direct routes
|
||||||
|
foreach (var directRoute in directRoutes)
|
||||||
|
{
|
||||||
|
if (serviceMap.TryGetValue(directRoute.Service, out var serviceUrl))
|
||||||
|
{
|
||||||
|
var cluster = new ClusterConfig
|
||||||
|
{
|
||||||
|
ClusterId = directRoute.Service,
|
||||||
|
Destinations = new Dictionary<string, DestinationConfig>
|
||||||
|
{
|
||||||
|
{ "destination1", new DestinationConfig { Address = serviceUrl } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
clusters.Add(cluster);
|
||||||
|
|
||||||
|
var route = new RouteConfig
|
||||||
|
{
|
||||||
|
RouteId = $"direct-{directRoute.Service}-{directRoute.Path.Replace("/", "-")}",
|
||||||
|
ClusterId = directRoute.Service,
|
||||||
|
Match = new RouteMatch { Path = directRoute.Path }
|
||||||
|
};
|
||||||
|
routes.Add(route);
|
||||||
|
_logger.LogInformation(" Added Direct Route: {Path} -> {Service}", directRoute.Path, directRoute.Service);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning(" Direct route service {Service} not found in Etcd.", directRoute.Service);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var serviceName in serviceMap.Keys)
|
||||||
|
{
|
||||||
|
if (serviceName == gatewayServiceName)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping gateway service: {ServiceName}", serviceName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceUrl = serviceMap[serviceName];
|
||||||
|
|
||||||
|
// Determine the path alias
|
||||||
|
string pathAlias;
|
||||||
|
if (pathAliases.TryGetValue(serviceName, out var alias))
|
||||||
|
{
|
||||||
|
pathAlias = alias;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
pathAlias = serviceName.Split('.').Last().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(" Service: {ServiceName}, URL: {ServiceUrl}, Path Alias: {PathAlias}", serviceName, serviceUrl, pathAlias);
|
||||||
|
|
||||||
|
var cluster = new ClusterConfig
|
||||||
|
{
|
||||||
|
ClusterId = serviceName,
|
||||||
|
Destinations = new Dictionary<string, DestinationConfig>
|
||||||
|
{
|
||||||
|
{ "destination1", new DestinationConfig { Address = serviceUrl } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
clusters.Add(cluster);
|
||||||
|
|
||||||
|
// Host-based routing
|
||||||
|
if (domainMappings.TryGetValue(serviceName, out var domain))
|
||||||
|
{
|
||||||
|
var hostRoute = new RouteConfig
|
||||||
|
{
|
||||||
|
RouteId = $"{serviceName}-host",
|
||||||
|
ClusterId = serviceName,
|
||||||
|
Match = new RouteMatch
|
||||||
|
{
|
||||||
|
Hosts = new[] { domain },
|
||||||
|
Path = "/{**catch-all}"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
routes.Add(hostRoute);
|
||||||
|
_logger.LogInformation(" Added Host-based Route: {Host}", domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path-based routing
|
||||||
|
var pathRoute = new RouteConfig
|
||||||
|
{
|
||||||
|
RouteId = $"{serviceName}-path",
|
||||||
|
ClusterId = serviceName,
|
||||||
|
Match = new RouteMatch { Path = $"/{pathAlias}/{{**catch-all}}" },
|
||||||
|
Transforms = new List<Dictionary<string, string>>
|
||||||
|
{
|
||||||
|
new Dictionary<string, string> { { "PathRemovePrefix", $"/{pathAlias}" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
routes.Add(pathRoute);
|
||||||
|
_logger.LogInformation(" Added Path-based Route: {Path}", pathRoute.Match.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomProxyConfig(routes, clusters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CustomProxyConfig(IReadOnlyList<RouteConfig> routes, IReadOnlyList<ClusterConfig> clusters)
|
||||||
|
: IProxyConfig
|
||||||
|
{
|
||||||
|
public IReadOnlyList<RouteConfig> Routes { get; } = routes;
|
||||||
|
public IReadOnlyList<ClusterConfig> Clusters { get; } = clusters;
|
||||||
|
public Microsoft.Extensions.Primitives.IChangeToken ChangeToken { get; } = new Microsoft.Extensions.Primitives.CancellationChangeToken(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DirectRouteConfig
|
||||||
|
{
|
||||||
|
public string Path { get; set; }
|
||||||
|
public string Service { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_cts.Cancel();
|
||||||
|
_cts.Dispose();
|
||||||
|
_watchCts.Cancel();
|
||||||
|
_watchCts.Dispose();
|
||||||
|
}
|
||||||
|
}
|
16
DysonNetwork.Gateway/Startup/ServiceCollectionExtensions.cs
Normal file
16
DysonNetwork.Gateway/Startup/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using Yarp.ReverseProxy.Configuration;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Gateway.Startup;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddGateway(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddReverseProxy();
|
||||||
|
services.AddRegistryService(configuration);
|
||||||
|
services.AddSingleton<IProxyConfigProvider, RegistryProxyConfigProvider>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
43
DysonNetwork.Gateway/appsettings.json
Normal file
43
DysonNetwork.Gateway/appsettings.json
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"Etcd": "etcd.orb.local:2379"
|
||||||
|
},
|
||||||
|
"Etcd": {
|
||||||
|
"Insecure": true
|
||||||
|
},
|
||||||
|
"Service": {
|
||||||
|
"Name": "DysonNetwork.Gateway",
|
||||||
|
"Url": "https://localhost:7034"
|
||||||
|
},
|
||||||
|
"DomainMappings": {
|
||||||
|
"DysonNetwork.Pass": "id.solsynth.dev",
|
||||||
|
"DysonNetwork.Drive": "drive.solsynth.dev",
|
||||||
|
"DysonNetwork.Pusher": "push.solsynth.dev",
|
||||||
|
"DysonNetwork.Sphere": "sphere.solsynth.dev"
|
||||||
|
},
|
||||||
|
"PathAliases": {
|
||||||
|
"DysonNetwork.Pass": "id",
|
||||||
|
"DysonNetwork.Drive": "drive"
|
||||||
|
},
|
||||||
|
"DirectRoutes": [
|
||||||
|
{
|
||||||
|
"Path": "/ws",
|
||||||
|
"Service": "DysonNetwork.Pusher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Path": "/.well-known/openid-configuration",
|
||||||
|
"Service": "DysonNetwork.Pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Path": "/.well-known/jwks",
|
||||||
|
"Service": "DysonNetwork.Pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
31
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
31
DysonNetwork.Pass/Account/AbuseReport.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
public enum AbuseReportType
|
||||||
|
{
|
||||||
|
Copyright,
|
||||||
|
Harassment,
|
||||||
|
Impersonation,
|
||||||
|
OffensiveContent,
|
||||||
|
Spam,
|
||||||
|
PrivacyViolation,
|
||||||
|
IllegalContent,
|
||||||
|
Other
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AbuseReport : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
|
||||||
|
public AbuseReportType Type { get; set; }
|
||||||
|
[MaxLength(8192)] public string Reason { get; set; } = null!;
|
||||||
|
|
||||||
|
public Instant? ResolvedAt { get; set; }
|
||||||
|
[MaxLength(8192)] public string? Resolution { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
}
|
@ -1,14 +1,13 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Sphere.Storage;
|
|
||||||
using DysonNetwork.Sphere.Wallet;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
[Index(nameof(Name), IsUnique = true)]
|
[Index(nameof(Name), IsUnique = true)]
|
||||||
public class Account : ModelBase
|
public class Account : ModelBase
|
||||||
@ -20,19 +19,41 @@ 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<Badge> Badges { get; set; } = new List<Badge>();
|
public ICollection<AccountBadge> Badges { get; set; } = new List<AccountBadge>();
|
||||||
|
|
||||||
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
|
||||||
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
|
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
|
||||||
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
|
[JsonIgnore] public ICollection<Auth.AuthSession> Sessions { get; set; } = new List<Auth.AuthSession>();
|
||||||
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();
|
[JsonIgnore] public ICollection<Auth.AuthChallenge> Challenges { get; set; } = new List<Auth.AuthChallenge>();
|
||||||
|
|
||||||
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
|
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
|
||||||
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
|
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
|
||||||
|
|
||||||
[JsonIgnore] public ICollection<Subscription> Subscriptions { get; set; } = new List<Subscription>();
|
public Shared.Proto.Account ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Shared.Proto.Account
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Name = Name,
|
||||||
|
Nick = Nick,
|
||||||
|
Language = Language,
|
||||||
|
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||||
|
IsSuperuser = IsSuperuser,
|
||||||
|
Profile = Profile.ToProtoValue()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add contacts
|
||||||
|
foreach (var contact in Contacts)
|
||||||
|
proto.Contacts.Add(contact.ToProtoValue());
|
||||||
|
|
||||||
|
// Add badges
|
||||||
|
foreach (var badge in Badges)
|
||||||
|
proto.Badges.Add(badge.ToProtoValue());
|
||||||
|
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract class Leveling
|
public abstract class Leveling
|
||||||
@ -57,7 +78,7 @@ public abstract class Leveling
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Profile : ModelBase
|
public class AccountProfile : ModelBase, IIdentifiedResource
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
[MaxLength(256)] public string? FirstName { get; set; }
|
[MaxLength(256)] public string? FirstName { get; set; }
|
||||||
@ -73,7 +94,6 @@ public class Profile : ModelBase
|
|||||||
|
|
||||||
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
|
[Column(TypeName = "jsonb")] public BadgeReferenceObject? ActiveBadge { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public SubscriptionReferenceObject? StellarMembership { get; set; }
|
|
||||||
|
|
||||||
public int Experience { get; set; } = 0;
|
public int Experience { get; set; } = 0;
|
||||||
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
|
[NotMapped] public int Level => Leveling.ExperiencePerLevel.Count(xp => Experience >= xp) - 1;
|
||||||
@ -84,15 +104,41 @@ public class Profile : ModelBase
|
|||||||
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
|
: (Experience - Leveling.ExperiencePerLevel[Level]) * 100.0 /
|
||||||
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
|
(Leveling.ExperiencePerLevel[Level + 1] - Leveling.ExperiencePerLevel[Level]);
|
||||||
|
|
||||||
// Outdated fields, for backward compability
|
|
||||||
[MaxLength(32)] public string? PictureId { get; set; }
|
|
||||||
[MaxLength(32)] public string? BackgroundId { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account Account { get; set; } = null!;
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public Shared.Proto.AccountProfile ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Shared.Proto.AccountProfile
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
FirstName = FirstName ?? string.Empty,
|
||||||
|
MiddleName = MiddleName ?? string.Empty,
|
||||||
|
LastName = LastName ?? string.Empty,
|
||||||
|
Bio = Bio ?? string.Empty,
|
||||||
|
Gender = Gender ?? string.Empty,
|
||||||
|
Pronouns = Pronouns ?? string.Empty,
|
||||||
|
TimeZone = TimeZone ?? string.Empty,
|
||||||
|
Location = Location ?? string.Empty,
|
||||||
|
Birthday = Birthday?.ToTimestamp(),
|
||||||
|
LastSeenAt = LastSeenAt?.ToTimestamp(),
|
||||||
|
Experience = Experience,
|
||||||
|
Level = Level,
|
||||||
|
LevelingProgress = LevelingProgress,
|
||||||
|
Picture = Picture?.ToProtoValue(),
|
||||||
|
Background = Background?.ToProtoValue(),
|
||||||
|
AccountId = AccountId.ToString(),
|
||||||
|
Verification = Verification?.ToProtoValue(),
|
||||||
|
ActiveBadge = ActiveBadge?.ToProtoValue()
|
||||||
|
};
|
||||||
|
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ResourceIdentifier => $"account:profile:{Id}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountContact : ModelBase
|
public class AccountContact : ModelBase
|
||||||
@ -105,6 +151,27 @@ public class AccountContact : ModelBase
|
|||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account Account { get; set; } = null!;
|
[JsonIgnore] public Account Account { get; set; } = null!;
|
||||||
|
|
||||||
|
public Shared.Proto.AccountContact ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Shared.Proto.AccountContact
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Type = Type switch
|
||||||
|
{
|
||||||
|
AccountContactType.Email => Shared.Proto.AccountContactType.Email,
|
||||||
|
AccountContactType.PhoneNumber => Shared.Proto.AccountContactType.PhoneNumber,
|
||||||
|
AccountContactType.Address => Shared.Proto.AccountContactType.Address,
|
||||||
|
_ => Shared.Proto.AccountContactType.Unspecified
|
||||||
|
},
|
||||||
|
Content = Content,
|
||||||
|
IsPrimary = IsPrimary,
|
||||||
|
VerifiedAt = VerifiedAt?.ToTimestamp(),
|
||||||
|
AccountId = AccountId.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum AccountContactType
|
public enum AccountContactType
|
||||||
@ -119,12 +186,15 @@ public class AccountAuthFactor : ModelBase
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public AccountAuthFactorType Type { get; set; }
|
public AccountAuthFactorType Type { get; set; }
|
||||||
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
|
[JsonIgnore] [MaxLength(8196)] public string? Secret { get; set; }
|
||||||
[JsonIgnore] [Column(TypeName = "jsonb")] public Dictionary<string, object>? Config { get; set; } = new();
|
|
||||||
|
[JsonIgnore]
|
||||||
|
[Column(TypeName = "jsonb")]
|
||||||
|
public Dictionary<string, object>? Config { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The trustworthy stands for how safe is this auth factor.
|
/// The trustworthy stands for how safe is this auth factor.
|
||||||
/// Basically, it affects how many steps it can complete in authentication.
|
/// Basically, it affects how many steps it can complete in authentication.
|
||||||
/// Besides, users may need to use some high trustworthy level auth factors when confirming some dangerous operations.
|
/// Besides, users may need to use some high-trustworthy level auth factors when confirming some dangerous operations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Trustworthy { get; set; } = 1;
|
public int Trustworthy { get; set; } = 1;
|
||||||
|
|
||||||
@ -148,6 +218,7 @@ public class AccountAuthFactor : ModelBase
|
|||||||
switch (Type)
|
switch (Type)
|
||||||
{
|
{
|
||||||
case AccountAuthFactorType.Password:
|
case AccountAuthFactorType.Password:
|
||||||
|
case AccountAuthFactorType.PinCode:
|
||||||
return BCrypt.Net.BCrypt.Verify(password, Secret);
|
return BCrypt.Net.BCrypt.Verify(password, Secret);
|
||||||
case AccountAuthFactorType.TimedCode:
|
case AccountAuthFactorType.TimedCode:
|
||||||
var otp = new Totp(Base32Encoding.ToBytes(Secret));
|
var otp = new Totp(Base32Encoding.ToBytes(Secret));
|
||||||
@ -172,7 +243,8 @@ public enum AccountAuthFactorType
|
|||||||
Password,
|
Password,
|
||||||
EmailCode,
|
EmailCode,
|
||||||
InAppCode,
|
InAppCode,
|
||||||
TimedCode
|
TimedCode,
|
||||||
|
PinCode,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AccountConnection : ModelBase
|
public class AccountConnection : ModelBase
|
||||||
@ -181,11 +253,11 @@ public class AccountConnection : ModelBase
|
|||||||
[MaxLength(4096)] public string Provider { get; set; } = null!;
|
[MaxLength(4096)] public string Provider { get; set; } = null!;
|
||||||
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
|
[MaxLength(8192)] public string ProvidedIdentifier { get; set; } = null!;
|
||||||
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
|
[Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } = new();
|
||||||
|
|
||||||
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
|
[JsonIgnore] [MaxLength(4096)] public string? AccessToken { get; set; }
|
||||||
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
|
[JsonIgnore] [MaxLength(4096)] public string? RefreshToken { get; set; }
|
||||||
public Instant? LastUsedAt { get; set; }
|
public Instant? LastUsedAt { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
public Account Account { get; set; } = null!;
|
public Account Account { get; set; } = null!;
|
||||||
}
|
}
|
@ -1,17 +1,13 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Sphere.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Sphere.Permission;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using NodaTime.Extensions;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/accounts")]
|
[Route("/api/accounts")]
|
||||||
public class AccountController(
|
public class AccountController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AuthService auth,
|
AuthService auth,
|
||||||
@ -33,9 +29,9 @@ public class AccountController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{name}/badges")]
|
[HttpGet("{name}/badges")]
|
||||||
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<List<Badge>>> GetBadgesByName(string name)
|
public async Task<ActionResult<List<AccountBadge>>> GetBadgesByName(string name)
|
||||||
{
|
{
|
||||||
var account = await db.Accounts
|
var account = await db.Accounts
|
||||||
.Include(e => e.Badges)
|
.Include(e => e.Badges)
|
||||||
@ -174,13 +170,4 @@ public class AccountController(
|
|||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("/maintenance/ensureProfileCreated")]
|
|
||||||
[Authorize]
|
|
||||||
[RequiredPermission("maintenance", "accounts.profiles")]
|
|
||||||
public async Task<ActionResult> EnsureProfileCreated()
|
|
||||||
{
|
|
||||||
await accounts.EnsureAccountProfileCreated();
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,24 +1,27 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Sphere.Auth;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Org.BouncyCastle.Utilities;
|
using AuthService = DysonNetwork.Pass.Auth.AuthService;
|
||||||
|
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
|
||||||
|
using ChallengePlatform = DysonNetwork.Pass.Auth.ChallengePlatform;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/accounts/me")]
|
[Route("/api/accounts/me")]
|
||||||
public class AccountCurrentController(
|
public class AccountCurrentController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
FileReferenceService fileRefService,
|
|
||||||
AccountEventService events,
|
AccountEventService events,
|
||||||
AuthService auth
|
AuthService auth,
|
||||||
|
FileService.FileServiceClient files,
|
||||||
|
FileReferenceService.FileReferenceServiceClient fileRefs
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@ -75,7 +78,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;
|
||||||
@ -97,58 +100,37 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
if (request.PictureId is not null)
|
if (request.PictureId is not null)
|
||||||
{
|
{
|
||||||
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
|
var file = await files.GetFileAsync(new GetFileRequest { Id = request.PictureId });
|
||||||
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
|
|
||||||
|
|
||||||
var profileResourceId = $"profile:{profile.Id}";
|
|
||||||
|
|
||||||
// Remove old references for the profile picture
|
|
||||||
if (profile.Picture is not null)
|
if (profile.Picture is not null)
|
||||||
{
|
await fileRefs.DeleteResourceReferencesAsync(
|
||||||
var oldPictureRefs =
|
new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
|
||||||
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.picture");
|
);
|
||||||
foreach (var oldRef in oldPictureRefs)
|
await fileRefs.CreateReferenceAsync(
|
||||||
|
new CreateReferenceRequest
|
||||||
{
|
{
|
||||||
await fileRefService.DeleteReferenceAsync(oldRef.Id);
|
ResourceId = profile.ResourceIdentifier,
|
||||||
|
FileId = request.PictureId,
|
||||||
|
Usage = "profile.picture"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
profile.Picture = picture.ToReferenceObject();
|
|
||||||
|
|
||||||
// Create new reference
|
|
||||||
await fileRefService.CreateReferenceAsync(
|
|
||||||
picture.Id,
|
|
||||||
"profile.picture",
|
|
||||||
profileResourceId
|
|
||||||
);
|
);
|
||||||
|
profile.Picture = CloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.BackgroundId is not null)
|
if (request.BackgroundId is not null)
|
||||||
{
|
{
|
||||||
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
|
var file = await files.GetFileAsync(new GetFileRequest { Id = request.BackgroundId });
|
||||||
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
|
|
||||||
|
|
||||||
var profileResourceId = $"profile:{profile.Id}";
|
|
||||||
|
|
||||||
// Remove old references for the profile background
|
|
||||||
if (profile.Background is not null)
|
if (profile.Background is not null)
|
||||||
{
|
await fileRefs.DeleteResourceReferencesAsync(
|
||||||
var oldBackgroundRefs =
|
new DeleteResourceReferencesRequest { ResourceId = profile.ResourceIdentifier }
|
||||||
await fileRefService.GetResourceReferencesAsync(profileResourceId, "profile.background");
|
);
|
||||||
foreach (var oldRef in oldBackgroundRefs)
|
await fileRefs.CreateReferenceAsync(
|
||||||
|
new CreateReferenceRequest
|
||||||
{
|
{
|
||||||
await fileRefService.DeleteReferenceAsync(oldRef.Id);
|
ResourceId = profile.ResourceIdentifier,
|
||||||
|
FileId = request.BackgroundId,
|
||||||
|
Usage = "profile.background"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
profile.Background = background.ToReferenceObject();
|
|
||||||
|
|
||||||
// Create new reference
|
|
||||||
await fileRefService.CreateReferenceAsync(
|
|
||||||
background.Id,
|
|
||||||
"profile.background",
|
|
||||||
profileResourceId
|
|
||||||
);
|
);
|
||||||
|
profile.Background = CloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Update(profile);
|
db.Update(profile);
|
||||||
@ -438,7 +420,7 @@ public class AccountCurrentController(
|
|||||||
public string UserAgent { get; set; } = null!;
|
public string UserAgent { get; set; } = null!;
|
||||||
public string DeviceId { get; set; } = null!;
|
public string DeviceId { get; set; } = null!;
|
||||||
public ChallengePlatform Platform { get; set; }
|
public ChallengePlatform Platform { get; set; }
|
||||||
public List<Session> Sessions { get; set; } = [];
|
public List<AuthSession> Sessions { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("devices")]
|
[HttpGet("devices")]
|
||||||
@ -446,7 +428,7 @@ public class AccountCurrentController(
|
|||||||
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
|
public async Task<ActionResult<List<AuthorizedDevice>>> GetDevices()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
@ -475,13 +457,13 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpGet("sessions")]
|
[HttpGet("sessions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<Session>>> GetSessions(
|
public async Task<ActionResult<List<AuthSession>>> GetSessions(
|
||||||
[FromQuery] int take = 20,
|
[FromQuery] int take = 20,
|
||||||
[FromQuery] int offset = 0
|
[FromQuery] int offset = 0
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
var query = db.AuthSessions
|
var query = db.AuthSessions
|
||||||
.Include(session => session.Account)
|
.Include(session => session.Account)
|
||||||
@ -503,7 +485,7 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("sessions/{id:guid}")]
|
[HttpDelete("sessions/{id:guid}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<Session>> DeleteSession(Guid id)
|
public async Task<ActionResult<AuthSession>> DeleteSession(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
@ -520,10 +502,10 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpDelete("sessions/current")]
|
[HttpDelete("sessions/current")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<Session>> DeleteCurrentSession()
|
public async Task<ActionResult<AuthSession>> DeleteCurrentSession()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -537,7 +519,7 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("sessions/{id:guid}/label")]
|
[HttpPatch("sessions/{id:guid}/label")]
|
||||||
public async Task<ActionResult<Session>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
public async Task<ActionResult<AuthSession>> UpdateSessionLabel(Guid id, [FromBody] string label)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
@ -553,10 +535,10 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("sessions/current/label")]
|
[HttpPatch("sessions/current/label")]
|
||||||
public async Task<ActionResult<Session>> UpdateCurrentSessionLabel([FromBody] string label)
|
public async Task<ActionResult<AuthSession>> UpdateCurrentSessionLabel([FromBody] string label)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not Session currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -672,9 +654,9 @@ public class AccountCurrentController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("badges")]
|
[HttpGet("badges")]
|
||||||
[ProducesResponseType<List<Badge>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<List<AccountBadge>>(StatusCodes.Status200OK)]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<Badge>>> GetBadges()
|
public async Task<ActionResult<List<AccountBadge>>> GetBadges()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
@ -686,7 +668,7 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpPost("badges/{id:guid}/active")]
|
[HttpPost("badges/{id:guid}/active")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<Badge>> ActivateBadge(Guid id)
|
public async Task<ActionResult<AccountBadge>> ActivateBadge(Guid id)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
@ -1,26 +1,31 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DysonNetwork.Sphere.Activity;
|
using DysonNetwork.Pass.Wallet;
|
||||||
using DysonNetwork.Sphere.Connection;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Sphere.Wallet;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Org.BouncyCastle.Asn1.X509;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class AccountEventService(
|
public class AccountEventService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
WebSocketService ws,
|
|
||||||
ICacheService cache,
|
|
||||||
PaymentService payment,
|
PaymentService payment,
|
||||||
IStringLocalizer<Localization.AccountEventResource> localizer
|
ICacheService cache,
|
||||||
|
IStringLocalizer<Localization.AccountEventResource> localizer,
|
||||||
|
PusherService.PusherServiceClient pusher
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly Random Random = new();
|
private static readonly Random Random = new();
|
||||||
private const string StatusCacheKey = "AccountStatus_";
|
private const string StatusCacheKey = "account:status:";
|
||||||
|
|
||||||
|
private async Task<bool> GetAccountIsConnected(Guid userId)
|
||||||
|
{
|
||||||
|
var resp = await pusher.GetWebsocketConnectionStatusAsync(
|
||||||
|
new GetWebsocketConnectionStatusRequest { UserId = userId.ToString() }
|
||||||
|
);
|
||||||
|
return resp.IsConnected;
|
||||||
|
}
|
||||||
|
|
||||||
public void PurgeStatusCache(Guid userId)
|
public void PurgeStatusCache(Guid userId)
|
||||||
{
|
{
|
||||||
@ -34,7 +39,7 @@ public class AccountEventService(
|
|||||||
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||||
if (cachedStatus is not null)
|
if (cachedStatus is not null)
|
||||||
{
|
{
|
||||||
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && ws.GetAccountIsConnected(userId);
|
cachedStatus!.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
|
||||||
return cachedStatus;
|
return cachedStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +49,7 @@ public class AccountEventService(
|
|||||||
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||||
.OrderByDescending(e => e.CreatedAt)
|
.OrderByDescending(e => e.CreatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
var isOnline = ws.GetAccountIsConnected(userId);
|
var isOnline = false; // TODO: Get connection status
|
||||||
if (status is not null)
|
if (status is not null)
|
||||||
{
|
{
|
||||||
status.IsOnline = !status.IsInvisible && isOnline;
|
status.IsOnline = !status.IsInvisible && isOnline;
|
||||||
@ -75,6 +80,70 @@ public class AccountEventService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds)
|
||||||
|
{
|
||||||
|
var results = new Dictionary<Guid, Status>();
|
||||||
|
var cacheMissUserIds = new List<Guid>();
|
||||||
|
|
||||||
|
foreach (var userId in userIds)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{StatusCacheKey}{userId}";
|
||||||
|
var cachedStatus = await cache.GetAsync<Status>(cacheKey);
|
||||||
|
if (cachedStatus != null)
|
||||||
|
{
|
||||||
|
cachedStatus.IsOnline = !cachedStatus.IsInvisible && await GetAccountIsConnected(userId);
|
||||||
|
results[userId] = cachedStatus;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cacheMissUserIds.Add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheMissUserIds.Count != 0)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
var statusesFromDb = await db.AccountStatuses
|
||||||
|
.Where(e => cacheMissUserIds.Contains(e.AccountId))
|
||||||
|
.Where(e => e.ClearedAt == null || e.ClearedAt > now)
|
||||||
|
.GroupBy(e => e.AccountId)
|
||||||
|
.Select(g => g.OrderByDescending(e => e.CreatedAt).First())
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var foundUserIds = new HashSet<Guid>();
|
||||||
|
|
||||||
|
foreach (var status in statusesFromDb)
|
||||||
|
{
|
||||||
|
var isOnline = await GetAccountIsConnected(status.AccountId);
|
||||||
|
status.IsOnline = !status.IsInvisible && isOnline;
|
||||||
|
results[status.AccountId] = status;
|
||||||
|
var cacheKey = $"{StatusCacheKey}{status.AccountId}";
|
||||||
|
await cache.SetAsync(cacheKey, status, TimeSpan.FromMinutes(5));
|
||||||
|
foundUserIds.Add(status.AccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var usersWithoutStatus = cacheMissUserIds.Except(foundUserIds).ToList();
|
||||||
|
if (usersWithoutStatus.Any())
|
||||||
|
{
|
||||||
|
foreach (var userId in usersWithoutStatus)
|
||||||
|
{
|
||||||
|
var isOnline = await GetAccountIsConnected(userId);
|
||||||
|
var defaultStatus = new Status
|
||||||
|
{
|
||||||
|
Attitude = StatusAttitude.Neutral,
|
||||||
|
IsOnline = isOnline,
|
||||||
|
IsCustomized = false,
|
||||||
|
Label = isOnline ? "Online" : "Offline",
|
||||||
|
AccountId = userId,
|
||||||
|
};
|
||||||
|
results[userId] = defaultStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Status> CreateStatus(Account user, Status status)
|
public async Task<Status> CreateStatus(Account user, Status status)
|
||||||
{
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
@ -134,11 +203,11 @@ public class AccountEventService(
|
|||||||
public async Task<CheckInResult> CheckInDaily(Account user)
|
public async Task<CheckInResult> CheckInDaily(Account user)
|
||||||
{
|
{
|
||||||
var lockKey = $"{CheckInLockKey}{user.Id}";
|
var lockKey = $"{CheckInLockKey}{user.Id}";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
|
var lk = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromMilliseconds(100));
|
||||||
|
|
||||||
if (lk != null)
|
if (lk != null)
|
||||||
await lk.ReleaseAsync();
|
await lk.ReleaseAsync();
|
||||||
}
|
}
|
||||||
@ -146,9 +215,10 @@ public class AccountEventService(
|
|||||||
{
|
{
|
||||||
// Ignore errors from this pre-check
|
// Ignore errors from this pre-check
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now try to acquire the lock properly
|
// Now try to acquire the lock properly
|
||||||
await using var lockObj = await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
|
await using var lockObj =
|
||||||
|
await cache.AcquireLockAsync(lockKey, TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(5));
|
||||||
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
|
if (lockObj is null) throw new InvalidOperationException("Check-in was in progress.");
|
||||||
|
|
||||||
var cultureInfo = new CultureInfo(user.Language, false);
|
var cultureInfo = new CultureInfo(user.Language, false);
|
||||||
@ -210,7 +280,7 @@ public class AccountEventService(
|
|||||||
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
s.SetProperty(b => b.Experience, b => b.Experience + result.RewardExperience)
|
||||||
);
|
);
|
||||||
db.AccountCheckInResults.Add(result);
|
db.AccountCheckInResults.Add(result);
|
||||||
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
await db.SaveChangesAsync(); // Don't forget to save changes to the database
|
||||||
|
|
||||||
// The lock will be automatically released by the await using statement
|
// The lock will be automatically released by the await using statement
|
||||||
return result;
|
return result;
|
@ -1,26 +1,26 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DysonNetwork.Sphere.Auth;
|
using DysonNetwork.Pass.Auth;
|
||||||
using DysonNetwork.Sphere.Auth.OpenId;
|
using DysonNetwork.Pass.Auth.OpenId;
|
||||||
using DysonNetwork.Sphere.Email;
|
using DysonNetwork.Pass.Email;
|
||||||
|
using DysonNetwork.Pass.Localization;
|
||||||
using DysonNetwork.Sphere.Localization;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Sphere.Permission;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Proto;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Org.BouncyCastle.Utilities;
|
|
||||||
using OtpNet;
|
using OtpNet;
|
||||||
|
using AuthSession = DysonNetwork.Pass.Auth.AuthSession;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class AccountService(
|
public class AccountService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
MagicSpellService spells,
|
MagicSpellService spells,
|
||||||
AccountUsernameService uname,
|
AccountUsernameService uname,
|
||||||
NotificationService nty,
|
|
||||||
EmailService mailer,
|
EmailService mailer,
|
||||||
|
PusherService.PusherServiceClient pusher,
|
||||||
IStringLocalizer<NotificationResource> localizer,
|
IStringLocalizer<NotificationResource> localizer,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
ILogger<AccountService> logger
|
ILogger<AccountService> logger
|
||||||
@ -57,6 +57,15 @@ public class AccountService(
|
|||||||
return contact?.Account;
|
return contact?.Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Account?> LookupAccountByConnection(string identifier, string provider)
|
||||||
|
{
|
||||||
|
var connection = await db.AccountConnections
|
||||||
|
.Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
|
||||||
|
.Include(c => c.Account)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
return connection?.Account;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int?> GetAccountLevel(Guid accountId)
|
public async Task<int?> GetAccountLevel(Guid accountId)
|
||||||
{
|
{
|
||||||
var profile = await db.AccountProfiles
|
var profile = await db.AccountProfiles
|
||||||
@ -108,7 +117,7 @@ public class AccountService(
|
|||||||
}.HashSecret()
|
}.HashSecret()
|
||||||
}
|
}
|
||||||
: [],
|
: [],
|
||||||
Profile = new Profile()
|
Profile = new AccountProfile()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isActivated)
|
if (isActivated)
|
||||||
@ -257,6 +266,18 @@ public class AccountService(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case AccountAuthFactorType.PinCode:
|
||||||
|
if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
|
||||||
|
if (!secret.All(char.IsDigit) || secret.Length != 6)
|
||||||
|
throw new ArgumentException("PIN code must be exactly 6 digits");
|
||||||
|
factor = new AccountAuthFactor
|
||||||
|
{
|
||||||
|
Type = AccountAuthFactorType.PinCode,
|
||||||
|
Trustworthy = 0, // Only for confirming, can't be used for login
|
||||||
|
Secret = secret,
|
||||||
|
EnabledAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
|
}.HashSecret();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
throw new ArgumentOutOfRangeException(nameof(type), type, null);
|
||||||
}
|
}
|
||||||
@ -334,13 +355,18 @@ public class AccountService(
|
|||||||
if (await _GetFactorCode(factor) is not null)
|
if (await _GetFactorCode(factor) is not null)
|
||||||
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
throw new InvalidOperationException("A factor code has been sent and in active duration.");
|
||||||
|
|
||||||
await nty.SendNotification(
|
await pusher.SendPushNotificationToUserAsync(
|
||||||
account,
|
new SendPushNotificationToUserRequest
|
||||||
"auth.verification",
|
{
|
||||||
localizer["AuthCodeTitle"],
|
UserId = account.Id.ToString(),
|
||||||
null,
|
Notification = new PushNotification
|
||||||
localizer["AuthCodeBody", code],
|
{
|
||||||
save: true
|
Topic = "auth.verification",
|
||||||
|
Title = localizer["AuthCodeTitle"],
|
||||||
|
Body = localizer["AuthCodeBody", code],
|
||||||
|
IsSavable = false
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
|
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
|
||||||
break;
|
break;
|
||||||
@ -376,16 +402,17 @@ public class AccountService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mailer.SendTemplatedEmailAsync<DysonNetwork.Sphere.Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
await mailer
|
||||||
account.Nick,
|
.SendTemplatedEmailAsync<Pages.Emails.VerificationEmail, VerificationEmailModel>(
|
||||||
contact.Content,
|
account.Nick,
|
||||||
localizer["VerificationEmail"],
|
contact.Content,
|
||||||
new VerificationEmailModel
|
localizer["VerificationEmail"],
|
||||||
{
|
new VerificationEmailModel
|
||||||
Name = account.Name,
|
{
|
||||||
Code = code
|
Name = account.Name,
|
||||||
}
|
Code = code
|
||||||
);
|
}
|
||||||
|
);
|
||||||
|
|
||||||
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
|
await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
|
||||||
break;
|
break;
|
||||||
@ -433,7 +460,7 @@ public class AccountService(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Session> UpdateSessionLabel(Account account, Guid sessionId, string label)
|
public async Task<AuthSession> UpdateSessionLabel(Account account, Guid sessionId, string label)
|
||||||
{
|
{
|
||||||
var session = await db.AuthSessions
|
var session = await db.AuthSessions
|
||||||
.Include(s => s.Challenge)
|
.Include(s => s.Challenge)
|
||||||
@ -470,7 +497,10 @@ public class AccountService(
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
if (session.Challenge.DeviceId is not null)
|
if (session.Challenge.DeviceId is not null)
|
||||||
await nty.UnsubscribePushNotifications(session.Challenge.DeviceId);
|
await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
|
||||||
|
{
|
||||||
|
DeviceId = session.Challenge.DeviceId
|
||||||
|
});
|
||||||
|
|
||||||
// The current session should be included in the sessions' list
|
// The current session should be included in the sessions' list
|
||||||
await db.AuthSessions
|
await db.AuthSessions
|
||||||
@ -553,7 +583,7 @@ public class AccountService(
|
|||||||
/// This method will grant a badge to the account.
|
/// This method will grant a badge to the account.
|
||||||
/// Shouldn't be exposed to normal user and the user itself.
|
/// Shouldn't be exposed to normal user and the user itself.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Badge> GrantBadge(Account account, Badge badge)
|
public async Task<AccountBadge> GrantBadge(Account account, AccountBadge badge)
|
||||||
{
|
{
|
||||||
badge.AccountId = account.Id;
|
badge.AccountId = account.Id;
|
||||||
db.Badges.Add(badge);
|
db.Badges.Add(badge);
|
||||||
@ -629,7 +659,8 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
152
DysonNetwork.Pass/Account/AccountServiceGrpc.cs
Normal file
152
DysonNetwork.Pass/Account/AccountServiceGrpc.cs
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
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,
|
||||||
|
RelationshipService relationships,
|
||||||
|
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));
|
||||||
|
|
||||||
|
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 account.ToProtoValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetAccountBatchResponse> GetAccountBatch(GetAccountBatchRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountIds = request.Id
|
||||||
|
.Select(id => Guid.TryParse(id, out var accountId) ? accountId : (Guid?)null)
|
||||||
|
.Where(id => id.HasValue)
|
||||||
|
.Select(id => id!.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var accounts = await _db.Accounts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => accountIds.Contains(a.Id))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var response = new GetAccountBatchResponse();
|
||||||
|
response.Accounts.AddRange(accounts.Select(a => a.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(x => x.ToProtoValue()));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<ListRelationshipSimpleResponse> ListFriends(
|
||||||
|
ListRelationshipSimpleRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(request.AccountId);
|
||||||
|
var relationship = await relationships.ListAccountFriends(accountId);
|
||||||
|
var resp = new ListRelationshipSimpleResponse();
|
||||||
|
resp.AccountsId.AddRange(relationship.Select(x => x.ToString()));
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<ListRelationshipSimpleResponse> ListBlocked(
|
||||||
|
ListRelationshipSimpleRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(request.AccountId);
|
||||||
|
var relationship = await relationships.ListAccountBlocked(accountId);
|
||||||
|
var resp = new ListRelationshipSimpleResponse();
|
||||||
|
resp.AccountsId.AddRange(relationship.Select(x => x.ToString()));
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<GetRelationshipResponse> GetRelationship(GetRelationshipRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
var relationship = await relationships.GetRelationship(
|
||||||
|
Guid.Parse(request.AccountId),
|
||||||
|
Guid.Parse(request.RelatedId),
|
||||||
|
status: (RelationshipStatus?)request.Status
|
||||||
|
);
|
||||||
|
return new GetRelationshipResponse
|
||||||
|
{
|
||||||
|
Relationship = relationship?.ToProtoValue()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<BoolValue> HasRelationship(GetRelationshipRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var hasRelationship = false;
|
||||||
|
if (!request.HasStatus)
|
||||||
|
hasRelationship = await relationships.HasExistingRelationship(
|
||||||
|
Guid.Parse(request.AccountId),
|
||||||
|
Guid.Parse(request.RelatedId)
|
||||||
|
);
|
||||||
|
else
|
||||||
|
hasRelationship = await relationships.HasRelationshipWithStatus(
|
||||||
|
Guid.Parse(request.AccountId),
|
||||||
|
Guid.Parse(request.RelatedId),
|
||||||
|
(RelationshipStatus)request.Status
|
||||||
|
);
|
||||||
|
return new BoolValue { Value = hasRelationship };
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Service for handling username generation and validation
|
/// Service for handling username generation and validation
|
44
DysonNetwork.Pass/Account/ActionLog.cs
Normal file
44
DysonNetwork.Pass/Account/ActionLog.cs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
public class ActionLog : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(4096)] public string Action { get; set; } = null!;
|
||||||
|
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
|
||||||
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
|
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||||
|
public Point? Location { get; set; }
|
||||||
|
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
public Guid? SessionId { get; set; }
|
||||||
|
|
||||||
|
public Shared.Proto.ActionLog ToProtoValue()
|
||||||
|
{
|
||||||
|
var protoLog = new Shared.Proto.ActionLog
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Action = Action,
|
||||||
|
UserAgent = UserAgent ?? string.Empty,
|
||||||
|
IpAddress = IpAddress ?? string.Empty,
|
||||||
|
Location = Location?.ToString() ?? string.Empty,
|
||||||
|
AccountId = AccountId.ToString(),
|
||||||
|
CreatedAt = CreatedAt.ToTimestamp()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert Meta dictionary to Struct
|
||||||
|
protoLog.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
|
||||||
|
|
||||||
|
if (SessionId.HasValue)
|
||||||
|
protoLog.SessionId = SessionId.Value.ToString();
|
||||||
|
|
||||||
|
return protoLog;
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +1,11 @@
|
|||||||
using Quartz;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Sphere.Connection;
|
using DysonNetwork.Shared.GeoIp;
|
||||||
using DysonNetwork.Sphere.Storage;
|
|
||||||
using DysonNetwork.Sphere.Storage.Handlers;
|
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
|
public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
|
||||||
{
|
{
|
||||||
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object> meta)
|
public void CreateActionLog(Guid accountId, string action, Dictionary<string, object?> meta)
|
||||||
{
|
{
|
||||||
var log = new ActionLog
|
var log = new ActionLog
|
||||||
{
|
{
|
||||||
@ -38,7 +36,7 @@ public class ActionLogService(GeoIpService geo, FlushBufferService fbs)
|
|||||||
else
|
else
|
||||||
throw new ArgumentException("No user context was found");
|
throw new ArgumentException("No user context was found");
|
||||||
|
|
||||||
if (request.HttpContext.Items["CurrentSession"] is Auth.Session currentSession)
|
if (request.HttpContext.Items["CurrentSession"] is Auth.AuthSession currentSession)
|
||||||
log.SessionId = currentSession.Id;
|
log.SessionId = currentSession.Id;
|
||||||
|
|
||||||
fbs.Enqueue(log);
|
fbs.Enqueue(log);
|
114
DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs
Normal file
114
DysonNetwork.Pass/Account/ActionLogServiceGrpc.cs
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
public class ActionLogServiceGrpc : Shared.Proto.ActionLogService.ActionLogServiceBase
|
||||||
|
{
|
||||||
|
private readonly ActionLogService _actionLogService;
|
||||||
|
private readonly AppDatabase _db;
|
||||||
|
private readonly ILogger<ActionLogServiceGrpc> _logger;
|
||||||
|
|
||||||
|
public ActionLogServiceGrpc(
|
||||||
|
ActionLogService actionLogService,
|
||||||
|
AppDatabase db,
|
||||||
|
ILogger<ActionLogServiceGrpc> logger)
|
||||||
|
{
|
||||||
|
_actionLogService = actionLogService ?? throw new ArgumentNullException(nameof(actionLogService));
|
||||||
|
_db = db ?? throw new ArgumentNullException(nameof(db));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<CreateActionLogResponse> CreateActionLog(CreateActionLogRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var meta = request.Meta
|
||||||
|
?.Select(x => new KeyValuePair<string, object?>(x.Key, GrpcTypeHelper.ConvertField(x.Value)))
|
||||||
|
.ToDictionary() ?? new Dictionary<string, object?>();
|
||||||
|
|
||||||
|
_actionLogService.CreateActionLog(
|
||||||
|
accountId,
|
||||||
|
request.Action,
|
||||||
|
meta
|
||||||
|
);
|
||||||
|
|
||||||
|
return new CreateActionLogResponse();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error creating action log");
|
||||||
|
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to create action log"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<ListActionLogsResponse> ListActionLogs(ListActionLogsRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(request.AccountId) || !Guid.TryParse(request.AccountId, out var accountId))
|
||||||
|
{
|
||||||
|
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.InvalidArgument, "Invalid account ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var query = _db.ActionLogs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(log => log.AccountId == accountId);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.Action))
|
||||||
|
{
|
||||||
|
query = query.Where(log => log.Action == request.Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply ordering (default to newest first)
|
||||||
|
query = (request.OrderBy?.ToLower() ?? "createdat desc") switch
|
||||||
|
{
|
||||||
|
"createdat" => query.OrderBy(log => log.CreatedAt),
|
||||||
|
"createdat desc" => query.OrderByDescending(log => log.CreatedAt),
|
||||||
|
_ => query.OrderByDescending(log => log.CreatedAt)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
var pageSize = request.PageSize == 0 ? 50 : Math.Min(request.PageSize, 1000);
|
||||||
|
var logs = await query
|
||||||
|
.Take(pageSize + 1) // Fetch one extra to determine if there are more pages
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var hasMore = logs.Count > pageSize;
|
||||||
|
if (hasMore)
|
||||||
|
{
|
||||||
|
logs.RemoveAt(logs.Count - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = new ListActionLogsResponse
|
||||||
|
{
|
||||||
|
TotalSize = await query.CountAsync()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasMore)
|
||||||
|
{
|
||||||
|
// In a real implementation, you'd generate a proper page token
|
||||||
|
response.NextPageToken = (logs.LastOrDefault()?.CreatedAt ?? SystemClock.Instance.GetCurrentInstant())
|
||||||
|
.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
response.ActionLogs.AddRange(logs.Select(log => log.ToProtoValue()));
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error listing action logs");
|
||||||
|
throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.Internal, "Failed to list action logs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class Badge : ModelBase
|
public class AccountBadge : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string Type { get; set; } = null!;
|
[MaxLength(1024)] public string Type { get; set; } = null!;
|
||||||
@ -32,6 +36,23 @@ public class Badge : ModelBase
|
|||||||
AccountId = AccountId
|
AccountId = AccountId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Shared.Proto.AccountBadge ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Shared.Proto.AccountBadge
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Type = Type,
|
||||||
|
Label = Label ?? string.Empty,
|
||||||
|
Caption = Caption ?? string.Empty,
|
||||||
|
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||||
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
|
AccountId = AccountId.ToString(),
|
||||||
|
};
|
||||||
|
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta));
|
||||||
|
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BadgeReferenceObject : ModelBase
|
public class BadgeReferenceObject : ModelBase
|
||||||
@ -44,4 +65,22 @@ public class BadgeReferenceObject : ModelBase
|
|||||||
public Instant? ActivatedAt { get; set; }
|
public Instant? ActivatedAt { get; set; }
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
|
public Shared.Proto.BadgeReferenceObject ToProtoValue()
|
||||||
|
{
|
||||||
|
var proto = new Shared.Proto.BadgeReferenceObject
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Type = Type,
|
||||||
|
Label = Label ?? string.Empty,
|
||||||
|
Caption = Caption ?? string.Empty,
|
||||||
|
ActivatedAt = ActivatedAt?.ToTimestamp(),
|
||||||
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
|
AccountId = AccountId.ToString()
|
||||||
|
};
|
||||||
|
if (Meta is not null)
|
||||||
|
proto.Meta.Add(GrpcTypeHelper.ConvertToValueMap(Meta!));
|
||||||
|
|
||||||
|
return proto;
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,8 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public enum StatusAttitude
|
public enum StatusAttitude
|
||||||
{
|
{
|
@ -1,10 +1,11 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public enum MagicSpellType
|
public enum MagicSpellType
|
||||||
{
|
{
|
@ -1,9 +1,9 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/spells")]
|
[Route("/api/spells")]
|
||||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("{spellId:guid}/resend")]
|
[HttpPost("{spellId:guid}/resend")]
|
@ -1,23 +1,18 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Sphere.Email;
|
using DysonNetwork.Pass.Permission;
|
||||||
using DysonNetwork.Sphere.Pages.Emails;
|
|
||||||
using DysonNetwork.Sphere.Permission;
|
|
||||||
using DysonNetwork.Sphere.Resources.Localization;
|
|
||||||
using DysonNetwork.Sphere.Resources.Pages.Emails;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using EmailResource = DysonNetwork.Pass.Localization.EmailResource;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class MagicSpellService(
|
public class MagicSpellService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
EmailService email,
|
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<MagicSpellService> logger,
|
ILogger<MagicSpellService> logger,
|
||||||
IStringLocalizer<Localization.EmailResource> localizer
|
IStringLocalizer<EmailResource> localizer
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<MagicSpell> CreateMagicSpell(
|
public async Task<MagicSpell> CreateMagicSpell(
|
||||||
@ -84,61 +79,61 @@ public class MagicSpellService(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
switch (spell.Type)
|
// switch (spell.Type)
|
||||||
{
|
// {
|
||||||
case MagicSpellType.AccountActivation:
|
// case MagicSpellType.AccountActivation:
|
||||||
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
// await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
||||||
contact.Account.Nick,
|
// contact.Account.Nick,
|
||||||
contact.Content,
|
// contact.Content,
|
||||||
localizer["EmailLandingTitle"],
|
// localizer["EmailLandingTitle"],
|
||||||
new LandingEmailModel
|
// new LandingEmailModel
|
||||||
{
|
// {
|
||||||
Name = contact.Account.Name,
|
// Name = contact.Account.Name,
|
||||||
Link = link
|
// Link = link
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
break;
|
// break;
|
||||||
case MagicSpellType.AccountRemoval:
|
// case MagicSpellType.AccountRemoval:
|
||||||
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
// await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||||
contact.Account.Nick,
|
// contact.Account.Nick,
|
||||||
contact.Content,
|
// contact.Content,
|
||||||
localizer["EmailAccountDeletionTitle"],
|
// localizer["EmailAccountDeletionTitle"],
|
||||||
new AccountDeletionEmailModel
|
// new AccountDeletionEmailModel
|
||||||
{
|
// {
|
||||||
Name = contact.Account.Name,
|
// Name = contact.Account.Name,
|
||||||
Link = link
|
// Link = link
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
break;
|
// break;
|
||||||
case MagicSpellType.AuthPasswordReset:
|
// case MagicSpellType.AuthPasswordReset:
|
||||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
// await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||||
contact.Account.Nick,
|
// contact.Account.Nick,
|
||||||
contact.Content,
|
// contact.Content,
|
||||||
localizer["EmailAccountDeletionTitle"],
|
// localizer["EmailAccountDeletionTitle"],
|
||||||
new PasswordResetEmailModel
|
// new PasswordResetEmailModel
|
||||||
{
|
// {
|
||||||
Name = contact.Account.Name,
|
// Name = contact.Account.Name,
|
||||||
Link = link
|
// Link = link
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
break;
|
// break;
|
||||||
case MagicSpellType.ContactVerification:
|
// case MagicSpellType.ContactVerification:
|
||||||
if (spell.Meta["contact_method"] is not string contactMethod)
|
// if (spell.Meta["contact_method"] is not string contactMethod)
|
||||||
throw new InvalidOperationException("Contact method is not found.");
|
// throw new InvalidOperationException("Contact method is not found.");
|
||||||
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
// await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||||
contact.Account.Nick,
|
// contact.Account.Nick,
|
||||||
contactMethod!,
|
// contactMethod!,
|
||||||
localizer["EmailContactVerificationTitle"],
|
// localizer["EmailContactVerificationTitle"],
|
||||||
new ContactVerificationEmailModel
|
// new ContactVerificationEmailModel
|
||||||
{
|
// {
|
||||||
Name = contact.Account.Name,
|
// Name = contact.Account.Name,
|
||||||
Link = link
|
// Link = link
|
||||||
}
|
// }
|
||||||
);
|
// );
|
||||||
break;
|
// break;
|
||||||
default:
|
// default:
|
||||||
throw new ArgumentOutOfRangeException();
|
// throw new ArgumentOutOfRangeException();
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
catch (Exception err)
|
catch (Exception err)
|
||||||
{
|
{
|
@ -1,10 +1,11 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class Notification : ModelBase
|
public class Notification : ModelBase
|
||||||
{
|
{
|
36
DysonNetwork.Pass/Account/Relationship.cs
Normal file
36
DysonNetwork.Pass/Account/Relationship.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
|
public enum RelationshipStatus : short
|
||||||
|
{
|
||||||
|
Friends = 100,
|
||||||
|
Pending = 0,
|
||||||
|
Blocked = -100
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Relationship : ModelBase
|
||||||
|
{
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public Account Account { get; set; } = null!;
|
||||||
|
public Guid RelatedId { get; set; }
|
||||||
|
public Account Related { get; set; } = null!;
|
||||||
|
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
|
||||||
|
public RelationshipStatus Status { get; set; } = RelationshipStatus.Pending;
|
||||||
|
|
||||||
|
public Shared.Proto.Relationship ToProtoValue() => new()
|
||||||
|
{
|
||||||
|
AccountId = AccountId.ToString(),
|
||||||
|
RelatedId = RelatedId.ToString(),
|
||||||
|
Account = Account.ToProtoValue(),
|
||||||
|
Related = Related.ToProtoValue(),
|
||||||
|
Status = (int)Status,
|
||||||
|
CreatedAt = CreatedAt.ToTimestamp(),
|
||||||
|
UpdatedAt = UpdatedAt.ToTimestamp()
|
||||||
|
};
|
||||||
|
}
|
@ -4,10 +4,10 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/relationships")]
|
[Route("/api/relationships")]
|
||||||
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
|
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@ -230,4 +230,24 @@ public class RelationshipController(AppDatabase db, RelationshipService rels) :
|
|||||||
return BadRequest(err.Message);
|
return BadRequest(err.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{userId:guid}/block")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<Relationship>> UnblockUser(Guid userId)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var relatedUser = await db.Accounts.FindAsync(userId);
|
||||||
|
if (relatedUser is null) return NotFound("Account was not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var relationship = await rels.UnblockAccount(currentUser, relatedUser);
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,12 +1,13 @@
|
|||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Account;
|
namespace DysonNetwork.Pass.Account;
|
||||||
|
|
||||||
public class RelationshipService(AppDatabase db, ICacheService cache)
|
public class RelationshipService(AppDatabase db, ICacheService cache)
|
||||||
{
|
{
|
||||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||||
|
private const string UserBlockedCacheKeyPrefix = "accounts:blocked:";
|
||||||
|
|
||||||
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
@ -50,9 +51,8 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
|
|
||||||
db.AccountRelationships.Add(relationship);
|
db.AccountRelationships.Add(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}");
|
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
|
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
@ -63,6 +63,18 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
return await UpdateRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||||
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
return await CreateRelationship(sender, target, RelationshipStatus.Blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Relationship> UnblockAccount(Account sender, Account target)
|
||||||
|
{
|
||||||
|
var relationship = await GetRelationship(sender.Id, target.Id, RelationshipStatus.Blocked);
|
||||||
|
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
|
||||||
|
db.Remove(relationship);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await PurgeRelationshipCache(sender.Id, target.Id);
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
|
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
|
||||||
{
|
{
|
||||||
@ -92,8 +104,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
.Where(r => r.AccountId == accountId && r.RelatedId == relatedId && r.Status == RelationshipStatus.Pending)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Relationship> AcceptFriendRelationship(
|
public async Task<Relationship> AcceptFriendRelationship(
|
||||||
@ -122,8 +133,7 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.AccountId}");
|
await PurgeRelationshipCache(relationship.AccountId, relationship.RelatedId);
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relationship.RelatedId}");
|
|
||||||
|
|
||||||
return relationshipBackward;
|
return relationshipBackward;
|
||||||
}
|
}
|
||||||
@ -137,21 +147,25 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
db.Update(relationship);
|
db.Update(relationship);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
await PurgeRelationshipCache(accountId, relatedId);
|
||||||
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
|
||||||
|
|
||||||
return relationship;
|
return relationship;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Guid>> ListAccountFriends(Account account)
|
public async Task<List<Guid>> ListAccountFriends(Account account)
|
||||||
{
|
{
|
||||||
string cacheKey = $"{UserFriendsCacheKeyPrefix}{account.Id}";
|
return await ListAccountFriends(account.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Guid>> ListAccountFriends(Guid accountId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{UserFriendsCacheKeyPrefix}{accountId}";
|
||||||
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
var friends = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
if (friends == null)
|
if (friends == null)
|
||||||
{
|
{
|
||||||
friends = await db.AccountRelationships
|
friends = await db.AccountRelationships
|
||||||
.Where(r => r.RelatedId == account.Id)
|
.Where(r => r.RelatedId == accountId)
|
||||||
.Where(r => r.Status == RelationshipStatus.Friends)
|
.Where(r => r.Status == RelationshipStatus.Friends)
|
||||||
.Select(r => r.AccountId)
|
.Select(r => r.AccountId)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@ -161,6 +175,30 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
|
|
||||||
return friends ?? [];
|
return friends ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Guid>> ListAccountBlocked(Account account)
|
||||||
|
{
|
||||||
|
return await ListAccountBlocked(account.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Guid>> ListAccountBlocked(Guid accountId)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{UserBlockedCacheKeyPrefix}{accountId}";
|
||||||
|
var blocked = await cache.GetAsync<List<Guid>>(cacheKey);
|
||||||
|
|
||||||
|
if (blocked == null)
|
||||||
|
{
|
||||||
|
blocked = await db.AccountRelationships
|
||||||
|
.Where(r => r.RelatedId == accountId)
|
||||||
|
.Where(r => r.Status == RelationshipStatus.Blocked)
|
||||||
|
.Select(r => r.AccountId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
await cache.SetAsync(cacheKey, blocked, TimeSpan.FromHours(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocked ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
public async Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
|
||||||
RelationshipStatus status = RelationshipStatus.Friends)
|
RelationshipStatus status = RelationshipStatus.Friends)
|
||||||
@ -168,4 +206,12 @@ public class RelationshipService(AppDatabase db, ICacheService cache)
|
|||||||
var relationship = await GetRelationship(accountId, relatedId, status);
|
var relationship = await GetRelationship(accountId, relatedId, status);
|
||||||
return relationship is not null;
|
return relationship is not null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PurgeRelationshipCache(Guid accountId, Guid relatedId)
|
||||||
|
{
|
||||||
|
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{accountId}");
|
||||||
|
await cache.RemoveAsync($"{UserFriendsCacheKeyPrefix}{relatedId}");
|
||||||
|
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{accountId}");
|
||||||
|
await cache.RemoveAsync($"{UserBlockedCacheKeyPrefix}{relatedId}");
|
||||||
|
}
|
||||||
}
|
}
|
276
DysonNetwork.Pass/AppDatabase.cs
Normal file
276
DysonNetwork.Pass/AppDatabase.cs
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Reflection;
|
||||||
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Pass.Auth;
|
||||||
|
using DysonNetwork.Pass.Developer;
|
||||||
|
using DysonNetwork.Pass.Permission;
|
||||||
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
|
using NodaTime;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass;
|
||||||
|
|
||||||
|
public class AppDatabase(
|
||||||
|
DbContextOptions<AppDatabase> options,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : DbContext(options)
|
||||||
|
{
|
||||||
|
public DbSet<PermissionNode> PermissionNodes { get; set; }
|
||||||
|
public DbSet<PermissionGroup> PermissionGroups { get; set; }
|
||||||
|
public DbSet<PermissionGroupMember> PermissionGroupMembers { get; set; }
|
||||||
|
|
||||||
|
public DbSet<MagicSpell> MagicSpells { get; set; }
|
||||||
|
public DbSet<Account.Account> Accounts { get; set; }
|
||||||
|
public DbSet<AccountConnection> AccountConnections { get; set; }
|
||||||
|
public DbSet<AccountProfile> AccountProfiles { get; set; }
|
||||||
|
public DbSet<AccountContact> AccountContacts { get; set; }
|
||||||
|
public DbSet<AccountAuthFactor> AccountAuthFactors { get; set; }
|
||||||
|
public DbSet<Relationship> AccountRelationships { get; set; }
|
||||||
|
public DbSet<Status> AccountStatuses { get; set; }
|
||||||
|
public DbSet<CheckInResult> AccountCheckInResults { get; set; }
|
||||||
|
public DbSet<Notification> Notifications { get; set; }
|
||||||
|
public DbSet<NotificationPushSubscription> NotificationPushSubscriptions { get; set; }
|
||||||
|
public DbSet<AccountBadge> Badges { get; set; }
|
||||||
|
public DbSet<ActionLog> ActionLogs { get; set; }
|
||||||
|
public DbSet<AbuseReport> AbuseReports { get; set; }
|
||||||
|
|
||||||
|
public DbSet<AuthSession> AuthSessions { get; set; }
|
||||||
|
public DbSet<AuthChallenge> AuthChallenges { get; set; }
|
||||||
|
|
||||||
|
public DbSet<Wallet.Wallet> Wallets { get; set; }
|
||||||
|
public DbSet<WalletPocket> WalletPockets { get; set; }
|
||||||
|
public DbSet<Order> PaymentOrders { get; set; }
|
||||||
|
public DbSet<Transaction> PaymentTransactions { get; set; }
|
||||||
|
public DbSet<Subscription> WalletSubscriptions { get; set; }
|
||||||
|
public DbSet<Coupon> WalletCoupons { get; set; }
|
||||||
|
|
||||||
|
public DbSet<CustomApp> CustomApps { get; set; }
|
||||||
|
public DbSet<CustomAppSecret> CustomAppSecrets { get; set; }
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
optionsBuilder.UseNpgsql(
|
||||||
|
configuration.GetConnectionString("App"),
|
||||||
|
opt => opt
|
||||||
|
.ConfigureDataSource(optSource => optSource.EnableDynamicJson())
|
||||||
|
.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
|
||||||
|
.UseNetTopologySuite()
|
||||||
|
.UseNodaTime()
|
||||||
|
).UseSnakeCaseNamingConvention();
|
||||||
|
|
||||||
|
optionsBuilder.UseAsyncSeeding(async (context, _, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var defaultPermissionGroup = await context.Set<PermissionGroup>()
|
||||||
|
.FirstOrDefaultAsync(g => g.Key == "default", cancellationToken);
|
||||||
|
if (defaultPermissionGroup is null)
|
||||||
|
{
|
||||||
|
context.Set<PermissionGroup>().Add(new PermissionGroup
|
||||||
|
{
|
||||||
|
Key = "default",
|
||||||
|
Nodes = new List<string>
|
||||||
|
{
|
||||||
|
"posts.create",
|
||||||
|
"posts.react",
|
||||||
|
"publishers.create",
|
||||||
|
"files.create",
|
||||||
|
"chat.create",
|
||||||
|
"chat.messages.create",
|
||||||
|
"chat.realtime.create",
|
||||||
|
"accounts.statuses.create",
|
||||||
|
"accounts.statuses.update",
|
||||||
|
"stickers.packs.create",
|
||||||
|
"stickers.create"
|
||||||
|
}.Select(permission =>
|
||||||
|
PermissionService.NewPermissionNode("group:default", "global", permission, true))
|
||||||
|
.ToList()
|
||||||
|
});
|
||||||
|
await context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
optionsBuilder.UseSeeding((context, _) => {});
|
||||||
|
|
||||||
|
base.OnConfiguring(optionsBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PermissionGroupMember>()
|
||||||
|
.HasKey(pg => new { pg.GroupId, pg.Actor });
|
||||||
|
modelBuilder.Entity<PermissionGroupMember>()
|
||||||
|
.HasOne(pg => pg.Group)
|
||||||
|
.WithMany(g => g.Members)
|
||||||
|
.HasForeignKey(pg => pg.GroupId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<Relationship>()
|
||||||
|
.HasKey(r => new { FromAccountId = r.AccountId, ToAccountId = r.RelatedId });
|
||||||
|
modelBuilder.Entity<Relationship>()
|
||||||
|
.HasOne(r => r.Account)
|
||||||
|
.WithMany(a => a.OutgoingRelationships)
|
||||||
|
.HasForeignKey(r => r.AccountId);
|
||||||
|
modelBuilder.Entity<Relationship>()
|
||||||
|
.HasOne(r => r.Related)
|
||||||
|
.WithMany(a => a.IncomingRelationships)
|
||||||
|
.HasForeignKey(r => r.RelatedId);
|
||||||
|
|
||||||
|
// Automatically apply soft-delete filter to all entities inheriting BaseModel
|
||||||
|
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||||
|
{
|
||||||
|
if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
|
||||||
|
var method = typeof(AppDatabase)
|
||||||
|
.GetMethod(nameof(SetSoftDeleteFilter),
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static)!
|
||||||
|
.MakeGenericMethod(entityType.ClrType);
|
||||||
|
|
||||||
|
method.Invoke(null, [modelBuilder]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
|
||||||
|
where TEntity : ModelBase
|
||||||
|
{
|
||||||
|
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<ModelBase>())
|
||||||
|
{
|
||||||
|
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 AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
logger.LogInformation("Cleaning up expired records...");
|
||||||
|
|
||||||
|
// Expired relationships
|
||||||
|
var affectedRows = await db.AccountRelationships
|
||||||
|
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
logger.LogDebug("Removed {Count} records of expired relationships.", affectedRows);
|
||||||
|
// Expired permission group members
|
||||||
|
affectedRows = await db.PermissionGroupMembers
|
||||||
|
.Where(x => x.ExpiredAt != null && x.ExpiredAt <= now)
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
logger.LogDebug("Removed {Count} records of expired permission group members.", affectedRows);
|
||||||
|
|
||||||
|
logger.LogInformation("Deleting soft-deleted records...");
|
||||||
|
|
||||||
|
var threshold = now - Duration.FromDays(7);
|
||||||
|
|
||||||
|
var entityTypes = db.Model.GetEntityTypes()
|
||||||
|
.Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
|
||||||
|
.Select(t => t.ClrType);
|
||||||
|
|
||||||
|
foreach (var entityType in entityTypes)
|
||||||
|
{
|
||||||
|
var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
|
||||||
|
.MakeGenericMethod(entityType).Invoke(db, null)!;
|
||||||
|
var parameter = Expression.Parameter(entityType, "e");
|
||||||
|
var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
|
||||||
|
var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
|
||||||
|
var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
|
||||||
|
var finalCondition = Expression.AndAlso(notNull, condition);
|
||||||
|
var lambda = Expression.Lambda(finalCondition, parameter);
|
||||||
|
|
||||||
|
var queryable = set.Provider.CreateQuery(
|
||||||
|
Expression.Call(
|
||||||
|
typeof(Queryable),
|
||||||
|
"Where",
|
||||||
|
[entityType],
|
||||||
|
set.Expression,
|
||||||
|
Expression.Quote(lambda)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
var toListAsync = typeof(EntityFrameworkQueryableExtensions)
|
||||||
|
.GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
|
||||||
|
.MakeGenericMethod(entityType);
|
||||||
|
|
||||||
|
var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
|
||||||
|
db.RemoveRange(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>();
|
||||||
|
return new AppDatabase(optionsBuilder.Options, configuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OptionalQueryExtensions
|
||||||
|
{
|
||||||
|
public static IQueryable<T> If<T>(
|
||||||
|
this IQueryable<T> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IQueryable<T>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<T> If<T, TP>(
|
||||||
|
this IIncludableQueryable<T, TP> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IQueryable<T> If<T, TP>(
|
||||||
|
this IIncludableQueryable<T, IEnumerable<TP>> source,
|
||||||
|
bool condition,
|
||||||
|
Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
|
||||||
|
)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
return condition ? transform(source) : source;
|
||||||
|
}
|
||||||
|
}
|
@ -1,26 +1,29 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Sphere.Storage;
|
|
||||||
using DysonNetwork.Sphere.Storage.Handlers;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||||
|
using DysonNetwork.Pass.Handlers;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using SystemClock = NodaTime.SystemClock;
|
using SystemClock = NodaTime.SystemClock;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public static class AuthConstants
|
public static class AuthConstants
|
||||||
{
|
{
|
||||||
public const string SchemeName = "DysonToken";
|
public const string SchemeName = "DysonToken";
|
||||||
public const string TokenQueryParamName = "tk";
|
public const string TokenQueryParamName = "tk";
|
||||||
|
public const string CookieTokenName = "AuthToken";
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TokenType
|
public enum TokenType
|
||||||
{
|
{
|
||||||
AuthKey,
|
AuthKey,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
|
OidcKey,
|
||||||
Unknown
|
Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,13 +41,14 @@ public class DysonTokenAuthHandler(
|
|||||||
ILoggerFactory logger,
|
ILoggerFactory logger,
|
||||||
UrlEncoder encoder,
|
UrlEncoder encoder,
|
||||||
AppDatabase database,
|
AppDatabase database,
|
||||||
|
OidcProviderService oidc,
|
||||||
ICacheService cache,
|
ICacheService cache,
|
||||||
FlushBufferService fbs
|
FlushBufferService fbs
|
||||||
)
|
)
|
||||||
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
: AuthenticationHandler<DysonTokenAuthOptions>(options, logger, encoder)
|
||||||
{
|
{
|
||||||
public const string AuthCachePrefix = "auth:";
|
public const string AuthCachePrefix = "auth:";
|
||||||
|
|
||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
var tokenInfo = _ExtractToken(Request);
|
var tokenInfo = _ExtractToken(Request);
|
||||||
@ -61,7 +65,7 @@ public class DysonTokenAuthHandler(
|
|||||||
return AuthenticateResult.Fail("Invalid token.");
|
return AuthenticateResult.Fail("Invalid token.");
|
||||||
|
|
||||||
// Try to get session from cache first
|
// Try to get session from cache first
|
||||||
var session = await cache.GetAsync<Session>($"{AuthCachePrefix}{sessionId}");
|
var session = await cache.GetAsync<AuthSession>($"{AuthCachePrefix}{sessionId}");
|
||||||
|
|
||||||
// If not in cache, load from database
|
// If not in cache, load from database
|
||||||
if (session is null)
|
if (session is null)
|
||||||
@ -126,7 +130,7 @@ public class DysonTokenAuthHandler(
|
|||||||
SeenAt = SystemClock.Instance.GetCurrentInstant(),
|
SeenAt = SystemClock.Instance.GetCurrentInstant(),
|
||||||
};
|
};
|
||||||
fbs.Enqueue(lastInfo);
|
fbs.Enqueue(lastInfo);
|
||||||
|
|
||||||
return AuthenticateResult.Success(ticket);
|
return AuthenticateResult.Success(ticket);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@ -141,35 +145,60 @@ public class DysonTokenAuthHandler(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Split the token
|
|
||||||
var parts = token.Split('.');
|
var parts = token.Split('.');
|
||||||
if (parts.Length != 2)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
// Decode the payload
|
switch (parts.Length)
|
||||||
var payloadBytes = Base64UrlDecode(parts[0]);
|
{
|
||||||
|
// Handle JWT tokens (3 parts)
|
||||||
|
case 3:
|
||||||
|
{
|
||||||
|
var (isValid, jwtResult) = oidc.ValidateToken(token);
|
||||||
|
if (!isValid) return false;
|
||||||
|
var jti = jwtResult?.Claims.FirstOrDefault(c => c.Type == "jti")?.Value;
|
||||||
|
if (jti is null) return false;
|
||||||
|
|
||||||
// Extract session ID
|
return Guid.TryParse(jti, out sessionId);
|
||||||
sessionId = new Guid(payloadBytes);
|
}
|
||||||
|
// Handle compact tokens (2 parts)
|
||||||
|
case 2:
|
||||||
|
// Original compact token validation logic
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Decode the payload
|
||||||
|
var payloadBytes = Base64UrlDecode(parts[0]);
|
||||||
|
|
||||||
// Load public key for verification
|
// Extract session ID
|
||||||
var publicKeyPem = File.ReadAllText(configuration["Jwt:PublicKeyPath"]!);
|
sessionId = new Guid(payloadBytes);
|
||||||
using var rsa = RSA.Create();
|
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
|
||||||
|
|
||||||
// Verify signature
|
// Load public key for verification
|
||||||
var signature = Base64UrlDecode(parts[1]);
|
var publicKeyPem = File.ReadAllText(configuration["AuthToken:PublicKeyPath"]!);
|
||||||
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
using var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
var signature = Base64UrlDecode(parts[1]);
|
||||||
|
return rsa.VerifyData(payloadBytes, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
Logger.LogWarning(ex, "Token validation failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] Base64UrlDecode(string base64Url)
|
private static byte[] Base64UrlDecode(string base64Url)
|
||||||
{
|
{
|
||||||
string padded = base64Url
|
var padded = base64Url
|
||||||
.Replace('-', '+')
|
.Replace('-', '+')
|
||||||
.Replace('_', '/');
|
.Replace('_', '/');
|
||||||
|
|
||||||
@ -182,7 +211,7 @@ public class DysonTokenAuthHandler(
|
|||||||
return Convert.FromBase64String(padded);
|
return Convert.FromBase64String(padded);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TokenInfo? _ExtractToken(HttpRequest request)
|
private TokenInfo? _ExtractToken(HttpRequest request)
|
||||||
{
|
{
|
||||||
// Check for token in query parameters
|
// Check for token in query parameters
|
||||||
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
|
if (request.Query.TryGetValue(AuthConstants.TokenQueryParamName, out var queryToken))
|
||||||
@ -194,20 +223,23 @@ public class DysonTokenAuthHandler(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Check for token in Authorization header
|
// Check for token in Authorization header
|
||||||
var authHeader = request.Headers.Authorization.ToString();
|
var authHeader = request.Headers.Authorization.ToString();
|
||||||
if (!string.IsNullOrEmpty(authHeader))
|
if (!string.IsNullOrEmpty(authHeader))
|
||||||
{
|
{
|
||||||
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
var parts = token.Split('.');
|
||||||
|
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
Token = authHeader["Bearer ".Length..].Trim(),
|
Token = token,
|
||||||
Type = TokenType.AuthKey
|
Type = parts.Length == 3 ? TokenType.OidcKey : TokenType.AuthKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
||||||
if (authHeader.StartsWith("AtField ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
@ -215,8 +247,7 @@ public class DysonTokenAuthHandler(
|
|||||||
Type = TokenType.AuthKey
|
Type = TokenType.AuthKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
||||||
if (authHeader.StartsWith("AkField ", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
@ -227,15 +258,16 @@ public class DysonTokenAuthHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for token in cookies
|
// Check for token in cookies
|
||||||
if (request.Cookies.TryGetValue(AuthConstants.TokenQueryParamName, out var cookieToken))
|
if (request.Cookies.TryGetValue(AuthConstants.CookieTokenName, out var cookieToken))
|
||||||
{
|
{
|
||||||
return new TokenInfo
|
return new TokenInfo
|
||||||
{
|
{
|
||||||
Token = cookieToken,
|
Token = cookieToken,
|
||||||
Type = TokenType.AuthKey
|
Type = cookieToken.Count(c => c == '.') == 2 ? TokenType.OidcKey : TokenType.AuthKey
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,17 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using DysonNetwork.Sphere.Account;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using DysonNetwork.Pass.Account;
|
||||||
using System.Text.Json;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Sphere.Connection;
|
using DysonNetwork.Shared.GeoIp;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/auth")]
|
[Route("/api/auth")]
|
||||||
public class AuthController(
|
public class AuthController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
AccountService accounts,
|
AccountService accounts,
|
||||||
@ -30,7 +28,7 @@ public class AuthController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("challenge")]
|
[HttpPost("challenge")]
|
||||||
public async Task<ActionResult<Challenge>> StartChallenge([FromBody] ChallengeRequest request)
|
public async Task<ActionResult<AuthChallenge>> StartChallenge([FromBody] ChallengeRequest request)
|
||||||
{
|
{
|
||||||
var account = await accounts.LookupAccount(request.Account);
|
var account = await accounts.LookupAccount(request.Account);
|
||||||
if (account is null) return NotFound("Account was not found.");
|
if (account is null) return NotFound("Account was not found.");
|
||||||
@ -50,7 +48,7 @@ public class AuthController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
if (existingChallenge is not null) return existingChallenge;
|
if (existingChallenge is not null) return existingChallenge;
|
||||||
|
|
||||||
var challenge = new Challenge
|
var challenge = new AuthChallenge
|
||||||
{
|
{
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddHours(1)),
|
||||||
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
StepTotal = await auth.DetectChallengeRisk(Request, account),
|
||||||
@ -75,7 +73,7 @@ public class AuthController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("challenge/{id:guid}")]
|
[HttpGet("challenge/{id:guid}")]
|
||||||
public async Task<ActionResult<Challenge>> GetChallenge([FromRoute] Guid id)
|
public async Task<ActionResult<AuthChallenge>> GetChallenge([FromRoute] Guid id)
|
||||||
{
|
{
|
||||||
var challenge = await db.AuthChallenges
|
var challenge = await db.AuthChallenges
|
||||||
.Include(e => e.Account)
|
.Include(e => e.Account)
|
||||||
@ -97,7 +95,7 @@ public class AuthController(
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
return challenge is null
|
return challenge is null
|
||||||
? NotFound("Auth challenge was not found.")
|
? NotFound("Auth challenge was not found.")
|
||||||
: challenge.Account.AuthFactors.Where(e => e.EnabledAt != null).ToList();
|
: challenge.Account.AuthFactors.Where(e => e is { EnabledAt: not null, Trustworthy: >= 1 }).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
|
||||||
@ -135,7 +133,7 @@ public class AuthController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("challenge/{id:guid}")]
|
[HttpPatch("challenge/{id:guid}")]
|
||||||
public async Task<ActionResult<Challenge>> DoChallenge(
|
public async Task<ActionResult<AuthChallenge>> DoChallenge(
|
||||||
[FromRoute] Guid id,
|
[FromRoute] Guid id,
|
||||||
[FromBody] PerformChallengeRequest request
|
[FromBody] PerformChallengeRequest request
|
||||||
)
|
)
|
||||||
@ -239,7 +237,7 @@ public class AuthController(
|
|||||||
if (session is not null)
|
if (session is not null)
|
||||||
return BadRequest("Session already exists for this challenge.");
|
return BadRequest("Session already exists for this challenge.");
|
||||||
|
|
||||||
session = new Session
|
session = new AuthSession
|
||||||
{
|
{
|
||||||
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
LastGrantedAt = Instant.FromDateTimeUtc(DateTime.UtcNow),
|
||||||
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(30)),
|
@ -1,11 +1,19 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFactory httpClientFactory, IHttpContextAccessor httpContextAccessor)
|
public class AuthService(
|
||||||
|
AppDatabase db,
|
||||||
|
IConfiguration config,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
ICacheService cache
|
||||||
|
)
|
||||||
{
|
{
|
||||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||||
|
|
||||||
@ -65,24 +73,25 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
|
|||||||
return totalRequiredSteps;
|
return totalRequiredSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Session> CreateSessionAsync(Account.Account account, Instant time)
|
public async Task<AuthSession> CreateSessionForOidcAsync(Account.Account account, Instant time, Guid? customAppId = null)
|
||||||
{
|
{
|
||||||
var challenge = new Challenge
|
var challenge = new AuthChallenge
|
||||||
{
|
{
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||||
StepRemain = 1,
|
StepRemain = 1,
|
||||||
StepTotal = 1,
|
StepTotal = 1,
|
||||||
Type = ChallengeType.Oidc
|
Type = customAppId is not null ? ChallengeType.OAuth : ChallengeType.Oidc
|
||||||
};
|
};
|
||||||
|
|
||||||
var session = new Session
|
var session = new AuthSession
|
||||||
{
|
{
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
CreatedAt = time,
|
CreatedAt = time,
|
||||||
LastGrantedAt = time,
|
LastGrantedAt = time,
|
||||||
Challenge = challenge
|
Challenge = challenge,
|
||||||
|
AppId = customAppId
|
||||||
};
|
};
|
||||||
|
|
||||||
db.AuthChallenges.Add(challenge);
|
db.AuthChallenges.Add(challenge);
|
||||||
@ -123,7 +132,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
|
|||||||
case "google":
|
case "google":
|
||||||
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
content = new StringContent($"secret={apiSecret}&response={token}", System.Text.Encoding.UTF8,
|
||||||
"application/x-www-form-urlencoded");
|
"application/x-www-form-urlencoded");
|
||||||
response = await client.PostAsync("https://www.google.com/recaptcha/api/siteverify", content);
|
response = await client.PostAsync("https://www.google.com/recaptcha/siteverify", content);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
json = await response.Content.ReadAsStringAsync();
|
json = await response.Content.ReadAsStringAsync();
|
||||||
@ -145,10 +154,10 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string CreateToken(Session session)
|
public string CreateToken(AuthSession session)
|
||||||
{
|
{
|
||||||
// Load the private key for signing
|
// Load the private key for signing
|
||||||
var privateKeyPem = File.ReadAllText(config["Jwt:PrivateKeyPath"]!);
|
var privateKeyPem = File.ReadAllText(config["AuthToken:PrivateKeyPath"]!);
|
||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(privateKeyPem);
|
rsa.ImportFromPem(privateKeyPem);
|
||||||
|
|
||||||
@ -174,6 +183,69 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
|
|||||||
return $"{payloadBase64}.{signatureBase64}";
|
return $"{payloadBase64}.{signatureBase64}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateSudoMode(AuthSession session, string? pinCode)
|
||||||
|
{
|
||||||
|
// Check if the session is already in sudo mode (cached)
|
||||||
|
var sudoModeKey = $"accounts:{session.Id}:sudo";
|
||||||
|
var (found, _) = await cache.GetAsyncWithStatus<bool>(sudoModeKey);
|
||||||
|
|
||||||
|
if (found)
|
||||||
|
{
|
||||||
|
// Session is already in sudo mode
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has a pin code
|
||||||
|
var hasPinCode = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == session.AccountId)
|
||||||
|
.Where(f => f.EnabledAt != null)
|
||||||
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
||||||
|
.AnyAsync();
|
||||||
|
|
||||||
|
if (!hasPinCode)
|
||||||
|
{
|
||||||
|
// User doesn't have a pin code, no validation needed
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If pin code is not provided, we can't validate
|
||||||
|
if (string.IsNullOrEmpty(pinCode))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Validate the pin code
|
||||||
|
var isValid = await ValidatePinCode(session.AccountId, pinCode);
|
||||||
|
|
||||||
|
if (isValid)
|
||||||
|
{
|
||||||
|
// Set session in sudo mode for 5 minutes
|
||||||
|
await cache.SetAsync(sudoModeKey, true, TimeSpan.FromMinutes(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
// No pin code enabled for this account, so validation is successful
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidatePinCode(Guid accountId, string pinCode)
|
||||||
|
{
|
||||||
|
var factor = await db.AccountAuthFactors
|
||||||
|
.Where(f => f.AccountId == accountId)
|
||||||
|
.Where(f => f.EnabledAt != null)
|
||||||
|
.Where(f => f.Type == AccountAuthFactorType.PinCode)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (factor is null) throw new InvalidOperationException("No pin code enabled for this account.");
|
||||||
|
|
||||||
|
return factor.VerifyPassword(pinCode);
|
||||||
|
}
|
||||||
|
|
||||||
public bool ValidateToken(string token, out Guid sessionId)
|
public bool ValidateToken(string token, out Guid sessionId)
|
||||||
{
|
{
|
||||||
sessionId = Guid.Empty;
|
sessionId = Guid.Empty;
|
||||||
@ -192,7 +264,7 @@ public class AuthService(AppDatabase db, IConfiguration config, IHttpClientFacto
|
|||||||
sessionId = new Guid(payloadBytes);
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
// Load public key for verification
|
// Load public key for verification
|
||||||
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
62
DysonNetwork.Pass/Auth/AuthServiceGrpc.cs
Normal file
62
DysonNetwork.Pass/Auth/AuthServiceGrpc.cs
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
|
public class AuthServiceGrpc(
|
||||||
|
AuthService authService,
|
||||||
|
ICacheService cache,
|
||||||
|
AppDatabase db
|
||||||
|
)
|
||||||
|
: Shared.Proto.AuthService.AuthServiceBase
|
||||||
|
{
|
||||||
|
public override async Task<AuthenticateResponse> Authenticate(
|
||||||
|
AuthenticateRequest request,
|
||||||
|
ServerCallContext context
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!authService.ValidateToken(request.Token, out var sessionId))
|
||||||
|
return new AuthenticateResponse { Valid = false, Message = "Invalid token." };
|
||||||
|
|
||||||
|
var session = await cache.GetAsync<AuthSession>($"{DysonTokenAuthHandler.AuthCachePrefix}{sessionId}");
|
||||||
|
if (session is not null)
|
||||||
|
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
||||||
|
|
||||||
|
session = await db.AuthSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(e => e.Challenge)
|
||||||
|
.Include(e => e.Account)
|
||||||
|
.ThenInclude(e => e.Profile)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
|
if (session == null)
|
||||||
|
return new AuthenticateResponse { Valid = false, Message = "Session was not found." };
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (session.ExpiredAt.HasValue && session.ExpiredAt < now)
|
||||||
|
return new AuthenticateResponse { Valid = false, Message = "Session has been expired." };
|
||||||
|
|
||||||
|
await cache.SetWithGroupsAsync(
|
||||||
|
$"auth:{sessionId}",
|
||||||
|
session,
|
||||||
|
[$"{Account.AccountService.AccountCachePrefix}{session.Account.Id}"],
|
||||||
|
TimeSpan.FromHours(1)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new AuthenticateResponse { Valid = true, Session = session.ToProtoValue() };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<ValidateResponse> ValidatePin(ValidatePinRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var accountId = Guid.Parse(request.AccountId);
|
||||||
|
var valid = await authService.ValidatePinCode(accountId, request.Pin);
|
||||||
|
return new ValidateResponse { Valid = valid };
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<ValidateResponse> ValidateCaptcha(ValidateCaptchaRequest request, ServerCallContext context)
|
||||||
|
{
|
||||||
|
var valid = await authService.ValidateCaptcha(request.Token);
|
||||||
|
return new ValidateResponse { Valid = valid };
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public class CaptchaVerificationResponse
|
public class CaptchaVerificationResponse
|
||||||
{
|
{
|
@ -1,13 +1,13 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public class CompactTokenService(IConfiguration config)
|
public class CompactTokenService(IConfiguration config)
|
||||||
{
|
{
|
||||||
private readonly string _privateKeyPath = config["Jwt:PrivateKeyPath"]
|
private readonly string _privateKeyPath = config["AuthToken:PrivateKeyPath"]
|
||||||
?? throw new InvalidOperationException("Jwt:PrivateKeyPath configuration is missing");
|
?? throw new InvalidOperationException("AuthToken:PrivateKeyPath configuration is missing");
|
||||||
|
|
||||||
public string CreateToken(Session session)
|
public string CreateToken(AuthSession session)
|
||||||
{
|
{
|
||||||
// Load the private key for signing
|
// Load the private key for signing
|
||||||
var privateKeyPem = File.ReadAllText(_privateKeyPath);
|
var privateKeyPem = File.ReadAllText(_privateKeyPath);
|
||||||
@ -54,7 +54,7 @@ public class CompactTokenService(IConfiguration config)
|
|||||||
sessionId = new Guid(payloadBytes);
|
sessionId = new Guid(payloadBytes);
|
||||||
|
|
||||||
// Load public key for verification
|
// Load public key for verification
|
||||||
var publicKeyPem = File.ReadAllText(config["Jwt:PublicKeyPath"]!);
|
var publicKeyPem = File.ReadAllText(config["AuthToken:PublicKeyPath"]!);
|
||||||
using var rsa = RSA.Create();
|
using var rsa = RSA.Create();
|
||||||
rsa.ImportFromPem(publicKeyPem);
|
rsa.ImportFromPem(publicKeyPem);
|
||||||
|
|
@ -0,0 +1,241 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Controllers;
|
||||||
|
|
||||||
|
[Route("/api/auth/open")]
|
||||||
|
[ApiController]
|
||||||
|
public class OidcProviderController(
|
||||||
|
AppDatabase db,
|
||||||
|
OidcProviderService oidcService,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IOptions<OidcProviderOptions> options,
|
||||||
|
ILogger<OidcProviderController> logger
|
||||||
|
)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
[HttpPost("token")]
|
||||||
|
[Consumes("application/x-www-form-urlencoded")]
|
||||||
|
public async Task<IActionResult> Token([FromForm] TokenRequest request)
|
||||||
|
{
|
||||||
|
switch (request.GrantType)
|
||||||
|
{
|
||||||
|
// Validate client credentials
|
||||||
|
case "authorization_code" when request.ClientId == null || string.IsNullOrEmpty(request.ClientSecret):
|
||||||
|
return BadRequest("Client credentials are required");
|
||||||
|
case "authorization_code" when request.Code == null:
|
||||||
|
return BadRequest("Authorization code is required");
|
||||||
|
case "authorization_code":
|
||||||
|
{
|
||||||
|
var client = await oidcService.FindClientByIdAsync(request.ClientId.Value);
|
||||||
|
if (client == null ||
|
||||||
|
!await oidcService.ValidateClientCredentialsAsync(request.ClientId.Value, request.ClientSecret))
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{ Error = "invalid_client", ErrorDescription = "Invalid client credentials" });
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: request.ClientId.Value,
|
||||||
|
authorizationCode: request.Code!,
|
||||||
|
redirectUri: request.RedirectUri,
|
||||||
|
codeVerifier: request.CodeVerifier
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
case "refresh_token" when string.IsNullOrEmpty(request.RefreshToken):
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{ Error = "invalid_request", ErrorDescription = "Refresh token is required" });
|
||||||
|
case "refresh_token":
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Decode the base64 refresh token to get the session ID
|
||||||
|
var sessionIdBytes = Convert.FromBase64String(request.RefreshToken);
|
||||||
|
var sessionId = new Guid(sessionIdBytes);
|
||||||
|
|
||||||
|
// Find the session and related data
|
||||||
|
var session = await oidcService.FindSessionByIdAsync(sessionId);
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
if (session?.App is null || session.ExpiredAt < now)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid or expired refresh token"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the client
|
||||||
|
var client = session.App;
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_client",
|
||||||
|
ErrorDescription = "Client not found"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
var tokenResponse = await oidcService.GenerateTokenResponseAsync(
|
||||||
|
clientId: session.AppId!.Value,
|
||||||
|
sessionId: session.Id
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(tokenResponse);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return BadRequest(new ErrorResponse
|
||||||
|
{
|
||||||
|
Error = "invalid_grant",
|
||||||
|
ErrorDescription = "Invalid refresh token format"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return BadRequest(new ErrorResponse { Error = "unsupported_grant_type" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("userinfo")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetUserInfo()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser ||
|
||||||
|
HttpContext.Items["CurrentSession"] is not AuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
|
// Get requested scopes from the token
|
||||||
|
var scopes = currentSession.Challenge.Scopes;
|
||||||
|
|
||||||
|
var userInfo = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["sub"] = currentUser.Id
|
||||||
|
};
|
||||||
|
|
||||||
|
// Include standard claims based on scopes
|
||||||
|
if (scopes.Contains("profile") || scopes.Contains("name"))
|
||||||
|
{
|
||||||
|
userInfo["name"] = currentUser.Name;
|
||||||
|
userInfo["preferred_username"] = currentUser.Nick;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userEmail = await db.AccountContacts
|
||||||
|
.Where(c => c.Type == AccountContactType.Email && c.AccountId == currentUser.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (scopes.Contains("email") && userEmail is not null)
|
||||||
|
{
|
||||||
|
userInfo["email"] = userEmail.Content;
|
||||||
|
userInfo["email_verified"] = userEmail.VerifiedAt is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(userInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/.well-known/openid-configuration")]
|
||||||
|
public IActionResult GetConfiguration()
|
||||||
|
{
|
||||||
|
var baseUrl = configuration["BaseUrl"];
|
||||||
|
var issuer = options.Value.IssuerUri.TrimEnd('/');
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
issuer = issuer,
|
||||||
|
authorization_endpoint = $"{baseUrl}/auth/authorize",
|
||||||
|
token_endpoint = $"{baseUrl}/auth/open/token",
|
||||||
|
userinfo_endpoint = $"{baseUrl}/auth/open/userinfo",
|
||||||
|
jwks_uri = $"{baseUrl}/.well-known/jwks",
|
||||||
|
scopes_supported = new[] { "openid", "profile", "email" },
|
||||||
|
response_types_supported = new[]
|
||||||
|
{ "code", "token", "id_token", "code token", "code id_token", "token id_token", "code token id_token" },
|
||||||
|
grant_types_supported = new[] { "authorization_code", "refresh_token" },
|
||||||
|
token_endpoint_auth_methods_supported = new[] { "client_secret_basic", "client_secret_post" },
|
||||||
|
id_token_signing_alg_values_supported = new[] { "HS256" },
|
||||||
|
subject_types_supported = new[] { "public" },
|
||||||
|
claims_supported = new[] { "sub", "name", "email", "email_verified" },
|
||||||
|
code_challenge_methods_supported = new[] { "S256" },
|
||||||
|
response_modes_supported = new[] { "query", "fragment", "form_post" },
|
||||||
|
request_parameter_supported = true,
|
||||||
|
request_uri_parameter_supported = true,
|
||||||
|
require_request_uri_registration = false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("/.well-known/jwks")]
|
||||||
|
public IActionResult GetJwks()
|
||||||
|
{
|
||||||
|
using var rsa = options.Value.GetRsaPublicKey();
|
||||||
|
if (rsa == null)
|
||||||
|
{
|
||||||
|
return BadRequest("Public key is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
var parameters = rsa.ExportParameters(false);
|
||||||
|
var keyId = Convert.ToBase64String(SHA256.HashData(parameters.Modulus!)[..8])
|
||||||
|
.Replace("+", "-")
|
||||||
|
.Replace("/", "_")
|
||||||
|
.Replace("=", "");
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
keys = new[]
|
||||||
|
{
|
||||||
|
new
|
||||||
|
{
|
||||||
|
kty = "RSA",
|
||||||
|
use = "sig",
|
||||||
|
kid = keyId,
|
||||||
|
n = Base64UrlEncoder.Encode(parameters.Modulus!),
|
||||||
|
e = Base64UrlEncoder.Encode(parameters.Exponent!),
|
||||||
|
alg = "RS256"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TokenRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("grant_type")]
|
||||||
|
[FromForm(Name = "grant_type")]
|
||||||
|
public string? GrantType { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
[FromForm(Name = "code")]
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("redirect_uri")]
|
||||||
|
[FromForm(Name = "redirect_uri")]
|
||||||
|
public string? RedirectUri { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("client_id")]
|
||||||
|
[FromForm(Name = "client_id")]
|
||||||
|
public Guid? ClientId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("client_secret")]
|
||||||
|
[FromForm(Name = "client_secret")]
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
[FromForm(Name = "refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
[FromForm(Name = "scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("code_verifier")]
|
||||||
|
[FromForm(Name = "code_verifier")]
|
||||||
|
public string? CodeVerifier { get; set; }
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Models;
|
||||||
|
|
||||||
|
public class AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
public Guid ClientId { get; set; }
|
||||||
|
public Guid AccountId { get; set; }
|
||||||
|
public string RedirectUri { get; set; } = string.Empty;
|
||||||
|
public List<string> Scopes { get; set; } = new();
|
||||||
|
public string? CodeChallenge { get; set; }
|
||||||
|
public string? CodeChallengeMethod { get; set; }
|
||||||
|
public string? Nonce { get; set; }
|
||||||
|
public Instant CreatedAt { get; set; }
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
|
|
||||||
|
public class OidcProviderOptions
|
||||||
|
{
|
||||||
|
public string IssuerUri { get; set; } = "https://your-issuer-uri.com";
|
||||||
|
public string? PublicKeyPath { get; set; }
|
||||||
|
public string? PrivateKeyPath { get; set; }
|
||||||
|
public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1);
|
||||||
|
public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(30);
|
||||||
|
public TimeSpan AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5);
|
||||||
|
public bool RequireHttpsMetadata { get; set; } = true;
|
||||||
|
|
||||||
|
public RSA? GetRsaPrivateKey()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(PrivateKeyPath) || !File.Exists(PrivateKeyPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var privateKey = File.ReadAllText(PrivateKeyPath);
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(privateKey.AsSpan());
|
||||||
|
return rsa;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RSA? GetRsaPublicKey()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(PublicKeyPath) || !File.Exists(PublicKeyPath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var publicKey = File.ReadAllText(PublicKeyPath);
|
||||||
|
var rsa = RSA.Create();
|
||||||
|
rsa.ImportFromPem(publicKey.AsSpan());
|
||||||
|
return rsa;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||||
|
|
||||||
|
public class AuthorizationResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("code")]
|
||||||
|
public string Code { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public string? State { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("session_state")]
|
||||||
|
public string? SessionState { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("iss")]
|
||||||
|
public string? Issuer { get; set; }
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||||
|
|
||||||
|
public class ErrorResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("error")]
|
||||||
|
public string Error { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("error_description")]
|
||||||
|
public string? ErrorDescription { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("error_uri")]
|
||||||
|
public string? ErrorUri { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("state")]
|
||||||
|
public string? State { get; set; }
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||||
|
|
||||||
|
public class TokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = null!;
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("token_type")]
|
||||||
|
public string TokenType { get; set; } = "Bearer";
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("scope")]
|
||||||
|
public string? Scope { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[JsonPropertyName("id_token")]
|
||||||
|
public string? IdToken { get; set; }
|
||||||
|
}
|
@ -0,0 +1,395 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Models;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Options;
|
||||||
|
using DysonNetwork.Pass.Auth.OidcProvider.Responses;
|
||||||
|
using DysonNetwork.Pass.Developer;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OidcProvider.Services;
|
||||||
|
|
||||||
|
public class OidcProviderService(
|
||||||
|
AppDatabase db,
|
||||||
|
AuthService auth,
|
||||||
|
ICacheService cache,
|
||||||
|
IOptions<OidcProviderOptions> options,
|
||||||
|
ILogger<OidcProviderService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private readonly OidcProviderOptions _options = options.Value;
|
||||||
|
|
||||||
|
public async Task<CustomApp?> FindClientByIdAsync(Guid clientId)
|
||||||
|
{
|
||||||
|
return await db.CustomApps
|
||||||
|
.Include(c => c.Secrets)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CustomApp?> FindClientByAppIdAsync(Guid appId)
|
||||||
|
{
|
||||||
|
return await db.CustomApps
|
||||||
|
.Include(c => c.Secrets)
|
||||||
|
.FirstOrDefaultAsync(c => c.Id == appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthSession?> FindValidSessionAsync(Guid accountId, Guid clientId)
|
||||||
|
{
|
||||||
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
|
return await db.AuthSessions
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Where(s => s.AccountId == accountId &&
|
||||||
|
s.AppId == clientId &&
|
||||||
|
(s.ExpiredAt == null || s.ExpiredAt > now) &&
|
||||||
|
s.Challenge.Type == ChallengeType.OAuth)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ValidateClientCredentialsAsync(Guid clientId, string clientSecret)
|
||||||
|
{
|
||||||
|
var client = await FindClientByIdAsync(clientId);
|
||||||
|
if (client == null) return false;
|
||||||
|
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var secret = client.Secrets
|
||||||
|
.Where(s => s.IsOidc && (s.ExpiredAt == null || s.ExpiredAt > clock.GetCurrentInstant()))
|
||||||
|
.FirstOrDefault(s => s.Secret == clientSecret); // In production, use proper hashing
|
||||||
|
|
||||||
|
return secret != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TokenResponse> GenerateTokenResponseAsync(
|
||||||
|
Guid clientId,
|
||||||
|
string? authorizationCode = null,
|
||||||
|
string? redirectUri = null,
|
||||||
|
string? codeVerifier = null,
|
||||||
|
Guid? sessionId = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var client = await FindClientByIdAsync(clientId);
|
||||||
|
if (client == null)
|
||||||
|
throw new InvalidOperationException("Client not found");
|
||||||
|
|
||||||
|
AuthSession session;
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
List<string>? scopes = null;
|
||||||
|
if (authorizationCode != null)
|
||||||
|
{
|
||||||
|
// Authorization code flow
|
||||||
|
var authCode = await ValidateAuthorizationCodeAsync(authorizationCode, clientId, redirectUri, codeVerifier);
|
||||||
|
if (authCode is null) throw new InvalidOperationException("Invalid authorization code");
|
||||||
|
var account = await db.Accounts.Where(a => a.Id == authCode.AccountId).FirstOrDefaultAsync();
|
||||||
|
if (account is null) throw new InvalidOperationException("Account was not found");
|
||||||
|
|
||||||
|
session = await auth.CreateSessionForOidcAsync(account, now, client.Id);
|
||||||
|
scopes = authCode.Scopes;
|
||||||
|
}
|
||||||
|
else if (sessionId.HasValue)
|
||||||
|
{
|
||||||
|
// Refresh token flow
|
||||||
|
session = await FindSessionByIdAsync(sessionId.Value) ??
|
||||||
|
throw new InvalidOperationException("Invalid session");
|
||||||
|
|
||||||
|
// Verify the session is still valid
|
||||||
|
if (session.ExpiredAt < now)
|
||||||
|
throw new InvalidOperationException("Session has expired");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Either authorization code or session ID must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresIn = (int)_options.AccessTokenLifetime.TotalSeconds;
|
||||||
|
var expiresAt = now.Plus(Duration.FromSeconds(expiresIn));
|
||||||
|
|
||||||
|
// Generate an access token
|
||||||
|
var accessToken = GenerateJwtToken(client, session, expiresAt, scopes);
|
||||||
|
var refreshToken = GenerateRefreshToken(session);
|
||||||
|
|
||||||
|
return new TokenResponse
|
||||||
|
{
|
||||||
|
AccessToken = accessToken,
|
||||||
|
ExpiresIn = expiresIn,
|
||||||
|
TokenType = "Bearer",
|
||||||
|
RefreshToken = refreshToken,
|
||||||
|
Scope = scopes != null ? string.Join(" ", scopes) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GenerateJwtToken(
|
||||||
|
CustomApp client,
|
||||||
|
AuthSession session,
|
||||||
|
Instant expiresAt,
|
||||||
|
IEnumerable<string>? scopes = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
var tokenDescriptor = new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Subject = new ClaimsIdentity([
|
||||||
|
new Claim(JwtRegisteredClaimNames.Sub, session.AccountId.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Jti, session.Id.ToString()),
|
||||||
|
new Claim(JwtRegisteredClaimNames.Iat, now.ToUnixTimeSeconds().ToString(),
|
||||||
|
ClaimValueTypes.Integer64),
|
||||||
|
new Claim("client_id", client.Id.ToString())
|
||||||
|
]),
|
||||||
|
Expires = expiresAt.ToDateTimeUtc(),
|
||||||
|
Issuer = _options.IssuerUri,
|
||||||
|
Audience = client.Id.ToString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to use RSA signing if keys are available, fall back to HMAC
|
||||||
|
var rsaPrivateKey = _options.GetRsaPrivateKey();
|
||||||
|
tokenDescriptor.SigningCredentials = new SigningCredentials(
|
||||||
|
new RsaSecurityKey(rsaPrivateKey),
|
||||||
|
SecurityAlgorithms.RsaSha256
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add scopes as claims if provided
|
||||||
|
var effectiveScopes = scopes?.ToList() ?? client.OauthConfig!.AllowedScopes?.ToList() ?? [];
|
||||||
|
if (effectiveScopes.Count != 0)
|
||||||
|
{
|
||||||
|
tokenDescriptor.Subject.AddClaims(
|
||||||
|
effectiveScopes.Select(scope => new Claim("scope", scope)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||||
|
return tokenHandler.WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (bool isValid, JwtSecurityToken? token) ValidateToken(string token)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tokenHandler = new JwtSecurityTokenHandler();
|
||||||
|
var validationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidIssuer = _options.IssuerUri,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ClockSkew = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to use RSA validation if public key is available
|
||||||
|
var rsaPublicKey = _options.GetRsaPublicKey();
|
||||||
|
validationParameters.IssuerSigningKey = new RsaSecurityKey(rsaPublicKey);
|
||||||
|
validationParameters.ValidateIssuerSigningKey = true;
|
||||||
|
validationParameters.ValidAlgorithms = new[] { SecurityAlgorithms.RsaSha256 };
|
||||||
|
|
||||||
|
|
||||||
|
tokenHandler.ValidateToken(token, validationParameters, out var validatedToken);
|
||||||
|
return (true, (JwtSecurityToken)validatedToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Token validation failed");
|
||||||
|
return (false, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AuthSession?> FindSessionByIdAsync(Guid sessionId)
|
||||||
|
{
|
||||||
|
return await db.AuthSessions
|
||||||
|
.Include(s => s.Account)
|
||||||
|
.Include(s => s.Challenge)
|
||||||
|
.Include(s => s.App)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRefreshToken(AuthSession session)
|
||||||
|
{
|
||||||
|
return Convert.ToBase64String(session.Id.ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifyHashedSecret(string secret, string hashedSecret)
|
||||||
|
{
|
||||||
|
// In a real implementation, you'd use a proper password hashing algorithm like PBKDF2, bcrypt, or Argon2
|
||||||
|
// For now, we'll do a simple comparison, but you should replace this with proper hashing
|
||||||
|
return string.Equals(secret, hashedSecret, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAuthorizationCodeForReuseSessionAsync(
|
||||||
|
AuthSession session,
|
||||||
|
Guid clientId,
|
||||||
|
string redirectUri,
|
||||||
|
IEnumerable<string> scopes,
|
||||||
|
string? codeChallenge = null,
|
||||||
|
string? codeChallengeMethod = null,
|
||||||
|
string? nonce = null)
|
||||||
|
{
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
var code = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
// Update the session's last activity time
|
||||||
|
await db.AuthSessions.Where(s => s.Id == session.Id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(s => s.LastGrantedAt, now));
|
||||||
|
|
||||||
|
// Create the authorization code info
|
||||||
|
var authCodeInfo = new AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
AccountId = session.AccountId,
|
||||||
|
RedirectUri = redirectUri,
|
||||||
|
Scopes = scopes.ToList(),
|
||||||
|
CodeChallenge = codeChallenge,
|
||||||
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
|
Nonce = nonce,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the code with its metadata in the cache
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, session.AccountId);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GenerateAuthorizationCodeAsync(
|
||||||
|
Guid clientId,
|
||||||
|
Guid userId,
|
||||||
|
string redirectUri,
|
||||||
|
IEnumerable<string> scopes,
|
||||||
|
string? codeChallenge = null,
|
||||||
|
string? codeChallengeMethod = null,
|
||||||
|
string? nonce = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Generate a random code
|
||||||
|
var clock = SystemClock.Instance;
|
||||||
|
var code = GenerateRandomString(32);
|
||||||
|
var now = clock.GetCurrentInstant();
|
||||||
|
|
||||||
|
// Create the authorization code info
|
||||||
|
var authCodeInfo = new AuthorizationCodeInfo
|
||||||
|
{
|
||||||
|
ClientId = clientId,
|
||||||
|
AccountId = userId,
|
||||||
|
RedirectUri = redirectUri,
|
||||||
|
Scopes = scopes.ToList(),
|
||||||
|
CodeChallenge = codeChallenge,
|
||||||
|
CodeChallengeMethod = codeChallengeMethod,
|
||||||
|
Nonce = nonce,
|
||||||
|
CreatedAt = now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store the code with its metadata in the cache
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
await cache.SetAsync(cacheKey, authCodeInfo, _options.AuthorizationCodeLifetime);
|
||||||
|
|
||||||
|
logger.LogInformation("Generated authorization code for client {ClientId} and user {UserId}", clientId, userId);
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<AuthorizationCodeInfo?> ValidateAuthorizationCodeAsync(
|
||||||
|
string code,
|
||||||
|
Guid clientId,
|
||||||
|
string? redirectUri = null,
|
||||||
|
string? codeVerifier = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var cacheKey = $"auth:code:{code}";
|
||||||
|
var (found, authCode) = await cache.GetAsyncWithStatus<AuthorizationCodeInfo>(cacheKey);
|
||||||
|
|
||||||
|
if (!found || authCode == null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Authorization code not found: {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify client ID matches
|
||||||
|
if (authCode.ClientId != clientId)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Client ID mismatch for code {Code}. Expected: {ExpectedClientId}, Actual: {ActualClientId}",
|
||||||
|
code, authCode.ClientId, clientId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify redirect URI if provided
|
||||||
|
if (!string.IsNullOrEmpty(redirectUri) && authCode.RedirectUri != redirectUri)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Redirect URI mismatch for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify PKCE code challenge if one was provided during authorization
|
||||||
|
if (!string.IsNullOrEmpty(authCode.CodeChallenge))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(codeVerifier))
|
||||||
|
{
|
||||||
|
logger.LogWarning("PKCE code verifier is required but not provided for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isValid = authCode.CodeChallengeMethod?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"S256" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "S256"),
|
||||||
|
"PLAIN" => VerifyCodeChallenge(codeVerifier, authCode.CodeChallenge, "PLAIN"),
|
||||||
|
_ => false // Unsupported code challenge method
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isValid)
|
||||||
|
{
|
||||||
|
logger.LogWarning("PKCE code verifier validation failed for code {Code}", code);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code is valid, remove it from the cache (codes are single-use)
|
||||||
|
await cache.RemoveAsync(cacheKey);
|
||||||
|
|
||||||
|
return authCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateRandomString(int length)
|
||||||
|
{
|
||||||
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
|
||||||
|
var random = RandomNumberGenerator.Create();
|
||||||
|
var result = new char[length];
|
||||||
|
|
||||||
|
for (int i = 0; i < length; i++)
|
||||||
|
{
|
||||||
|
var randomNumber = new byte[4];
|
||||||
|
random.GetBytes(randomNumber);
|
||||||
|
var index = (int)(BitConverter.ToUInt32(randomNumber, 0) % chars.Length);
|
||||||
|
result[i] = chars[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new string(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifyCodeChallenge(string codeVerifier, string codeChallenge, string method)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(codeVerifier)) return false;
|
||||||
|
|
||||||
|
if (method == "S256")
|
||||||
|
{
|
||||||
|
using var sha256 = SHA256.Create();
|
||||||
|
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
|
||||||
|
var base64 = Base64UrlEncoder.Encode(hash);
|
||||||
|
return string.Equals(base64, codeChallenge, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method == "PLAIN")
|
||||||
|
{
|
||||||
|
return string.Equals(codeVerifier, codeChallenge, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
95
DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs
Normal file
95
DysonNetwork.Pass/Auth/OpenId/AfdianOidcService.cs
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
|
public class AfdianOidcService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
AppDatabase db,
|
||||||
|
AuthService auth,
|
||||||
|
ICacheService cache,
|
||||||
|
ILogger<AfdianOidcService> logger
|
||||||
|
)
|
||||||
|
: OidcService(configuration, httpClientFactory, db, auth, cache)
|
||||||
|
{
|
||||||
|
public override string ProviderName => "Afdian";
|
||||||
|
protected override string DiscoveryEndpoint => ""; // Afdian doesn't have a standard OIDC discovery endpoint
|
||||||
|
protected override string ConfigSectionName => "Afdian";
|
||||||
|
|
||||||
|
public override string GetAuthorizationUrl(string state, string nonce)
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var queryParams = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
{ "response_type", "code" },
|
||||||
|
{ "scope", "basic" },
|
||||||
|
{ "state", state },
|
||||||
|
};
|
||||||
|
|
||||||
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
|
return $"https://afdian.com/oauth2/authorize?{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||||
|
{
|
||||||
|
return Task.FromResult(new OidcDiscoveryDocument
|
||||||
|
{
|
||||||
|
AuthorizationEndpoint = "https://afdian.com/oauth2/authorize",
|
||||||
|
TokenEndpoint = "https://afdian.com/oauth2/access_token",
|
||||||
|
UserinfoEndpoint = null,
|
||||||
|
JwksUri = null
|
||||||
|
})!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<OidcUserInfo> ProcessCallbackAsync(OidcCallbackData callbackData)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = GetProviderConfig();
|
||||||
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "client_id", config.ClientId },
|
||||||
|
{ "client_secret", config.ClientSecret },
|
||||||
|
{ "grant_type", "authorization_code" },
|
||||||
|
{ "code", callbackData.Code },
|
||||||
|
{ "redirect_uri", config.RedirectUri },
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = HttpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://afdian.com/oauth2/access_token");
|
||||||
|
request.Content = content;
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
logger.LogInformation("Trying get userinfo from afdian, response: {Response}", json);
|
||||||
|
var afdianResponse = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
var user = afdianResponse.TryGetProperty("data", out var dataElement) ? dataElement : default;
|
||||||
|
var userId = user.TryGetProperty("user_id", out var userIdElement) ? userIdElement.GetString() ?? "" : "";
|
||||||
|
var avatar = user.TryGetProperty("avatar", out var avatarElement) ? avatarElement.GetString() : null;
|
||||||
|
|
||||||
|
return new OidcUserInfo
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
DisplayName = (user.TryGetProperty("name", out var nameElement)
|
||||||
|
? nameElement.GetString()
|
||||||
|
: null) ?? "",
|
||||||
|
ProfilePictureUrl = avatar,
|
||||||
|
Provider = ProviderName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Due to afidan's API isn't compliant with OAuth2, we want more logs from it to investigate.
|
||||||
|
logger.LogError(ex, "Failed to get user info from Afdian");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
public class AppleMobileConnectRequest
|
public class AppleMobileConnectRequest
|
||||||
{
|
{
|
@ -3,10 +3,11 @@ using System.Security.Cryptography;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Implementation of OpenID Connect service for Apple Sign In
|
/// Implementation of OpenID Connect service for Apple Sign In
|
@ -1,14 +1,14 @@
|
|||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Cache;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/accounts/me/connections")]
|
[Route("/api/accounts/me/connections")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ConnectionController(
|
public class ConnectionController(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
@ -164,7 +164,7 @@ public class ConnectionController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[Route("/auth/callback/{provider}")]
|
[Route("/api/auth/callback/{provider}")]
|
||||||
[HttpGet, HttpPost]
|
[HttpGet, HttpPost]
|
||||||
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
public async Task<IActionResult> HandleCallback([FromRoute] string provider)
|
||||||
{
|
{
|
||||||
@ -376,7 +376,7 @@ public class ConnectionController(
|
|||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var loginSession = await auth.CreateSessionAsync(account, clock.GetCurrentInstant());
|
var loginSession = await auth.CreateSessionForOidcAsync(account, clock.GetCurrentInstant());
|
||||||
var loginToken = auth.CreateToken(loginSession);
|
var loginToken = auth.CreateToken(loginSession);
|
||||||
return Redirect($"/auth/token?token={loginToken}");
|
return Redirect($"/auth/token?token={loginToken}");
|
||||||
}
|
}
|
@ -1,8 +1,9 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
public class DiscordOidcService(
|
public class DiscordOidcService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@ -30,7 +31,7 @@ public class DiscordOidcService(
|
|||||||
};
|
};
|
||||||
|
|
||||||
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
var queryString = string.Join("&", queryParams.Select(p => $"{p.Key}={Uri.EscapeDataString(p.Value)}"));
|
||||||
return $"https://discord.com/api/oauth2/authorize?{queryString}";
|
return $"https://discord.com/oauth2/authorize?{queryString}";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
protected override Task<OidcDiscoveryDocument?> GetDiscoveryDocumentAsync()
|
||||||
@ -38,8 +39,8 @@ public class DiscordOidcService(
|
|||||||
return Task.FromResult(new OidcDiscoveryDocument
|
return Task.FromResult(new OidcDiscoveryDocument
|
||||||
{
|
{
|
||||||
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
|
AuthorizationEndpoint = "https://discord.com/oauth2/authorize",
|
||||||
TokenEndpoint = "https://discord.com/api/oauth2/token",
|
TokenEndpoint = "https://discord.com/oauth2/token",
|
||||||
UserinfoEndpoint = "https://discord.com/api/users/@me",
|
UserinfoEndpoint = "https://discord.com/users/@me",
|
||||||
JwksUri = null
|
JwksUri = null
|
||||||
})!;
|
})!;
|
||||||
}
|
}
|
||||||
@ -75,7 +76,7 @@ public class DiscordOidcService(
|
|||||||
{ "redirect_uri", config.RedirectUri },
|
{ "redirect_uri", config.RedirectUri },
|
||||||
});
|
});
|
||||||
|
|
||||||
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
|
var response = await client.PostAsync("https://discord.com/oauth2/token", content);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
return await response.Content.ReadFromJsonAsync<OidcTokenResponse>();
|
||||||
@ -84,7 +85,7 @@ public class DiscordOidcService(
|
|||||||
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
|
private async Task<OidcUserInfo> GetUserInfoAsync(string accessToken)
|
||||||
{
|
{
|
||||||
var client = HttpClientFactory.CreateClient();
|
var client = HttpClientFactory.CreateClient();
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/users/@me");
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/users/@me");
|
||||||
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
var response = await client.SendAsync(request);
|
var response = await client.SendAsync(request);
|
@ -1,8 +1,9 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
public class GitHubOidcService(
|
public class GitHubOidcService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@ -77,7 +78,7 @@ public class GitHubOidcService(
|
|||||||
var client = HttpClientFactory.CreateClient();
|
var client = HttpClientFactory.CreateClient();
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user");
|
||||||
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
request.Headers.Add("User-Agent", "DysonNetwork.Sphere");
|
request.Headers.Add("User-Agent", "DysonNetwork.Pass");
|
||||||
|
|
||||||
var response = await client.SendAsync(request);
|
var response = await client.SendAsync(request);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@ -109,7 +110,7 @@ public class GitHubOidcService(
|
|||||||
var client = HttpClientFactory.CreateClient();
|
var client = HttpClientFactory.CreateClient();
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails");
|
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.github.com/user/emails");
|
||||||
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
request.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
request.Headers.Add("User-Agent", "DysonNetwork.Sphere");
|
request.Headers.Add("User-Agent", "DysonNetwork.Pass");
|
||||||
|
|
||||||
var response = await client.SendAsync(request);
|
var response = await client.SendAsync(request);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
@ -2,10 +2,11 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
public class GoogleOidcService(
|
public class GoogleOidcService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
@ -1,8 +1,7 @@
|
|||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Cache;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
public class MicrosoftOidcService(
|
public class MicrosoftOidcService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
@ -1,14 +1,14 @@
|
|||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("/auth/login")]
|
[Route("/api/auth/login")]
|
||||||
public class OidcController(
|
public class OidcController(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
@ -68,7 +68,7 @@ public class OidcController(
|
|||||||
/// Handles Apple authentication directly from mobile apps
|
/// Handles Apple authentication directly from mobile apps
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("apple/mobile")]
|
[HttpPost("apple/mobile")]
|
||||||
public async Task<ActionResult<Challenge>> AppleMobileLogin(
|
public async Task<ActionResult<AuthChallenge>> AppleMobileLogin(
|
||||||
[FromBody] AppleMobileSignInRequest request)
|
[FromBody] AppleMobileSignInRequest request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -120,6 +120,7 @@ public class OidcController(
|
|||||||
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
"microsoft" => serviceProvider.GetRequiredService<MicrosoftOidcService>(),
|
||||||
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
|
"discord" => serviceProvider.GetRequiredService<DiscordOidcService>(),
|
||||||
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
|
"github" => serviceProvider.GetRequiredService<GitHubOidcService>(),
|
||||||
|
"afdian" => serviceProvider.GetRequiredService<AfdianOidcService>(),
|
||||||
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
_ => throw new ArgumentException($"Unsupported provider: {provider}")
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -1,13 +1,12 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
using DysonNetwork.Sphere.Storage;
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base service for OpenID Connect authentication providers
|
/// Base service for OpenID Connect authentication providers
|
||||||
@ -188,7 +187,7 @@ public abstract class OidcService(
|
|||||||
/// Creates a challenge and session for an authenticated user
|
/// Creates a challenge and session for an authenticated user
|
||||||
/// Also creates or updates the account connection
|
/// Also creates or updates the account connection
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Challenge> CreateChallengeForUserAsync(
|
public async Task<AuthChallenge> CreateChallengeForUserAsync(
|
||||||
OidcUserInfo userInfo,
|
OidcUserInfo userInfo,
|
||||||
Account.Account account,
|
Account.Account account,
|
||||||
HttpContext request,
|
HttpContext request,
|
||||||
@ -218,7 +217,7 @@ public abstract class OidcService(
|
|||||||
|
|
||||||
// Create a challenge that's already completed
|
// Create a challenge that's already completed
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var challenge = new Challenge
|
var challenge = new AuthChallenge
|
||||||
{
|
{
|
||||||
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
ExpiredAt = now.Plus(Duration.FromHours(1)),
|
||||||
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
StepTotal = await auth.DetectChallengeRisk(request.Request, account),
|
@ -1,7 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the state parameter used in OpenID Connect flows.
|
/// Represents the state parameter used in OpenID Connect flows.
|
@ -1,4 +1,4 @@
|
|||||||
namespace DysonNetwork.Sphere.Auth.OpenId;
|
namespace DysonNetwork.Pass.Auth.OpenId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Represents the user information from an OIDC provider
|
/// Represents the user information from an OIDC provider
|
@ -1,12 +1,16 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Pass;
|
||||||
|
using DysonNetwork.Pass.Developer;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using NodaTime.Serialization.Protobuf;
|
||||||
using Point = NetTopologySuite.Geometries.Point;
|
using Point = NetTopologySuite.Geometries.Point;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Auth;
|
namespace DysonNetwork.Pass.Auth;
|
||||||
|
|
||||||
public class Session : ModelBase
|
public class AuthSession : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
[MaxLength(1024)] public string? Label { get; set; }
|
[MaxLength(1024)] public string? Label { get; set; }
|
||||||
@ -16,7 +20,21 @@ public class Session : ModelBase
|
|||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
public Guid ChallengeId { get; set; }
|
public Guid ChallengeId { get; set; }
|
||||||
public Challenge Challenge { get; set; } = null!;
|
public AuthChallenge Challenge { get; set; } = null!;
|
||||||
|
public Guid? AppId { get; set; }
|
||||||
|
public CustomApp? App { get; set; }
|
||||||
|
|
||||||
|
public Shared.Proto.AuthSession ToProtoValue() => new()
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
Label = Label,
|
||||||
|
LastGrantedAt = LastGrantedAt?.ToTimestamp(),
|
||||||
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
|
AccountId = AccountId.ToString(),
|
||||||
|
ChallengeId = ChallengeId.ToString(),
|
||||||
|
Challenge = Challenge.ToProtoValue(),
|
||||||
|
AppId = AppId?.ToString()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ChallengeType
|
public enum ChallengeType
|
||||||
@ -37,7 +55,7 @@ public enum ChallengePlatform
|
|||||||
Linux
|
Linux
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Challenge : ModelBase
|
public class AuthChallenge : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Instant? ExpiredAt { get; set; }
|
public Instant? ExpiredAt { get; set; }
|
||||||
@ -58,9 +76,28 @@ public class Challenge : ModelBase
|
|||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||||
|
|
||||||
public Challenge Normalize()
|
public AuthChallenge Normalize()
|
||||||
{
|
{
|
||||||
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
|
if (StepRemain == 0 && BlacklistFactors.Count == 0) StepRemain = StepTotal;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Shared.Proto.AuthChallenge ToProtoValue() => new()
|
||||||
|
{
|
||||||
|
Id = Id.ToString(),
|
||||||
|
ExpiredAt = ExpiredAt?.ToTimestamp(),
|
||||||
|
StepRemain = StepRemain,
|
||||||
|
StepTotal = StepTotal,
|
||||||
|
FailedAttempts = FailedAttempts,
|
||||||
|
Platform = (Shared.Proto.ChallengePlatform)Platform,
|
||||||
|
Type = (Shared.Proto.ChallengeType)Type,
|
||||||
|
BlacklistFactors = { BlacklistFactors.Select(x => x.ToString()) },
|
||||||
|
Audiences = { Audiences },
|
||||||
|
Scopes = { Scopes },
|
||||||
|
IpAddress = IpAddress,
|
||||||
|
UserAgent = UserAgent,
|
||||||
|
DeviceId = DeviceId,
|
||||||
|
Nonce = Nonce,
|
||||||
|
AccountId = AccountId.ToString()
|
||||||
|
};
|
||||||
}
|
}
|
68
DysonNetwork.Pass/Developer/CustomApp.cs
Normal file
68
DysonNetwork.Pass/Developer/CustomApp.cs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Developer;
|
||||||
|
|
||||||
|
public enum CustomAppStatus
|
||||||
|
{
|
||||||
|
Developing,
|
||||||
|
Staging,
|
||||||
|
Production,
|
||||||
|
Suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomApp : ModelBase, IIdentifiedResource
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(1024)] public string Slug { get; set; } = null!;
|
||||||
|
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||||
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
|
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Picture { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public CloudFileReferenceObject? Background { get; set; }
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public CustomAppOauthConfig? OauthConfig { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public CustomAppLinks? Links { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore] public ICollection<CustomAppSecret> Secrets { get; set; } = new List<CustomAppSecret>();
|
||||||
|
|
||||||
|
// TODO: Publisher
|
||||||
|
|
||||||
|
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomAppLinks
|
||||||
|
{
|
||||||
|
[MaxLength(8192)] public string? HomePage { get; set; }
|
||||||
|
[MaxLength(8192)] public string? PrivacyPolicy { get; set; }
|
||||||
|
[MaxLength(8192)] public string? TermsOfService { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomAppOauthConfig
|
||||||
|
{
|
||||||
|
[MaxLength(1024)] public string? ClientUri { get; set; }
|
||||||
|
[MaxLength(4096)] public string[] RedirectUris { get; set; } = [];
|
||||||
|
[MaxLength(4096)] public string[]? PostLogoutRedirectUris { get; set; }
|
||||||
|
[MaxLength(256)] public string[]? AllowedScopes { get; set; } = ["openid", "profile", "email"];
|
||||||
|
[MaxLength(256)] public string[] AllowedGrantTypes { get; set; } = ["authorization_code", "refresh_token"];
|
||||||
|
public bool RequirePkce { get; set; } = true;
|
||||||
|
public bool AllowOfflineAccess { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomAppSecret : ModelBase
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
[MaxLength(1024)] public string Secret { get; set; } = null!;
|
||||||
|
[MaxLength(4096)] public string? Description { get; set; } = null!;
|
||||||
|
public Instant? ExpiredAt { get; set; }
|
||||||
|
public bool IsOidc { get; set; } = false; // Indicates if this secret is for OIDC/OAuth
|
||||||
|
|
||||||
|
public Guid AppId { get; set; }
|
||||||
|
public CustomApp App { get; set; } = null!;
|
||||||
|
}
|
23
DysonNetwork.Pass/Dockerfile
Normal file
23
DysonNetwork.Pass/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.Pass/DysonNetwork.Pass.csproj", "DysonNetwork.Pass/"]
|
||||||
|
RUN dotnet restore "DysonNetwork.Pass/DysonNetwork.Pass.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/DysonNetwork.Pass"
|
||||||
|
RUN dotnet build "./DysonNetwork.Pass.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./DysonNetwork.Pass.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "DysonNetwork.Pass.dll"]
|
123
DysonNetwork.Pass/DysonNetwork.Pass.csproj
Normal file
123
DysonNetwork.Pass/DysonNetwork.Pass.csproj
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Grpc.AspNetCore.Server" Version="2.71.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.7" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="NodaTime" Version="3.2.2" />
|
||||||
|
<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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.Design" Version="1.1.0" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="9.0.4" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||||
|
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
|
<PackageReference Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
|
||||||
|
<PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
|
<PackageReference Include="prometheus-net.EntityFramework" Version="0.9.5" />
|
||||||
|
<PackageReference Include="prometheus-net.SystemMetrics" Version="3.1.0" />
|
||||||
|
<PackageReference Include="Quartz" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.AspNetCore" Version="3.14.0" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.14.0" />
|
||||||
|
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||||
|
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.1" />
|
||||||
|
<PackageReference Include="EFCore.BulkExtensions.PostgreSql" Version="9.0.1" />
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="9.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DysonNetwork.Shared\DysonNetwork.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>NotificationResource.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>SharedResource.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Update="Resources\Localization\NotificationResource.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>NotificationResource.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Update="Resources\Localization\SharedResource.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>SharedResource.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>AccountEventResource.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Update="Resources\Localization\AccountEventResource.Designer.cs">
|
||||||
|
<DesignTime>True</DesignTime>
|
||||||
|
<AutoGen>True</AutoGen>
|
||||||
|
<DependentUpon>AccountEventResource.resx</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Update="Resources\Localization\EmailResource.resx">
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>Email.LandingResource.Designer.cs</LastGenOutput>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Update="Resources\Localization\NotificationResource.resx">
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>NotificationResource.Designer.cs</LastGenOutput>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Update="Resources\Localization\SharedResource.resx">
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
|
||||||
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Update="Resources\Localization\AccountEventResource.resx">
|
||||||
|
<Generator>ResXFileCodeGenerator</Generator>
|
||||||
|
<LastGenOutput>AccountEventResource.Designer.cs</LastGenOutput>
|
||||||
|
</EmbeddedResource>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AdditionalFiles Include="Pages\Checkpoint\CheckpointPage.cshtml" />
|
||||||
|
<AdditionalFiles Include="Pages\Emails\AccountDeletionEmail.razor" />
|
||||||
|
<AdditionalFiles Include="Pages\Emails\ContactVerificationEmail.razor" />
|
||||||
|
<AdditionalFiles Include="Pages\Emails\EmailLayout.razor" />
|
||||||
|
<AdditionalFiles Include="Pages\Emails\LandingEmail.razor" />
|
||||||
|
<AdditionalFiles Include="Pages\Emails\PasswordResetEmail.razor" />
|
||||||
|
<AdditionalFiles Include="Pages\Emails\VerificationEmail.razor" />
|
||||||
|
<AdditionalFiles Include="Pages\Shared\_Layout.cshtml" />
|
||||||
|
<AdditionalFiles Include="Pages\Shared\_ValidationScriptsPartial.cshtml" />
|
||||||
|
<AdditionalFiles Include="Pages\Spell\MagicSpellPage.cshtml" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\AccountEventResource.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\AccountEventResource.zh-hans.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\EmailResource.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\EmailResource.zh-hans.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\NotificationResource.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\NotificationResource.zh-hans.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\SharedResource.resx" />
|
||||||
|
<AdditionalFiles Include="Resources\Localization\SharedResource.zh-hans.resx" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
@ -1,4 +1,4 @@
|
|||||||
namespace DysonNetwork.Sphere.Email;
|
namespace DysonNetwork.Pass.Email;
|
||||||
|
|
||||||
public class LandingEmailModel
|
public class LandingEmailModel
|
||||||
{
|
{
|
52
DysonNetwork.Pass/Email/EmailService.cs
Normal file
52
DysonNetwork.Pass/Email/EmailService.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
using dotnet_etcd;
|
||||||
|
using dotnet_etcd.interfaces;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Email;
|
||||||
|
|
||||||
|
public class EmailService(
|
||||||
|
PusherService.PusherServiceClient pusher,
|
||||||
|
RazorViewRenderer viewRenderer,
|
||||||
|
ILogger<EmailService> logger
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public async Task SendEmailAsync(
|
||||||
|
string? recipientName,
|
||||||
|
string recipientEmail,
|
||||||
|
string subject,
|
||||||
|
string htmlBody
|
||||||
|
)
|
||||||
|
{
|
||||||
|
subject = $"[Solarpass] {subject}";
|
||||||
|
|
||||||
|
await pusher.SendEmailAsync(
|
||||||
|
new SendEmailRequest()
|
||||||
|
{
|
||||||
|
Email = new EmailMessage()
|
||||||
|
{
|
||||||
|
ToName = recipientName,
|
||||||
|
ToAddress = recipientEmail,
|
||||||
|
Subject = subject,
|
||||||
|
Body = htmlBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail,
|
||||||
|
string subject, TModel model)
|
||||||
|
where TComponent : IComponent
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var htmlBody = await viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
|
||||||
|
await SendEmailAsync(recipientName, recipientEmail, subject, htmlBody);
|
||||||
|
}
|
||||||
|
catch (Exception err)
|
||||||
|
{
|
||||||
|
logger.LogError(err, "Failed to render email template...");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -9,7 +9,7 @@ using Microsoft.AspNetCore.Mvc.ViewEngines;
|
|||||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||||
using RouteData = Microsoft.AspNetCore.Routing.RouteData;
|
using RouteData = Microsoft.AspNetCore.Routing.RouteData;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Email;
|
namespace DysonNetwork.Pass.Email;
|
||||||
|
|
||||||
public class RazorViewRenderer(
|
public class RazorViewRenderer(
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
@ -1,8 +1,9 @@
|
|||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using EFCore.BulkExtensions;
|
using EFCore.BulkExtensions;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage.Handlers;
|
namespace DysonNetwork.Pass.Handlers;
|
||||||
|
|
||||||
public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<ActionLog>
|
public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<ActionLog>
|
||||||
{
|
{
|
@ -1,12 +1,13 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Storage.Handlers;
|
namespace DysonNetwork.Pass.Handlers;
|
||||||
|
|
||||||
public class LastActiveInfo
|
public class LastActiveInfo
|
||||||
{
|
{
|
||||||
public Auth.Session Session { get; set; } = null!;
|
public Auth.AuthSession Session { get; set; } = null!;
|
||||||
public Account.Account Account { get; set; } = null!;
|
public Account.Account Account { get; set; } = null!;
|
||||||
public Instant SeenAt { get; set; }
|
public Instant SeenAt { get; set; }
|
||||||
}
|
}
|
6
DysonNetwork.Pass/Localization/AccountEventResource.cs
Normal file
6
DysonNetwork.Pass/Localization/AccountEventResource.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace DysonNetwork.Pass.Localization;
|
||||||
|
|
||||||
|
public class AccountEventResource
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
5
DysonNetwork.Pass/Localization/EmailResource.cs
Normal file
5
DysonNetwork.Pass/Localization/EmailResource.cs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
namespace DysonNetwork.Pass.Localization;
|
||||||
|
|
||||||
|
public class EmailResource
|
||||||
|
{
|
||||||
|
}
|
6
DysonNetwork.Pass/Localization/NotificationResource.cs
Normal file
6
DysonNetwork.Pass/Localization/NotificationResource.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace DysonNetwork.Pass.Localization;
|
||||||
|
|
||||||
|
public class NotificationResource
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
6
DysonNetwork.Pass/Localization/SharedResource.cs
Normal file
6
DysonNetwork.Pass/Localization/SharedResource.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace DysonNetwork.Pass.Localization;
|
||||||
|
|
||||||
|
public class SharedResource
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
1977
DysonNetwork.Pass/Migrations/20250713121237_InitialMigration.Designer.cs
generated
Normal file
1977
DysonNetwork.Pass/Migrations/20250713121237_InitialMigration.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
998
DysonNetwork.Pass/Migrations/20250713121237_InitialMigration.cs
Normal file
998
DysonNetwork.Pass/Migrations/20250713121237_InitialMigration.cs
Normal file
@ -0,0 +1,998 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DysonNetwork.Pass.Account;
|
||||||
|
using DysonNetwork.Pass.Developer;
|
||||||
|
using DysonNetwork.Pass.Wallet;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using NetTopologySuite.Geometries;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialMigration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "accounts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
nick = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
language = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||||
|
activated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
is_superuser = table.Column<bool>(type: "boolean", 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: "custom_apps",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
|
||||||
|
oauth_config = table.Column<CustomAppOauthConfig>(type: "jsonb", nullable: true),
|
||||||
|
links = table.Column<CustomAppLinks>(type: "jsonb", nullable: true),
|
||||||
|
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_custom_apps", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "permission_groups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, 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_permission_groups", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "wallet_coupons",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
code = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
discount_amount = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
discount_rate = table.Column<double>(type: "double precision", nullable: true),
|
||||||
|
max_usage = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
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_wallet_coupons", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "abuse_reports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
resource_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
reason = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
|
||||||
|
resolved_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
resolution = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_abuse_reports", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_abuse_reports_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "account_auth_factors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
secret = table.Column<string>(type: "character varying(8196)", maxLength: 8196, nullable: true),
|
||||||
|
config = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
|
trustworthy = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
enabled_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_check_in_results",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
level = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
reward_points = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
reward_experience = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
tips = table.Column<ICollection<FortuneTip>>(type: "jsonb", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_check_in_results", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_check_in_results_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "account_connections",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
provider = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
provided_identifier = table.Column<string>(type: "character varying(8192)", maxLength: 8192, nullable: false),
|
||||||
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
|
access_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
refresh_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_connections", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_connections_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<Guid>(type: "uuid", nullable: false),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
verified_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
is_primary = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
content = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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.CreateTable(
|
||||||
|
name: "account_profiles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
first_name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
middle_name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
last_name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
bio = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
gender = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
pronouns = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
time_zone = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
location = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
birthday = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
last_seen_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
verification = table.Column<VerificationMark>(type: "jsonb", nullable: true),
|
||||||
|
active_badge = table.Column<BadgeReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
experience = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
picture_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
background_id = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||||
|
picture = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
background = table.Column<CloudFileReferenceObject>(type: "jsonb", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_profiles", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_profiles_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "account_relationships",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
related_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
status = table.Column<short>(type: "smallint", 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_relationships", x => new { x.account_id, x.related_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_relationships_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_relationships_accounts_related_id",
|
||||||
|
column: x => x.related_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "account_statuses",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
attitude = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
is_invisible = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
is_not_disturb = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
cleared_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_statuses", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_account_statuses_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "action_logs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
action = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||||
|
ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
location = table.Column<Point>(type: "geometry", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
session_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_action_logs", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_action_logs_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "auth_challenges",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
step_remain = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
step_total = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
failed_attempts = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
platform = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
blacklist_factors = table.Column<List<Guid>>(type: "jsonb", nullable: false),
|
||||||
|
audiences = table.Column<List<string>>(type: "jsonb", nullable: false),
|
||||||
|
scopes = table.Column<List<string>>(type: "jsonb", nullable: false),
|
||||||
|
ip_address = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||||
|
user_agent = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: true),
|
||||||
|
device_id = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||||
|
nonce = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
location = table.Column<Point>(type: "geometry", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_auth_challenges", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_auth_challenges_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "badges",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
type = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
caption = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
activated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_badges", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_badges_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "magic_spells",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
spell = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_magic_spells", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_magic_spells_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "notification_push_subscriptions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
device_id = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
device_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
provider = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_notification_push_subscriptions", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_notification_push_subscriptions_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "notifications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
topic = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
title = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
subtitle = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
|
||||||
|
content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
|
priority = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
viewed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_notifications", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_notifications_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "wallets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_wallets", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_wallets_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "custom_app_secrets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
secret = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
is_oidc = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
app_id = table.Column<Guid>(type: "uuid", 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_custom_app_secrets", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_custom_app_secrets_custom_apps_app_id",
|
||||||
|
column: x => x.app_id,
|
||||||
|
principalTable: "custom_apps",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "permission_group_members",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
group_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
actor = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
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_permission_group_members", x => new { x.group_id, x.actor });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_permission_group_members_permission_groups_group_id",
|
||||||
|
column: x => x.group_id,
|
||||||
|
principalTable: "permission_groups",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "permission_nodes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
actor = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
area = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||||
|
value = table.Column<JsonDocument>(type: "jsonb", nullable: false),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
affected_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
group_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_permission_nodes", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_permission_nodes_permission_groups_group_id",
|
||||||
|
column: x => x.group_id,
|
||||||
|
principalTable: "permission_groups",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "wallet_subscriptions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
begun_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
ended_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
is_free_trial = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
payment_method = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
|
||||||
|
payment_details = table.Column<PaymentDetails>(type: "jsonb", nullable: false),
|
||||||
|
base_price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
coupon_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
renewal_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", 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_wallet_subscriptions", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_wallet_subscriptions_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_wallet_subscriptions_wallet_coupons_coupon_id",
|
||||||
|
column: x => x.coupon_id,
|
||||||
|
principalTable: "wallet_coupons",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "auth_sessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
label = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
|
||||||
|
last_granted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
|
||||||
|
account_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
challenge_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
app_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_auth_sessions", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_auth_sessions_accounts_account_id",
|
||||||
|
column: x => x.account_id,
|
||||||
|
principalTable: "accounts",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_auth_sessions_auth_challenges_challenge_id",
|
||||||
|
column: x => x.challenge_id,
|
||||||
|
principalTable: "auth_challenges",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_auth_sessions_custom_apps_app_id",
|
||||||
|
column: x => x.app_id,
|
||||||
|
principalTable: "custom_apps",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "payment_transactions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
remarks = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
type = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
payer_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
payee_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_payment_transactions", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_payment_transactions_wallets_payee_wallet_id",
|
||||||
|
column: x => x.payee_wallet_id,
|
||||||
|
principalTable: "wallets",
|
||||||
|
principalColumn: "id");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_payment_transactions_wallets_payer_wallet_id",
|
||||||
|
column: x => x.payer_wallet_id,
|
||||||
|
principalTable: "wallets",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "wallet_pockets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
wallet_id = table.Column<Guid>(type: "uuid", 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_wallet_pockets", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_wallet_pockets_wallets_wallet_id",
|
||||||
|
column: x => x.wallet_id,
|
||||||
|
principalTable: "wallets",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "payment_orders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||||
|
remarks = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
app_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
|
||||||
|
meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true),
|
||||||
|
amount = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
|
||||||
|
payee_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
transaction_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
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_payment_orders", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_payment_orders_payment_transactions_transaction_id",
|
||||||
|
column: x => x.transaction_id,
|
||||||
|
principalTable: "payment_transactions",
|
||||||
|
principalColumn: "id");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_payment_orders_wallets_payee_wallet_id",
|
||||||
|
column: x => x.payee_wallet_id,
|
||||||
|
principalTable: "wallets",
|
||||||
|
principalColumn: "id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_abuse_reports_account_id",
|
||||||
|
table: "abuse_reports",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_auth_factors_account_id",
|
||||||
|
table: "account_auth_factors",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_check_in_results_account_id",
|
||||||
|
table: "account_check_in_results",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_connections_account_id",
|
||||||
|
table: "account_connections",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_contacts_account_id",
|
||||||
|
table: "account_contacts",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_profiles_account_id",
|
||||||
|
table: "account_profiles",
|
||||||
|
column: "account_id",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_relationships_related_id",
|
||||||
|
table: "account_relationships",
|
||||||
|
column: "related_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_account_statuses_account_id",
|
||||||
|
table: "account_statuses",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_accounts_name",
|
||||||
|
table: "accounts",
|
||||||
|
column: "name",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_action_logs_account_id",
|
||||||
|
table: "action_logs",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_challenges_account_id",
|
||||||
|
table: "auth_challenges",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_sessions_account_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_sessions_app_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "app_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_auth_sessions_challenge_id",
|
||||||
|
table: "auth_sessions",
|
||||||
|
column: "challenge_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_badges_account_id",
|
||||||
|
table: "badges",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_custom_app_secrets_app_id",
|
||||||
|
table: "custom_app_secrets",
|
||||||
|
column: "app_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_magic_spells_account_id",
|
||||||
|
table: "magic_spells",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_magic_spells_spell",
|
||||||
|
table: "magic_spells",
|
||||||
|
column: "spell",
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_notification_push_subscriptions_account_id",
|
||||||
|
table: "notification_push_subscriptions",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_notification_push_subscriptions_device_token_device_id_acco",
|
||||||
|
table: "notification_push_subscriptions",
|
||||||
|
columns: new[] { "device_token", "device_id", "account_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_notifications_account_id",
|
||||||
|
table: "notifications",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_payment_orders_payee_wallet_id",
|
||||||
|
table: "payment_orders",
|
||||||
|
column: "payee_wallet_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_payment_orders_transaction_id",
|
||||||
|
table: "payment_orders",
|
||||||
|
column: "transaction_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_payment_transactions_payee_wallet_id",
|
||||||
|
table: "payment_transactions",
|
||||||
|
column: "payee_wallet_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_payment_transactions_payer_wallet_id",
|
||||||
|
table: "payment_transactions",
|
||||||
|
column: "payer_wallet_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_permission_nodes_group_id",
|
||||||
|
table: "permission_nodes",
|
||||||
|
column: "group_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_permission_nodes_key_area_actor",
|
||||||
|
table: "permission_nodes",
|
||||||
|
columns: new[] { "key", "area", "actor" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_wallet_pockets_wallet_id",
|
||||||
|
table: "wallet_pockets",
|
||||||
|
column: "wallet_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_wallet_subscriptions_account_id",
|
||||||
|
table: "wallet_subscriptions",
|
||||||
|
column: "account_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_wallet_subscriptions_coupon_id",
|
||||||
|
table: "wallet_subscriptions",
|
||||||
|
column: "coupon_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_wallet_subscriptions_identifier",
|
||||||
|
table: "wallet_subscriptions",
|
||||||
|
column: "identifier");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_wallets_account_id",
|
||||||
|
table: "wallets",
|
||||||
|
column: "account_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "abuse_reports");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_auth_factors");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_check_in_results");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_connections");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_contacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_profiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_relationships");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "account_statuses");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "action_logs");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "badges");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "custom_app_secrets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "magic_spells");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "notification_push_subscriptions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "notifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "payment_orders");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "permission_group_members");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "permission_nodes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "wallet_pockets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "wallet_subscriptions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "auth_challenges");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "custom_apps");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "payment_transactions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "permission_groups");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "wallet_coupons");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "wallets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "accounts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1967
DysonNetwork.Pass/Migrations/20250715075623_ReinitalMigration.Designer.cs
generated
Normal file
1967
DysonNetwork.Pass/Migrations/20250715075623_ReinitalMigration.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ReinitalMigration : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "background_id",
|
||||||
|
table: "account_profiles");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "picture_id",
|
||||||
|
table: "account_profiles");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "background_id",
|
||||||
|
table: "account_profiles",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "picture_id",
|
||||||
|
table: "account_profiles",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1964
DysonNetwork.Pass/Migrations/AppDatabaseModelSnapshot.cs
Normal file
1964
DysonNetwork.Pass/Migrations/AppDatabaseModelSnapshot.cs
Normal file
File diff suppressed because it is too large
Load Diff
110
DysonNetwork.Pass/Pages/Checkpoint/CheckpointPage.cshtml
Normal file
110
DysonNetwork.Pass/Pages/Checkpoint/CheckpointPage.cshtml
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
@page "/auth/captcha"
|
||||||
|
@model DysonNetwork.Pass.Pages.Checkpoint.CheckpointPage
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Security Checkpoint";
|
||||||
|
var cfg = ViewData.Model.Configuration;
|
||||||
|
var provider = cfg.GetSection("Captcha")["Provider"]?.ToLower();
|
||||||
|
var apiKey = cfg.GetSection("Captcha")["ApiKey"];
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
@switch (provider)
|
||||||
|
{
|
||||||
|
case "recaptcha":
|
||||||
|
<script src="https://www.recaptcha.net/recaptcha/api.js" async defer></script>
|
||||||
|
break;
|
||||||
|
case "cloudflare":
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
break;
|
||||||
|
case "hcaptcha":
|
||||||
|
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getQueryParam(name) {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSuccess(token) {
|
||||||
|
window.parent.postMessage("captcha_tk=" + token, "*");
|
||||||
|
const redirectUri = getQueryParam("redirect_uri");
|
||||||
|
if (redirectUri) {
|
||||||
|
window.location.href = `${redirectUri}?captcha_tk=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="hero min-h-full bg-base-200">
|
||||||
|
<div class="hero-content text-center">
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h1 class="card-title">Security Check</h1>
|
||||||
|
<p>Please complete the contest below to confirm you're not a robot</p>
|
||||||
|
|
||||||
|
<div class="flex justify-center my-8">
|
||||||
|
@switch (provider)
|
||||||
|
{
|
||||||
|
case "cloudflare":
|
||||||
|
<div class="cf-turnstile"
|
||||||
|
data-sitekey="@apiKey"
|
||||||
|
data-callback="onSuccess">
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
case "recaptcha":
|
||||||
|
<div class="g-recaptcha"
|
||||||
|
data-sitekey="@apiKey"
|
||||||
|
data-callback="onSuccess">
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
case "hcaptcha":
|
||||||
|
<div class="h-captcha"
|
||||||
|
data-sitekey="@apiKey"
|
||||||
|
data-callback="onSuccess">
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<span>Captcha provider not configured correctly.</span>
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center text-sm">
|
||||||
|
<div class="font-semibold mb-1">Solar Network Anti-Robot</div>
|
||||||
|
<div class="text-base-content/70">
|
||||||
|
Powered by
|
||||||
|
@switch (provider)
|
||||||
|
{
|
||||||
|
case "cloudflare":
|
||||||
|
<a href="https://www.cloudflare.com/turnstile/" class="link link-hover">
|
||||||
|
Cloudflare Turnstile
|
||||||
|
</a>
|
||||||
|
break;
|
||||||
|
case "recaptcha":
|
||||||
|
<a href="https://www.google.com/recaptcha/" class="link link-hover">
|
||||||
|
Google reCaptcha
|
||||||
|
</a>
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
<span>Nothing</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
<br/>
|
||||||
|
Hosted by
|
||||||
|
<a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
|
||||||
|
DysonNetwork.Pass
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,7 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Pages.Checkpoint;
|
namespace DysonNetwork.Pass.Pages.Checkpoint;
|
||||||
|
|
||||||
public class CheckpointPage(IConfiguration configuration) : PageModel
|
public class CheckpointPage(IConfiguration configuration) : PageModel
|
||||||
{
|
{
|
42
DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor
Normal file
42
DysonNetwork.Pass/Pages/Emails/AccountDeletionEmail.razor
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
@using DysonNetwork.Pass.Localization
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
|
||||||
|
<EmailLayout>
|
||||||
|
<tr>
|
||||||
|
<td class="wrapper">
|
||||||
|
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
|
||||||
|
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
|
||||||
|
<p>@(Localizer["AccountDeletionPara2"])</p>
|
||||||
|
<p>@(Localizer["AccountDeletionPara3"])</p>
|
||||||
|
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="@Link" target="_blank">
|
||||||
|
@(Localizer["AccountDeletionButton"])
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>@(Localizer["AccountDeletionPara4"])</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</EmailLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required string Name { get; set; }
|
||||||
|
[Parameter] public required string Link { get; set; }
|
||||||
|
|
||||||
|
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
@using DysonNetwork.Pass.Localization
|
||||||
|
@using Microsoft.Extensions.Localization
|
||||||
|
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||||
|
|
||||||
|
<EmailLayout>
|
||||||
|
<tr>
|
||||||
|
<td class="wrapper">
|
||||||
|
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
|
||||||
|
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
|
||||||
|
<p>@(Localizer["ContactVerificationPara2"])</p>
|
||||||
|
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="@Link" target="_blank">
|
||||||
|
@(Localizer["ContactVerificationButton"])
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>@(Localizer["ContactVerificationPara3"])</p>
|
||||||
|
<p>@(Localizer["ContactVerificationPara4"])</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</EmailLayout>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required string Name { get; set; }
|
||||||
|
[Parameter] public required string Link { get; set; }
|
||||||
|
|
||||||
|
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user