Compare commits
20 Commits
270c211cb8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
8642737a07
|
|||
|
8181938aaf
|
|||
|
922afc2239
|
|||
|
a071bd2738
|
|||
|
43945fc524
|
|||
|
e477429a35
|
|||
|
fe3a057185
|
|||
|
ad3c104c5c
|
|||
|
2020d625aa
|
|||
|
f471c5635d
|
|||
|
eaeaa28c60
|
|||
|
ee5c7cb7ce
|
|||
|
33abf12e41
|
|||
|
4a71f92ef0
|
|||
|
4faa1a4b64
|
|||
|
e49a1ec49a
|
|||
|
a88f42b26a
|
|||
|
c45be62331
|
|||
|
c8228e0c8e
|
|||
|
c642c6d646
|
@@ -1,22 +1,28 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5071",
|
"BaseUrl": "http://localhost:5071",
|
||||||
"SiteUrl": "https://solian.app",
|
"SiteUrl": "https://solian.app",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
],
|
||||||
|
"Swagger": {
|
||||||
|
"PublicBasePath": "/develop"
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"Etcd": {
|
||||||
|
"Insecure": true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
|
||||||
},
|
|
||||||
"KnownProxies": ["127.0.0.1", "::1"],
|
|
||||||
"Swagger": {
|
|
||||||
"PublicBasePath": "/develop"
|
|
||||||
},
|
|
||||||
"Etcd": {
|
|
||||||
"Insecure": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,118 +1,121 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5090",
|
"BaseUrl": "http://localhost:5090",
|
||||||
"GatewayUrl": "http://localhost:5094",
|
"GatewayUrl": "http://localhost:5094",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"ConnectionStrings": {
|
"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"
|
"App": "Host=localhost;Port=5432;Database=dyson_drive;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Schemes": {
|
"Schemes": {
|
||||||
"Bearer": {
|
"Bearer": {
|
||||||
"ValidAudiences": [
|
"ValidAudiences": [
|
||||||
"http://localhost:5071",
|
"http://localhost:5071",
|
||||||
"https://localhost:7099"
|
"https://localhost:7099"
|
||||||
],
|
],
|
||||||
"ValidIssuer": "solar-network"
|
"ValidIssuer": "solar-network"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AuthToken": {
|
"AuthToken": {
|
||||||
"PublicKeyPath": "Keys/PublicKey.pem",
|
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||||
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
"Uploads": "Uploads",
|
"Uploads": "Uploads",
|
||||||
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
||||||
"Remote": [
|
"Remote": [
|
||||||
{
|
{
|
||||||
"Id": "minio",
|
"Id": "minio",
|
||||||
"Label": "Minio",
|
"Label": "Minio",
|
||||||
"Region": "auto",
|
"Region": "auto",
|
||||||
"Bucket": "solar-network-development",
|
"Bucket": "solar-network-development",
|
||||||
"Endpoint": "localhost:9000",
|
"Endpoint": "localhost:9000",
|
||||||
"SecretId": "littlesheep",
|
"SecretId": "littlesheep",
|
||||||
"SecretKey": "password",
|
"SecretKey": "password",
|
||||||
"EnabledSigned": true,
|
"EnabledSigned": true,
|
||||||
"EnableSsl": false
|
"EnableSsl": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Id": "cloudflare",
|
"Id": "cloudflare",
|
||||||
"Label": "Cloudflare R2",
|
"Label": "Cloudflare R2",
|
||||||
"Region": "auto",
|
"Region": "auto",
|
||||||
"Bucket": "solar-network",
|
"Bucket": "solar-network",
|
||||||
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
"Endpoint": "0a70a6d1b7128888c823359d0008f4e1.r2.cloudflarestorage.com",
|
||||||
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
"SecretId": "8ff5d06c7b1639829d60bc6838a542e6",
|
||||||
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
|
"SecretKey": "fd58158c5201be16d1872c9209d9cf199421dae3c2f9972f94b2305976580d67",
|
||||||
"EnableSigned": true,
|
"EnableSigned": true,
|
||||||
"EnableSsl": 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"Captcha": {
|
|
||||||
"Provider": "cloudflare",
|
|
||||||
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
|
||||||
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
|
||||||
},
|
|
||||||
"Notifications": {
|
|
||||||
"Topic": "dev.solsynth.solian",
|
|
||||||
"Endpoint": "http://localhost:8088"
|
|
||||||
},
|
|
||||||
"Email": {
|
|
||||||
"Server": "smtp4dev.orb.local",
|
|
||||||
"Port": 25,
|
|
||||||
"UseSsl": false,
|
|
||||||
"Username": "no-reply@mail.solsynth.dev",
|
|
||||||
"Password": "password",
|
|
||||||
"FromAddress": "no-reply@mail.solsynth.dev",
|
|
||||||
"FromName": "Alphabot",
|
|
||||||
"SubjectPrefix": "Solar Network"
|
|
||||||
},
|
|
||||||
"RealtimeChat": {
|
|
||||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
|
||||||
"ApiKey": "APIs6TiL8wj3A4j",
|
|
||||||
"ApiSecret": "SffxRneIwTnlHPtEf3zicmmv3LUEl7xXael4PvWZrEhE"
|
|
||||||
},
|
|
||||||
"GeoIp": {
|
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
|
||||||
},
|
|
||||||
"Oidc": {
|
|
||||||
"Google": {
|
|
||||||
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
|
||||||
"ClientSecret": ""
|
|
||||||
},
|
|
||||||
"Apple": {
|
|
||||||
"ClientId": "dev.solsynth.solian",
|
|
||||||
"TeamId": "W7HPZ53V6B",
|
|
||||||
"KeyId": "B668YP4KBG",
|
|
||||||
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
|
||||||
},
|
|
||||||
"Microsoft": {
|
|
||||||
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
|
||||||
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
|
||||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Payment": {
|
|
||||||
"Auth": {
|
|
||||||
"Afdian": "<token here>"
|
|
||||||
},
|
|
||||||
"Subscriptions": {
|
|
||||||
"Afdian": {
|
|
||||||
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
|
||||||
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
|
||||||
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"KnownProxies": [
|
|
||||||
"127.0.0.1",
|
|
||||||
"::1"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"SiteUrl": "http://localhost:3000",
|
||||||
|
"Client": {
|
||||||
|
"SomeSetting": "SomeValue"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"SiteUrl": "http://localhost:3000",
|
|
||||||
"Client": {
|
|
||||||
"SomeSetting": "SomeValue"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,9 @@
|
|||||||
"Etcd": {
|
"Etcd": {
|
||||||
"Insecure": true
|
"Insecure": true
|
||||||
},
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
"Thinking": {
|
"Thinking": {
|
||||||
"DefaultService": "deepseek-chat",
|
"DefaultService": "deepseek-chat",
|
||||||
"Services": {
|
"Services": {
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ public class AccountCurrentController(
|
|||||||
|
|
||||||
[HttpGet("devices")]
|
[HttpGet("devices")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<SnAuthClientWithChallenge>>> GetDevices()
|
public async Task<ActionResult<List<SnAuthClientWithSessions>>> GetDevices()
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser ||
|
||||||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||||
@@ -571,18 +571,41 @@ public class AccountCurrentController(
|
|||||||
.Where(device => device.AccountId == currentUser.Id)
|
.Where(device => device.AccountId == currentUser.Id)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var challengeDevices = devices.Select(SnAuthClientWithChallenge.FromClient).ToList();
|
var sessionDevices = devices.ConvertAll(SnAuthClientWithSessions.FromClient).ToList();
|
||||||
var deviceIds = challengeDevices.Select(x => x.DeviceId).ToList();
|
var clientIds = sessionDevices.Select(x => x.Id).ToList();
|
||||||
|
|
||||||
var authChallenges = await db.AuthChallenges
|
var authSessions = await db.AuthSessions
|
||||||
.Where(c => deviceIds.Contains(c.DeviceId))
|
.Where(c => c.ClientId != null && clientIds.Contains(c.ClientId.Value))
|
||||||
.GroupBy(c => c.DeviceId)
|
.GroupBy(c => c.ClientId!.Value)
|
||||||
.ToDictionaryAsync(c => c.Key, c => c.ToList());
|
.ToDictionaryAsync(c => c.Key, c => c.ToList());
|
||||||
foreach (var challengeDevice in challengeDevices)
|
foreach (var dev in sessionDevices)
|
||||||
if (authChallenges.TryGetValue(challengeDevice.DeviceId, out var challenge))
|
if (authSessions.TryGetValue(dev.Id, out var challenge))
|
||||||
challengeDevice.Challenges = challenge;
|
dev.Sessions = challenge;
|
||||||
|
|
||||||
return Ok(challengeDevices);
|
return Ok(sessionDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("challenges")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<List<SnAuthChallenge>>> GetChallenges(
|
||||||
|
[FromQuery] int take = 20,
|
||||||
|
[FromQuery] int offset = 0
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var query = db.AuthChallenges
|
||||||
|
.Where(challenge => challenge.AccountId == currentUser.Id)
|
||||||
|
.OrderByDescending(c => c.CreatedAt);
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
Response.Headers.Append("X-Total", total.ToString());
|
||||||
|
|
||||||
|
var challenges = await query
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(challenges);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("sessions")]
|
[HttpGet("sessions")]
|
||||||
@@ -596,6 +619,7 @@ public class AccountCurrentController(
|
|||||||
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
HttpContext.Items["CurrentSession"] is not SnAuthSession currentSession) return Unauthorized();
|
||||||
|
|
||||||
var query = db.AuthSessions
|
var query = db.AuthSessions
|
||||||
|
.OrderByDescending(x => x.LastGrantedAt)
|
||||||
.Include(session => session.Account)
|
.Include(session => session.Account)
|
||||||
.Where(session => session.Account.Id == currentUser.Id);
|
.Where(session => session.Account.Id == currentUser.Id);
|
||||||
|
|
||||||
@@ -604,7 +628,6 @@ public class AccountCurrentController(
|
|||||||
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
Response.Headers.Append("X-Auth-Session", currentSession.Id.ToString());
|
||||||
|
|
||||||
var sessions = await query
|
var sessions = await query
|
||||||
.OrderByDescending(x => x.LastGrantedAt)
|
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -7,6 +9,20 @@ namespace DysonNetwork.Pass.Account;
|
|||||||
[Route("/api/spells")]
|
[Route("/api/spells")]
|
||||||
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
public class MagicSpellController(AppDatabase db, MagicSpellService sp) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpPost("activation/resend")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult> ResendActivationMagicSpell()
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
|
||||||
|
|
||||||
|
var spell = await db.MagicSpells.FirstOrDefaultAsync(s =>
|
||||||
|
s.Type == MagicSpellType.AccountActivation && s.AccountId == currentUser.Id);
|
||||||
|
if (spell is null) return BadRequest("Unable to find activation magic spell.");
|
||||||
|
|
||||||
|
await sp.NotifyMagicSpell(spell, true);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{spellId:guid}/resend")]
|
[HttpPost("{spellId:guid}/resend")]
|
||||||
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
public async Task<ActionResult> ResendMagicSpell(Guid spellId)
|
||||||
{
|
{
|
||||||
@@ -38,7 +54,8 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{spellWord}/apply")]
|
[HttpPost("{spellWord}/apply")]
|
||||||
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord, [FromBody] MagicSpellApplyRequest? request)
|
public async Task<ActionResult> ApplyMagicSpell([FromRoute] string spellWord,
|
||||||
|
[FromBody] MagicSpellApplyRequest? request)
|
||||||
{
|
{
|
||||||
var word = Uri.UnescapeDataString(spellWord);
|
var word = Uri.UnescapeDataString(spellWord);
|
||||||
var spell = await db.MagicSpells
|
var spell = await db.MagicSpells
|
||||||
@@ -59,6 +76,7 @@ public class MagicSpellController(AppDatabase db, MagicSpellService sp) : Contro
|
|||||||
{
|
{
|
||||||
return BadRequest(ex.Message);
|
return BadRequest(ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using DysonNetwork.Shared.Cache;
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.GeoIp;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
@@ -14,7 +15,8 @@ public class AuthService(
|
|||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ICacheService cache
|
ICacheService cache,
|
||||||
|
GeoIpService geo
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
private HttpContext HttpContext => httpContextAccessor.HttpContext!;
|
||||||
@@ -48,7 +50,10 @@ public class AuthService(
|
|||||||
.Take(10)
|
.Take(10)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var recentChallengeIds = recentSessions.Where(s => s.ChallengeId != null).Select(s => s.ChallengeId.Value).ToList();
|
var recentChallengeIds =
|
||||||
|
recentSessions
|
||||||
|
.Where(s => s.ChallengeId != null)
|
||||||
|
.Select(s => s.ChallengeId!.Value).ToList();
|
||||||
var recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
|
var recentChallenges = await db.AuthChallenges.Where(c => recentChallengeIds.Contains(c.Id)).ToListAsync();
|
||||||
|
|
||||||
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
var ipAddress = request.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
@@ -183,16 +188,23 @@ public class AuthService(
|
|||||||
return totalRequiredSteps;
|
return totalRequiredSteps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnAuthSession> CreateSessionForOidcAsync(SnAccount account, Instant time,
|
public async Task<SnAuthSession> CreateSessionForOidcAsync(
|
||||||
Guid? customAppId = null, SnAuthSession? parentSession = null)
|
SnAccount account,
|
||||||
|
Instant time,
|
||||||
|
Guid? customAppId = null,
|
||||||
|
SnAuthSession? parentSession = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
|
var ipAddr = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var geoLocation = ipAddr is not null ? geo.GetPointFromIp(ipAddr) : null;
|
||||||
var session = new SnAuthSession
|
var session = new SnAuthSession
|
||||||
{
|
{
|
||||||
AccountId = account.Id,
|
AccountId = account.Id,
|
||||||
CreatedAt = time,
|
CreatedAt = time,
|
||||||
LastGrantedAt = time,
|
LastGrantedAt = time,
|
||||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
IpAddress = ipAddr,
|
||||||
UserAgent = HttpContext.Request.Headers.UserAgent,
|
UserAgent = HttpContext.Request.Headers.UserAgent,
|
||||||
|
Location = geoLocation,
|
||||||
AppId = customAppId,
|
AppId = customAppId,
|
||||||
ParentSessionId = parentSession?.Id,
|
ParentSessionId = parentSession?.Id,
|
||||||
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
|
Type = customAppId is not null ? SessionType.OAuth : SessionType.Oidc,
|
||||||
@@ -211,7 +223,8 @@ public class AuthService(
|
|||||||
ClientPlatform platform = ClientPlatform.Unidentified
|
ClientPlatform platform = ClientPlatform.Unidentified
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var device = await db.AuthClients.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
var device = await db.AuthClients
|
||||||
|
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.AccountId == accountId);
|
||||||
if (device is not null) return device;
|
if (device is not null) return device;
|
||||||
device = new SnAuthClient
|
device = new SnAuthClient
|
||||||
{
|
{
|
||||||
@@ -332,12 +345,8 @@ public class AuthService(
|
|||||||
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
|
/// <param name="sessionsToRevoke">A HashSet to store the IDs of all sessions to be revoked.</param>
|
||||||
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
|
private async Task CollectSessionsToRevoke(Guid currentSessionId, HashSet<Guid> sessionsToRevoke)
|
||||||
{
|
{
|
||||||
if (sessionsToRevoke.Contains(currentSessionId))
|
if (!sessionsToRevoke.Add(currentSessionId))
|
||||||
{
|
|
||||||
return; // Already processed this session
|
return; // Already processed this session
|
||||||
}
|
|
||||||
|
|
||||||
sessionsToRevoke.Add(currentSessionId);
|
|
||||||
|
|
||||||
// Find direct children
|
// Find direct children
|
||||||
var childSessions = await db.AuthSessions
|
var childSessions = await db.AuthSessions
|
||||||
@@ -409,8 +418,12 @@ public class AuthService(
|
|||||||
if (challenge.StepRemain != 0)
|
if (challenge.StepRemain != 0)
|
||||||
throw new ArgumentException("Challenge not yet completed.");
|
throw new ArgumentException("Challenge not yet completed.");
|
||||||
|
|
||||||
var device = await GetOrCreateDeviceAsync(challenge.AccountId, challenge.DeviceId, challenge.DeviceName,
|
var device = await GetOrCreateDeviceAsync(
|
||||||
challenge.Platform);
|
challenge.AccountId,
|
||||||
|
challenge.DeviceId,
|
||||||
|
challenge.DeviceName,
|
||||||
|
challenge.Platform
|
||||||
|
);
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var session = new SnAuthSession
|
var session = new SnAuthSession
|
||||||
@@ -420,8 +433,9 @@ public class AuthService(
|
|||||||
AccountId = challenge.AccountId,
|
AccountId = challenge.AccountId,
|
||||||
IpAddress = challenge.IpAddress,
|
IpAddress = challenge.IpAddress,
|
||||||
UserAgent = challenge.UserAgent,
|
UserAgent = challenge.UserAgent,
|
||||||
|
Location = challenge.Location,
|
||||||
Scopes = challenge.Scopes,
|
Scopes = challenge.Scopes,
|
||||||
Audiences = challenge.Audiences,
|
Audiences = challenge.Audiences,
|
||||||
ChallengeId = challenge.Id,
|
ChallengeId = challenge.Id,
|
||||||
ClientId = device.Id,
|
ClientId = device.Id,
|
||||||
};
|
};
|
||||||
@@ -537,7 +551,8 @@ public class AuthService(
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null, SnAuthSession? parentSession = null)
|
public async Task<SnApiKey> CreateApiKey(Guid accountId, string label, Instant? expiredAt = null,
|
||||||
|
SnAuthSession? parentSession = null)
|
||||||
{
|
{
|
||||||
var key = new SnApiKey
|
var key = new SnApiKey
|
||||||
{
|
{
|
||||||
@@ -673,9 +688,16 @@ public class AuthService(
|
|||||||
{
|
{
|
||||||
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
|
var device = await GetOrCreateDeviceAsync(parentSession.AccountId, deviceId, deviceName, platform);
|
||||||
|
|
||||||
|
var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var userAgent = HttpContext.Request.Headers.UserAgent.ToString();
|
||||||
|
var geoLocation = ipAddress is not null ? geo.GetPointFromIp(ipAddress) : null;
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
var session = new SnAuthSession
|
var session = new SnAuthSession
|
||||||
{
|
{
|
||||||
|
IpAddress = ipAddress,
|
||||||
|
UserAgent = userAgent,
|
||||||
|
Location = geoLocation,
|
||||||
AccountId = parentSession.AccountId,
|
AccountId = parentSession.AccountId,
|
||||||
CreatedAt = now,
|
CreatedAt = now,
|
||||||
LastGrantedAt = now,
|
LastGrantedAt = now,
|
||||||
|
|||||||
@@ -23,11 +23,6 @@ public class OidcController(
|
|||||||
private const string StateCachePrefix = "oidc-state:";
|
private const string StateCachePrefix = "oidc-state:";
|
||||||
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan StateExpiration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
public class TokenExchangeResponse
|
|
||||||
{
|
|
||||||
public string Token { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{provider}")]
|
[HttpGet("{provider}")]
|
||||||
public async Task<ActionResult> OidcLogin(
|
public async Task<ActionResult> OidcLogin(
|
||||||
[FromRoute] string provider,
|
[FromRoute] string provider,
|
||||||
@@ -81,7 +76,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<TokenExchangeResponse>> AppleMobileLogin(
|
public async Task<ActionResult<AuthController.TokenExchangeResponse>> AppleMobileLogin(
|
||||||
[FromBody] AppleMobileSignInRequest request
|
[FromBody] AppleMobileSignInRequest request
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -118,7 +113,7 @@ public class OidcController(
|
|||||||
);
|
);
|
||||||
|
|
||||||
var token = auth.CreateToken(session);
|
var token = auth.CreateToken(session);
|
||||||
return Ok(new TokenExchangeResponse { Token = token });
|
return Ok(new AuthController.TokenExchangeResponse { Token = token });
|
||||||
}
|
}
|
||||||
catch (SecurityTokenValidationException ex)
|
catch (SecurityTokenValidationException ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ using Quartz;
|
|||||||
|
|
||||||
namespace DysonNetwork.Pass.Handlers;
|
namespace DysonNetwork.Pass.Handlers;
|
||||||
|
|
||||||
public class ActionLogFlushHandler(IServiceProvider serviceProvider) : IFlushHandler<SnActionLog>
|
public class ActionLogFlushHandler(IServiceProvider sp) : IFlushHandler<SnActionLog>
|
||||||
{
|
{
|
||||||
public async Task FlushAsync(IReadOnlyList<SnActionLog> items)
|
public async Task FlushAsync(IReadOnlyList<SnActionLog> items)
|
||||||
{
|
{
|
||||||
using var scope = serviceProvider.CreateScope();
|
using var scope = sp.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|||||||
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
2886
DysonNetwork.Pass/Migrations/20251203163459_AddLocationToSession.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using DysonNetwork.Shared.GeoIp;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Pass.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLocationToSession : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<GeoPoint>(
|
||||||
|
name: "location",
|
||||||
|
table: "auth_sessions",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "location",
|
||||||
|
table: "auth_sessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1053,6 +1053,10 @@ namespace DysonNetwork.Pass.Migrations
|
|||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_granted_at");
|
.HasColumnName("last_granted_at");
|
||||||
|
|
||||||
|
b.Property<GeoPoint>("Location")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("location");
|
||||||
|
|
||||||
b.Property<Guid?>("ParentSessionId")
|
b.Property<Guid?>("ParentSessionId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("parent_session_id");
|
.HasColumnName("parent_session_id");
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ public class RealmServiceGrpc(
|
|||||||
: realm.ToProtoValue();
|
: realm.ToProtoValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request, ServerCallContext context)
|
public override async Task<GetRealmBatchResponse> GetRealmBatch(GetRealmBatchRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
var ids = request.Ids.Select(Guid.Parse).ToList();
|
var ids = request.Ids.Select(Guid.Parse).ToList();
|
||||||
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
|
var realms = await db.Realms.Where(r => ids.Contains(r.Id)).ToListAsync();
|
||||||
@@ -67,19 +68,33 @@ public class RealmServiceGrpc(
|
|||||||
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
|
return new GetUserRealmsResponse { RealmIds = { realms.Select(g => g.ToString()) } };
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<GetPublicRealmsResponse> GetPublicRealms(Empty request, ServerCallContext context)
|
public override Task<GetPublicRealmsResponse> GetPublicRealms(
|
||||||
|
GetPublicRealmsRequest request,
|
||||||
|
ServerCallContext context
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var realms = await db.Realms.Where(r => r.IsPublic).ToListAsync();
|
var realmsQueryable = db.Realms.Where(r => r.IsPublic).AsQueryable();
|
||||||
|
|
||||||
|
realmsQueryable = request.OrderBy switch
|
||||||
|
{
|
||||||
|
"random" => realmsQueryable.OrderBy(_ => EF.Functions.Random()),
|
||||||
|
"name" => realmsQueryable.OrderBy(r => r.Name),
|
||||||
|
"popularity" => realmsQueryable.OrderByDescending(r => r.Members.Count),
|
||||||
|
_ => realmsQueryable.OrderByDescending(r => r.CreatedAt)
|
||||||
|
};
|
||||||
|
|
||||||
var response = new GetPublicRealmsResponse();
|
var response = new GetPublicRealmsResponse();
|
||||||
response.Realms.AddRange(realms.Select(r => r.ToProtoValue()));
|
response.Realms.AddRange(realmsQueryable.Select(r => r.ToProtoValue()));
|
||||||
return response;
|
return Task.FromResult(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request, ServerCallContext context)
|
public override async Task<GetPublicRealmsResponse> SearchRealms(SearchRealmsRequest request,
|
||||||
|
ServerCallContext context)
|
||||||
{
|
{
|
||||||
var realms = await db.Realms
|
var realms = await db.Realms
|
||||||
.Where(r => r.IsPublic)
|
.Where(r => r.IsPublic)
|
||||||
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") || EF.Functions.Like(r.Name, $"{request.Query}%"))
|
.Where(r => EF.Functions.Like(r.Slug, $"{request.Query}%") ||
|
||||||
|
EF.Functions.Like(r.Name, $"{request.Query}%"))
|
||||||
.Take(request.Limit)
|
.Take(request.Limit)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
var response = new GetPublicRealmsResponse();
|
var response = new GetPublicRealmsResponse();
|
||||||
|
|||||||
@@ -1,75 +1,84 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5001",
|
"BaseUrl": "http://localhost:5001",
|
||||||
"SiteUrl": "http://localhost:3000",
|
"SiteUrl": "http://localhost:3000",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
|
||||||
},
|
|
||||||
"Authentication": {
|
|
||||||
"Schemes": {
|
|
||||||
"Bearer": {
|
|
||||||
"ValidAudiences": ["http://localhost:5071", "https://localhost:7099"],
|
|
||||||
"ValidIssuer": "solar-network"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AuthToken": {
|
|
||||||
"CookieDomain": "localhost",
|
|
||||||
"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
|
|
||||||
},
|
|
||||||
"Captcha": {
|
|
||||||
"Provider": "cloudflare",
|
|
||||||
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
|
||||||
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
|
||||||
},
|
|
||||||
"GeoIp": {
|
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
|
||||||
},
|
|
||||||
"Oidc": {
|
|
||||||
"Google": {
|
|
||||||
"ClientId": "961776991058-963m1qin2vtp8fv693b5fdrab5hmpl89.apps.googleusercontent.com",
|
|
||||||
"ClientSecret": ""
|
|
||||||
},
|
},
|
||||||
"Apple": {
|
"AllowedHosts": "*",
|
||||||
"ClientId": "dev.solsynth.solian",
|
"ConnectionStrings": {
|
||||||
"TeamId": "W7HPZ53V6B",
|
"App": "Host=localhost;Port=5432;Database=dyson_pass;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
"KeyId": "B668YP4KBG",
|
|
||||||
"PrivateKeyPath": "./Keys/Solarpass.p8"
|
|
||||||
},
|
},
|
||||||
"Microsoft": {
|
"Authentication": {
|
||||||
"ClientId": "YOUR_MICROSOFT_CLIENT_ID",
|
"Schemes": {
|
||||||
"ClientSecret": "YOUR_MICROSOFT_CLIENT_SECRET",
|
"Bearer": {
|
||||||
"DiscoveryEndpoint": "YOUR_MICROSOFT_DISCOVERY_ENDPOINT"
|
"ValidAudiences": [
|
||||||
}
|
"http://localhost:5071",
|
||||||
},
|
"https://localhost:7099"
|
||||||
"Payment": {
|
],
|
||||||
"Auth": {
|
"ValidIssuer": "solar-network"
|
||||||
"Afdian": "<token here>"
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Subscriptions": {
|
"AuthToken": {
|
||||||
"Afdian": {
|
"CookieDomain": "localhost",
|
||||||
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||||
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
"PrivateKeyPath": "Keys/PrivateKey.pem"
|
||||||
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
},
|
||||||
}
|
"OidcProvider": {
|
||||||
}
|
"IssuerUri": "https://nt.solian.app",
|
||||||
},
|
"PublicKeyPath": "Keys/PublicKey.pem",
|
||||||
"KnownProxies": ["127.0.0.1", "::1"]
|
"PrivateKeyPath": "Keys/PrivateKey.pem",
|
||||||
|
"AccessTokenLifetime": "01:00:00",
|
||||||
|
"RefreshTokenLifetime": "30.00:00:00",
|
||||||
|
"AuthorizationCodeLifetime": "00:30:00",
|
||||||
|
"RequireHttpsMetadata": true
|
||||||
|
},
|
||||||
|
"Captcha": {
|
||||||
|
"Provider": "cloudflare",
|
||||||
|
"ApiKey": "0x4AAAAAABCDUdOujj4feOb_",
|
||||||
|
"ApiSecret": "0x4AAAAAABCDUWABiJQweqlB7tYq-IqIm8U"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"Payment": {
|
||||||
|
"Auth": {
|
||||||
|
"Afdian": "<token here>"
|
||||||
|
},
|
||||||
|
"Subscriptions": {
|
||||||
|
"Afdian": {
|
||||||
|
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
|
||||||
|
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
|
||||||
|
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public class WebSocketController(
|
|||||||
var deviceId = currentSession.ClientId;
|
var deviceId = currentSession.ClientId;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(deviceId))
|
if (string.IsNullOrEmpty(deviceId))
|
||||||
return BadRequest("Unable to determine device id");
|
deviceId = Guid.NewGuid().ToString();
|
||||||
if (deviceAlt is not null)
|
if (deviceAlt is not null)
|
||||||
deviceId = $"{deviceId}+{deviceAlt}";
|
deviceId = $"{deviceId}+{deviceAlt}";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using CorePush.Apple;
|
|||||||
using CorePush.Firebase;
|
using CorePush.Firebase;
|
||||||
using DysonNetwork.Ring.Connection;
|
using DysonNetwork.Ring.Connection;
|
||||||
using DysonNetwork.Ring.Services;
|
using DysonNetwork.Ring.Services;
|
||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -17,12 +18,14 @@ public class PushService
|
|||||||
private readonly ILogger<PushService> _logger;
|
private readonly ILogger<PushService> _logger;
|
||||||
private readonly FirebaseSender? _fcm;
|
private readonly FirebaseSender? _fcm;
|
||||||
private readonly ApnSender? _apns;
|
private readonly ApnSender? _apns;
|
||||||
|
private readonly FlushBufferService _fbs;
|
||||||
private readonly string? _apnsTopic;
|
private readonly string? _apnsTopic;
|
||||||
|
|
||||||
public PushService(
|
public PushService(
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
QueueService queueService,
|
QueueService queueService,
|
||||||
|
FlushBufferService fbs,
|
||||||
IHttpClientFactory httpFactory,
|
IHttpClientFactory httpFactory,
|
||||||
ILogger<PushService> logger
|
ILogger<PushService> logger
|
||||||
)
|
)
|
||||||
@@ -52,6 +55,7 @@ public class PushService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_fbs = fbs;
|
||||||
_queueService = queueService;
|
_queueService = queueService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -144,7 +148,8 @@ public class PushService
|
|||||||
_ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
|
_ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeliverPushNotification(SnNotification notification, CancellationToken cancellationToken = default)
|
public async Task DeliverPushNotification(SnNotification notification,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
|
WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
|
||||||
{
|
{
|
||||||
@@ -260,7 +265,8 @@ public class PushService
|
|||||||
await DeliverPushNotification(notification);
|
await DeliverPushNotification(notification);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription, SnNotification notification)
|
private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription,
|
||||||
|
SnNotification notification)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -302,7 +308,9 @@ public class PushService
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fcmResult.Error != null)
|
if (fcmResult.StatusCode is 404 or 410)
|
||||||
|
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
|
||||||
|
else if (fcmResult.Error != null)
|
||||||
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
|
throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -338,7 +346,10 @@ public class PushService
|
|||||||
apnsPriority: notification.Priority,
|
apnsPriority: notification.Priority,
|
||||||
apnPushType: ApnPushType.Alert
|
apnPushType: ApnPushType.Alert
|
||||||
);
|
);
|
||||||
if (apnResult.Error != null)
|
|
||||||
|
if (apnResult.StatusCode is 404 or 410)
|
||||||
|
_fbs.Enqueue(new PushSubRemovalRequest { SubId = subscription.Id });
|
||||||
|
else if (apnResult.Error != null)
|
||||||
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
|
throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|||||||
35
DysonNetwork.Ring/Services/PushSubFlushHandler.cs
Normal file
35
DysonNetwork.Ring/Services/PushSubFlushHandler.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using DysonNetwork.Shared.Cache;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Ring.Services;
|
||||||
|
|
||||||
|
public class PushSubRemovalRequest
|
||||||
|
{
|
||||||
|
public Guid SubId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PushSubFlushHandler(IServiceProvider sp) : IFlushHandler<PushSubRemovalRequest>
|
||||||
|
{
|
||||||
|
public async Task FlushAsync(IReadOnlyList<PushSubRemovalRequest> items)
|
||||||
|
{
|
||||||
|
using var scope = sp.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PushSubFlushHandler>>();
|
||||||
|
|
||||||
|
var tokenIds = items.Select(x => x.SubId).Distinct().ToList();
|
||||||
|
|
||||||
|
var count = await db.PushSubscriptions
|
||||||
|
.Where(s => tokenIds.Contains(s.Id))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
logger.LogInformation("Removed {Count} invalid push notification tokens...", count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PushSubFlushJob(FlushBufferService fbs, PushSubFlushHandler hdl) : IJob
|
||||||
|
{
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
await fbs.FlushAsync(hdl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using DysonNetwork.Ring.Notification;
|
using DysonNetwork.Ring.Services;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace DysonNetwork.Ring.Startup;
|
namespace DysonNetwork.Ring.Startup;
|
||||||
@@ -15,6 +15,15 @@ public static class ScheduledJobsConfiguration
|
|||||||
.ForJob(appDatabaseRecyclingJob)
|
.ForJob(appDatabaseRecyclingJob)
|
||||||
.WithIdentity("AppDatabaseRecyclingTrigger")
|
.WithIdentity("AppDatabaseRecyclingTrigger")
|
||||||
.WithCronSchedule("0 0 0 * * ?"));
|
.WithCronSchedule("0 0 0 * * ?"));
|
||||||
|
|
||||||
|
q.AddJob<PushSubFlushJob>(opts => opts.WithIdentity("PushSubFlush"));
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob("PushSubFlush")
|
||||||
|
.WithIdentity("PushSubFlushTrigger")
|
||||||
|
.WithSimpleSchedule(o => o
|
||||||
|
.WithIntervalInMinutes(5)
|
||||||
|
.RepeatForever())
|
||||||
|
);
|
||||||
});
|
});
|
||||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public static class ServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
public static IServiceCollection AddAppFlushHandlers(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<FlushBufferService>();
|
services.AddSingleton<FlushBufferService>();
|
||||||
|
services.AddScoped<PushSubFlushHandler>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,53 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5212",
|
"BaseUrl": "http://localhost:5212",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
|
},
|
||||||
|
"Notifications": {
|
||||||
|
"Push": {
|
||||||
|
"Production": true,
|
||||||
|
"Google": "./Keys/Solian.json",
|
||||||
|
"Apple": {
|
||||||
|
"PrivateKey": "./Keys/Solian.p8",
|
||||||
|
"PrivateKeyId": "4US4KSX4W6",
|
||||||
|
"TeamId": "W7HPZ53V6B",
|
||||||
|
"BundleIdentifier": "dev.solsynth.solian"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"GeoIp": {
|
||||||
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
],
|
||||||
|
"Service": {
|
||||||
|
"Name": "DysonNetwork.Ring",
|
||||||
|
"Url": "https://localhost:7259"
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"Etcd": {
|
||||||
|
"Insecure": true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
|
||||||
},
|
|
||||||
"Notifications": {
|
|
||||||
"Push": {
|
|
||||||
"Production": true,
|
|
||||||
"Google": "./Keys/Solian.json",
|
|
||||||
"Apple": {
|
|
||||||
"PrivateKey": "./Keys/Solian.p8",
|
|
||||||
"PrivateKeyId": "4US4KSX4W6",
|
|
||||||
"TeamId": "W7HPZ53V6B",
|
|
||||||
"BundleIdentifier": "dev.solsynth.solian"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
|
||||||
"GeoIp": {
|
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
|
||||||
},
|
|
||||||
"KnownProxies": ["127.0.0.1", "::1"],
|
|
||||||
"Service": {
|
|
||||||
"Name": "DysonNetwork.Ring",
|
|
||||||
"Url": "https://localhost:7259"
|
|
||||||
},
|
|
||||||
"Etcd": {
|
|
||||||
"Insecure": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,7 @@ public static class Extensions
|
|||||||
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
|
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
|
||||||
|
|
||||||
builder.AddNatsClient("queue");
|
builder.AddNatsClient("queue");
|
||||||
builder.AddRedisClient("cache", configureOptions: opts =>
|
builder.AddRedisClient("cache", configureOptions: opts => { opts.AbortOnConnectFail = false; });
|
||||||
{
|
|
||||||
opts.AbortOnConnectFail = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup cache service
|
// Setup cache service
|
||||||
builder.Services.AddStackExchangeRedisCache(options =>
|
builder.Services.AddStackExchangeRedisCache(options =>
|
||||||
@@ -70,7 +67,10 @@ public static class Extensions
|
|||||||
return RedLockFactory.Create(new List<RedLockMultiplexer> { new(mux) });
|
return RedLockFactory.Create(new List<RedLockMultiplexer> { new(mux) });
|
||||||
});
|
});
|
||||||
builder.Services.AddSingleton<ICacheService, CacheServiceRedis>();
|
builder.Services.AddSingleton<ICacheService, CacheServiceRedis>();
|
||||||
builder.Services.AddSingleton<ICacheSerializer, MessagePackCacheSerializer>();
|
if (builder.Configuration.GetSection("Cache")["Serializer"] == "MessagePack")
|
||||||
|
builder.Services.AddSingleton<ICacheSerializer, MessagePackCacheSerializer>();
|
||||||
|
else
|
||||||
|
builder.Services.AddSingleton<ICacheSerializer, JsonCacheSerializer>();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class SnAuthSession : ModelBase
|
|||||||
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
|
[Column(TypeName = "jsonb")] public List<string> Scopes { get; set; } = [];
|
||||||
[MaxLength(128)] public string? IpAddress { get; set; }
|
[MaxLength(128)] public string? IpAddress { get; set; }
|
||||||
[MaxLength(512)] public string? UserAgent { get; set; }
|
[MaxLength(512)] public string? UserAgent { get; set; }
|
||||||
|
[Column(TypeName = "jsonb")] public GeoPoint? Location { get; set; }
|
||||||
|
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
[JsonIgnore] public SnAccount Account { get; set; } = null!;
|
[JsonIgnore] public SnAccount Account { get; set; } = null!;
|
||||||
@@ -153,13 +154,13 @@ public class SnAuthClient : ModelBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SnAuthClientWithChallenge : SnAuthClient
|
public class SnAuthClientWithSessions : SnAuthClient
|
||||||
{
|
{
|
||||||
public List<SnAuthChallenge> Challenges { get; set; } = [];
|
public List<SnAuthSession> Sessions { get; set; } = [];
|
||||||
|
|
||||||
public static SnAuthClientWithChallenge FromClient(SnAuthClient client)
|
public static SnAuthClientWithSessions FromClient(SnAuthClient client)
|
||||||
{
|
{
|
||||||
return new SnAuthClientWithChallenge
|
return new SnAuthClientWithSessions
|
||||||
{
|
{
|
||||||
Id = client.Id,
|
Id = client.Id,
|
||||||
Platform = client.Platform,
|
Platform = client.Platform,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public class SnCustomApp : ModelBase, IIdentifiedResource
|
|||||||
[NotMapped]
|
[NotMapped]
|
||||||
public SnDeveloper Developer => Project.Developer;
|
public SnDeveloper Developer => Project.Developer;
|
||||||
|
|
||||||
[NotMapped] public string ResourceIdentifier => "custom-app:" + Id;
|
[NotMapped] public string ResourceIdentifier => "developer.app:" + Id;
|
||||||
|
|
||||||
public Proto.CustomApp ToProto()
|
public Proto.CustomApp ToProto()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ public class FilePool : ModelBase, IIdentifiedResource
|
|||||||
|
|
||||||
public Guid? AccountId { get; set; }
|
public Guid? AccountId { get; set; }
|
||||||
|
|
||||||
public string ResourceIdentifier => $"file-pool/{Id}";
|
public string ResourceIdentifier => $"file.pool:{Id}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ public enum PublicationSiteMode
|
|||||||
SelfManaged
|
SelfManaged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PublicationSiteConfig
|
||||||
|
{
|
||||||
|
public string? StyleOverride { get; set; }
|
||||||
|
public List<PublicationSiteNavItem>? NavItems { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublicationSiteNavItem
|
||||||
|
{
|
||||||
|
[MaxLength(1024)] public string Label { get; set; } = null!;
|
||||||
|
[MaxLength(8192)] public string Href { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
public class SnPublicationSite : ModelBase
|
public class SnPublicationSite : ModelBase
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -29,6 +41,7 @@ public class SnPublicationSite : ModelBase
|
|||||||
|
|
||||||
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
|
public PublicationSiteMode Mode { get; set; } = PublicationSiteMode.FullyManaged;
|
||||||
public List<SnPublicationPage> Pages { get; set; } = [];
|
public List<SnPublicationPage> Pages { get; set; } = [];
|
||||||
|
[Column(TypeName = "jsonb")] public PublicationSiteConfig Config { get; set; } = new();
|
||||||
|
|
||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
[NotMapped] public SnPublisher Publisher { get; set; } = null!;
|
[NotMapped] public SnPublisher Publisher { get; set; } = null!;
|
||||||
@@ -46,7 +59,11 @@ public enum PublicationPageType
|
|||||||
/// The redirect mode allows user to create a shortcut for their own link.
|
/// The redirect mode allows user to create a shortcut for their own link.
|
||||||
/// such as example.solian.page/rickroll -- DyZ 301 -> youtube.com/...
|
/// such as example.solian.page/rickroll -- DyZ 301 -> youtube.com/...
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Redirect
|
Redirect,
|
||||||
|
/// <summary>
|
||||||
|
/// The Post Page type allows user render a list of posts based on the preconfigured filter.
|
||||||
|
/// </summary>
|
||||||
|
PostPage
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SnPublicationPage : ModelBase
|
public class SnPublicationPage : ModelBase
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 MessagePack;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DysonNetwork.Shared.Models;
|
namespace DysonNetwork.Shared.Models;
|
||||||
@@ -9,32 +10,34 @@ namespace DysonNetwork.Shared.Models;
|
|||||||
public class SnSticker : ModelBase, IIdentifiedResource
|
public class SnSticker : ModelBase, IIdentifiedResource
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
[MaxLength(128)] public string Slug { get; set; } = null!;
|
[MaxLength(128)] public string Slug { get; set; } = null!;
|
||||||
|
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject Image { get; set; } = null!;
|
||||||
// Outdated fields, for backward compability
|
|
||||||
[MaxLength(32)] public string? ImageId { get; set; }
|
|
||||||
|
|
||||||
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Image { get; set; } = null!;
|
|
||||||
|
|
||||||
public Guid PackId { get; set; }
|
public Guid PackId { get; set; }
|
||||||
[JsonIgnore] public StickerPack Pack { get; set; } = null!;
|
[IgnoreMember] [JsonIgnore] public StickerPack Pack { get; set; } = null!;
|
||||||
|
|
||||||
public string ResourceIdentifier => $"sticker/{Id}";
|
public string ResourceIdentifier => $"sticker:{Id}";
|
||||||
}
|
}
|
||||||
|
|
||||||
[Index(nameof(Prefix), IsUnique = true)]
|
[Index(nameof(Prefix), IsUnique = true)]
|
||||||
public class StickerPack : ModelBase
|
public class StickerPack : ModelBase, IIdentifiedResource
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Column(TypeName = "jsonb")] public SnCloudFileReferenceObject? Icon { get; set; }
|
||||||
[MaxLength(1024)] public string Name { get; set; } = null!;
|
[MaxLength(1024)] public string Name { get; set; } = null!;
|
||||||
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
|
[MaxLength(4096)] public string Description { get; set; } = string.Empty;
|
||||||
[MaxLength(128)] public string Prefix { get; set; } = null!;
|
[MaxLength(128)] public string Prefix { get; set; } = null!;
|
||||||
|
|
||||||
public List<SnSticker> Stickers { get; set; } = [];
|
public List<SnSticker> Stickers { get; set; } = [];
|
||||||
[JsonIgnore] public List<StickerPackOwnership> Ownerships { get; set; } = [];
|
|
||||||
|
[IgnoreMember] [JsonIgnore] public List<StickerPackOwnership> Ownerships { get; set; } = [];
|
||||||
|
|
||||||
public Guid PublisherId { get; set; }
|
public Guid PublisherId { get; set; }
|
||||||
public SnPublisher Publisher { get; set; } = null!;
|
public SnPublisher Publisher { get; set; } = null!;
|
||||||
|
|
||||||
|
public string ResourceIdentifier => $"sticker.pack:{Id}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class StickerPackOwnership : ModelBase
|
public class StickerPackOwnership : ModelBase
|
||||||
@@ -44,5 +47,6 @@ public class StickerPackOwnership : ModelBase
|
|||||||
public Guid PackId { get; set; }
|
public Guid PackId { get; set; }
|
||||||
public StickerPack Pack { get; set; } = null!;
|
public StickerPack Pack { get; set; } = null!;
|
||||||
public Guid AccountId { get; set; }
|
public Guid AccountId { get; set; }
|
||||||
|
|
||||||
[NotMapped] public SnAccount Account { get; set; } = null!;
|
[NotMapped] public SnAccount Account { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ service RealmService {
|
|||||||
// Get realms for a user
|
// Get realms for a user
|
||||||
rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {}
|
rpc GetUserRealms(GetUserRealmsRequest) returns (GetUserRealmsResponse) {}
|
||||||
// Get public realms
|
// Get public realms
|
||||||
rpc GetPublicRealms(google.protobuf.Empty) returns (GetPublicRealmsResponse) {}
|
rpc GetPublicRealms(GetPublicRealmsRequest) returns (GetPublicRealmsResponse) {}
|
||||||
// Search public realms
|
// Search public realms
|
||||||
rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {}
|
rpc SearchRealms(SearchRealmsRequest) returns (GetPublicRealmsResponse) {}
|
||||||
// Send invitation notification
|
// Send invitation notification
|
||||||
@@ -84,6 +84,10 @@ message GetUserRealmsResponse {
|
|||||||
repeated string realm_ids = 1;
|
repeated string realm_ids = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetPublicRealmsRequest {
|
||||||
|
optional string order_by = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message GetPublicRealmsResponse {
|
message GetPublicRealmsResponse {
|
||||||
repeated Realm realms = 1;
|
repeated Realm realms = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ public class RemoteRealmService(RealmService.RealmServiceClient realms)
|
|||||||
return response.RealmIds.Select(Guid.Parse).ToList();
|
return response.RealmIds.Select(Guid.Parse).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SnRealm>> GetPublicRealms()
|
public async Task<List<SnRealm>> GetPublicRealms(string orderBy = "date")
|
||||||
{
|
{
|
||||||
var response = await realms.GetPublicRealmsAsync(new Empty());
|
var response = await realms.GetPublicRealmsAsync(new GetPublicRealmsRequest
|
||||||
|
{
|
||||||
|
OrderBy = orderBy
|
||||||
|
});
|
||||||
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
|
return response.Realms.Select(SnRealm.FromProtoValue).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,24 +8,10 @@ public class DiscoveryService(RemoteRealmService remoteRealmService)
|
|||||||
string? query,
|
string? query,
|
||||||
int take = 10,
|
int take = 10,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
bool randomizer = false
|
bool random = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var allRealms = await remoteRealmService.GetPublicRealms();
|
var allRealms = await remoteRealmService.GetPublicRealms(random ? "random" : "popularity");
|
||||||
var communityRealms = allRealms.Where(r => r.IsCommunity);
|
return allRealms.Where(r => r.IsCommunity).Skip(offset).Take(take).ToList();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(query))
|
|
||||||
{
|
|
||||||
communityRealms = communityRealms.Where(r =>
|
|
||||||
r.Name.Contains(query, StringComparison.OrdinalIgnoreCase)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Since we don't have CreatedAt in the proto model, we'll just apply randomizer if requested
|
|
||||||
var orderedRealms = randomizer
|
|
||||||
? communityRealms.OrderBy(_ => Random.Shared.Next())
|
|
||||||
: communityRealms;
|
|
||||||
|
|
||||||
return orderedRealms.Skip(offset).Take(take).ToList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1940
DysonNetwork.Sphere/Migrations/20251203161208_AddStickerPackIcon.Designer.cs
generated
Normal file
1940
DysonNetwork.Sphere/Migrations/20251203161208_AddStickerPackIcon.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddStickerPackIcon : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "image_id",
|
||||||
|
table: "stickers");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<SnCloudFileReferenceObject>(
|
||||||
|
name: "image",
|
||||||
|
table: "stickers",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: false,
|
||||||
|
oldClrType: typeof(SnCloudFileReferenceObject),
|
||||||
|
oldType: "jsonb",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<SnCloudFileReferenceObject>(
|
||||||
|
name: "icon",
|
||||||
|
table: "sticker_packs",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "icon",
|
||||||
|
table: "sticker_packs");
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<SnCloudFileReferenceObject>(
|
||||||
|
name: "image",
|
||||||
|
table: "stickers",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(SnCloudFileReferenceObject),
|
||||||
|
oldType: "jsonb");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "image_id",
|
||||||
|
table: "stickers",
|
||||||
|
type: "character varying(32)",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1147,14 +1147,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnName("deleted_at");
|
.HasColumnName("deleted_at");
|
||||||
|
|
||||||
b.Property<SnCloudFileReferenceObject>("Image")
|
b.Property<SnCloudFileReferenceObject>("Image")
|
||||||
|
.IsRequired()
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("image");
|
.HasColumnName("image");
|
||||||
|
|
||||||
b.Property<string>("ImageId")
|
|
||||||
.HasMaxLength(32)
|
|
||||||
.HasColumnType("character varying(32)")
|
|
||||||
.HasColumnName("image_id");
|
|
||||||
|
|
||||||
b.Property<Guid>("PackId")
|
b.Property<Guid>("PackId")
|
||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("pack_id");
|
.HasColumnName("pack_id");
|
||||||
@@ -1202,6 +1198,10 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
.HasColumnType("character varying(4096)")
|
.HasColumnType("character varying(4096)")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<SnCloudFileReferenceObject>("Icon")
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("icon");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(1024)
|
.HasMaxLength(1024)
|
||||||
|
|||||||
831
DysonNetwork.Sphere/Post/PostActionController.cs
Normal file
831
DysonNetwork.Sphere/Post/PostActionController.cs
Normal file
@@ -0,0 +1,831 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
|
using DysonNetwork.Shared.Auth;
|
||||||
|
using DysonNetwork.Shared.Data;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using DysonNetwork.Sphere.Poll;
|
||||||
|
using DysonNetwork.Sphere.Wallet;
|
||||||
|
using DysonNetwork.Sphere.WebReader;
|
||||||
|
using Grpc.Core;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
using PostType = DysonNetwork.Shared.Models.PostType;
|
||||||
|
using PublisherMemberRole = DysonNetwork.Shared.Models.PublisherMemberRole;
|
||||||
|
using PublisherService = DysonNetwork.Sphere.Publisher.PublisherService;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Sphere.Post;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("/api/posts")]
|
||||||
|
public class PostActionController(
|
||||||
|
AppDatabase db,
|
||||||
|
PostService ps,
|
||||||
|
PublisherService pub,
|
||||||
|
AccountService.AccountServiceClient accounts,
|
||||||
|
ActionLogService.ActionLogServiceClient als,
|
||||||
|
PaymentService.PaymentServiceClient payments,
|
||||||
|
PollService polls,
|
||||||
|
RemoteRealmService rs
|
||||||
|
) : ControllerBase
|
||||||
|
{
|
||||||
|
public class PostRequest
|
||||||
|
{
|
||||||
|
[MaxLength(1024)] public string? Title { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(1024)] public string? Slug { get; set; }
|
||||||
|
public string? Content { get; set; }
|
||||||
|
|
||||||
|
public Shared.Models.PostVisibility? Visibility { get; set; } =
|
||||||
|
Shared.Models.PostVisibility.Public;
|
||||||
|
|
||||||
|
public Shared.Models.PostType? Type { get; set; }
|
||||||
|
public Shared.Models.PostEmbedView? EmbedView { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(16)] public List<string>? Tags { get; set; }
|
||||||
|
[MaxLength(8)] public List<string>? Categories { get; set; }
|
||||||
|
[MaxLength(32)] public List<string>? Attachments { get; set; }
|
||||||
|
|
||||||
|
public Dictionary<string, object>? Meta { get; set; }
|
||||||
|
public Instant? PublishedAt { get; set; }
|
||||||
|
public Guid? RepliedPostId { get; set; }
|
||||||
|
public Guid? ForwardedPostId { get; set; }
|
||||||
|
public Guid? RealmId { get; set; }
|
||||||
|
|
||||||
|
public Guid? PollId { get; set; }
|
||||||
|
public Guid? FundId { get; set; }
|
||||||
|
public string? ThumbnailId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[AskPermission("posts.create")]
|
||||||
|
public async Task<ActionResult<SnPost>> CreatePost(
|
||||||
|
[FromBody] PostRequest request,
|
||||||
|
[FromQuery(Name = "pub")] string? pubName
|
||||||
|
)
|
||||||
|
{
|
||||||
|
request.Content = TextSanitizer.Sanitize(request.Content);
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
|
||||||
|
return BadRequest("Content is required.");
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && request.Type != PostType.Article)
|
||||||
|
return BadRequest("Thumbnail only supported in article.");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) &&
|
||||||
|
!(request.Attachments?.Contains(request.ThumbnailId) ?? false))
|
||||||
|
return BadRequest("Thumbnail must be presented in attachment list.");
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
SnPublisher? publisher;
|
||||||
|
if (pubName is null)
|
||||||
|
{
|
||||||
|
// Use the first personal publisher
|
||||||
|
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
|
||||||
|
e.AccountId == accountId && e.Type == Shared.Models.PublisherType.Individual
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
publisher = await pub.GetPublisherByName(pubName);
|
||||||
|
if (publisher is null)
|
||||||
|
return BadRequest("Publisher was not found.");
|
||||||
|
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(403, "You need at least be an editor to post as this publisher.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publisher is null)
|
||||||
|
return BadRequest("Publisher was not found.");
|
||||||
|
|
||||||
|
var post = new SnPost
|
||||||
|
{
|
||||||
|
Title = request.Title,
|
||||||
|
Description = request.Description,
|
||||||
|
Slug = request.Slug,
|
||||||
|
Content = request.Content,
|
||||||
|
Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public,
|
||||||
|
PublishedAt = request.PublishedAt,
|
||||||
|
Type = request.Type ?? Shared.Models.PostType.Moment,
|
||||||
|
Meta = request.Meta,
|
||||||
|
EmbedView = request.EmbedView,
|
||||||
|
Publisher = publisher,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.RepliedPostId is not null)
|
||||||
|
{
|
||||||
|
var repliedPost = await db
|
||||||
|
.Posts.Where(p => p.Id == request.RepliedPostId.Value)
|
||||||
|
.Include(p => p.Publisher)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (repliedPost is null)
|
||||||
|
return BadRequest("Post replying to was not found.");
|
||||||
|
post.RepliedPost = repliedPost;
|
||||||
|
post.RepliedPostId = repliedPost.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ForwardedPostId is not null)
|
||||||
|
{
|
||||||
|
var forwardedPost = await db
|
||||||
|
.Posts.Where(p => p.Id == request.ForwardedPostId.Value)
|
||||||
|
.Include(p => p.Publisher)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (forwardedPost is null)
|
||||||
|
return BadRequest("Forwarded post was not found.");
|
||||||
|
post.ForwardedPost = forwardedPost;
|
||||||
|
post.ForwardedPostId = forwardedPost.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.RealmId is not null)
|
||||||
|
{
|
||||||
|
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
|
||||||
|
if (
|
||||||
|
!await rs.IsMemberWithRole(
|
||||||
|
realm.Id,
|
||||||
|
accountId,
|
||||||
|
new List<int> { RealmMemberRole.Normal }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return StatusCode(403, "You are not a member of this realm.");
|
||||||
|
post.RealmId = realm.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.PollId.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (
|
||||||
|
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||||
|
|| existingEmbeds is not List<EmbeddableBase>
|
||||||
|
)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
|
||||||
|
post.Meta["embeds"] = embeds;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.FundId.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
|
||||||
|
{
|
||||||
|
FundId = request.FundId.Value.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the fund was created by the current user
|
||||||
|
if (fundResponse.CreatorAccountId != currentUser.Id)
|
||||||
|
return BadRequest("You can only share funds that you created.");
|
||||||
|
|
||||||
|
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (
|
||||||
|
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||||
|
|| existingEmbeds is not List<EmbeddableBase>
|
||||||
|
)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
|
||||||
|
post.Meta["embeds"] = embeds;
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return BadRequest("The specified fund does not exist.");
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||||
|
{
|
||||||
|
return BadRequest("Invalid fund ID.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ThumbnailId is not null)
|
||||||
|
{
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
post.Meta["thumbnail"] = request.ThumbnailId;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
post = await ps.PostAsync(
|
||||||
|
post,
|
||||||
|
attachments: request.Attachments,
|
||||||
|
tags: request.Tags,
|
||||||
|
categories: request.Categories
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(
|
||||||
|
new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostCreate,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"post_id",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id,
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
post.Publisher = publisher;
|
||||||
|
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostReactionRequest
|
||||||
|
{
|
||||||
|
[MaxLength(256)] public string Symbol { get; set; } = null!;
|
||||||
|
public Shared.Models.PostReactionAttitude Attitude { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static readonly List<string> ReactionsAllowedDefault =
|
||||||
|
[
|
||||||
|
"thumb_up",
|
||||||
|
"thumb_down",
|
||||||
|
"just_okay",
|
||||||
|
"cry",
|
||||||
|
"confuse",
|
||||||
|
"clap",
|
||||||
|
"laugh",
|
||||||
|
"angry",
|
||||||
|
"party",
|
||||||
|
"pray",
|
||||||
|
"heart",
|
||||||
|
];
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/reactions")]
|
||||||
|
[Authorize]
|
||||||
|
[AskPermission("posts.react")]
|
||||||
|
public async Task<ActionResult<SnPostReaction>> ReactPost(
|
||||||
|
Guid id,
|
||||||
|
[FromBody] PostReactionRequest request
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var friendsResponse = await accounts.ListFriendsAsync(
|
||||||
|
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
|
||||||
|
);
|
||||||
|
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
|
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
|
if (!ReactionsAllowedDefault.Contains(request.Symbol))
|
||||||
|
if (currentUser.PerkSubscription is null)
|
||||||
|
return BadRequest("You need subscription to send custom reactions");
|
||||||
|
|
||||||
|
var post = await db
|
||||||
|
.Posts.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
var isSelfReact =
|
||||||
|
post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId;
|
||||||
|
|
||||||
|
var isExistingReaction = await db.PostReactions.AnyAsync(r =>
|
||||||
|
r.PostId == post.Id && r.Symbol == request.Symbol && r.AccountId == accountId
|
||||||
|
);
|
||||||
|
var reaction = new SnPostReaction
|
||||||
|
{
|
||||||
|
Symbol = request.Symbol,
|
||||||
|
Attitude = request.Attitude,
|
||||||
|
PostId = post.Id,
|
||||||
|
AccountId = accountId,
|
||||||
|
};
|
||||||
|
var isRemoving = await ps.ModifyPostVotes(
|
||||||
|
post,
|
||||||
|
reaction,
|
||||||
|
currentUser,
|
||||||
|
isExistingReaction,
|
||||||
|
isSelfReact
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isRemoving)
|
||||||
|
return NoContent();
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(
|
||||||
|
new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostReact,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"post_id",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
||||||
|
},
|
||||||
|
{ "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) },
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(reaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostAwardRequest
|
||||||
|
{
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public Shared.Models.PostReactionAttitude Attitude { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(4096)] public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/awards")]
|
||||||
|
public async Task<ActionResult<SnPostAward>> GetPostAwards(
|
||||||
|
Guid id,
|
||||||
|
[FromQuery] int offset = 0,
|
||||||
|
[FromQuery] int take = 20
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var queryable = db.PostAwards.Where(a => a.PostId == id).AsQueryable();
|
||||||
|
|
||||||
|
var totalCount = await queryable.CountAsync();
|
||||||
|
Response.Headers.Append("X-Total", totalCount.ToString());
|
||||||
|
|
||||||
|
var awards = await queryable.Take(take).Skip(offset).ToListAsync();
|
||||||
|
|
||||||
|
return Ok(awards);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostAwardResponse
|
||||||
|
{
|
||||||
|
public Guid OrderId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/awards")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<PostAwardResponse>> AwardPost(
|
||||||
|
Guid id,
|
||||||
|
[FromBody] PostAwardRequest request
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral)
|
||||||
|
return BadRequest("You cannot create a neutral post award");
|
||||||
|
|
||||||
|
var friendsResponse = await accounts.ListFriendsAsync(
|
||||||
|
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
|
||||||
|
);
|
||||||
|
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
||||||
|
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
||||||
|
|
||||||
|
var post = await db
|
||||||
|
.Posts.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
|
var orderRemark = string.IsNullOrWhiteSpace(post.Title)
|
||||||
|
? "from @" + post.Publisher.Name
|
||||||
|
: post.Title;
|
||||||
|
var order = await payments.CreateOrderAsync(
|
||||||
|
new CreateOrderRequest
|
||||||
|
{
|
||||||
|
ProductIdentifier = "posts.award",
|
||||||
|
Currency = "points", // NSP - Source Points
|
||||||
|
Remarks = $"Award post {orderRemark}",
|
||||||
|
Amount = request.Amount.ToString(CultureInfo.InvariantCulture),
|
||||||
|
Meta = GrpcTypeHelper.ConvertObjectToByteString(
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["account_id"] = accountId,
|
||||||
|
["post_id"] = post.Id,
|
||||||
|
["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture),
|
||||||
|
["message"] = request.Message,
|
||||||
|
["attitude"] = request.Attitude,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(new PostAwardResponse() { OrderId = Guid.Parse(order.Id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PostPinRequest
|
||||||
|
{
|
||||||
|
[Required] public Shared.Models.PostPinMode Mode { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id:guid}/pin")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SnPost>> PinPost(Guid id, [FromBody] PostPinRequest request)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var post = await db
|
||||||
|
.Posts.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(403, "You are not an editor of this publisher");
|
||||||
|
|
||||||
|
if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!await rs.IsMemberWithRole(
|
||||||
|
post.RealmId.Value,
|
||||||
|
accountId,
|
||||||
|
new List<int> { RealmMemberRole.Moderator }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return StatusCode(403, "You are not a moderator of this realm");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ps.PinPostAsync(post, currentUser, request.Mode);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(
|
||||||
|
new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostPin,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"post_id",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mode",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}/pin")]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<ActionResult<SnPost>> UnpinPost(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var post = await db
|
||||||
|
.Posts.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.Include(e => e.RepliedPost)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(403, "You are not an editor of this publisher");
|
||||||
|
|
||||||
|
if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null })
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
!await rs.IsMemberWithRole(
|
||||||
|
post.RealmId.Value,
|
||||||
|
accountId,
|
||||||
|
new List<int> { RealmMemberRole.Moderator }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return StatusCode(403, "You are not a moderator of this realm");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ps.UnpinPostAsync(post, currentUser);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(
|
||||||
|
new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostUnpin,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"post_id",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}")]
|
||||||
|
public async Task<ActionResult<SnPost>> UpdatePost(
|
||||||
|
Guid id,
|
||||||
|
[FromBody] PostRequest request,
|
||||||
|
[FromQuery(Name = "pub")] string? pubName
|
||||||
|
)
|
||||||
|
{
|
||||||
|
request.Content = TextSanitizer.Sanitize(request.Content);
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
|
||||||
|
return BadRequest("Content is required.");
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) && request.Type != PostType.Article)
|
||||||
|
return BadRequest("Thumbnail only supported in article.");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.ThumbnailId) &&
|
||||||
|
!(request.Attachments?.Contains(request.ThumbnailId) ?? false))
|
||||||
|
return BadRequest("Thumbnail must be presented in attachment list.");
|
||||||
|
|
||||||
|
var post = await db
|
||||||
|
.Posts.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.Include(e => e.Categories)
|
||||||
|
.Include(e => e.Tags)
|
||||||
|
.Include(e => e.FeaturedRecords)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(403, "You need at least be an editor to edit this publisher's post.");
|
||||||
|
|
||||||
|
if (pubName is not null)
|
||||||
|
{
|
||||||
|
var publisher = await pub.GetPublisherByName(pubName);
|
||||||
|
if (publisher is null)
|
||||||
|
return NotFound();
|
||||||
|
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
|
||||||
|
return StatusCode(
|
||||||
|
403,
|
||||||
|
"You need at least be an editor to transfer this post to this publisher."
|
||||||
|
);
|
||||||
|
post.PublisherId = publisher.Id;
|
||||||
|
post.Publisher = publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Title is not null)
|
||||||
|
post.Title = request.Title;
|
||||||
|
if (request.Description is not null)
|
||||||
|
post.Description = request.Description;
|
||||||
|
if (request.Slug is not null)
|
||||||
|
post.Slug = request.Slug;
|
||||||
|
if (request.Content is not null)
|
||||||
|
post.Content = request.Content;
|
||||||
|
if (request.Visibility is not null)
|
||||||
|
post.Visibility = request.Visibility.Value;
|
||||||
|
if (request.Type is not null)
|
||||||
|
post.Type = request.Type.Value;
|
||||||
|
if (request.Meta is not null)
|
||||||
|
post.Meta = request.Meta;
|
||||||
|
|
||||||
|
// The same, this field can be null, so update it anyway.
|
||||||
|
post.EmbedView = request.EmbedView;
|
||||||
|
|
||||||
|
// All the fields are updated when the request contains the specific fields
|
||||||
|
// But the Poll can be null, so it will be updated whatever it included in requests or not
|
||||||
|
if (request.PollId.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (
|
||||||
|
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||||
|
|| existingEmbeds is not List<EmbeddableBase>
|
||||||
|
)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
// Remove all old poll embeds
|
||||||
|
embeds.RemoveAll(e =>
|
||||||
|
e.TryGetValue("type", out var type) && type.ToString() == "poll"
|
||||||
|
);
|
||||||
|
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
|
||||||
|
post.Meta["embeds"] = embeds;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return BadRequest(ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (
|
||||||
|
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||||
|
|| existingEmbeds is not List<EmbeddableBase>
|
||||||
|
)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
// Remove all old poll embeds
|
||||||
|
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle fund embeds
|
||||||
|
if (request.FundId.HasValue)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
|
||||||
|
{
|
||||||
|
FundId = request.FundId.Value.ToString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the fund was created by the current user
|
||||||
|
if (fundResponse.CreatorAccountId != currentUser.Id)
|
||||||
|
return BadRequest("You can only share funds that you created.");
|
||||||
|
|
||||||
|
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (
|
||||||
|
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||||
|
|| existingEmbeds is not List<EmbeddableBase>
|
||||||
|
)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
// Remove all old fund embeds
|
||||||
|
embeds.RemoveAll(e =>
|
||||||
|
e.TryGetValue("type", out var type) && type.ToString() == "fund"
|
||||||
|
);
|
||||||
|
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
|
||||||
|
post.Meta["embeds"] = embeds;
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
||||||
|
{
|
||||||
|
return BadRequest("The specified fund does not exist.");
|
||||||
|
}
|
||||||
|
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
||||||
|
{
|
||||||
|
return BadRequest("Invalid fund ID.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
if (
|
||||||
|
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
||||||
|
|| existingEmbeds is not List<EmbeddableBase>
|
||||||
|
)
|
||||||
|
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
||||||
|
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
||||||
|
// Remove all old fund embeds
|
||||||
|
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.ThumbnailId is not null)
|
||||||
|
{
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
post.Meta["thumbnail"] = request.ThumbnailId;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
post.Meta ??= new Dictionary<string, object>();
|
||||||
|
post.Meta.Remove("thumbnail");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The realm is the same as well as the poll
|
||||||
|
if (request.RealmId is not null)
|
||||||
|
{
|
||||||
|
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
|
||||||
|
if (
|
||||||
|
!await rs.IsMemberWithRole(
|
||||||
|
realm.Id,
|
||||||
|
accountId,
|
||||||
|
new List<int> { RealmMemberRole.Normal }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return StatusCode(403, "You are not a member of this realm.");
|
||||||
|
post.RealmId = realm.Id;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
post.RealmId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
post = await ps.UpdatePostAsync(
|
||||||
|
post,
|
||||||
|
attachments: request.Attachments,
|
||||||
|
tags: request.Tags,
|
||||||
|
categories: request.Categories,
|
||||||
|
publishedAt: request.PublishedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException err)
|
||||||
|
{
|
||||||
|
return BadRequest(err.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(
|
||||||
|
new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostUpdate,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"post_id",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Ok(post);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
public async Task<ActionResult<SnPost>> DeletePost(Guid id)
|
||||||
|
{
|
||||||
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
||||||
|
return Unauthorized();
|
||||||
|
|
||||||
|
var post = await db
|
||||||
|
.Posts.Where(e => e.Id == id)
|
||||||
|
.Include(e => e.Publisher)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (post is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!await pub.IsMemberWithRole(
|
||||||
|
post.Publisher.Id,
|
||||||
|
Guid.Parse(currentUser.Id),
|
||||||
|
PublisherMemberRole.Editor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return StatusCode(
|
||||||
|
403,
|
||||||
|
"You need at least be an editor to delete the publisher's post."
|
||||||
|
);
|
||||||
|
|
||||||
|
await ps.DeletePostAsync(post);
|
||||||
|
|
||||||
|
_ = als.CreateActionLogAsync(
|
||||||
|
new CreateActionLogRequest
|
||||||
|
{
|
||||||
|
Action = ActionLogType.PostDelete,
|
||||||
|
Meta =
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"post_id",
|
||||||
|
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
AccountId = currentUser.Id.ToString(),
|
||||||
|
UserAgent = Request.Headers.UserAgent,
|
||||||
|
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,6 @@ public class PostController(
|
|||||||
PublisherService pub,
|
PublisherService pub,
|
||||||
RemoteAccountService remoteAccountsHelper,
|
RemoteAccountService remoteAccountsHelper,
|
||||||
AccountService.AccountServiceClient accounts,
|
AccountService.AccountServiceClient accounts,
|
||||||
ActionLogService.ActionLogServiceClient als,
|
|
||||||
PaymentService.PaymentServiceClient payments,
|
|
||||||
PollService polls,
|
|
||||||
RemoteRealmService rs
|
RemoteRealmService rs
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -43,27 +40,6 @@ public class PostController(
|
|||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a paginated list of posts with optional filtering and sorting.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="includeReplies">Whether to include reply posts in the results. If false, only root posts are returned.</param>
|
|
||||||
/// <param name="offset">The number of posts to skip for pagination.</param>
|
|
||||||
/// <param name="take">The maximum number of posts to return (default: 20).</param>
|
|
||||||
/// <param name="pubName">Filter posts by publisher name.</param>
|
|
||||||
/// <param name="realmName">Filter posts by realm slug.</param>
|
|
||||||
/// <param name="type">Filter posts by post type (as integer).</param>
|
|
||||||
/// <param name="categories">Filter posts by category slugs.</param>
|
|
||||||
/// <param name="tags">Filter posts by tag slugs.</param>
|
|
||||||
/// <param name="queryTerm">Search term to filter posts by title, description, or content.</param>
|
|
||||||
/// <param name="queryVector">If true, uses vector search with the query term. If false, performs a simple ILIKE search.</param>
|
|
||||||
/// <param name="onlyMedia">If true, only returns posts that have attachments.</param>
|
|
||||||
/// <param name="shuffle">If true, returns posts in random order. If false, orders by published/created date (newest first).</param>
|
|
||||||
/// <param name="pinned">If true, returns posts that pinned. If false, returns posts that are not pinned. If null, returns all posts.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// Returns an ActionResult containing a list of Post objects that match the specified criteria.
|
|
||||||
/// Includes an X-Total header with the total count of matching posts before pagination.
|
|
||||||
/// </returns>
|
|
||||||
/// <response code="200">Returns the list of posts matching the criteria.</response>
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))]
|
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List<SnPost>))]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
@@ -493,771 +469,4 @@ public class PostController(
|
|||||||
|
|
||||||
return Ok(posts);
|
return Ok(posts);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PostRequest
|
|
||||||
{
|
|
||||||
[MaxLength(1024)] public string? Title { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(1024)] public string? Slug { get; set; }
|
|
||||||
public string? Content { get; set; }
|
|
||||||
|
|
||||||
public Shared.Models.PostVisibility? Visibility { get; set; } =
|
|
||||||
Shared.Models.PostVisibility.Public;
|
|
||||||
|
|
||||||
public Shared.Models.PostType? Type { get; set; }
|
|
||||||
public Shared.Models.PostEmbedView? EmbedView { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(16)] public List<string>? Tags { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(8)] public List<string>? Categories { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(32)] public List<string>? Attachments { get; set; }
|
|
||||||
public Dictionary<string, object>? Meta { get; set; }
|
|
||||||
public Instant? PublishedAt { get; set; }
|
|
||||||
public Guid? RepliedPostId { get; set; }
|
|
||||||
public Guid? ForwardedPostId { get; set; }
|
|
||||||
public Guid? RealmId { get; set; }
|
|
||||||
|
|
||||||
public Guid? PollId { get; set; }
|
|
||||||
public Guid? FundId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
[AskPermission("posts.create")]
|
|
||||||
public async Task<ActionResult<SnPost>> CreatePost(
|
|
||||||
[FromBody] PostRequest request,
|
|
||||||
[FromQuery(Name = "pub")] string? pubName
|
|
||||||
)
|
|
||||||
{
|
|
||||||
request.Content = TextSanitizer.Sanitize(request.Content);
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
|
|
||||||
return BadRequest("Content is required.");
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
|
||||||
|
|
||||||
SnPublisher? publisher;
|
|
||||||
if (pubName is null)
|
|
||||||
{
|
|
||||||
// Use the first personal publisher
|
|
||||||
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
|
|
||||||
e.AccountId == accountId && e.Type == Shared.Models.PublisherType.Individual
|
|
||||||
);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
publisher = await pub.GetPublisherByName(pubName);
|
|
||||||
if (publisher is null)
|
|
||||||
return BadRequest("Publisher was not found.");
|
|
||||||
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
|
|
||||||
return StatusCode(403, "You need at least be an editor to post as this publisher.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (publisher is null)
|
|
||||||
return BadRequest("Publisher was not found.");
|
|
||||||
|
|
||||||
var post = new SnPost
|
|
||||||
{
|
|
||||||
Title = request.Title,
|
|
||||||
Description = request.Description,
|
|
||||||
Slug = request.Slug,
|
|
||||||
Content = request.Content,
|
|
||||||
Visibility = request.Visibility ?? Shared.Models.PostVisibility.Public,
|
|
||||||
PublishedAt = request.PublishedAt,
|
|
||||||
Type = request.Type ?? Shared.Models.PostType.Moment,
|
|
||||||
Meta = request.Meta,
|
|
||||||
EmbedView = request.EmbedView,
|
|
||||||
Publisher = publisher,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (request.RepliedPostId is not null)
|
|
||||||
{
|
|
||||||
var repliedPost = await db
|
|
||||||
.Posts.Where(p => p.Id == request.RepliedPostId.Value)
|
|
||||||
.Include(p => p.Publisher)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (repliedPost is null)
|
|
||||||
return BadRequest("Post replying to was not found.");
|
|
||||||
post.RepliedPost = repliedPost;
|
|
||||||
post.RepliedPostId = repliedPost.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.ForwardedPostId is not null)
|
|
||||||
{
|
|
||||||
var forwardedPost = await db
|
|
||||||
.Posts.Where(p => p.Id == request.ForwardedPostId.Value)
|
|
||||||
.Include(p => p.Publisher)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (forwardedPost is null)
|
|
||||||
return BadRequest("Forwarded post was not found.");
|
|
||||||
post.ForwardedPost = forwardedPost;
|
|
||||||
post.ForwardedPostId = forwardedPost.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.RealmId is not null)
|
|
||||||
{
|
|
||||||
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
|
|
||||||
if (
|
|
||||||
!await rs.IsMemberWithRole(
|
|
||||||
realm.Id,
|
|
||||||
accountId,
|
|
||||||
new List<int> { RealmMemberRole.Normal }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return StatusCode(403, "You are not a member of this realm.");
|
|
||||||
post.RealmId = realm.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.PollId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
|
|
||||||
post.Meta ??= new Dictionary<string, object>();
|
|
||||||
if (
|
|
||||||
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
|
||||||
|| existingEmbeds is not List<EmbeddableBase>
|
|
||||||
)
|
|
||||||
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
||||||
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
|
||||||
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
|
|
||||||
post.Meta["embeds"] = embeds;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.FundId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
|
|
||||||
{
|
|
||||||
FundId = request.FundId.Value.ToString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the fund was created by the current user
|
|
||||||
if (fundResponse.CreatorAccountId != currentUser.Id)
|
|
||||||
return BadRequest("You can only share funds that you created.");
|
|
||||||
|
|
||||||
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
|
|
||||||
post.Meta ??= new Dictionary<string, object>();
|
|
||||||
if (
|
|
||||||
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
|
||||||
|| existingEmbeds is not List<EmbeddableBase>
|
|
||||||
)
|
|
||||||
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
||||||
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
|
||||||
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
|
|
||||||
post.Meta["embeds"] = embeds;
|
|
||||||
}
|
|
||||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
|
||||||
{
|
|
||||||
return BadRequest("The specified fund does not exist.");
|
|
||||||
}
|
|
||||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
|
||||||
{
|
|
||||||
return BadRequest("Invalid fund ID.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
post = await ps.PostAsync(
|
|
||||||
post,
|
|
||||||
attachments: request.Attachments,
|
|
||||||
tags: request.Tags,
|
|
||||||
categories: request.Categories
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException err)
|
|
||||||
{
|
|
||||||
return BadRequest(err.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(
|
|
||||||
new CreateActionLogRequest
|
|
||||||
{
|
|
||||||
Action = ActionLogType.PostCreate,
|
|
||||||
Meta =
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"post_id",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AccountId = currentUser.Id.ToString(),
|
|
||||||
UserAgent = Request.Headers.UserAgent,
|
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
post.Publisher = publisher;
|
|
||||||
|
|
||||||
return post;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PostReactionRequest
|
|
||||||
{
|
|
||||||
[MaxLength(256)] public string Symbol { get; set; } = null!;
|
|
||||||
public Shared.Models.PostReactionAttitude Attitude { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public static readonly List<string> ReactionsAllowedDefault =
|
|
||||||
[
|
|
||||||
"thumb_up",
|
|
||||||
"thumb_down",
|
|
||||||
"just_okay",
|
|
||||||
"cry",
|
|
||||||
"confuse",
|
|
||||||
"clap",
|
|
||||||
"laugh",
|
|
||||||
"angry",
|
|
||||||
"party",
|
|
||||||
"pray",
|
|
||||||
"heart",
|
|
||||||
];
|
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reactions")]
|
|
||||||
[Authorize]
|
|
||||||
[AskPermission("posts.react")]
|
|
||||||
public async Task<ActionResult<SnPostReaction>> ReactPost(
|
|
||||||
Guid id,
|
|
||||||
[FromBody] PostReactionRequest request
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(
|
|
||||||
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
|
|
||||||
);
|
|
||||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
|
||||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
|
||||||
|
|
||||||
if (!ReactionsAllowedDefault.Contains(request.Symbol))
|
|
||||||
if (currentUser.PerkSubscription is null)
|
|
||||||
return BadRequest("You need subscription to send custom reactions");
|
|
||||||
|
|
||||||
var post = await db
|
|
||||||
.Posts.Where(e => e.Id == id)
|
|
||||||
.Include(e => e.Publisher)
|
|
||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (post is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
|
||||||
var isSelfReact =
|
|
||||||
post.Publisher.AccountId is not null && post.Publisher.AccountId == accountId;
|
|
||||||
|
|
||||||
var isExistingReaction = await db.PostReactions.AnyAsync(r =>
|
|
||||||
r.PostId == post.Id && r.Symbol == request.Symbol && r.AccountId == accountId
|
|
||||||
);
|
|
||||||
var reaction = new SnPostReaction
|
|
||||||
{
|
|
||||||
Symbol = request.Symbol,
|
|
||||||
Attitude = request.Attitude,
|
|
||||||
PostId = post.Id,
|
|
||||||
AccountId = accountId,
|
|
||||||
};
|
|
||||||
var isRemoving = await ps.ModifyPostVotes(
|
|
||||||
post,
|
|
||||||
reaction,
|
|
||||||
currentUser,
|
|
||||||
isExistingReaction,
|
|
||||||
isSelfReact
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isRemoving)
|
|
||||||
return NoContent();
|
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(
|
|
||||||
new CreateActionLogRequest
|
|
||||||
{
|
|
||||||
Action = ActionLogType.PostReact,
|
|
||||||
Meta =
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"post_id",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
|
||||||
},
|
|
||||||
{ "reaction", Google.Protobuf.WellKnownTypes.Value.ForString(request.Symbol) },
|
|
||||||
},
|
|
||||||
AccountId = currentUser.Id.ToString(),
|
|
||||||
UserAgent = Request.Headers.UserAgent,
|
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(reaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PostAwardRequest
|
|
||||||
{
|
|
||||||
public decimal Amount { get; set; }
|
|
||||||
public Shared.Models.PostReactionAttitude Attitude { get; set; }
|
|
||||||
|
|
||||||
[MaxLength(4096)] public string? Message { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id:guid}/awards")]
|
|
||||||
public async Task<ActionResult<SnPostAward>> GetPostAwards(
|
|
||||||
Guid id,
|
|
||||||
[FromQuery] int offset = 0,
|
|
||||||
[FromQuery] int take = 20
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var queryable = db.PostAwards.Where(a => a.PostId == id).AsQueryable();
|
|
||||||
|
|
||||||
var totalCount = await queryable.CountAsync();
|
|
||||||
Response.Headers.Append("X-Total", totalCount.ToString());
|
|
||||||
|
|
||||||
var awards = await queryable.Take(take).Skip(offset).ToListAsync();
|
|
||||||
|
|
||||||
return Ok(awards);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PostAwardResponse
|
|
||||||
{
|
|
||||||
public Guid OrderId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id:guid}/awards")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<ActionResult<PostAwardResponse>> AwardPost(
|
|
||||||
Guid id,
|
|
||||||
[FromBody] PostAwardRequest request
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
if (request.Attitude == Shared.Models.PostReactionAttitude.Neutral)
|
|
||||||
return BadRequest("You cannot create a neutral post award");
|
|
||||||
|
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(
|
|
||||||
new ListRelationshipSimpleRequest { AccountId = currentUser.Id.ToString() }
|
|
||||||
);
|
|
||||||
var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
|
||||||
var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id));
|
|
||||||
|
|
||||||
var post = await db
|
|
||||||
.Posts.Where(e => e.Id == id)
|
|
||||||
.Include(e => e.Publisher)
|
|
||||||
.FilterWithVisibility(currentUser, userFriends, userPublishers)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (post is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
|
||||||
|
|
||||||
var orderRemark = string.IsNullOrWhiteSpace(post.Title)
|
|
||||||
? "from @" + post.Publisher.Name
|
|
||||||
: post.Title;
|
|
||||||
var order = await payments.CreateOrderAsync(
|
|
||||||
new CreateOrderRequest
|
|
||||||
{
|
|
||||||
ProductIdentifier = "posts.award",
|
|
||||||
Currency = "points", // NSP - Source Points
|
|
||||||
Remarks = $"Award post {orderRemark}",
|
|
||||||
Amount = request.Amount.ToString(CultureInfo.InvariantCulture),
|
|
||||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(
|
|
||||||
new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["account_id"] = accountId,
|
|
||||||
["post_id"] = post.Id,
|
|
||||||
["amount"] = request.Amount.ToString(CultureInfo.InvariantCulture),
|
|
||||||
["message"] = request.Message,
|
|
||||||
["attitude"] = request.Attitude,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(new PostAwardResponse() { OrderId = Guid.Parse(order.Id) });
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PostPinRequest
|
|
||||||
{
|
|
||||||
[Required] public Shared.Models.PostPinMode Mode { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{id:guid}/pin")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<ActionResult<SnPost>> PinPost(Guid id, [FromBody] PostPinRequest request)
|
|
||||||
{
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var post = await db
|
|
||||||
.Posts.Where(e => e.Id == id)
|
|
||||||
.Include(e => e.Publisher)
|
|
||||||
.Include(e => e.RepliedPost)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (post is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
|
||||||
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
|
|
||||||
return StatusCode(403, "You are not an editor of this publisher");
|
|
||||||
|
|
||||||
if (request.Mode == Shared.Models.PostPinMode.RealmPage && post.RealmId != null)
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
!await rs.IsMemberWithRole(
|
|
||||||
post.RealmId.Value,
|
|
||||||
accountId,
|
|
||||||
new List<int> { RealmMemberRole.Moderator }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return StatusCode(403, "You are not a moderator of this realm");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ps.PinPostAsync(post, currentUser, request.Mode);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException err)
|
|
||||||
{
|
|
||||||
return BadRequest(err.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(
|
|
||||||
new CreateActionLogRequest
|
|
||||||
{
|
|
||||||
Action = ActionLogType.PostPin,
|
|
||||||
Meta =
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"post_id",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mode",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(request.Mode.ToString())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AccountId = currentUser.Id.ToString(),
|
|
||||||
UserAgent = Request.Headers.UserAgent,
|
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(post);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}/pin")]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<ActionResult<SnPost>> UnpinPost(Guid id)
|
|
||||||
{
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var post = await db
|
|
||||||
.Posts.Where(e => e.Id == id)
|
|
||||||
.Include(e => e.Publisher)
|
|
||||||
.Include(e => e.RepliedPost)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (post is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
|
||||||
if (!await pub.IsMemberWithRole(post.PublisherId, accountId, PublisherMemberRole.Editor))
|
|
||||||
return StatusCode(403, "You are not an editor of this publisher");
|
|
||||||
|
|
||||||
if (post is { PinMode: Shared.Models.PostPinMode.RealmPage, RealmId: not null })
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
!await rs.IsMemberWithRole(
|
|
||||||
post.RealmId.Value,
|
|
||||||
accountId,
|
|
||||||
new List<int> { RealmMemberRole.Moderator }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return StatusCode(403, "You are not a moderator of this realm");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ps.UnpinPostAsync(post, currentUser);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException err)
|
|
||||||
{
|
|
||||||
return BadRequest(err.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(
|
|
||||||
new CreateActionLogRequest
|
|
||||||
{
|
|
||||||
Action = ActionLogType.PostUnpin,
|
|
||||||
Meta =
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"post_id",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AccountId = currentUser.Id.ToString(),
|
|
||||||
UserAgent = Request.Headers.UserAgent,
|
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(post);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
|
||||||
public async Task<ActionResult<SnPost>> UpdatePost(
|
|
||||||
Guid id,
|
|
||||||
[FromBody] PostRequest request,
|
|
||||||
[FromQuery(Name = "pub")] string? pubName
|
|
||||||
)
|
|
||||||
{
|
|
||||||
request.Content = TextSanitizer.Sanitize(request.Content);
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments is { Count: 0 })
|
|
||||||
return BadRequest("Content is required.");
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var post = await db
|
|
||||||
.Posts.Where(e => e.Id == id)
|
|
||||||
.Include(e => e.Publisher)
|
|
||||||
.Include(e => e.Categories)
|
|
||||||
.Include(e => e.Tags)
|
|
||||||
.Include(e => e.FeaturedRecords)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (post is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
var accountId = Guid.Parse(currentUser.Id);
|
|
||||||
if (!await pub.IsMemberWithRole(post.Publisher.Id, accountId, PublisherMemberRole.Editor))
|
|
||||||
return StatusCode(403, "You need at least be an editor to edit this publisher's post.");
|
|
||||||
|
|
||||||
if (pubName is not null)
|
|
||||||
{
|
|
||||||
var publisher = await pub.GetPublisherByName(pubName);
|
|
||||||
if (publisher is null)
|
|
||||||
return NotFound();
|
|
||||||
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
|
|
||||||
return StatusCode(
|
|
||||||
403,
|
|
||||||
"You need at least be an editor to transfer this post to this publisher."
|
|
||||||
);
|
|
||||||
post.PublisherId = publisher.Id;
|
|
||||||
post.Publisher = publisher;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.Title is not null)
|
|
||||||
post.Title = request.Title;
|
|
||||||
if (request.Description is not null)
|
|
||||||
post.Description = request.Description;
|
|
||||||
if (request.Slug is not null)
|
|
||||||
post.Slug = request.Slug;
|
|
||||||
if (request.Content is not null)
|
|
||||||
post.Content = request.Content;
|
|
||||||
if (request.Visibility is not null)
|
|
||||||
post.Visibility = request.Visibility.Value;
|
|
||||||
if (request.Type is not null)
|
|
||||||
post.Type = request.Type.Value;
|
|
||||||
if (request.Meta is not null)
|
|
||||||
post.Meta = request.Meta;
|
|
||||||
|
|
||||||
// The same, this field can be null, so update it anyway.
|
|
||||||
post.EmbedView = request.EmbedView;
|
|
||||||
|
|
||||||
// All the fields are updated when the request contains the specific fields
|
|
||||||
// But the Poll can be null, so it will be updated whatever it included in requests or not
|
|
||||||
if (request.PollId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var pollEmbed = await polls.MakePollEmbed(request.PollId.Value);
|
|
||||||
post.Meta ??= new Dictionary<string, object>();
|
|
||||||
if (
|
|
||||||
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
|
||||||
|| existingEmbeds is not List<EmbeddableBase>
|
|
||||||
)
|
|
||||||
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
||||||
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
|
||||||
// Remove all old poll embeds
|
|
||||||
embeds.RemoveAll(e =>
|
|
||||||
e.TryGetValue("type", out var type) && type.ToString() == "poll"
|
|
||||||
);
|
|
||||||
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
|
|
||||||
post.Meta["embeds"] = embeds;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return BadRequest(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
post.Meta ??= new Dictionary<string, object>();
|
|
||||||
if (
|
|
||||||
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
|
||||||
|| existingEmbeds is not List<EmbeddableBase>
|
|
||||||
)
|
|
||||||
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
||||||
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
|
||||||
// Remove all old poll embeds
|
|
||||||
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle fund embeds
|
|
||||||
if (request.FundId.HasValue)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest
|
|
||||||
{
|
|
||||||
FundId = request.FundId.Value.ToString()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the fund was created by the current user
|
|
||||||
if (fundResponse.CreatorAccountId != currentUser.Id)
|
|
||||||
return BadRequest("You can only share funds that you created.");
|
|
||||||
|
|
||||||
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
|
|
||||||
post.Meta ??= new Dictionary<string, object>();
|
|
||||||
if (
|
|
||||||
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
|
||||||
|| existingEmbeds is not List<EmbeddableBase>
|
|
||||||
)
|
|
||||||
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
||||||
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
|
||||||
// Remove all old fund embeds
|
|
||||||
embeds.RemoveAll(e =>
|
|
||||||
e.TryGetValue("type", out var type) && type.ToString() == "fund"
|
|
||||||
);
|
|
||||||
embeds.Add(EmbeddableBase.ToDictionary(fundEmbed));
|
|
||||||
post.Meta["embeds"] = embeds;
|
|
||||||
}
|
|
||||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound)
|
|
||||||
{
|
|
||||||
return BadRequest("The specified fund does not exist.");
|
|
||||||
}
|
|
||||||
catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument)
|
|
||||||
{
|
|
||||||
return BadRequest("Invalid fund ID.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
post.Meta ??= new Dictionary<string, object>();
|
|
||||||
if (
|
|
||||||
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|
|
||||||
|| existingEmbeds is not List<EmbeddableBase>
|
|
||||||
)
|
|
||||||
post.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
||||||
var embeds = (List<Dictionary<string, object>>)post.Meta["embeds"];
|
|
||||||
// Remove all old fund embeds
|
|
||||||
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The realm is the same as well as the poll
|
|
||||||
if (request.RealmId is not null)
|
|
||||||
{
|
|
||||||
var realm = await rs.GetRealm(request.RealmId.Value.ToString());
|
|
||||||
if (
|
|
||||||
!await rs.IsMemberWithRole(
|
|
||||||
realm.Id,
|
|
||||||
accountId,
|
|
||||||
new List<int> { RealmMemberRole.Normal }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return StatusCode(403, "You are not a member of this realm.");
|
|
||||||
post.RealmId = realm.Id;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
post.RealmId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
post = await ps.UpdatePostAsync(
|
|
||||||
post,
|
|
||||||
attachments: request.Attachments,
|
|
||||||
tags: request.Tags,
|
|
||||||
categories: request.Categories,
|
|
||||||
publishedAt: request.PublishedAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException err)
|
|
||||||
{
|
|
||||||
return BadRequest(err.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(
|
|
||||||
new CreateActionLogRequest
|
|
||||||
{
|
|
||||||
Action = ActionLogType.PostUpdate,
|
|
||||||
Meta =
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"post_id",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AccountId = currentUser.Id.ToString(),
|
|
||||||
UserAgent = Request.Headers.UserAgent,
|
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return Ok(post);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
|
||||||
public async Task<ActionResult<SnPost>> DeletePost(Guid id)
|
|
||||||
{
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser)
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
var post = await db
|
|
||||||
.Posts.Where(e => e.Id == id)
|
|
||||||
.Include(e => e.Publisher)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (post is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
if (
|
|
||||||
!await pub.IsMemberWithRole(
|
|
||||||
post.Publisher.Id,
|
|
||||||
Guid.Parse(currentUser.Id),
|
|
||||||
PublisherMemberRole.Editor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return StatusCode(
|
|
||||||
403,
|
|
||||||
"You need at least be an editor to delete the publisher's post."
|
|
||||||
);
|
|
||||||
|
|
||||||
await ps.DeletePostAsync(post);
|
|
||||||
|
|
||||||
_ = als.CreateActionLogAsync(
|
|
||||||
new CreateActionLogRequest
|
|
||||||
{
|
|
||||||
Action = ActionLogType.PostDelete,
|
|
||||||
Meta =
|
|
||||||
{
|
|
||||||
{
|
|
||||||
"post_id",
|
|
||||||
Google.Protobuf.WellKnownTypes.Value.ForString(post.Id.ToString())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AccountId = currentUser.Id.ToString(),
|
|
||||||
UserAgent = Request.Headers.UserAgent,
|
|
||||||
IpAddress = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class StickerController(
|
|||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
StickerService st,
|
StickerService st,
|
||||||
Publisher.PublisherService ps,
|
Publisher.PublisherService ps,
|
||||||
FileService.FileServiceClient files
|
FileService.FileServiceClient files,
|
||||||
|
FileReferenceService.FileReferenceServiceClient fileRefs
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
private async Task<IActionResult> _CheckStickerPackPermissions(
|
private async Task<IActionResult> _CheckStickerPackPermissions(
|
||||||
@@ -114,6 +115,7 @@ public class StickerController(
|
|||||||
|
|
||||||
public class StickerPackRequest
|
public class StickerPackRequest
|
||||||
{
|
{
|
||||||
|
public string? IconId { get; set; }
|
||||||
[MaxLength(1024)] public string? Name { get; set; }
|
[MaxLength(1024)] public string? Name { get; set; }
|
||||||
[MaxLength(4096)] public string? Description { get; set; }
|
[MaxLength(4096)] public string? Description { get; set; }
|
||||||
[MaxLength(128)] public string? Prefix { get; set; }
|
[MaxLength(128)] public string? Prefix { get; set; }
|
||||||
@@ -147,8 +149,28 @@ public class StickerController(
|
|||||||
PublisherId = publisher.Id
|
PublisherId = publisher.Id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (request.IconId is not null)
|
||||||
|
{
|
||||||
|
var file = await files.GetFileAsync(new GetFileRequest { Id = request.IconId });
|
||||||
|
if (file is null)
|
||||||
|
return BadRequest("Icon not found.");
|
||||||
|
|
||||||
|
pack.Icon = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
|
}
|
||||||
|
|
||||||
db.StickerPacks.Add(pack);
|
db.StickerPacks.Add(pack);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (pack.Icon is not null)
|
||||||
|
{
|
||||||
|
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
|
||||||
|
{
|
||||||
|
FileId = pack.Icon.Id,
|
||||||
|
Usage = StickerService.StickerPackUsageIdentifier,
|
||||||
|
ResourceId = pack.ResourceIdentifier
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(pack);
|
return Ok(pack);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +201,32 @@ public class StickerController(
|
|||||||
if (request.Prefix is not null)
|
if (request.Prefix is not null)
|
||||||
pack.Prefix = request.Prefix;
|
pack.Prefix = request.Prefix;
|
||||||
|
|
||||||
|
if (request.IconId is not null)
|
||||||
|
{
|
||||||
|
var file = await files.GetFileAsync(new GetFileRequest { Id = request.IconId });
|
||||||
|
if (file is null)
|
||||||
|
return BadRequest("Icon not found.");
|
||||||
|
|
||||||
|
if (file.Id != pack.Icon?.Id)
|
||||||
|
{
|
||||||
|
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||||
|
{ ResourceId = pack.ResourceIdentifier, Usage = StickerService.StickerPackUsageIdentifier });
|
||||||
|
|
||||||
|
pack.Icon = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
|
await fileRefs.CreateReferenceAsync(new CreateReferenceRequest
|
||||||
|
{
|
||||||
|
FileId = pack.Icon.Id,
|
||||||
|
Usage = StickerService.StickerPackUsageIdentifier,
|
||||||
|
ResourceId = pack.ResourceIdentifier
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Still update the column in case user want to sync the changes of the file meta
|
||||||
|
pack.Icon = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.StickerPacks.Update(pack);
|
db.StickerPacks.Update(pack);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(pack);
|
return Ok(pack);
|
||||||
@@ -239,7 +287,11 @@ public class StickerController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<ActionResult<List<SnSticker>>> SearchSticker([FromQuery] string query, [FromQuery] int take = 10, [FromQuery] int offset = 0)
|
public async Task<ActionResult<List<SnSticker>>> SearchSticker(
|
||||||
|
[FromQuery] string query,
|
||||||
|
[FromQuery] int take = 10,
|
||||||
|
[FromQuery] int offset = 0
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var queryable = db.Stickers
|
var queryable = db.Stickers
|
||||||
.Include(s => s.Pack)
|
.Include(s => s.Pack)
|
||||||
@@ -300,7 +352,6 @@ public class StickerController(
|
|||||||
var file = await files.GetFileAsync(new GetFileRequest { Id = request.ImageId });
|
var file = await files.GetFileAsync(new GetFileRequest { Id = request.ImageId });
|
||||||
if (file is null)
|
if (file is null)
|
||||||
return BadRequest("Image not found");
|
return BadRequest("Image not found");
|
||||||
sticker.ImageId = request.ImageId;
|
|
||||||
sticker.Image = SnCloudFileReferenceObject.FromProtoValue(file);
|
sticker.Image = SnCloudFileReferenceObject.FromProtoValue(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +418,6 @@ public class StickerController(
|
|||||||
var sticker = new SnSticker
|
var sticker = new SnSticker
|
||||||
{
|
{
|
||||||
Slug = request.Slug,
|
Slug = request.Slug,
|
||||||
ImageId = file.Id,
|
|
||||||
Image = SnCloudFileReferenceObject.FromProtoValue(file),
|
Image = SnCloudFileReferenceObject.FromProtoValue(file),
|
||||||
Pack = pack
|
Pack = pack
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public class StickerService(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
public const string StickerFileUsageIdentifier = "sticker";
|
public const string StickerFileUsageIdentifier = "sticker";
|
||||||
|
public const string StickerPackUsageIdentifier = "sticker.pack";
|
||||||
|
|
||||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
@@ -36,7 +37,8 @@ public class StickerService(
|
|||||||
{
|
{
|
||||||
if (newImage is not null)
|
if (newImage is not null)
|
||||||
{
|
{
|
||||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = sticker.ResourceIdentifier });
|
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||||
|
{ ResourceId = sticker.ResourceIdentifier });
|
||||||
|
|
||||||
sticker.Image = newImage;
|
sticker.Image = newImage;
|
||||||
|
|
||||||
@@ -63,7 +65,8 @@ public class StickerService(
|
|||||||
var stickerResourceId = $"sticker:{sticker.Id}";
|
var stickerResourceId = $"sticker:{sticker.Id}";
|
||||||
|
|
||||||
// Delete all file references for this sticker
|
// Delete all file references for this sticker
|
||||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = stickerResourceId });
|
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||||
|
{ ResourceId = stickerResourceId });
|
||||||
|
|
||||||
db.Stickers.Remove(sticker);
|
db.Stickers.Remove(sticker);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -82,11 +85,12 @@ public class StickerService(
|
|||||||
|
|
||||||
// Delete all file references for each sticker in the pack
|
// Delete all file references for each sticker in the pack
|
||||||
foreach (var stickerResourceId in stickers.Select(sticker => $"sticker:{sticker.Id}"))
|
foreach (var stickerResourceId in stickers.Select(sticker => $"sticker:{sticker.Id}"))
|
||||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = stickerResourceId });
|
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||||
|
{ ResourceId = stickerResourceId });
|
||||||
|
|
||||||
// Delete any references for the pack itself
|
// Delete any references for the pack itself
|
||||||
var packResourceId = $"stickerpack:{pack.Id}";
|
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest
|
||||||
await fileRefs.DeleteResourceReferencesAsync(new DeleteResourceReferencesRequest { ResourceId = packResourceId });
|
{ ResourceId = pack.ResourceIdentifier });
|
||||||
|
|
||||||
db.Stickers.RemoveRange(stickers);
|
db.Stickers.RemoveRange(stickers);
|
||||||
db.StickerPacks.Remove(pack);
|
db.StickerPacks.Remove(pack);
|
||||||
@@ -119,7 +123,8 @@ public class StickerService(
|
|||||||
{
|
{
|
||||||
var packPart = identifierParts[0];
|
var packPart = identifierParts[0];
|
||||||
var stickerPart = identifierParts[1];
|
var stickerPart = identifierParts[1];
|
||||||
query = query.Where(e => EF.Functions.ILike(e.Pack.Prefix, packPart) && EF.Functions.ILike(e.Slug, stickerPart));
|
query = query.Where(e =>
|
||||||
|
EF.Functions.ILike(e.Pack.Prefix, packPart) && EF.Functions.ILike(e.Slug, stickerPart));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ public class ActivityController(TimelineService acts) : ControllerBase
|
|||||||
public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents(
|
public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents(
|
||||||
[FromQuery] string? cursor,
|
[FromQuery] string? cursor,
|
||||||
[FromQuery] string? filter,
|
[FromQuery] string? filter,
|
||||||
[FromQuery] int take = 20,
|
[FromQuery] int take = 20
|
||||||
[FromQuery] string? debugInclude = null
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Instant? cursorTimestamp = null;
|
Instant? cursorTimestamp = null;
|
||||||
@@ -42,13 +41,9 @@ public class ActivityController(TimelineService acts) : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
|
|
||||||
|
|
||||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
return currentUserValue is not Account currentUser
|
return currentUserValue is not Account currentUser
|
||||||
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp, debugIncludeSet))
|
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp))
|
||||||
: Ok(
|
: Ok(await acts.ListEvents(take, cursorTimestamp, currentUser, filter));
|
||||||
await acts.ListEvents(take, cursorTimestamp, currentUser, filter, debugIncludeSet)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,14 +32,9 @@ public class TimelineService(
|
|||||||
return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2);
|
return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SnTimelineEvent>> ListEventsForAnyone(
|
public async Task<List<SnTimelineEvent>> ListEventsForAnyone(int take, Instant? cursor)
|
||||||
int take,
|
|
||||||
Instant? cursor,
|
|
||||||
HashSet<string>? debugInclude = null
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var activities = new List<SnTimelineEvent>();
|
var activities = new List<SnTimelineEvent>();
|
||||||
debugInclude ??= new HashSet<string>();
|
|
||||||
|
|
||||||
// Get and process posts
|
// Get and process posts
|
||||||
var publicRealms = await rs.GetPublicRealms();
|
var publicRealms = await rs.GetPublicRealms();
|
||||||
@@ -60,7 +55,7 @@ public class TimelineService(
|
|||||||
// Randomly insert a discovery activity before some posts
|
// Randomly insert a discovery activity before some posts
|
||||||
if (random.NextDouble() < 0.15)
|
if (random.NextDouble() < 0.15)
|
||||||
{
|
{
|
||||||
var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor);
|
var discovery = await MaybeGetDiscoveryActivity(cursor: cursor);
|
||||||
if (discovery != null)
|
if (discovery != null)
|
||||||
interleaved.Add(discovery);
|
interleaved.Add(discovery);
|
||||||
}
|
}
|
||||||
@@ -80,12 +75,10 @@ public class TimelineService(
|
|||||||
int take,
|
int take,
|
||||||
Instant? cursor,
|
Instant? cursor,
|
||||||
Account currentUser,
|
Account currentUser,
|
||||||
string? filter = null,
|
string? filter = null
|
||||||
HashSet<string>? debugInclude = null
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var activities = new List<SnTimelineEvent>();
|
var activities = new List<SnTimelineEvent>();
|
||||||
debugInclude ??= new HashSet<string>();
|
|
||||||
|
|
||||||
// Get user's friends and publishers
|
// Get user's friends and publishers
|
||||||
var friendsResponse = await accounts.ListFriendsAsync(
|
var friendsResponse = await accounts.ListFriendsAsync(
|
||||||
@@ -126,7 +119,7 @@ public class TimelineService(
|
|||||||
{
|
{
|
||||||
if (random.NextDouble() < 0.15)
|
if (random.NextDouble() < 0.15)
|
||||||
{
|
{
|
||||||
var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor);
|
var discovery = await MaybeGetDiscoveryActivity(cursor: cursor);
|
||||||
if (discovery != null)
|
if (discovery != null)
|
||||||
interleaved.Add(discovery);
|
interleaved.Add(discovery);
|
||||||
}
|
}
|
||||||
@@ -142,21 +135,16 @@ public class TimelineService(
|
|||||||
return activities;
|
return activities;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(
|
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(Instant? cursor)
|
||||||
HashSet<string> debugInclude,
|
|
||||||
Instant? cursor
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (cursor != null)
|
|
||||||
return null;
|
|
||||||
var options = new List<Func<Task<SnTimelineEvent?>>>();
|
var options = new List<Func<Task<SnTimelineEvent?>>>();
|
||||||
if (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)
|
if (Random.Shared.NextDouble() < 0.5)
|
||||||
options.Add(() => GetRealmDiscoveryActivity());
|
options.Add(() => GetRealmDiscoveryActivity());
|
||||||
if (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2)
|
if (Random.Shared.NextDouble() < 0.5)
|
||||||
options.Add(() => GetPublisherDiscoveryActivity());
|
options.Add(() => GetPublisherDiscoveryActivity());
|
||||||
if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
|
if (Random.Shared.NextDouble() < 0.5)
|
||||||
options.Add(() => GetArticleDiscoveryActivity());
|
options.Add(() => GetArticleDiscoveryActivity());
|
||||||
if (debugInclude.Contains("shuffledPosts") || Random.Shared.NextDouble() < 0.2)
|
if (Random.Shared.NextDouble() < 0.5)
|
||||||
options.Add(() => GetShuffledPostsActivity());
|
options.Add(() => GetShuffledPostsActivity());
|
||||||
if (options.Count == 0)
|
if (options.Count == 0)
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,41 +1,44 @@
|
|||||||
{
|
{
|
||||||
"Debug": true,
|
"Debug": true,
|
||||||
"BaseUrl": "http://localhost:5071",
|
"BaseUrl": "http://localhost:5071",
|
||||||
"SiteUrl": "https://solian.app",
|
"SiteUrl": "https://solian.app",
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"App": "Host=localhost;Port=5432;Database=dyson_sphere;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
|
},
|
||||||
|
"GeoIp": {
|
||||||
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
|
},
|
||||||
|
"RealtimeChat": {
|
||||||
|
"Endpoint": "https://example.livekit.cloud",
|
||||||
|
"ApiKey": "APIeY2atwJUogZ1",
|
||||||
|
"ApiSecret": "ABEeYpcNpNfKWBh2W0gZtM5xkqRhInhWjHOhv7XVakB"
|
||||||
|
},
|
||||||
|
"Translation": {
|
||||||
|
"Provider": "Tencent",
|
||||||
|
"Region": "ap-hongkong",
|
||||||
|
"ProjectId": "0",
|
||||||
|
"SecretId": "",
|
||||||
|
"SecretKey": ""
|
||||||
|
},
|
||||||
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
],
|
||||||
|
"Etcd": {
|
||||||
|
"Insecure": true
|
||||||
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
|
"Service": {
|
||||||
|
"Name": "DysonNetwork.Sphere",
|
||||||
|
"Url": "https://localhost:7099"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_sphere;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
|
||||||
},
|
|
||||||
"GeoIp": {
|
|
||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
|
||||||
},
|
|
||||||
"RealtimeChat": {
|
|
||||||
"Endpoint": "https://example.livekit.cloud",
|
|
||||||
"ApiKey": "APIeY2atwJUogZ1",
|
|
||||||
"ApiSecret": "ABEeYpcNpNfKWBh2W0gZtM5xkqRhInhWjHOhv7XVakB"
|
|
||||||
},
|
|
||||||
"Translation": {
|
|
||||||
"Provider": "Tencent",
|
|
||||||
"Region": "ap-hongkong",
|
|
||||||
"ProjectId": "0",
|
|
||||||
"SecretId": "",
|
|
||||||
"SecretKey": ""
|
|
||||||
},
|
|
||||||
"KnownProxies": [
|
|
||||||
"127.0.0.1",
|
|
||||||
"::1"
|
|
||||||
],
|
|
||||||
"Etcd": {
|
|
||||||
"Insecure": true
|
|
||||||
},
|
|
||||||
"Service": {
|
|
||||||
"Name": "DysonNetwork.Sphere",
|
|
||||||
"Url": "https://localhost:7099"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
DysonNetwork.Zone/Customization/PostPageConfig.cs
Normal file
18
DysonNetwork.Zone/Customization/PostPageConfig.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DysonNetwork.Zone.Customization;
|
||||||
|
|
||||||
|
// PostPage.Config -> filter
|
||||||
|
public class PostPageFilterConfig
|
||||||
|
{
|
||||||
|
public List<int> Types { get; set; }
|
||||||
|
public string? PubName { get; set; }
|
||||||
|
public string? OrderBy { get; set; }
|
||||||
|
public bool OrderDesc { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostPage.Config -> layout
|
||||||
|
public class PostPageLayoutConfig
|
||||||
|
{
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool ShowPub { get; set; } = true;
|
||||||
|
}
|
||||||
156
DysonNetwork.Zone/Migrations/20251210104942_AddSiteGlobalConfig.Designer.cs
generated
Normal file
156
DysonNetwork.Zone/Migrations/20251210104942_AddSiteGlobalConfig.Designer.cs
generated
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Zone;
|
||||||
|
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.Zone.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDatabase))]
|
||||||
|
[Migration("20251210104942_AddSiteGlobalConfig")]
|
||||||
|
partial class AddSiteGlobalConfig
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "9.0.11")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationPage", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Dictionary<string, object>>("Config")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("config");
|
||||||
|
|
||||||
|
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>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8192)
|
||||||
|
.HasColumnType("character varying(8192)")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<Guid>("SiteId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("site_id");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_publication_pages");
|
||||||
|
|
||||||
|
b.HasIndex("SiteId")
|
||||||
|
.HasDatabaseName("ix_publication_pages_site_id");
|
||||||
|
|
||||||
|
b.ToTable("publication_pages", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationSite", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid>("AccountId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<PublicationSiteConfig>("Config")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("config");
|
||||||
|
|
||||||
|
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(8192)
|
||||||
|
.HasColumnType("character varying(8192)")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<int>("Mode")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("mode");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<Guid>("PublisherId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("publisher_id");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(4096)
|
||||||
|
.HasColumnType("character varying(4096)")
|
||||||
|
.HasColumnName("slug");
|
||||||
|
|
||||||
|
b.Property<Instant>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("updated_at");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_publication_sites");
|
||||||
|
|
||||||
|
b.ToTable("publication_sites", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationPage", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DysonNetwork.Shared.Models.SnPublicationSite", "Site")
|
||||||
|
.WithMany("Pages")
|
||||||
|
.HasForeignKey("SiteId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired()
|
||||||
|
.HasConstraintName("fk_publication_pages_publication_sites_site_id");
|
||||||
|
|
||||||
|
b.Navigation("Site");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Shared.Models.SnPublicationSite", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Pages");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using DysonNetwork.Shared.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DysonNetwork.Zone.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSiteGlobalConfig : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<PublicationSiteConfig>(
|
||||||
|
name: "config",
|
||||||
|
table: "publication_sites",
|
||||||
|
type: "jsonb",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new PublicationSiteConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "config",
|
||||||
|
table: "publication_sites");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Zone;
|
using DysonNetwork.Zone;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -82,6 +83,11 @@ namespace DysonNetwork.Zone.Migrations
|
|||||||
.HasColumnType("uuid")
|
.HasColumnType("uuid")
|
||||||
.HasColumnName("account_id");
|
.HasColumnName("account_id");
|
||||||
|
|
||||||
|
b.Property<PublicationSiteConfig>("Config")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("config");
|
||||||
|
|
||||||
b.Property<Instant>("CreatedAt")
|
b.Property<Instant>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("created_at");
|
.HasColumnName("created_at");
|
||||||
|
|||||||
8
DysonNetwork.Zone/Pages/Dynamic/DynamicPage.cshtml
Normal file
8
DysonNetwork.Zone/Pages/Dynamic/DynamicPage.cshtml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@model DynamicPage
|
||||||
|
@{
|
||||||
|
Layout = "_LayoutContained";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="dynamic-content">
|
||||||
|
@Html.Raw(Model.Html)
|
||||||
|
</div>
|
||||||
8
DysonNetwork.Zone/Pages/Dynamic/DynamicPage.cshtml.cs
Normal file
8
DysonNetwork.Zone/Pages/Dynamic/DynamicPage.cshtml.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace DysonNetwork.Zone.Pages.Dynamic;
|
||||||
|
|
||||||
|
public class DynamicPage : PageModel
|
||||||
|
{
|
||||||
|
public string Html { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
@model DysonNetwork.Zone.Pages.PostsModel
|
@model DysonNetwork.Zone.Pages.PostsModel
|
||||||
@{
|
@{
|
||||||
Layout = "_LayoutContained";
|
Layout = "_LayoutContained";
|
||||||
const string defaultAvatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp";
|
|
||||||
|
|
||||||
var pageTitle = "Posts";
|
var pageTitle = Model.LayoutConfig?.Title ?? "Posts";
|
||||||
var pageDescription = "A collection of posts.";
|
var pageDescription = Model.LayoutConfig?.Description ?? "A collection of posts.";
|
||||||
string? ogImageUrl = null;
|
string? ogImageUrl = null;
|
||||||
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
var canonicalUrl = $"{Request.Scheme}://{Request.Host}{Request.Path}{Request.QueryString}";
|
||||||
var siteName = Model.Site?.Name ?? "Solar Network";
|
var siteName = Model.Site?.Name ?? "Solar Network";
|
||||||
@@ -48,7 +47,7 @@
|
|||||||
|
|
||||||
<div class="container mx-auto px-8 py-8">
|
<div class="container mx-auto px-8 py-8">
|
||||||
<h1 class="text-3xl font-bold mb-8 px-5">
|
<h1 class="text-3xl font-bold mb-8 px-5">
|
||||||
<span class="mdi mdi-note-text-outline"></span> Posts
|
<span class="mdi mdi-note-text-outline"></span> @pageTitle
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="w-full grid grid-cols-3 gap-4">
|
<div class="w-full grid grid-cols-3 gap-4">
|
||||||
@@ -75,8 +74,8 @@
|
|||||||
<div class="join">
|
<div class="join">
|
||||||
@{
|
@{
|
||||||
const int maxPagesToShow = 5; // e.g., 2 before, current, 2 after
|
const int maxPagesToShow = 5; // e.g., 2 before, current, 2 after
|
||||||
var startPage = Math.Max(1, Model.CurrentPage - (maxPagesToShow / 2));
|
var startPage = Math.Max(1, Model.Index - (maxPagesToShow / 2));
|
||||||
var endPage = Math.Min(Model.TotalPages, Model.CurrentPage + (maxPagesToShow / 2));
|
var endPage = Math.Min(Model.TotalPages, Model.Index + (maxPagesToShow / 2));
|
||||||
|
|
||||||
// Adjust startPage and endPage to ensure exactly maxPagesToShow are shown if possible
|
// Adjust startPage and endPage to ensure exactly maxPagesToShow are shown if possible
|
||||||
if (endPage - startPage + 1 < maxPagesToShow)
|
if (endPage - startPage + 1 < maxPagesToShow)
|
||||||
@@ -92,15 +91,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<a asp-page="/Posts"
|
<a href="/posts?index=@(Model.Index > 1 ? Model.Index - 1 : 1)"
|
||||||
asp-route-currentPage="@(Model.CurrentPage > 1 ? Model.CurrentPage - 1 : 1)"
|
class="join-item btn @(Model.Index == 1 ? "btn-disabled" : "")">«</a>
|
||||||
class="join-item btn @(Model.CurrentPage == 1 ? "btn-disabled" : "")">«</a>
|
|
||||||
|
|
||||||
@if (startPage > 1)
|
@if (startPage > 1)
|
||||||
{
|
{
|
||||||
<a asp-page="/Posts"
|
<a asp-page="/Posts"
|
||||||
asp-route-currentPage="1"
|
asp-route-currentPage="1"
|
||||||
class="join-item btn @(1 == Model.CurrentPage ? "btn-active" : "")">
|
class="join-item btn @(1 == Model.Index ? "btn-active" : "")">
|
||||||
1
|
1
|
||||||
</a>
|
</a>
|
||||||
@if (startPage > 2)
|
@if (startPage > 2)
|
||||||
@@ -111,11 +109,8 @@
|
|||||||
|
|
||||||
@for (var idx = startPage; idx <= endPage; idx++)
|
@for (var idx = startPage; idx <= endPage; idx++)
|
||||||
{
|
{
|
||||||
var pageIdx = idx;
|
<a href="/posts?index=@(idx)" class="join-item btn @(idx == Model.Index ? "btn-active" : "")">
|
||||||
<a asp-page="/Posts"
|
@idx
|
||||||
asp-route-currentPage="@pageIdx"
|
|
||||||
class="join-item btn @(pageIdx == Model.CurrentPage ? "btn-active" : "")">
|
|
||||||
@pageIdx
|
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,16 +120,14 @@
|
|||||||
{
|
{
|
||||||
<span class="join-item btn btn-disabled">...</span>
|
<span class="join-item btn btn-disabled">...</span>
|
||||||
}
|
}
|
||||||
<a asp-page="/Posts"
|
<a href="/posts?index=@(Model.TotalPages)"
|
||||||
asp-route-currentPage="@Model.TotalPages"
|
class="join-item btn @(Model.TotalPages == Model.Index ? "btn-active" : "")">
|
||||||
class="join-item btn @(Model.TotalPages == Model.CurrentPage ? "btn-active" : "")">
|
|
||||||
@Model.TotalPages
|
@Model.TotalPages
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<a asp-page="/Posts"
|
<a href="/posts?index=@(Model.Index < Model.TotalPages ? Model.Index + 1 : Model.TotalPages)"
|
||||||
asp-route-currentPage="@(Model.CurrentPage < Model.TotalPages ? Model.CurrentPage + 1 : Model.TotalPages)"
|
class="join-item btn @(Model.Index == Model.TotalPages ? "btn-disabled" : "")">»</a>
|
||||||
class="join-item btn @(Model.CurrentPage == Model.TotalPages ? "btn-disabled" : "")">»</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
using DysonNetwork.Shared.Proto;
|
using DysonNetwork.Shared.Proto;
|
||||||
using DysonNetwork.Shared.Registry;
|
using DysonNetwork.Shared.Registry;
|
||||||
|
using DysonNetwork.Zone.Customization;
|
||||||
using DysonNetwork.Zone.Publication;
|
using DysonNetwork.Zone.Publication;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
// Add this using statement
|
// Add this using statement
|
||||||
@@ -15,34 +16,54 @@ public class PostsModel(
|
|||||||
MarkdownConverter markdownConverter
|
MarkdownConverter markdownConverter
|
||||||
) : PageModel
|
) : PageModel
|
||||||
{
|
{
|
||||||
[FromQuery] public bool ShowAll { get; set; } = false;
|
|
||||||
|
|
||||||
public SnPublicationSite? Site { get; set; }
|
public SnPublicationSite? Site { get; set; }
|
||||||
public SnPublisher? Publisher { get; set; }
|
public SnPublisher? Publisher { get; set; }
|
||||||
public List<SnPost> Posts { get; set; } = [];
|
public List<SnPost> Posts { get; set; } = [];
|
||||||
public int TotalCount { get; set; }
|
public int TotalCount { get; set; }
|
||||||
|
|
||||||
public int CurrentPage { get; set; }
|
public int Index { get; set; }
|
||||||
public int PageSize { get; set; } = 10;
|
public int PageSize { get; set; } = 10;
|
||||||
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
|
||||||
|
|
||||||
public async Task OnGetAsync(int currentPage = 1)
|
public PostPageFilterConfig? FilterConfig { get; set; }
|
||||||
{
|
public PostPageLayoutConfig? LayoutConfig { get; set; }
|
||||||
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
|
||||||
CurrentPage = currentPage;
|
|
||||||
|
|
||||||
Publisher = await rps.GetPublisher(id: Site!.PublisherId.ToString());
|
public async Task OnGetAsync(int index = 1)
|
||||||
|
{
|
||||||
|
FilterConfig = HttpContext.Items["PostPage_FilterConfig"] as PostPageFilterConfig;
|
||||||
|
LayoutConfig = HttpContext.Items["PostPage_LayoutConfig"] as PostPageLayoutConfig;
|
||||||
|
Site = HttpContext.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
||||||
|
Index = index;
|
||||||
|
|
||||||
|
Publisher = FilterConfig?.PubName is not null
|
||||||
|
? await rps.GetPublisher(FilterConfig.PubName)
|
||||||
|
: await rps.GetPublisher(id: Site!.PublisherId.ToString());
|
||||||
|
|
||||||
var request = new ListPostsRequest
|
var request = new ListPostsRequest
|
||||||
{
|
{
|
||||||
OrderBy = "date",
|
OrderBy = FilterConfig?.OrderBy,
|
||||||
OrderDesc = true,
|
OrderDesc = FilterConfig?.OrderDesc ?? true,
|
||||||
PageSize = PageSize,
|
PageSize = PageSize,
|
||||||
PageToken = ((CurrentPage - 1) * PageSize).ToString(),
|
PageToken = ((Index - 1) * PageSize).ToString(),
|
||||||
PublisherId = Site!.PublisherId.ToString()
|
PublisherId = Publisher!.Id.ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!ShowAll) request.Types_.Add(DysonNetwork.Shared.Proto.PostType.Article);
|
if (FilterConfig?.Types is not null)
|
||||||
|
{
|
||||||
|
foreach (var type in FilterConfig.Types)
|
||||||
|
{
|
||||||
|
request.Types_.Add(type switch
|
||||||
|
{
|
||||||
|
0 => DysonNetwork.Shared.Proto.PostType.Moment,
|
||||||
|
1 => DysonNetwork.Shared.Proto.PostType.Article,
|
||||||
|
_ => DysonNetwork.Shared.Proto.PostType.Unspecified,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
request.Types_.Add(DysonNetwork.Shared.Proto.PostType.Article);
|
||||||
|
}
|
||||||
|
|
||||||
var response = await postClient.ListPostsAsync(request);
|
var response = await postClient.ListPostsAsync(request);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@using DysonNetwork.Zone.Publication
|
@using DysonNetwork.Zone.Publication
|
||||||
@using DysonNetwork.Shared.Models
|
@using DysonNetwork.Shared.Models
|
||||||
|
@using Microsoft.IdentityModel.Tokens
|
||||||
@{
|
@{
|
||||||
Layout = "_Layout";
|
Layout = "_Layout";
|
||||||
var site = Context.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
var site = Context.Items[PublicationSiteMiddleware.SiteContextKey] as SnPublicationSite;
|
||||||
@@ -8,13 +9,26 @@
|
|||||||
|
|
||||||
<div class="navbar backdrop-blur-md bg-white/1 shadow-xl px-5">
|
<div class="navbar backdrop-blur-md bg-white/1 shadow-xl px-5">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<a class="btn btn-ghost text-xl" asp-page="/Index">@siteDisplayName</a>
|
<a class="btn btn-ghost text-xl" href="/">@siteDisplayName</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-none">
|
<div class="flex-none">
|
||||||
<ul class="menu menu-horizontal px-1">
|
@if (site?.Config.NavItems is null || site.Config.NavItems.IsNullOrEmpty())
|
||||||
<li><a asp-page="/Posts">Posts</a></li>
|
{
|
||||||
<li><a asp-page="/About">About</a></li>
|
@*Use preset navs*@
|
||||||
</ul>
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
<li><a href="/posts">Posts</a></li>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<ul class="menu menu-horizontal px-1">
|
||||||
|
@foreach (var item in site.Config.NavItems)
|
||||||
|
{
|
||||||
|
<li><a href="@item.Href">@item.Label</a></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,6 +45,11 @@
|
|||||||
{
|
{
|
||||||
@await RenderSectionAsync("Head", required: false)
|
@await RenderSectionAsync("Head", required: false)
|
||||||
|
|
||||||
|
@if (site?.Config.StyleOverride is not null)
|
||||||
|
{
|
||||||
|
<style>@(site.Config.StyleOverride)</style>
|
||||||
|
}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.navbar {
|
.navbar {
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|||||||
@@ -80,8 +80,7 @@
|
|||||||
<div class="text-sm text-base-content/60">
|
<div class="text-sm text-base-content/60">
|
||||||
Posted on @Model.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd")
|
Posted on @Model.CreatedAt.ToDateTimeOffset().ToString("yyyy-MM-dd")
|
||||||
</div>
|
</div>
|
||||||
<a asp-page="/Posts/Details" asp-route-slug="@(Model.Slug ?? Model.Id.ToString())"
|
<a href="/p/@(Model.Slug ?? Model.Id.ToString())"class="btn btn-sm btn-ghost btn-circle">
|
||||||
class="btn btn-sm btn-ghost btn-circle">
|
|
||||||
<span class="mdi mdi-arrow-right text-lg"></span>
|
<span class="mdi mdi-arrow-right text-lg"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ public class PublicationSiteController(
|
|||||||
|
|
||||||
[HttpPost("{pubName}")]
|
[HttpPost("{pubName}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnPublicationSite>> CreateSite([FromRoute] string pubName, [FromBody] PublicationSiteRequest request)
|
public async Task<ActionResult<SnPublicationSite>> CreateSite([FromRoute] string pubName,
|
||||||
|
[FromBody] PublicationSiteRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
@@ -75,6 +76,7 @@ public class PublicationSiteController(
|
|||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
PublisherId = publisher.Id,
|
PublisherId = publisher.Id,
|
||||||
|
Config = request.Config ?? new PublicationSiteConfig(),
|
||||||
AccountId = accountId
|
AccountId = accountId
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +98,8 @@ public class PublicationSiteController(
|
|||||||
|
|
||||||
[HttpPatch("{pubName}/{slug}")]
|
[HttpPatch("{pubName}/{slug}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnPublicationSite>> UpdateSite([FromRoute] string pubName, string slug, [FromBody] PublicationSiteRequest request)
|
public async Task<ActionResult<SnPublicationSite>> UpdateSite([FromRoute] string pubName, string slug,
|
||||||
|
[FromBody] PublicationSiteRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
@@ -113,6 +116,7 @@ public class PublicationSiteController(
|
|||||||
site.Slug = request.Slug;
|
site.Slug = request.Slug;
|
||||||
site.Name = request.Name;
|
site.Name = request.Name;
|
||||||
site.Description = request.Description ?? site.Description;
|
site.Description = request.Description ?? site.Description;
|
||||||
|
site.Config = request.Config ?? site.Config;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -153,18 +157,10 @@ public class PublicationSiteController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("site/{slug}/page")]
|
|
||||||
public async Task<ActionResult<SnPublicationPage>> RenderPage(string slug, [FromQuery] string path = "/")
|
|
||||||
{
|
|
||||||
var page = await publicationService.RenderPage(slug, path);
|
|
||||||
if (page == null)
|
|
||||||
return NotFound();
|
|
||||||
return Ok(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{pubName}/{siteSlug}/pages")]
|
[HttpGet("{pubName}/{siteSlug}/pages")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<List<SnPublicationPage>>> ListPagesForSite([FromRoute] string pubName, [FromRoute] string siteSlug)
|
public async Task<ActionResult<List<SnPublicationPage>>> ListPagesForSite([FromRoute] string pubName,
|
||||||
|
[FromRoute] string siteSlug)
|
||||||
{
|
{
|
||||||
var site = await publicationService.GetSiteBySlug(siteSlug);
|
var site = await publicationService.GetSiteBySlug(siteSlug);
|
||||||
if (site == null) return NotFound();
|
if (site == null) return NotFound();
|
||||||
@@ -187,7 +183,8 @@ public class PublicationSiteController(
|
|||||||
|
|
||||||
[HttpPost("{pubName}/{siteSlug}/pages")]
|
[HttpPost("{pubName}/{siteSlug}/pages")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<ActionResult<SnPublicationPage>> CreatePage([FromRoute] string pubName, [FromRoute] string siteSlug, [FromBody] PublicationPageRequest request)
|
public async Task<ActionResult<SnPublicationPage>> CreatePage([FromRoute] string pubName,
|
||||||
|
[FromRoute] string siteSlug, [FromBody] PublicationPageRequest request)
|
||||||
{
|
{
|
||||||
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
if (HttpContext.Items["CurrentUser"] is not Shared.Proto.Account currentUser)
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
@@ -280,6 +277,7 @@ public class PublicationSiteController(
|
|||||||
[MaxLength(4096)] public string Slug { get; set; } = null!;
|
[MaxLength(4096)] public string Slug { get; set; } = null!;
|
||||||
[MaxLength(4096)] public string Name { get; set; } = null!;
|
[MaxLength(4096)] public string Name { get; set; } = null!;
|
||||||
[MaxLength(8192)] public string? Description { get; set; }
|
[MaxLength(8192)] public string? Description { get; set; }
|
||||||
|
public PublicationSiteConfig? Config { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PublicationPageRequest
|
public class PublicationPageRequest
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DysonNetwork.Shared.Models;
|
using DysonNetwork.Shared.Models;
|
||||||
|
using DysonNetwork.Shared.Proto;
|
||||||
|
using DysonNetwork.Zone.Customization;
|
||||||
|
using DysonNetwork.Zone.Pages.Dynamic;
|
||||||
|
using Microsoft.AspNetCore.Http.Features;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||||
using Microsoft.AspNetCore.StaticFiles;
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -9,7 +19,13 @@ public class PublicationSiteMiddleware(RequestDelegate next)
|
|||||||
{
|
{
|
||||||
public const string SiteContextKey = "PubSite";
|
public const string SiteContextKey = "PubSite";
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context, AppDatabase db, PublicationSiteManager psm)
|
public async Task InvokeAsync(
|
||||||
|
HttpContext context,
|
||||||
|
AppDatabase db,
|
||||||
|
PublicationSiteManager psm,
|
||||||
|
IRazorViewEngine viewEngine,
|
||||||
|
ITempDataProvider tempData
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var siteNameValue = context.Request.Headers["X-SiteName"].ToString();
|
var siteNameValue = context.Request.Headers["X-SiteName"].ToString();
|
||||||
var currentPath = context.Request.Path.Value ?? "";
|
var currentPath = context.Request.Path.Value ?? "";
|
||||||
@@ -21,7 +37,8 @@ public class PublicationSiteMiddleware(RequestDelegate next)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var site = await db.PublicationSites
|
var site = await db.PublicationSites
|
||||||
.FirstOrDefaultAsync(s => EF.Functions.ILike(s.Slug, siteNameValue));
|
.FirstOrDefaultAsync(s => EF.Functions.ILike(s.Slug, siteNameValue)
|
||||||
|
);
|
||||||
if (site == null)
|
if (site == null)
|
||||||
{
|
{
|
||||||
await next(context);
|
await next(context);
|
||||||
@@ -38,13 +55,43 @@ public class PublicationSiteMiddleware(RequestDelegate next)
|
|||||||
{
|
{
|
||||||
case PublicationPageType.HtmlPage
|
case PublicationPageType.HtmlPage
|
||||||
when page.Config.TryGetValue("html", out var html) && html is JsonElement content:
|
when page.Config.TryGetValue("html", out var html) && html is JsonElement content:
|
||||||
context.Response.ContentType = "text/html";
|
if (site.Mode == PublicationSiteMode.FullyManaged)
|
||||||
await context.Response.WriteAsync(content.ToString());
|
{
|
||||||
|
context.Items["PublicationHtmlContent"] = content.ToString();
|
||||||
|
var layoutedHtml = await RenderViewAsync(
|
||||||
|
context,
|
||||||
|
viewEngine,
|
||||||
|
tempData,
|
||||||
|
"/Pages/Dynamic/DynamicPage.cshtml",
|
||||||
|
new DynamicPage { Html = content.ToString() }
|
||||||
|
);
|
||||||
|
context.Response.ContentType = "text/html";
|
||||||
|
await context.Response.WriteAsync(layoutedHtml);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/html";
|
||||||
|
await context.Response.WriteAsync(content.ToString());
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case PublicationPageType.Redirect
|
case PublicationPageType.Redirect
|
||||||
when page.Config.TryGetValue("target", out var tgt) && tgt is JsonElement redirectUrl:
|
when page.Config.TryGetValue("target", out var tgt) && tgt is JsonElement redirectUrl:
|
||||||
context.Response.Redirect(redirectUrl.ToString());
|
context.Response.Redirect(redirectUrl.ToString());
|
||||||
return;
|
return;
|
||||||
|
case PublicationPageType.PostPage:
|
||||||
|
PostPageFilterConfig? filterConfig = null;
|
||||||
|
if (page.Config.TryGetValue("filter", out var filter) && filter is JsonElement filterJson)
|
||||||
|
filterConfig = filterJson.Deserialize<PostPageFilterConfig>(GrpcTypeHelper.SerializerOptions);
|
||||||
|
PostPageLayoutConfig? layoutConfig = null;
|
||||||
|
if (page.Config.TryGetValue("layout", out var layout) && layout is JsonElement layoutJson)
|
||||||
|
layoutConfig = layoutJson.Deserialize<PostPageLayoutConfig>(GrpcTypeHelper.SerializerOptions);
|
||||||
|
context.Items["PostPage_LayoutConfig"] = layoutConfig;
|
||||||
|
context.Items["PostPage_FilterConfig"] = filterConfig;
|
||||||
|
context.Request.Path = "/Posts";
|
||||||
|
await next(context);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +132,51 @@ public class PublicationSiteMiddleware(RequestDelegate next)
|
|||||||
|
|
||||||
await next(context);
|
await next(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string> RenderViewAsync(
|
||||||
|
HttpContext context,
|
||||||
|
IRazorViewEngine engine,
|
||||||
|
ITempDataProvider tempDataProvider,
|
||||||
|
string viewPath,
|
||||||
|
object model)
|
||||||
|
{
|
||||||
|
var endpointFeature = context.Features.Get<IEndpointFeature>();
|
||||||
|
var endpoint = endpointFeature?.Endpoint;
|
||||||
|
|
||||||
|
var routeData = context.GetRouteData();
|
||||||
|
|
||||||
|
var actionContext = new ActionContext(
|
||||||
|
context,
|
||||||
|
routeData,
|
||||||
|
new ActionDescriptor()
|
||||||
|
);
|
||||||
|
|
||||||
|
await using var sw = new StringWriter();
|
||||||
|
|
||||||
|
var viewResult = engine.GetView(null, viewPath, true);
|
||||||
|
|
||||||
|
if (!viewResult.Success)
|
||||||
|
throw new FileNotFoundException($"View '{viewPath}' not found.");
|
||||||
|
|
||||||
|
var viewData = new ViewDataDictionary(
|
||||||
|
new EmptyModelMetadataProvider(),
|
||||||
|
new ModelStateDictionary())
|
||||||
|
{
|
||||||
|
Model = model
|
||||||
|
};
|
||||||
|
|
||||||
|
var tempData = new TempDataDictionary(context, tempDataProvider);
|
||||||
|
|
||||||
|
var viewContext = new ViewContext(
|
||||||
|
actionContext,
|
||||||
|
viewResult.View,
|
||||||
|
viewData,
|
||||||
|
tempData,
|
||||||
|
sw,
|
||||||
|
new HtmlHelperOptions()
|
||||||
|
);
|
||||||
|
|
||||||
|
await viewResult.View.RenderAsync(viewContext);
|
||||||
|
return sw.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,10 +10,16 @@
|
|||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"App": "Host=localhost;Port=5432;Database=dyson_zone;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
"App": "Host=localhost;Port=5432;Database=dyson_zone;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
|
||||||
},
|
},
|
||||||
"KnownProxies": ["127.0.0.1", "::1"],
|
"KnownProxies": [
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1"
|
||||||
|
],
|
||||||
"Swagger": {
|
"Swagger": {
|
||||||
"PublicBasePath": "/zone"
|
"PublicBasePath": "/zone"
|
||||||
},
|
},
|
||||||
|
"Cache": {
|
||||||
|
"Serializer": "MessagePack"
|
||||||
|
},
|
||||||
"Sites": {
|
"Sites": {
|
||||||
"BasePath": "SiteData"
|
"BasePath": "SiteData"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,8 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOk_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F01d30b32e2ff422cb80129ca2a441c4242600_003F3b_003F237bf104_003FOk_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenApiInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffcedb617b237dc31e998d31e01f101e8441948433938518c5f20cec1a845c1_003FOpenApiInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOpenApiInfo_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Ffcedb617b237dc31e998d31e01f101e8441948433938518c5f20cec1a845c1_003FOpenApiInfo_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AOptionsConfigurationServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6622dea924b14dc7aa3ee69d7c84e5735000_003Fe0_003F024ba0b7_003FOptionsConfigurationServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageActionDescriptor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003Fc2_003F5bba515a_003FPageActionDescriptor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageLoader_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003F9e_003Ff8e508b5_003FPageLoader_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageModel_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003Ff3_003Fd92c30ee_003FPageModel_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APageModel_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2be344df4d74430c8cfa6e1fd83191ea7b110_003Ff3_003Fd92c30ee_003FPageModel_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathStringTransform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fc5_003Fc4220f9f_003FPathStringTransform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathStringTransform_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fc5_003Fc4220f9f_003FPathStringTransform_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathTransformExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fd9_003Faff65774_003FPathTransformExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003APathTransformExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003Fd9_003Faff65774_003FPathTransformExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
@@ -155,6 +157,9 @@
|
|||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ASourceCustom_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fdaa8d9c408cd4b4286bbef7e35f1a42e31c00_003F45_003F5839ca6c_003FSourceCustom_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F3bef61b8a21d4c8e96872ecdd7782fa0e55000_003F7a_003F870020d0_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fdf_003F3fcdc4d2_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodePagesExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe98808dec0c44c18de3f97b316370d478f08_003F20_003F1750deb2_003FStatusCodePagesExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodePagesMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe98808dec0c44c18de3f97b316370d478f08_003F0b_003Ff955e54b_003FStatusCodePagesMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodePagesOptions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Ffe98808dec0c44c18de3f97b316370d478f08_003F01_003F859257a9_003FStatusCodePagesOptions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStatusCodeResult_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F0b5acdd962e549369896cece0026e556214600_003F7c_003F8b7572ae_003FStatusCodeResult_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamConfig_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7140aa4493a2490fb306b1e68b5d533c98200_003Fbc_003Fccf922ff_003FStreamConfig_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStreamConfig_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F7140aa4493a2490fb306b1e68b5d533c98200_003Fbc_003Fccf922ff_003FStreamConfig_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStructuredTransformer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F5c_003F73acd7b4_003FStructuredTransformer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStructuredTransformer_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fbf3f51607a3e4e76b5b91640cd7409195c430_003F5c_003F73acd7b4_003FStructuredTransformer_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
|
||||||
|
|||||||
Reference in New Issue
Block a user