mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-08-13 03:50:19 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bfe0957b | ||
|
|
7f614f6954 | ||
|
|
6663778e3e | ||
|
|
14f0a3b37e | ||
|
|
82a1152f9f | ||
|
|
580524b855 | ||
|
|
159fe0e376 | ||
|
|
30fdaae36c | ||
|
|
815c8fb84c | ||
|
|
04b0c305ed | ||
|
|
31c57f95b2 | ||
|
|
33413b0a5c |
@@ -42,6 +42,7 @@ namespace Notesnook.API.Accessors
|
|||||||
public SyncItemsRepository Colors { get; }
|
public SyncItemsRepository Colors { get; }
|
||||||
public SyncItemsRepository Vaults { get; }
|
public SyncItemsRepository Vaults { get; }
|
||||||
public SyncItemsRepository Tags { get; }
|
public SyncItemsRepository Tags { get; }
|
||||||
|
public SyncItemsRepository InboxItemsHistory { get; }
|
||||||
public Repository<UserSettings> UsersSettings { get; }
|
public Repository<UserSettings> UsersSettings { get; }
|
||||||
public Repository<Monograph> Monographs { get; }
|
public Repository<Monograph> Monographs { get; }
|
||||||
public Repository<InboxApiKey> InboxApiKey { get; }
|
public Repository<InboxApiKey> InboxApiKey { get; }
|
||||||
@@ -75,6 +76,8 @@ namespace Notesnook.API.Accessors
|
|||||||
IMongoCollection<SyncItem> vaults,
|
IMongoCollection<SyncItem> vaults,
|
||||||
[FromKeyedServices(Collections.TagsKey)]
|
[FromKeyedServices(Collections.TagsKey)]
|
||||||
IMongoCollection<SyncItem> tags,
|
IMongoCollection<SyncItem> tags,
|
||||||
|
[FromKeyedServices(Collections.InboxItemsHistoryKey)]
|
||||||
|
IMongoCollection<SyncItem> inboxItemsHistory,
|
||||||
|
|
||||||
Repository<UserSettings> usersSettings,
|
Repository<UserSettings> usersSettings,
|
||||||
Repository<Monograph> monographs,
|
Repository<Monograph> monographs,
|
||||||
@@ -102,6 +105,7 @@ namespace Notesnook.API.Accessors
|
|||||||
Colors = new SyncItemsRepository(dbContext, colors, logger);
|
Colors = new SyncItemsRepository(dbContext, colors, logger);
|
||||||
Vaults = new SyncItemsRepository(dbContext, vaults, logger);
|
Vaults = new SyncItemsRepository(dbContext, vaults, logger);
|
||||||
Tags = new SyncItemsRepository(dbContext, tags, logger);
|
Tags = new SyncItemsRepository(dbContext, tags, logger);
|
||||||
|
InboxItemsHistory = new SyncItemsRepository(dbContext, inboxItemsHistory, logger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,5 +18,6 @@ namespace Notesnook.API
|
|||||||
public const string InboxApiKeysKey = "inbox_api_keys";
|
public const string InboxApiKeysKey = "inbox_api_keys";
|
||||||
public const string SyncDevicesKey = "sync_devices";
|
public const string SyncDevicesKey = "sync_devices";
|
||||||
public const string DeviceIdsChunksKey = "device_ids_chunks";
|
public const string DeviceIdsChunksKey = "device_ids_chunks";
|
||||||
|
public const string InboxItemsHistoryKey = "inbox_items_history";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -63,7 +64,7 @@ namespace Notesnook.API.Controllers
|
|||||||
|
|
||||||
[HttpPost("api-keys")]
|
[HttpPost("api-keys")]
|
||||||
[Authorize(Policy = "Notesnook")]
|
[Authorize(Policy = "Notesnook")]
|
||||||
public async Task<IActionResult> CreateApiKeyAsync([FromBody] InboxApiKey request)
|
public async Task<IActionResult> CreateApiKeyAsync([FromBody] CreateInboxApiKeyRequest request)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
try
|
try
|
||||||
@@ -72,9 +73,9 @@ namespace Notesnook.API.Controllers
|
|||||||
{
|
{
|
||||||
return BadRequest(new { error = "Api key name is required." });
|
return BadRequest(new { error = "Api key name is required." });
|
||||||
}
|
}
|
||||||
if (request.ExpiryDate <= -1)
|
if (request.ExpiryDate == null)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Valid expiry date is required." });
|
return BadRequest(new { error = "Expiry date is required." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId);
|
var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId);
|
||||||
@@ -133,7 +134,7 @@ namespace Notesnook.API.Controllers
|
|||||||
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
|
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
|
||||||
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
|
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Inbox public key is not configured." });
|
return NotFound(new { error = "Inbox public key is not configured." });
|
||||||
}
|
}
|
||||||
return Ok(new { key = userSetting.InboxKeys.Public });
|
return Ok(new { key = userSetting.InboxKeys.Public });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ namespace Notesnook.API.Hubs
|
|||||||
"color",
|
"color",
|
||||||
"tag",
|
"tag",
|
||||||
"vault",
|
"vault",
|
||||||
|
"inboxitemhistory",
|
||||||
"relation", // relations must sync at the end to prevent invalid state
|
"relation", // relations must sync at the end to prevent invalid state
|
||||||
];
|
];
|
||||||
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
|
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
|
||||||
@@ -92,6 +93,7 @@ namespace Notesnook.API.Hubs
|
|||||||
Repositories.Colors.FindItemsById,
|
Repositories.Colors.FindItemsById,
|
||||||
Repositories.Tags.FindItemsById,
|
Repositories.Tags.FindItemsById,
|
||||||
Repositories.Vaults.FindItemsById,
|
Repositories.Vaults.FindItemsById,
|
||||||
|
Repositories.InboxItemsHistory.FindItemsById,
|
||||||
Repositories.Relations.FindItemsById,
|
Repositories.Relations.FindItemsById,
|
||||||
];
|
];
|
||||||
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
|
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
|
||||||
@@ -106,6 +108,7 @@ namespace Notesnook.API.Hubs
|
|||||||
{ "color", Repositories.Colors.UpsertMany },
|
{ "color", Repositories.Colors.UpsertMany },
|
||||||
{ "vault", Repositories.Vaults.UpsertMany },
|
{ "vault", Repositories.Vaults.UpsertMany },
|
||||||
{ "tag", Repositories.Tags.UpsertMany },
|
{ "tag", Repositories.Tags.UpsertMany },
|
||||||
|
{ "inboxitemhistory", Repositories.InboxItemsHistory.UpsertMany },
|
||||||
}.ToFrozenDictionary();
|
}.ToFrozenDictionary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +125,19 @@ namespace Notesnook.API.Hubs
|
|||||||
await base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
if (exception != null)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(exception, "Connection {ConnectionId} disconnected with error (server-side drop)", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Connection {ConnectionId} disconnected cleanly (client-initiated)", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<int> PushItems(string deviceId, SyncTransferItemV2 pushItem)
|
public async Task<int> PushItems(string deviceId, SyncTransferItemV2 pushItem)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace Notesnook.API.Interfaces
|
|||||||
SyncItemsRepository Colors { get; }
|
SyncItemsRepository Colors { get; }
|
||||||
SyncItemsRepository Vaults { get; }
|
SyncItemsRepository Vaults { get; }
|
||||||
SyncItemsRepository Tags { get; }
|
SyncItemsRepository Tags { get; }
|
||||||
|
SyncItemsRepository InboxItemsHistory { get; }
|
||||||
Repository<UserSettings> UsersSettings { get; }
|
Repository<UserSettings> UsersSettings { get; }
|
||||||
Repository<Monograph> Monographs { get; }
|
Repository<Monograph> Monographs { get; }
|
||||||
Repository<InboxApiKey> InboxApiKey { get; }
|
Repository<InboxApiKey> InboxApiKey { get; }
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ using NanoidDotNet;
|
|||||||
|
|
||||||
namespace Notesnook.API.Models
|
namespace Notesnook.API.Models
|
||||||
{
|
{
|
||||||
|
public class CreateInboxApiKeyRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expiryDate")]
|
||||||
|
public long ExpiryDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class InboxApiKey
|
public class InboxApiKey
|
||||||
{
|
{
|
||||||
public InboxApiKey()
|
public InboxApiKey()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Aws.S3" Version="9.0.0" />
|
<PackageReference Include="AspNetCore.HealthChecks.Aws.S3" Version="9.0.0" />
|
||||||
|
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
|
||||||
<PackageReference Include="AWSSDK.Core" Version="3.7.304.31" />
|
<PackageReference Include="AWSSDK.Core" Version="3.7.304.31" />
|
||||||
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
||||||
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
|
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
|
||||||
|
|||||||
@@ -174,15 +174,6 @@ namespace Notesnook.API.Services
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
userSettings.InboxKeys = keys.InboxKeys;
|
userSettings.InboxKeys = keys.InboxKeys;
|
||||||
var defaultInboxKey = new InboxApiKey
|
|
||||||
{
|
|
||||||
UserId = userId,
|
|
||||||
Name = "Default",
|
|
||||||
DateCreated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
||||||
ExpiryDate = DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds(),
|
|
||||||
LastUsedAt = 0
|
|
||||||
};
|
|
||||||
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
|
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
|
||||||
@@ -208,6 +199,7 @@ namespace Notesnook.API.Services
|
|||||||
Repositories.Colors.DeleteByUserId(userId);
|
Repositories.Colors.DeleteByUserId(userId);
|
||||||
Repositories.Tags.DeleteByUserId(userId);
|
Repositories.Tags.DeleteByUserId(userId);
|
||||||
Repositories.Vaults.DeleteByUserId(userId);
|
Repositories.Vaults.DeleteByUserId(userId);
|
||||||
|
Repositories.InboxItemsHistory.DeleteByUserId(userId);
|
||||||
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
|
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
|
||||||
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
||||||
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
||||||
@@ -269,6 +261,7 @@ namespace Notesnook.API.Services
|
|||||||
Repositories.Colors.DeleteByUserId(userId);
|
Repositories.Colors.DeleteByUserId(userId);
|
||||||
Repositories.Tags.DeleteByUserId(userId);
|
Repositories.Tags.DeleteByUserId(userId);
|
||||||
Repositories.Vaults.DeleteByUserId(userId);
|
Repositories.Vaults.DeleteByUserId(userId);
|
||||||
|
Repositories.InboxItemsHistory.DeleteByUserId(userId);
|
||||||
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
||||||
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
||||||
if (!await unit.Commit()) return false;
|
if (!await unit.Commit()) return false;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ using System.Text;
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Amazon.Runtime;
|
using Amazon.Runtime;
|
||||||
|
using StackExchange.Redis;
|
||||||
using IdentityModel.AspNetCore.OAuth2Introspection;
|
using IdentityModel.AspNetCore.OAuth2Introspection;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
@@ -196,7 +197,8 @@ namespace Notesnook.API
|
|||||||
.AddMongoCollection(Collections.ColorsKey)
|
.AddMongoCollection(Collections.ColorsKey)
|
||||||
.AddMongoCollection(Collections.VaultsKey)
|
.AddMongoCollection(Collections.VaultsKey)
|
||||||
.AddMongoCollection(Collections.InboxItemsKey)
|
.AddMongoCollection(Collections.InboxItemsKey)
|
||||||
.AddMongoCollection(Collections.InboxApiKeysKey);
|
.AddMongoCollection(Collections.InboxApiKeysKey)
|
||||||
|
.AddMongoCollection(Collections.InboxItemsHistoryKey);
|
||||||
|
|
||||||
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
|
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
|
||||||
services.AddScoped<SyncDeviceService>();
|
services.AddScoped<SyncDeviceService>();
|
||||||
@@ -219,7 +221,20 @@ namespace Notesnook.API
|
|||||||
}).AddMessagePackProtocol().AddJsonProtocol();
|
}).AddMessagePackProtocol().AddJsonProtocol();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(Constants.SIGNALR_REDIS_CONNECTION_STRING))
|
if (!string.IsNullOrEmpty(Constants.SIGNALR_REDIS_CONNECTION_STRING))
|
||||||
signalR.AddStackExchangeRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING);
|
{
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING, tags: ["ready"]);
|
||||||
|
signalR.AddStackExchangeRedis(options =>
|
||||||
|
{
|
||||||
|
options.Configuration = ConfigurationOptions.Parse(Constants.SIGNALR_REDIS_CONNECTION_STRING);
|
||||||
|
options.Configuration.AbortOnConnectFail = false;
|
||||||
|
options.Configuration.ConnectRetry = 5;
|
||||||
|
options.Configuration.ReconnectRetryPolicy = new ExponentialRetry(5000, 30000);
|
||||||
|
options.Configuration.KeepAlive = 60;
|
||||||
|
options.Configuration.ConnectTimeout = 5000;
|
||||||
|
options.Configuration.SyncTimeout = 5000;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
services.AddResponseCompression(options =>
|
services.AddResponseCompression(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const RawInboxItemSchema = z.object({
|
|||||||
notebookIds: z.array(z.string()).optional(),
|
notebookIds: z.array(z.string()).optional(),
|
||||||
tagIds: z.array(z.string()).optional(),
|
tagIds: z.array(z.string()).optional(),
|
||||||
type: z.enum(["note"]),
|
type: z.enum(["note"]),
|
||||||
source: z.string(),
|
source: z.string().min(1, "Source is required"),
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
content: z
|
content: z
|
||||||
.object({
|
.object({
|
||||||
@@ -56,7 +56,9 @@ async function encrypt(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getInboxPublicEncryptionKey(apiKey: string) {
|
async function getInboxPublicEncryptionKey(
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<{ status: "unauthorized" } | { status: "ok"; key: string | null }> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
|
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
|
||||||
{
|
{
|
||||||
@@ -65,6 +67,9 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { status: "unauthorized" };
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`failed to fetch inbox public encryption key: ${await response.text()}`,
|
`failed to fetch inbox public encryption key: ${await response.text()}`,
|
||||||
@@ -72,7 +77,7 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as unknown as any;
|
const data = (await response.json()) as unknown as any;
|
||||||
return (data?.key as string) || null;
|
return { status: "ok", key: (data?.key as string) || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postEncryptedInboxItem(
|
async function postEncryptedInboxItem(
|
||||||
@@ -110,10 +115,14 @@ app.post("/", async (req, res) => {
|
|||||||
return res.status(401).json({ error: "unauthorized" });
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey);
|
const encryptionKeyResult = await getInboxPublicEncryptionKey(apiKey);
|
||||||
if (!inboxPublicKey) {
|
if (encryptionKeyResult.status === "unauthorized") {
|
||||||
return res.status(403).json({ error: "inbox public key not found" });
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
if (!encryptionKeyResult.key) {
|
||||||
|
return res.status(404).json({ error: "inbox public key not found" });
|
||||||
|
}
|
||||||
|
const inboxPublicKey = encryptionKeyResult.key;
|
||||||
console.log("[info] fetched inbox public key");
|
console.log("[info] fetched inbox public key");
|
||||||
|
|
||||||
const validationResult = RawInboxItemSchema.safeParse(req.body);
|
const validationResult = RawInboxItemSchema.safeParse(req.body);
|
||||||
|
|||||||
@@ -190,8 +190,8 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
var client = Clients.FindClientById(form.ClientId);
|
var client = Clients.FindClientById(form.ClientId);
|
||||||
if (client == null) return BadRequest("Invalid client_id.");
|
if (client == null) return BadRequest("Invalid client_id.");
|
||||||
|
|
||||||
var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
|
var user = await UserManager.FindByEmailAsync(form.Email);
|
||||||
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
|
if (user == null || !await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
|
||||||
|
|
||||||
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
|
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
|
||||||
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
|
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace Streetwriters.Identity.Interfaces
|
|||||||
{
|
{
|
||||||
public interface ISMSSender
|
public interface ISMSSender
|
||||||
{
|
{
|
||||||
Task<string> SendOTPAsync(string number, IClient client);
|
Task<string?> SendOTPAsync(string number, IClient client);
|
||||||
Task<bool> VerifyOTPAsync(string id, string code);
|
Task<bool> VerifyOTPAsync(string id, string code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +186,8 @@ namespace Streetwriters.Identity.Services
|
|||||||
ArgumentNullException.ThrowIfNull(form.PhoneNumber);
|
ArgumentNullException.ThrowIfNull(form.PhoneNumber);
|
||||||
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
|
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
|
||||||
var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client);
|
var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client);
|
||||||
|
if (string.IsNullOrEmpty(id)) throw new Exception("Failed to send SMS. Please try again.");
|
||||||
|
|
||||||
logger.LogInformation("SMS OTP sent for user: {UserId}, SMS ID: {SmsId}", user.Id, id);
|
logger.LogInformation("SMS OTP sent for user: {UserId}, SMS ID: {SmsId}", user.Id, id);
|
||||||
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
|
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -23,36 +23,56 @@ using Streetwriters.Common;
|
|||||||
using Twilio.Rest.Verify.V2.Service;
|
using Twilio.Rest.Verify.V2.Service;
|
||||||
using Twilio;
|
using Twilio;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Streetwriters.Identity.Services
|
namespace Streetwriters.Identity.Services
|
||||||
{
|
{
|
||||||
public class SMSSender : ISMSSender
|
public class SMSSender : ISMSSender
|
||||||
{
|
{
|
||||||
public SMSSender()
|
private readonly ILogger<SMSSender> Logger;
|
||||||
|
public SMSSender(ILogger<SMSSender> logger)
|
||||||
{
|
{
|
||||||
|
Logger = logger;
|
||||||
if (!string.IsNullOrEmpty(Constants.TWILIO_ACCOUNT_SID) && !string.IsNullOrEmpty(Constants.TWILIO_AUTH_TOKEN))
|
if (!string.IsNullOrEmpty(Constants.TWILIO_ACCOUNT_SID) && !string.IsNullOrEmpty(Constants.TWILIO_AUTH_TOKEN))
|
||||||
{
|
{
|
||||||
TwilioClient.Init(Constants.TWILIO_ACCOUNT_SID, Constants.TWILIO_AUTH_TOKEN);
|
TwilioClient.Init(Constants.TWILIO_ACCOUNT_SID, Constants.TWILIO_AUTH_TOKEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> SendOTPAsync(string number, IClient app)
|
public async Task<string?> SendOTPAsync(string number, IClient app)
|
||||||
{
|
{
|
||||||
var verification = await VerificationResource.CreateAsync(
|
try
|
||||||
to: number,
|
{
|
||||||
channel: "sms",
|
var verification = await VerificationResource.CreateAsync(
|
||||||
pathServiceSid: Constants.TWILIO_SERVICE_SID
|
to: number,
|
||||||
);
|
channel: "sms",
|
||||||
return verification.Sid;
|
pathServiceSid: Constants.TWILIO_SERVICE_SID
|
||||||
|
);
|
||||||
|
return verification.Sid;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error sending OTP with Twilio");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> VerifyOTPAsync(string id, string code)
|
public async Task<bool> VerifyOTPAsync(string id, string code)
|
||||||
{
|
{
|
||||||
return (await VerificationCheckResource.CreateAsync(
|
try
|
||||||
verificationSid: id,
|
{
|
||||||
pathServiceSid: Constants.TWILIO_SERVICE_SID,
|
return (await VerificationCheckResource.CreateAsync(
|
||||||
code: code
|
verificationSid: id,
|
||||||
)).Status == "approved";
|
pathServiceSid: Constants.TWILIO_SERVICE_SID,
|
||||||
|
code: code
|
||||||
|
)).Status == "approved";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error verifying OTP with Twilio");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,7 +504,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
@@ -536,7 +535,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
@@ -567,7 +565,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
|
|||||||
@@ -309,7 +309,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
|
|||||||
@@ -401,7 +401,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
background-color: rgb(
|
background-color: rgb(
|
||||||
|
|||||||
@@ -467,7 +467,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
@@ -483,9 +482,7 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
"
|
"
|
||||||
><em
|
><em
|
||||||
>If you did not request to reset
|
>If you did not request to reset your account password, you can safely ignore this email.</em
|
||||||
your account password, you can
|
|
||||||
safely ignore this email.</em
|
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +551,6 @@
|
|||||||
>
|
>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
.
|
|
||||||
<td
|
<td
|
||||||
style="
|
style="
|
||||||
padding: 18px 0px 18px 0px;
|
padding: 18px 0px 18px 0px;
|
||||||
|
|||||||
@@ -18,28 +18,49 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Lib.AspNetCore.ServerSentEvents;
|
using Lib.AspNetCore.ServerSentEvents;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Streetwriters.Messenger.Helpers
|
namespace Streetwriters.Messenger.Helpers
|
||||||
{
|
{
|
||||||
public class SSEHelper
|
public class SSEHelper
|
||||||
{
|
{
|
||||||
public static async Task SendEventToUserAsync(string data, IServerSentEventsService sseService, string userId, string? originTokenId = null)
|
public static async Task SendEventToUserAsync(string data, IServerSentEventsService sseService, string userId, string? originTokenId = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var clients = sseService.GetClients().Where(c => c.User.FindFirstValue("sub") == userId);
|
var clients = sseService.GetClients()
|
||||||
foreach (var client in clients)
|
.Where(c => c.User?.FindFirstValue("sub") == userId)
|
||||||
{
|
.Where(c => originTokenId == null || c.User?.FindFirstValue("jti") != originTokenId);
|
||||||
if (originTokenId != null && client.User.FindFirstValue("jti") == originTokenId) continue;
|
|
||||||
if (!client.IsConnected) continue;
|
await SendEventToClientsAsync(clients, data, cancellationToken);
|
||||||
await client.SendEventAsync(data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task SendEventToAllUsersAsync(string data, IServerSentEventsService sseService)
|
public static async Task SendEventToAllUsersAsync(string data, IServerSentEventsService sseService, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await sseService.SendEventAsync(data);
|
await SendEventToClientsAsync(sseService.GetClients(), data, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendEventToClientsAsync(IEnumerable<IServerSentEventsClient> clients, string data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var client in clients)
|
||||||
|
{
|
||||||
|
if (!client.IsConnected) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.SendEventAsync(data, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Lib.AspNetCore.ServerSentEvents;
|
using Lib.AspNetCore.ServerSentEvents;
|
||||||
using Streetwriters.Messenger.Helpers;
|
using Streetwriters.Messenger.Helpers;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -33,12 +34,14 @@ namespace Streetwriters.Messenger.Services
|
|||||||
private const string HEARTBEAT_MESSAGE_FORMAT = "Streetwriters Heartbeat ({0} UTC)";
|
private const string HEARTBEAT_MESSAGE_FORMAT = "Streetwriters Heartbeat ({0} UTC)";
|
||||||
|
|
||||||
private readonly IServerSentEventsService _serverSentEventsService;
|
private readonly IServerSentEventsService _serverSentEventsService;
|
||||||
|
private readonly ILogger<HeartbeatService> _logger;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constructor
|
#region Constructor
|
||||||
public HeartbeatService(IServerSentEventsService serverSentEventsService)
|
public HeartbeatService(IServerSentEventsService serverSentEventsService, ILogger<HeartbeatService> logger)
|
||||||
{
|
{
|
||||||
_serverSentEventsService = serverSentEventsService;
|
_serverSentEventsService = serverSentEventsService;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -47,15 +50,28 @@ namespace Streetwriters.Messenger.Services
|
|||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Serialize(new
|
try
|
||||||
{
|
{
|
||||||
type = "heartbeat",
|
var message = JsonSerializer.Serialize(new
|
||||||
data = JsonSerializer.Serialize(new
|
|
||||||
{
|
{
|
||||||
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
type = "heartbeat",
|
||||||
})
|
data = JsonSerializer.Serialize(new
|
||||||
});
|
{
|
||||||
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService);
|
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to send SSE heartbeat to one or more clients.");
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
||||||
<PackageReference Include="Lib.AspNetCore.ServerSentEvents" Version="6.0.0" />
|
<PackageReference Include="Lib.AspNetCore.ServerSentEvents" Version="9.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.0"
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.0"
|
||||||
NoWarn="NU1605" />
|
NoWarn="NU1605" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.0"
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user