mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-08-13 20:10:18 +02:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ce7f02646 | ||
|
|
a1dbd3f8b8 | ||
|
|
99489b9b4c | ||
|
|
1b953f756e | ||
|
|
d27ab68735 | ||
|
|
294d885dbf | ||
|
|
bdd5017394 | ||
|
|
7ad70c63ee | ||
|
|
0367ab6f80 | ||
|
|
f3bfe0957b | ||
|
|
7f614f6954 | ||
|
|
6663778e3e | ||
|
|
14f0a3b37e | ||
|
|
82a1152f9f | ||
|
|
580524b855 | ||
|
|
159fe0e376 | ||
|
|
30fdaae36c | ||
|
|
815c8fb84c | ||
|
|
04b0c305ed | ||
|
|
31c57f95b2 | ||
|
|
33413b0a5c | ||
|
|
4278b0624e | ||
|
|
a56ef1fe11 | ||
|
|
d9c282fcf8 | ||
|
|
73750613c4 | ||
|
|
bb008c032d | ||
|
|
5715f4c9ca | ||
|
|
8116ce70e4 | ||
|
|
2f8b0ad607 | ||
|
|
1c5bcd6eff | ||
|
|
3a2a04317f | ||
|
|
8d92aff8cd | ||
|
|
4bc1469dfe | ||
|
|
da58262afb | ||
|
|
864baa702b | ||
|
|
55c5cd0a7c | ||
|
|
077d411fc7 | ||
|
|
1cecfe4b3c | ||
|
|
b8a7bd16a6 | ||
|
|
fe7c546d9b | ||
|
|
8d4336d1bc | ||
|
|
9ae5db378d | ||
|
|
d5790d8785 | ||
|
|
9424afed68 | ||
|
|
b9385ae112 | ||
|
|
014c4e3b32 | ||
|
|
bf70a32b95 | ||
|
|
d047bd052e | ||
|
|
03f230dbca | ||
|
|
0a8c707387 | ||
|
|
8908b8c6ed | ||
|
|
da8df8973c | ||
|
|
7e50311c92 |
@@ -36,6 +36,10 @@ jobs:
|
||||
- image: streetwriters/sse
|
||||
file: ./Streetwriters.Messenger/Dockerfile
|
||||
context: .
|
||||
|
||||
- image: streetwriters/notesnook-inbox
|
||||
file: ./Notesnook.Inbox.API/Dockerfile
|
||||
context: ./Notesnook.Inbox.API/
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace Notesnook.API.Accessors
|
||||
public SyncItemsRepository Colors { get; }
|
||||
public SyncItemsRepository Vaults { get; }
|
||||
public SyncItemsRepository Tags { get; }
|
||||
public SyncItemsRepository InboxItemsHistory { get; }
|
||||
public Repository<UserSettings> UsersSettings { get; }
|
||||
public Repository<Monograph> Monographs { get; }
|
||||
public Repository<InboxApiKey> InboxApiKey { get; }
|
||||
@@ -75,6 +76,8 @@ namespace Notesnook.API.Accessors
|
||||
IMongoCollection<SyncItem> vaults,
|
||||
[FromKeyedServices(Collections.TagsKey)]
|
||||
IMongoCollection<SyncItem> tags,
|
||||
[FromKeyedServices(Collections.InboxItemsHistoryKey)]
|
||||
IMongoCollection<SyncItem> inboxItemsHistory,
|
||||
|
||||
Repository<UserSettings> usersSettings,
|
||||
Repository<Monograph> monographs,
|
||||
@@ -102,6 +105,7 @@ namespace Notesnook.API.Accessors
|
||||
Colors = new SyncItemsRepository(dbContext, colors, logger);
|
||||
Vaults = new SyncItemsRepository(dbContext, vaults, logger);
|
||||
Tags = new SyncItemsRepository(dbContext, tags, logger);
|
||||
InboxItemsHistory = new SyncItemsRepository(dbContext, inboxItemsHistory, logger);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ namespace Notesnook.API.Authorization
|
||||
return AuthenticateResult.Fail("API key has expired");
|
||||
}
|
||||
|
||||
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey);
|
||||
|
||||
var claims = new[]
|
||||
|
||||
@@ -18,5 +18,6 @@ namespace Notesnook.API
|
||||
public const string InboxApiKeysKey = "inbox_api_keys";
|
||||
public const string SyncDevicesKey = "sync_devices";
|
||||
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.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
@@ -63,7 +64,7 @@ namespace Notesnook.API.Controllers
|
||||
|
||||
[HttpPost("api-keys")]
|
||||
[Authorize(Policy = "Notesnook")]
|
||||
public async Task<IActionResult> CreateApiKeyAsync([FromBody] InboxApiKey request)
|
||||
public async Task<IActionResult> CreateApiKeyAsync([FromBody] CreateInboxApiKeyRequest request)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
try
|
||||
@@ -72,9 +73,9 @@ namespace Notesnook.API.Controllers
|
||||
{
|
||||
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);
|
||||
@@ -133,7 +134,7 @@ namespace Notesnook.API.Controllers
|
||||
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
|
||||
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 });
|
||||
}
|
||||
@@ -151,34 +152,18 @@ namespace Notesnook.API.Controllers
|
||||
var userId = User.GetUserId();
|
||||
try
|
||||
{
|
||||
if (request.Key.Algorithm != Algorithms.XSAL_X25519_7)
|
||||
if (string.IsNullOrWhiteSpace(request.Cipher))
|
||||
{
|
||||
return BadRequest(new { error = $"Only {Algorithms.XSAL_X25519_7} is supported for inbox item password." });
|
||||
return BadRequest(new { error = "Inbox item is required." });
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request.Key.Cipher))
|
||||
if (string.IsNullOrWhiteSpace(request.Algorithm))
|
||||
{
|
||||
return BadRequest(new { error = "Inbox item password cipher is required." });
|
||||
}
|
||||
if (request.Key.Length <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid inbox item password length is required." });
|
||||
}
|
||||
if (request.Algorithm != Algorithms.Default)
|
||||
{
|
||||
return BadRequest(new { error = $"Only {Algorithms.Default} is supported for inbox item." });
|
||||
return BadRequest(new { error = "Inbox item algorithm is required." });
|
||||
}
|
||||
if (request.Version <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid inbox item version is required." });
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(request.Cipher) || string.IsNullOrWhiteSpace(request.IV))
|
||||
{
|
||||
return BadRequest(new { error = "Inbox item cipher and iv is required." });
|
||||
}
|
||||
if (request.Length <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "Valid inbox item length is required." });
|
||||
}
|
||||
|
||||
request.UserId = userId;
|
||||
request.ItemId = ObjectId.GenerateNewId().ToString();
|
||||
|
||||
@@ -18,27 +18,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using Notesnook.API.Authorization;
|
||||
using NanoidDotNet;
|
||||
using Notesnook.API.Extensions;
|
||||
using Notesnook.API.Models;
|
||||
using Notesnook.API.Services;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Accessors;
|
||||
using Streetwriters.Common.Enums;
|
||||
using Streetwriters.Common.Helpers;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
using Streetwriters.Common.Messages;
|
||||
using Streetwriters.Data.Interfaces;
|
||||
using Streetwriters.Data.Repositories;
|
||||
|
||||
namespace Notesnook.API.Controllers
|
||||
@@ -46,7 +46,7 @@ namespace Notesnook.API.Controllers
|
||||
[ApiController]
|
||||
[Route("monographs")]
|
||||
[Authorize("Sync")]
|
||||
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, ILogger<MonographsController> logger) : ControllerBase
|
||||
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, WampServiceAccessor serviceAccessor, ILogger<MonographsController> logger) : ControllerBase
|
||||
{
|
||||
const string SVG_PIXEL = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><circle r='9'/></svg>";
|
||||
private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
|
||||
@@ -95,6 +95,29 @@ namespace Notesnook.API.Controllers
|
||||
return await result.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<Monograph> FindMonographBySlugAsync(string slug)
|
||||
{
|
||||
var result = await monographs.Collection.FindAsync(
|
||||
Builders<Monograph>.Filter.Eq("Slug", slug), new FindOptions<Monograph>
|
||||
{
|
||||
Limit = 1
|
||||
});
|
||||
return await result.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private async Task<string> GenerateUniqueSlugAsync(int length = 10, int maxAttempts = 5)
|
||||
{
|
||||
for (var i = 0; i < maxAttempts; i++)
|
||||
{
|
||||
var slug = Nanoid.Generate(size: length);
|
||||
var exists = await monographs.Collection.Find(Builders<Monograph>.Filter.Eq("Slug", slug))
|
||||
.Limit(1)
|
||||
.AnyAsync();
|
||||
if (!exists) return slug;
|
||||
}
|
||||
throw new Exception("Failed to generate unique slug");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
||||
{
|
||||
@@ -106,20 +129,12 @@ namespace Notesnook.API.Controllers
|
||||
var existingMonograph = await FindMonographAsync(userId, monograph);
|
||||
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
|
||||
|
||||
if (monograph.EncryptedContent == null)
|
||||
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
|
||||
monograph.UserId = userId;
|
||||
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
|
||||
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
|
||||
|
||||
monograph = await CreateMonographAsync(monograph, userId);
|
||||
if (existingMonograph != null)
|
||||
{
|
||||
monograph.Id = existingMonograph.Id;
|
||||
}
|
||||
monograph.Deleted = false;
|
||||
monograph.ViewCount = 0;
|
||||
|
||||
await monographs.Collection.ReplaceOneAsync(
|
||||
CreateMonographFilter(userId, monograph),
|
||||
monograph,
|
||||
@@ -131,13 +146,54 @@ namespace Notesnook.API.Controllers
|
||||
return Ok(new
|
||||
{
|
||||
id = monograph.ItemId,
|
||||
datePublished = monograph.DatePublished
|
||||
datePublished = monograph.DatePublished,
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to publish monograph");
|
||||
return BadRequest();
|
||||
return BadRequest(new { error = e.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("v2")]
|
||||
public async Task<IActionResult> PublishV2Async([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = this.User.GetUserId();
|
||||
var jti = this.User.FindFirstValue("jti");
|
||||
|
||||
var existingMonograph = await FindMonographAsync(userId, monograph);
|
||||
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
|
||||
|
||||
monograph = await CreateMonographAsync(monograph, userId);
|
||||
monograph.Slug = await GenerateUniqueSlugAsync();
|
||||
|
||||
if (existingMonograph != null)
|
||||
{
|
||||
monograph.Id = existingMonograph.Id;
|
||||
}
|
||||
|
||||
await monographs.Collection.ReplaceOneAsync(
|
||||
CreateMonographFilter(userId, monograph),
|
||||
monograph,
|
||||
new ReplaceOptions { IsUpsert = true }
|
||||
);
|
||||
|
||||
await MarkMonographForSyncAsync(userId, monograph.ItemId ?? monograph.Id, deviceId, jti);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
id = monograph.ItemId,
|
||||
datePublished = monograph.DatePublished,
|
||||
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph)
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to publish monograph");
|
||||
return BadRequest(new { error = e.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,8 +214,12 @@ namespace Notesnook.API.Controllers
|
||||
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
|
||||
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
|
||||
|
||||
var sanitizationLevel = ContentSanitizationLevel.Unknown;
|
||||
if (monograph.EncryptedContent == null)
|
||||
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
|
||||
{
|
||||
sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
|
||||
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
|
||||
}
|
||||
else
|
||||
monograph.Content = null;
|
||||
|
||||
@@ -173,6 +233,7 @@ namespace Notesnook.API.Controllers
|
||||
.Set(m => m.SelfDestruct, monograph.SelfDestruct)
|
||||
.Set(m => m.Title, monograph.Title)
|
||||
.Set(m => m.Password, monograph.Password)
|
||||
.Set(m => m.ContentSanitizationLevel, sanitizationLevel)
|
||||
);
|
||||
if (!result.IsAcknowledged) return BadRequest();
|
||||
|
||||
@@ -181,13 +242,14 @@ namespace Notesnook.API.Controllers
|
||||
return Ok(new
|
||||
{
|
||||
id = monograph.ItemId,
|
||||
datePublished = monograph.DatePublished
|
||||
datePublished = monograph.DatePublished,
|
||||
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(existingMonograph)
|
||||
});
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Failed to update monograph");
|
||||
return BadRequest();
|
||||
return BadRequest(new { error = e.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +284,7 @@ namespace Notesnook.API.Controllers
|
||||
});
|
||||
}
|
||||
|
||||
if (monograph.EncryptedContent == null)
|
||||
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
|
||||
monograph.ItemId ??= monograph.Id;
|
||||
return Ok(monograph);
|
||||
return Ok(await ProcessMonographAsync(monograph));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/view")]
|
||||
@@ -233,47 +292,47 @@ namespace Notesnook.API.Controllers
|
||||
public async Task<IActionResult> TrackView([FromRoute] string id)
|
||||
{
|
||||
var monograph = await FindMonographAsync(id);
|
||||
if (monograph == null || monograph.Deleted) return Content(SVG_PIXEL, "image/svg+xml");
|
||||
if (monograph == null || monograph.Deleted)
|
||||
return Content(SVG_PIXEL, "image/svg+xml");
|
||||
|
||||
var cookieName = $"viewed_{id}";
|
||||
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
|
||||
|
||||
if (monograph.SelfDestruct)
|
||||
{
|
||||
await monographs.Collection.ReplaceOneAsync(
|
||||
CreateMonographFilter(monograph.UserId, monograph),
|
||||
new Monograph
|
||||
{
|
||||
ItemId = id,
|
||||
Id = monograph.Id,
|
||||
Deleted = true,
|
||||
UserId = monograph.UserId,
|
||||
ViewCount = 0
|
||||
}
|
||||
);
|
||||
await MarkMonographForSyncAsync(monograph.UserId, id);
|
||||
}
|
||||
else if (!hasVisitedBefore)
|
||||
{
|
||||
await monographs.Collection.UpdateOneAsync(
|
||||
CreateMonographFilter(monograph.UserId, monograph),
|
||||
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
|
||||
);
|
||||
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
Path = $"/monographs/{id}",
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
Expires = DateTimeOffset.UtcNow.AddMonths(1)
|
||||
};
|
||||
Response.Cookies.Append(cookieName, "1", cookieOptions);
|
||||
}
|
||||
await TrackViewAsync(monograph, cookieName, $"/monographs/{id}");
|
||||
|
||||
return Content(SVG_PIXEL, "image/svg+xml");
|
||||
}
|
||||
|
||||
[HttpGet("v2/{slug}/view")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> TrackViewV2([FromRoute] string slug)
|
||||
{
|
||||
var monograph = await FindMonographBySlugAsync(slug);
|
||||
if (monograph == null || monograph.Deleted)
|
||||
return Content(SVG_PIXEL, "image/svg+xml");
|
||||
|
||||
var cookieName = $"viewed_{slug}";
|
||||
await TrackViewAsync(monograph, cookieName, $"/monographs/v2/{slug}");
|
||||
return Content(SVG_PIXEL, "image/svg+xml");
|
||||
}
|
||||
|
||||
[HttpGet("v2/{slug}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> GetMonographBySlugAsync([FromRoute] string slug)
|
||||
{
|
||||
var monograph = await FindMonographBySlugAsync(slug);
|
||||
if (monograph == null || monograph.Deleted)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
error = "invalid_id",
|
||||
error_description = $"No such monograph found."
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(await ProcessMonographAsync(monograph));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/analytics")]
|
||||
[Obsolete("This endpoint is deprecated and will be removed in future versions. Use GET /monographs/{id}/metadata instead.")]
|
||||
public async Task<IActionResult> GetMonographAnalyticsAsync([FromRoute] string id)
|
||||
{
|
||||
if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User))
|
||||
@@ -317,6 +376,29 @@ namespace Notesnook.API.Controllers
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("{id}/metadata")]
|
||||
public async Task<IActionResult> GetMetadataAsync([FromRoute] string id)
|
||||
{
|
||||
var userId = this.User.GetUserId();
|
||||
var monograph = await FindMonographAsync(id);
|
||||
if (monograph == null || monograph.Deleted || monograph.UserId != userId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var isPro = FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User);
|
||||
var totalViews = isPro ? monograph.ViewCount : 0;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph),
|
||||
analytics = new
|
||||
{
|
||||
totalViews
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
|
||||
{
|
||||
if (deviceId == null) return;
|
||||
@@ -329,7 +411,102 @@ namespace Notesnook.API.Controllers
|
||||
await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(monographId, "monograph")]);
|
||||
}
|
||||
|
||||
private async Task<string> CleanupContentAsync(ClaimsPrincipal user, string? content)
|
||||
// (selector, url-bearing attribute) pairs to inspect
|
||||
private static readonly (string Selector, string Attribute)[] urlElements =
|
||||
[
|
||||
("a", "href"),
|
||||
("img", "src"),
|
||||
("iframe", "src"),
|
||||
("embed", "src"),
|
||||
("object", "data"),
|
||||
("source", "src"),
|
||||
("video", "src"),
|
||||
("audio", "src"),
|
||||
];
|
||||
|
||||
private async Task<Monograph> CreateMonographAsync(Monograph monograph, string userId)
|
||||
{
|
||||
if (monograph.EncryptedContent == null)
|
||||
{
|
||||
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
|
||||
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
|
||||
monograph.ContentSanitizationLevel = sanitizationLevel;
|
||||
}
|
||||
|
||||
monograph.UserId = userId;
|
||||
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
|
||||
throw new Exception("Monograph is too big. Max allowed size is 15mb.");
|
||||
|
||||
monograph.Deleted = false;
|
||||
monograph.ViewCount = 0;
|
||||
|
||||
return monograph;
|
||||
}
|
||||
|
||||
private async Task TrackViewAsync(Monograph monograph, string cookieName, string cookiePath)
|
||||
{
|
||||
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
|
||||
|
||||
if (monograph.SelfDestruct)
|
||||
{
|
||||
await monographs.Collection.ReplaceOneAsync(
|
||||
CreateMonographFilter(monograph.UserId!, monograph),
|
||||
new Monograph
|
||||
{
|
||||
ItemId = monograph.ItemId,
|
||||
Id = monograph.Id,
|
||||
Deleted = true,
|
||||
UserId = monograph.UserId,
|
||||
ViewCount = 0
|
||||
}
|
||||
);
|
||||
await MarkMonographForSyncAsync(monograph.UserId!, monograph.ItemId ?? monograph.Id);
|
||||
}
|
||||
else if (!hasVisitedBefore)
|
||||
{
|
||||
await monographs.Collection.UpdateOneAsync(
|
||||
CreateMonographFilter(monograph.UserId!, monograph),
|
||||
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
|
||||
);
|
||||
|
||||
var cookieOptions = new CookieOptions
|
||||
{
|
||||
Path = cookiePath,
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
Expires = DateTimeOffset.UtcNow.AddMonths(1)
|
||||
};
|
||||
Response.Cookies.Append(cookieName, "1", cookieOptions);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Monograph> ProcessMonographAsync(Monograph monograph)
|
||||
{
|
||||
|
||||
if (monograph.EncryptedContent == null)
|
||||
{
|
||||
var isContentUnsanitized = monograph.ContentSanitizationLevel == ContentSanitizationLevel.Partial || monograph.ContentSanitizationLevel == ContentSanitizationLevel.Unknown;
|
||||
if (!Constants.IS_SELF_HOSTED && isContentUnsanitized && serviceAccessor.UserSubscriptionService != null && !await serviceAccessor.UserSubscriptionService.IsUserSubscribedAsync(Clients.Notesnook.Id, monograph.UserId!))
|
||||
{
|
||||
var cleaned = await SanitizeContentAsync(monograph.CompressedContent?.DecompressBrotli(), ContentSanitizationLevel.Full);
|
||||
monograph.CompressedContent = cleaned.CompressBrotli();
|
||||
await monographs.Collection.UpdateOneAsync(
|
||||
CreateMonographFilter(monograph.UserId!, monograph),
|
||||
Builders<Monograph>.Update
|
||||
.Set(m => m.CompressedContent, monograph.CompressedContent)
|
||||
.Set(m => m.ContentSanitizationLevel, ContentSanitizationLevel.Full)
|
||||
);
|
||||
}
|
||||
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
|
||||
}
|
||||
|
||||
monograph.ItemId ??= monograph.Id;
|
||||
return monograph;
|
||||
}
|
||||
|
||||
private async Task<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content)) return string.Empty;
|
||||
if (Constants.IS_SELF_HOSTED) return content;
|
||||
@@ -338,31 +515,36 @@ namespace Notesnook.API.Controllers
|
||||
var json = JsonSerializer.Deserialize<MonographContent>(content) ?? throw new Exception("Invalid monograph content.");
|
||||
var html = json.Data;
|
||||
|
||||
if (user.IsUserSubscribed())
|
||||
if (level == ContentSanitizationLevel.Partial)
|
||||
{
|
||||
var config = Configuration.Default.WithDefaultLoader();
|
||||
var context = BrowsingContext.New(config);
|
||||
var document = await context.OpenAsync(r => r.Content(html));
|
||||
foreach (var element in document.QuerySelectorAll("a"))
|
||||
|
||||
foreach (var (selector, attribute) in urlElements)
|
||||
{
|
||||
var href = element.GetAttribute("href");
|
||||
if (string.IsNullOrEmpty(href)) continue;
|
||||
if (!await analyzer.IsURLSafeAsync(href))
|
||||
foreach (var element in document.QuerySelectorAll(selector))
|
||||
{
|
||||
logger.LogInformation("Malicious URL detected: {Url}", href);
|
||||
element.RemoveAttribute("href");
|
||||
var url = element.GetAttribute(attribute);
|
||||
if (string.IsNullOrEmpty(url)) continue;
|
||||
if (!await analyzer.IsURLSafeAsync(url))
|
||||
{
|
||||
logger.LogInformation("Malicious URL detected in <{Selector} {Attribute}>: {Url}", selector, attribute, url);
|
||||
element.RemoveAttribute(attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html = document.ToHtml();
|
||||
}
|
||||
else
|
||||
else if (level == ContentSanitizationLevel.Full)
|
||||
{
|
||||
var config = Configuration.Default.WithDefaultLoader();
|
||||
var context = BrowsingContext.New(config);
|
||||
var document = await context.OpenAsync(r => r.Content(html));
|
||||
foreach (var element in document.QuerySelectorAll("a,iframe,img,object,svg,button,link"))
|
||||
{
|
||||
foreach (var attr in element.Attributes)
|
||||
foreach (var attr in element.Attributes.ToList())
|
||||
element.RemoveAttribute(attr.Name);
|
||||
}
|
||||
html = document.ToHtml();
|
||||
|
||||
@@ -21,20 +21,17 @@ using System;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.S3.Model;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using Notesnook.API.Accessors;
|
||||
using Notesnook.API.Helpers;
|
||||
using Notesnook.API.Interfaces;
|
||||
using Notesnook.API.Models;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Accessors;
|
||||
using Streetwriters.Common.Extensions;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Notesnook.API.Controllers
|
||||
@@ -186,8 +183,8 @@ namespace Notesnook.API.Controllers
|
||||
try
|
||||
{
|
||||
var userId = this.User.GetUserId();
|
||||
var size = await s3Service.GetObjectSizeAsync(userId, name);
|
||||
HttpContext.Response.Headers.ContentLength = size;
|
||||
var size = await s3Service.GetObjectSizeAsync(userId, name); Response.Headers.ContentLength = size;
|
||||
Response.Headers["X-Object-Size"] = size.ToString();
|
||||
return Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -212,5 +209,26 @@ namespace Notesnook.API.Controllers
|
||||
return BadRequest(new { error = "Failed to delete attachment." });
|
||||
}
|
||||
}
|
||||
|
||||
// [HttpPost("bulk-delete")]
|
||||
// public async Task<IActionResult> DeleteBulkAsync([FromBody] DeleteBulkObjectsRequest request)
|
||||
// {
|
||||
// try
|
||||
// {
|
||||
// if (request.Names == null || request.Names.Length == 0)
|
||||
// {
|
||||
// return BadRequest(new { error = "No files specified for deletion." });
|
||||
// }
|
||||
|
||||
// var userId = this.User.GetUserId();
|
||||
// await s3Service.DeleteObjectsAsync(userId, request.Names);
|
||||
// return Ok();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// logger.LogError(ex, "Error deleting objects for user.");
|
||||
// return BadRequest(new { error = "Failed to delete attachments." });
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
@@ -28,22 +30,25 @@ using Notesnook.API.Interfaces;
|
||||
using Notesnook.API.Models;
|
||||
using Notesnook.API.Models.Responses;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Accessors;
|
||||
using Streetwriters.Common.Extensions;
|
||||
using Streetwriters.Common.Messages;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Notesnook.API.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("users")]
|
||||
public class UsersController(IUserService UserService, ILogger<UsersController> logger) : ControllerBase
|
||||
public class UsersController(IUserService UserService, WampServiceAccessor serviceAccessor, ILogger<UsersController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Signup()
|
||||
public async Task<IActionResult> Signup([FromForm] SignupForm form)
|
||||
{
|
||||
try
|
||||
{
|
||||
await UserService.CreateUserAsync();
|
||||
return Ok();
|
||||
return Ok(await UserService.CreateUserAsync(form));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -85,6 +90,44 @@ namespace Notesnook.API.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("password/{type}")]
|
||||
public async Task<IActionResult> ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form)
|
||||
{
|
||||
return BadRequest(new { error = "Password change is currently disabled." });
|
||||
// var userId = User.GetUserId();
|
||||
// var clientId = User.FindFirstValue("client_id");
|
||||
// var jti = User.FindFirstValue("jti");
|
||||
// var isPasswordReset = type == "reset";
|
||||
// try
|
||||
// {
|
||||
// var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword);
|
||||
// if (!result)
|
||||
// return BadRequest("Failed to change password.");
|
||||
|
||||
// await UserService.SetUserKeysAsync(userId, form.UserKeys);
|
||||
|
||||
// await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null);
|
||||
|
||||
// await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||
// {
|
||||
// UserId = userId,
|
||||
// OriginTokenId = jti,
|
||||
// Message = new Message
|
||||
// {
|
||||
// Type = "logout",
|
||||
// Data = JsonSerializer.Serialize(new { reason = "Password changed." })
|
||||
// }
|
||||
// });
|
||||
|
||||
// return Ok();
|
||||
// }
|
||||
// catch (Exception ex)
|
||||
// {
|
||||
// logger.LogError(ex, "Failed to change password");
|
||||
// return BadRequest(new { error = ex.Message });
|
||||
// }
|
||||
}
|
||||
|
||||
[HttpPost("reset")]
|
||||
public async Task<IActionResult> Reset([FromForm] bool removeAttachments)
|
||||
{
|
||||
|
||||
@@ -27,8 +27,6 @@ FROM build AS publish
|
||||
RUN dotnet publish -c Release -o /app/publish \
|
||||
#--runtime alpine-x64 \
|
||||
--self-contained true \
|
||||
/p:TrimMode=partial \
|
||||
/p:PublishTrimmed=true \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:JsonSerializerIsReflectionEnabledByDefault=true \
|
||||
-a $TARGETARCH
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the Affero GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
Affero GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the Affero GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using Notesnook.API.Models;
|
||||
using Streetwriters.Common;
|
||||
|
||||
namespace Notesnook.API.Helpers
|
||||
{
|
||||
public class UrlHelper
|
||||
{
|
||||
public static string ConstructPublishUrl(string slug)
|
||||
{
|
||||
var baseUrl = Constants.MONOGRAPH_PUBLIC_URL;
|
||||
return $"{baseUrl}/{slug}";
|
||||
}
|
||||
public static string ConstructPublishUrl(Monograph monograph)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(monograph.Slug))
|
||||
{
|
||||
return ConstructPublishUrl("s/" + monograph.Slug);
|
||||
}
|
||||
return ConstructPublishUrl(monograph.ItemId ?? monograph.Id);
|
||||
}
|
||||
|
||||
public static string ConstructPublishUrl(MonographMetadata metadata)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(metadata.PublishUrl))
|
||||
{
|
||||
return ConstructPublishUrl("s/" + metadata.PublishUrl);
|
||||
}
|
||||
return ConstructPublishUrl(metadata.PublishUrl ?? metadata.ItemId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using Notesnook.API.Authorization;
|
||||
using Notesnook.API.Extensions;
|
||||
using Notesnook.API.Helpers;
|
||||
using Notesnook.API.Interfaces;
|
||||
using Notesnook.API.Models;
|
||||
using Notesnook.API.Services;
|
||||
@@ -55,21 +57,9 @@ namespace Notesnook.API.Hubs
|
||||
private ISyncItemsRepositoryAccessor Repositories { get; }
|
||||
private SyncDeviceService SyncDeviceService { get; }
|
||||
private readonly IUnitOfWork unit;
|
||||
private static readonly string[] CollectionKeys = [
|
||||
"settingitem",
|
||||
"attachment",
|
||||
"note",
|
||||
"notebook",
|
||||
"content",
|
||||
"shortcut",
|
||||
"reminder",
|
||||
"color",
|
||||
"tag",
|
||||
"vault",
|
||||
"relation", // relations must sync at the end to prevent invalid state
|
||||
];
|
||||
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
|
||||
private readonly Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections;
|
||||
private readonly CollectionDef[] BaseCollectionDefs;
|
||||
private readonly CollectionDef[] V4CollectionDefs;
|
||||
ILogger<SyncV2Hub> Logger { get; }
|
||||
|
||||
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger)
|
||||
@@ -79,18 +69,32 @@ namespace Notesnook.API.Hubs
|
||||
unit = unitOfWork;
|
||||
SyncDeviceService = syncDeviceService;
|
||||
|
||||
Collections = [
|
||||
Repositories.Settings.FindItemsById,
|
||||
Repositories.Attachments.FindItemsById,
|
||||
Repositories.Notes.FindItemsById,
|
||||
Repositories.Notebooks.FindItemsById,
|
||||
Repositories.Contents.FindItemsById,
|
||||
Repositories.Shortcuts.FindItemsById,
|
||||
Repositories.Reminders.FindItemsById,
|
||||
Repositories.Colors.FindItemsById,
|
||||
Repositories.Tags.FindItemsById,
|
||||
Repositories.Vaults.FindItemsById,
|
||||
Repositories.Relations.FindItemsById,
|
||||
BaseCollectionDefs = [
|
||||
new("settingitem", Repositories.Settings.FindItemsById),
|
||||
new("attachment", Repositories.Attachments.FindItemsById),
|
||||
new("note", Repositories.Notes.FindItemsById),
|
||||
new("notebook", Repositories.Notebooks.FindItemsById),
|
||||
new("content", Repositories.Contents.FindItemsById),
|
||||
new("shortcut", Repositories.Shortcuts.FindItemsById),
|
||||
new("reminder", Repositories.Reminders.FindItemsById),
|
||||
new("color", Repositories.Colors.FindItemsById),
|
||||
new("tag", Repositories.Tags.FindItemsById),
|
||||
new("vault", Repositories.Vaults.FindItemsById),
|
||||
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
|
||||
];
|
||||
V4CollectionDefs = [
|
||||
new("settingitem", Repositories.Settings.FindItemsById),
|
||||
new("attachment", Repositories.Attachments.FindItemsById),
|
||||
new("note", Repositories.Notes.FindItemsById),
|
||||
new("notebook", Repositories.Notebooks.FindItemsById),
|
||||
new("content", Repositories.Contents.FindItemsById),
|
||||
new("shortcut", Repositories.Shortcuts.FindItemsById),
|
||||
new("reminder", Repositories.Reminders.FindItemsById),
|
||||
new("color", Repositories.Colors.FindItemsById),
|
||||
new("tag", Repositories.Tags.FindItemsById),
|
||||
new("vault", Repositories.Vaults.FindItemsById),
|
||||
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
|
||||
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
|
||||
];
|
||||
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
|
||||
{ "settingitem", Repositories.Settings.UpsertMany },
|
||||
@@ -104,6 +108,7 @@ namespace Notesnook.API.Hubs
|
||||
{ "color", Repositories.Colors.UpsertMany },
|
||||
{ "vault", Repositories.Vaults.UpsertMany },
|
||||
{ "tag", Repositories.Tags.UpsertMany },
|
||||
{ "inboxitemhistory", Repositories.InboxItemsHistory.UpsertMany },
|
||||
}.ToFrozenDictionary();
|
||||
}
|
||||
|
||||
@@ -120,6 +125,19 @@ namespace Notesnook.API.Hubs
|
||||
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)
|
||||
{
|
||||
@@ -130,13 +148,19 @@ namespace Notesnook.API.Hubs
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
|
||||
var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}.");
|
||||
UpsertItems(pushItem.Items, userId, 1);
|
||||
|
||||
if (!await unit.Commit()) return 0;
|
||||
|
||||
await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type)));
|
||||
|
||||
// we need to delete the inbox items from the inbox collection
|
||||
// after syncing to prevent them from being sent again in the
|
||||
// next fetch.
|
||||
var itemIds = pushItem.Items.Select(i => i.ItemId).ToList();
|
||||
await Repositories.InboxItems.DeleteManyAsync(i => i.UserId == userId && itemIds.Contains(i.ItemId));
|
||||
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
@@ -160,17 +184,17 @@ namespace Notesnook.API.Hubs
|
||||
return true;
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes)
|
||||
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes, CollectionDef[] collectionDefs)
|
||||
{
|
||||
var itemsProcessed = 0;
|
||||
for (int i = 0; i < Collections.Length; i++)
|
||||
foreach (var def in collectionDefs)
|
||||
{
|
||||
var type = CollectionKeys[i];
|
||||
var type = def.Key;
|
||||
|
||||
var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray();
|
||||
if (!resetSync && filteredIds.Length == 0) continue;
|
||||
|
||||
using var cursor = await Collections[i](userId, filteredIds, resetSync, size);
|
||||
using var cursor = await def.FindItems(userId, filteredIds, resetSync, size);
|
||||
|
||||
var chunk = new List<SyncItem>();
|
||||
long totalBytes = 0;
|
||||
@@ -212,20 +236,25 @@ namespace Notesnook.API.Hubs
|
||||
|
||||
public async Task<SyncV2Metadata> RequestFetch(string deviceId)
|
||||
{
|
||||
return await HandleRequestFetch(deviceId, false, false);
|
||||
return await HandleRequestFetch(deviceId, false, false, BaseCollectionDefs);
|
||||
}
|
||||
|
||||
public async Task<SyncV2Metadata> RequestFetchV2(string deviceId)
|
||||
{
|
||||
return await HandleRequestFetch(deviceId, true, false);
|
||||
return await HandleRequestFetch(deviceId, true, false, BaseCollectionDefs);
|
||||
}
|
||||
|
||||
public async Task<SyncV2Metadata> RequestFetchV3(string deviceId)
|
||||
{
|
||||
return await HandleRequestFetch(deviceId, true, true);
|
||||
return await HandleRequestFetch(deviceId, true, true, BaseCollectionDefs);
|
||||
}
|
||||
|
||||
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems)
|
||||
public async Task<SyncV2Metadata> RequestFetchV4(string deviceId)
|
||||
{
|
||||
return await HandleRequestFetch(deviceId, true, true, V4CollectionDefs);
|
||||
}
|
||||
|
||||
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems, CollectionDef[] collectionDefs)
|
||||
{
|
||||
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
|
||||
|
||||
@@ -247,9 +276,10 @@ namespace Notesnook.API.Hubs
|
||||
var chunks = PrepareChunks(
|
||||
userId,
|
||||
ids,
|
||||
size: 1000,
|
||||
size: 100,
|
||||
resetSync: device.IsSyncReset,
|
||||
maxBytes: 7 * 1024 * 1024
|
||||
maxBytes: 3 * 1024 * 1024,
|
||||
collectionDefs
|
||||
);
|
||||
|
||||
await foreach (var chunk in chunks)
|
||||
@@ -275,15 +305,25 @@ namespace Notesnook.API.Hubs
|
||||
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds)
|
||||
)
|
||||
);
|
||||
var userMonographs = await Repositories.Monographs.Collection.Find(filter).Project((m) => new MonographMetadata
|
||||
var userMonographs = await Repositories.Monographs.Collection
|
||||
.Find(filter)
|
||||
.Project((m) => new MonographMetadata
|
||||
{
|
||||
DatePublished = m.DatePublished,
|
||||
Deleted = m.Deleted,
|
||||
Password = m.Password,
|
||||
SelfDestruct = m.SelfDestruct,
|
||||
Title = m.Title,
|
||||
ItemId = m.ItemId ?? m.Id.ToString(),
|
||||
PublishUrl = m.Slug // this will be converted to full url in the end, but we only need slug for now
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
userMonographs = userMonographs.Select((p) =>
|
||||
{
|
||||
DatePublished = m.DatePublished,
|
||||
Deleted = m.Deleted,
|
||||
Password = m.Password,
|
||||
SelfDestruct = m.SelfDestruct,
|
||||
Title = m.Title,
|
||||
ItemId = m.ItemId ?? m.Id.ToString()
|
||||
}).ToListAsync();
|
||||
p.PublishUrl = UrlHelper.ConstructPublishUrl(p);
|
||||
return p;
|
||||
}).ToList();
|
||||
|
||||
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
|
||||
throw new HubException("Client rejected monographs.");
|
||||
@@ -294,7 +334,7 @@ namespace Notesnook.API.Hubs
|
||||
var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId);
|
||||
var userInboxItems = device.IsSyncReset
|
||||
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
|
||||
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId ?? m.Id.ToString()));
|
||||
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId));
|
||||
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
|
||||
{
|
||||
throw new HubException("Client rejected inbox items.");
|
||||
@@ -313,6 +353,11 @@ namespace Notesnook.API.Hubs
|
||||
SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
private record CollectionDef(
|
||||
string Key,
|
||||
Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>> FindItems
|
||||
);
|
||||
}
|
||||
|
||||
[MessagePack.MessagePackObject]
|
||||
|
||||
@@ -17,18 +17,16 @@ You should have received a copy of the Affero GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.S3.Model;
|
||||
using Notesnook.API.Models;
|
||||
using Notesnook.API.Models.Responses;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
|
||||
namespace Notesnook.API.Interfaces
|
||||
{
|
||||
public interface IS3Service
|
||||
{
|
||||
Task DeleteObjectAsync(string userId, string name);
|
||||
Task DeleteObjectsAsync(string userId, string[] names);
|
||||
Task DeleteDirectoryAsync(string userId);
|
||||
Task<long> GetObjectSizeAsync(string userId, string name);
|
||||
Task<string?> GetUploadObjectUrlAsync(string userId, string name);
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace Notesnook.API.Interfaces
|
||||
SyncItemsRepository Colors { get; }
|
||||
SyncItemsRepository Vaults { get; }
|
||||
SyncItemsRepository Tags { get; }
|
||||
SyncItemsRepository InboxItemsHistory { get; }
|
||||
Repository<UserSettings> UsersSettings { get; }
|
||||
Repository<Monograph> Monographs { get; }
|
||||
Repository<InboxApiKey> InboxApiKey { get; }
|
||||
|
||||
@@ -20,12 +20,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
using System.Threading.Tasks;
|
||||
using Notesnook.API.Models;
|
||||
using Notesnook.API.Models.Responses;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Notesnook.API.Interfaces
|
||||
{
|
||||
public interface IUserService
|
||||
{
|
||||
Task CreateUserAsync();
|
||||
Task<SignupResponse> CreateUserAsync(SignupForm form);
|
||||
Task DeleteUserAsync(string userId);
|
||||
Task DeleteUserAsync(string userId, string? jti, string password);
|
||||
Task<bool> ResetUserAsync(string userId, bool removeAttachments);
|
||||
|
||||
@@ -22,6 +22,5 @@ namespace Notesnook.API.Models
|
||||
public class Algorithms
|
||||
{
|
||||
public static string Default => "xcha-argon2i13-7";
|
||||
public static string XSAL_X25519_7 => "xsal-x25519-7";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Notesnook.API.Models
|
||||
{
|
||||
public class ChangePasswordForm
|
||||
{
|
||||
public string? OldPassword
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[Required]
|
||||
public required string NewPassword
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[Required]
|
||||
public required UserKeys UserKeys
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the Affero GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
Affero GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the Affero GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace Notesnook.API.Models
|
||||
{
|
||||
public enum ContentSanitizationLevel
|
||||
{
|
||||
Unknown = 0,
|
||||
/// <summary>
|
||||
/// Full sanitization applied: links, iframes, images, and other embeds are stripped.
|
||||
/// Applied to monographs published by free-tier users.
|
||||
/// </summary>
|
||||
Full = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Partial sanitization: only unsafe/malicious URLs are removed; rich content is preserved.
|
||||
/// Applied to monographs published by subscribed users. Requires re-sanitization if the
|
||||
/// publisher's subscription lapses.
|
||||
/// </summary>
|
||||
Partial = 2
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the Affero GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
Affero GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the Affero GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace Notesnook.API.Models;
|
||||
|
||||
public class DeleteBulkObjectsRequest
|
||||
{
|
||||
public required string[] Names { get; set; }
|
||||
}
|
||||
@@ -24,6 +24,15 @@ using NanoidDotNet;
|
||||
|
||||
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 InboxApiKey()
|
||||
|
||||
@@ -20,46 +20,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Notesnook.API.Models
|
||||
{
|
||||
|
||||
[MessagePack.MessagePackObject]
|
||||
public class InboxSyncItem : SyncItem
|
||||
public class InboxSyncItem
|
||||
{
|
||||
[DataMember(Name = "key")]
|
||||
[JsonPropertyName("key")]
|
||||
[MessagePack.Key("key")]
|
||||
[Required]
|
||||
public required EncryptedKey Key { get; set; }
|
||||
|
||||
[DataMember(Name = "salt")]
|
||||
[JsonPropertyName("salt")]
|
||||
[MessagePack.Key("salt")]
|
||||
[Required]
|
||||
public required string Salt { get; set; }
|
||||
}
|
||||
|
||||
[MessagePack.MessagePackObject]
|
||||
public class EncryptedKey
|
||||
{
|
||||
[DataMember(Name = "alg")]
|
||||
[JsonPropertyName("alg")]
|
||||
[MessagePack.Key("alg")]
|
||||
[Required]
|
||||
public required string Algorithm { get; set; }
|
||||
|
||||
[DataMember(Name = "cipher")]
|
||||
[JsonPropertyName("cipher")]
|
||||
[MessagePack.Key("cipher")]
|
||||
[Required]
|
||||
public required string Cipher { get; set; }
|
||||
public string Cipher
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[JsonPropertyName("length")]
|
||||
[DataMember(Name = "length")]
|
||||
[MessagePack.Key("length")]
|
||||
[DataMember(Name = "userId")]
|
||||
[JsonPropertyName("userId")]
|
||||
[MessagePack.Key("userId")]
|
||||
public string? UserId
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[DataMember(Name = "id")]
|
||||
[JsonPropertyName("id")]
|
||||
[MessagePack.Key("id")]
|
||||
public string? ItemId
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[BsonId]
|
||||
[BsonIgnoreIfDefault]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
[JsonIgnore]
|
||||
[MessagePack.IgnoreMember]
|
||||
public ObjectId Id
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[JsonPropertyName("v")]
|
||||
[DataMember(Name = "v")]
|
||||
[MessagePack.Key("v")]
|
||||
[Required]
|
||||
public long Length
|
||||
public double Version
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
[DataMember(Name = "alg")]
|
||||
[MessagePack.Key("alg")]
|
||||
[Required]
|
||||
public string Algorithm
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ namespace Notesnook.API.Models
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("slug")]
|
||||
public string? Slug { get; set; }
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
@@ -83,5 +86,8 @@ namespace Notesnook.API.Models
|
||||
|
||||
[JsonPropertyName("viewCount")]
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public ContentSanitizationLevel ContentSanitizationLevel { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
using System.Runtime.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Notesnook.API.Models
|
||||
{
|
||||
@@ -37,6 +35,9 @@ namespace Notesnook.API.Models
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[JsonPropertyName("publishUrl")]
|
||||
public string? PublishUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("selfDestruct")]
|
||||
public bool SelfDestruct { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Notesnook.API.Models
|
||||
{
|
||||
public class ResetPasswordForm
|
||||
{
|
||||
[Required]
|
||||
public required string NewPassword
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[Required]
|
||||
public required UserKeys UserKeys
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Notesnook.API.Models.Responses
|
||||
{
|
||||
public class SignupResponse : Response
|
||||
{
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public string[]? Errors { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ namespace Notesnook.API.Models.Responses
|
||||
|
||||
[JsonPropertyName("monographPasswordsKey")]
|
||||
public EncryptedData? MonographPasswordsKey { get; set; }
|
||||
[JsonPropertyName("dataEncryptionKey")]
|
||||
public EncryptedData? DataEncryptionKey { get; set; }
|
||||
|
||||
[JsonPropertyName("legacyDataEncryptionKey")]
|
||||
public EncryptedData? LegacyDataEncryptionKey { get; set; }
|
||||
|
||||
[JsonPropertyName("inboxKeys")]
|
||||
public InboxKeys? InboxKeys { get; set; }
|
||||
|
||||
@@ -98,6 +98,14 @@ namespace Notesnook.API.Models
|
||||
get; set;
|
||||
}
|
||||
|
||||
[JsonPropertyName("keyVersion")]
|
||||
[DataMember(Name = "keyVersion")]
|
||||
[MessagePack.Key("keyVersion")]
|
||||
public int? KeyVersion
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
[JsonPropertyName("alg")]
|
||||
[DataMember(Name = "alg")]
|
||||
[MessagePack.Key("alg")]
|
||||
|
||||
@@ -24,6 +24,8 @@ namespace Notesnook.API.Models
|
||||
public EncryptedData? AttachmentsKey { get; set; }
|
||||
public EncryptedData? MonographPasswordsKey { get; set; }
|
||||
public InboxKeys? InboxKeys { get; set; }
|
||||
public EncryptedData? DataEncryptionKey { get; set; }
|
||||
public EncryptedData? LegacyDataEncryptionKey { get; set; }
|
||||
}
|
||||
|
||||
public class InboxKeys
|
||||
|
||||
@@ -55,6 +55,8 @@ namespace Notesnook.API.Models
|
||||
public EncryptedData? VaultKey { get; set; }
|
||||
public EncryptedData? AttachmentsKey { get; set; }
|
||||
public EncryptedData? MonographPasswordsKey { get; set; }
|
||||
public EncryptedData? DataEncryptionKey { get; set; }
|
||||
public EncryptedData? LegacyDataEncryptionKey { get; set; }
|
||||
public InboxKeys? InboxKeys { get; set; }
|
||||
public Limit? StorageLimit { get; set; }
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.3.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="DotNetEnv" Version="2.3.0" />
|
||||
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
|
||||
@@ -17,6 +18,7 @@
|
||||
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="6.0.1-rc2.2" />
|
||||
<PackageReference Include="AWSSDK.S3" Version="3.7.310.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="6.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.13" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.2.0" />
|
||||
<PackageReference Include="Nanoid" Version="3.1.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-alpha.2" />
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace Notesnook.API.Repositories
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private readonly List<string> ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7];
|
||||
private readonly List<string> ALGORITHMS = [Algorithms.Default];
|
||||
private bool IsValidAlgorithm(string algorithm)
|
||||
{
|
||||
return ALGORITHMS.Contains(algorithm);
|
||||
@@ -91,11 +91,7 @@ namespace Notesnook.API.Repositories
|
||||
public void DeleteByUserId(string userId)
|
||||
{
|
||||
var filter = Builders<SyncItem>.Filter.Eq("UserId", userId);
|
||||
var writes = new List<WriteModel<SyncItem>>
|
||||
{
|
||||
new DeleteManyModel<SyncItem>(filter)
|
||||
};
|
||||
dbContext.AddCommand((handle, ct) => Collection.BulkWriteAsync(handle, writes, options: null, ct));
|
||||
dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filter, null, ct));
|
||||
}
|
||||
|
||||
public void Upsert(SyncItem item, string userId, long dateSynced)
|
||||
|
||||
@@ -24,21 +24,15 @@ using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MongoDB.Driver;
|
||||
using Notesnook.API.Accessors;
|
||||
using Notesnook.API.Helpers;
|
||||
using Notesnook.API.Interfaces;
|
||||
using Notesnook.API.Models;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Accessors;
|
||||
using Streetwriters.Common.Enums;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Notesnook.API.Services
|
||||
{
|
||||
@@ -110,6 +104,70 @@ namespace Notesnook.API.Services
|
||||
throw new Exception("Could not delete object.");
|
||||
}
|
||||
|
||||
public async Task DeleteObjectsAsync(string userId, string[] names)
|
||||
{
|
||||
var objectsToDelete = new List<KeyVersion>();
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
var objectName = GetFullObjectName(userId, name);
|
||||
if (objectName == null) continue;
|
||||
|
||||
objectsToDelete.Add(new KeyVersion { Key = objectName });
|
||||
}
|
||||
|
||||
if (objectsToDelete.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 DeleteObjectsRequest supports max 1000 keys per request
|
||||
var batchSize = 1000;
|
||||
var deleteErrors = new List<DeleteError>();
|
||||
var failedBatches = 0;
|
||||
|
||||
for (int i = 0; i < objectsToDelete.Count; i += batchSize)
|
||||
{
|
||||
var batch = objectsToDelete.Skip(i).Take(batchSize).ToList();
|
||||
var deleteObjectsResponse = await S3InternalClient.ExecuteWithFailoverAsync(
|
||||
(client) => client.DeleteObjectsAsync(new DeleteObjectsRequest
|
||||
{
|
||||
BucketName = INTERNAL_BUCKET_NAME,
|
||||
Objects = batch,
|
||||
}),
|
||||
operationName: "DeleteObjects",
|
||||
isWriteOperation: true
|
||||
);
|
||||
|
||||
if (!IsSuccessStatusCode((int)deleteObjectsResponse.HttpStatusCode))
|
||||
{
|
||||
failedBatches++;
|
||||
}
|
||||
|
||||
if (deleteObjectsResponse.DeleteErrors.Count > 0)
|
||||
{
|
||||
deleteErrors.AddRange(deleteObjectsResponse.DeleteErrors);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedBatches > 0 || deleteErrors.Count > 0)
|
||||
{
|
||||
var errorParts = new List<string>();
|
||||
|
||||
if (failedBatches > 0)
|
||||
{
|
||||
errorParts.Add($"{failedBatches} batch(es) failed with unsuccessful status code");
|
||||
}
|
||||
|
||||
if (deleteErrors.Count > 0)
|
||||
{
|
||||
errorParts.Add(string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")));
|
||||
}
|
||||
|
||||
throw new Exception(string.Join("; ", errorParts));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteDirectoryAsync(string userId)
|
||||
{
|
||||
var request = new ListObjectsV2Request
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace Notesnook.API.Services
|
||||
return result;
|
||||
}
|
||||
|
||||
const int MaxIdsPerChunk = 400_000;
|
||||
const int MaxIdsPerChunk = 25_000;
|
||||
public async Task AppendIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
|
||||
{
|
||||
var filter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Where(x => x.Ids.Length < MaxIdsPerChunk);
|
||||
@@ -81,8 +81,8 @@ namespace Notesnook.API.Services
|
||||
|
||||
if (chunk != null)
|
||||
{
|
||||
var update = Builders<DeviceIdsChunk>.Update.PushEach(x => x.Ids, ids.Select(i => i.ToString()));
|
||||
await repositories.DeviceIdsChunks.Collection.UpdateOneAsync(
|
||||
var update = Builders<DeviceIdsChunk>.Update.AddToSetEach(x => x.Ids, ids.Select(i => i.ToString()));
|
||||
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).UpdateOneAsync(
|
||||
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
|
||||
update
|
||||
);
|
||||
@@ -96,11 +96,11 @@ namespace Notesnook.API.Services
|
||||
Key = key,
|
||||
Ids = [.. ids.Select(i => i.ToString())]
|
||||
};
|
||||
await repositories.DeviceIdsChunks.Collection.InsertOneAsync(newChunk);
|
||||
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).InsertOneAsync(newChunk);
|
||||
}
|
||||
|
||||
var emptyChunksFilter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Size(x => x.Ids, 0);
|
||||
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(emptyChunksFilter);
|
||||
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).DeleteManyAsync(emptyChunksFilter);
|
||||
}
|
||||
|
||||
public async Task WriteIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
|
||||
@@ -121,7 +121,7 @@ namespace Notesnook.API.Services
|
||||
};
|
||||
writes.Add(new InsertOneModel<DeviceIdsChunk>(newChunk));
|
||||
}
|
||||
await repositories.DeviceIdsChunks.Collection.BulkWriteAsync(writes);
|
||||
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).BulkWriteAsync(writes);
|
||||
}
|
||||
|
||||
public async Task<HashSet<ItemKey>> FetchUnsyncedIdsAsync(string userId, string deviceId)
|
||||
|
||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -50,15 +51,16 @@ namespace Notesnook.API.Services
|
||||
private IS3Service S3Service { get; set; } = s3Service;
|
||||
private readonly IUnitOfWork unit = unitOfWork;
|
||||
|
||||
public async Task CreateUserAsync()
|
||||
public async Task<SignupResponse> CreateUserAsync(SignupForm form)
|
||||
{
|
||||
SignupResponse response = await httpClient.ForwardAsync<SignupResponse>(this.HttpContextAccessor, $"{Servers.IdentityServer}/signup", HttpMethod.Post);
|
||||
if (!response.Success || (response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
|
||||
SignupResponse response = await serviceAccessor.UserAccountService.CreateUserAsync(form.ClientId, form.Email, form.Password, HttpContextAccessor.HttpContext?.Request.Headers["User-Agent"].ToString());
|
||||
|
||||
if ((response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
|
||||
{
|
||||
logger.LogError("Failed to sign up user: {Response}", JsonSerializer.Serialize(response));
|
||||
if (response.Errors != null && response.Errors.Length > 0)
|
||||
throw new Exception(string.Join(" ", response.Errors));
|
||||
else throw new Exception("Could not create a new account. Error code: " + response.StatusCode);
|
||||
else throw new Exception("Could not create a new account.");
|
||||
}
|
||||
|
||||
await Repositories.UsersSettings.InsertAsync(new UserSettings
|
||||
@@ -83,7 +85,7 @@ namespace Notesnook.API.Services
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("New user created: {Response}", JsonSerializer.Serialize(response));
|
||||
return response;
|
||||
}
|
||||
|
||||
public async Task<UserResponse> GetUserAsync(string userId)
|
||||
@@ -133,6 +135,8 @@ namespace Notesnook.API.Services
|
||||
PhoneNumber = user.PhoneNumber,
|
||||
AttachmentsKey = userSettings.AttachmentsKey,
|
||||
MonographPasswordsKey = userSettings.MonographPasswordsKey,
|
||||
DataEncryptionKey = userSettings.DataEncryptionKey,
|
||||
LegacyDataEncryptionKey = userSettings.LegacyDataEncryptionKey,
|
||||
InboxKeys = userSettings.InboxKeys,
|
||||
Salt = userSettings.Salt,
|
||||
Subscription = subscription,
|
||||
@@ -155,6 +159,11 @@ namespace Notesnook.API.Services
|
||||
{
|
||||
userSettings.MonographPasswordsKey = keys.MonographPasswordsKey;
|
||||
}
|
||||
if (keys.DataEncryptionKey != null)
|
||||
userSettings.DataEncryptionKey = keys.DataEncryptionKey;
|
||||
if (keys.LegacyDataEncryptionKey != null)
|
||||
userSettings.LegacyDataEncryptionKey = keys.LegacyDataEncryptionKey;
|
||||
|
||||
if (keys.InboxKeys != null)
|
||||
{
|
||||
if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null)
|
||||
@@ -165,16 +174,19 @@ namespace Notesnook.API.Services
|
||||
else
|
||||
{
|
||||
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 WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||
{
|
||||
OriginTokenId = null,
|
||||
UserId = userId,
|
||||
Message = new Message
|
||||
{
|
||||
Type = "inboxUpdated",
|
||||
Data = JsonSerializer.Serialize(new { reason = "Inbox PGP keys added, updated, or removed." })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
|
||||
@@ -182,6 +194,7 @@ namespace Notesnook.API.Services
|
||||
|
||||
public async Task DeleteUserAsync(string userId)
|
||||
{
|
||||
logger.LogInformation("Deleting user {UserId}", userId);
|
||||
var cc = new CancellationTokenSource();
|
||||
|
||||
Repositories.Notes.DeleteByUserId(userId);
|
||||
@@ -196,6 +209,7 @@ namespace Notesnook.API.Services
|
||||
Repositories.Colors.DeleteByUserId(userId);
|
||||
Repositories.Tags.DeleteByUserId(userId);
|
||||
Repositories.Vaults.DeleteByUserId(userId);
|
||||
Repositories.InboxItemsHistory.DeleteByUserId(userId);
|
||||
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
|
||||
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
||||
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
||||
@@ -257,6 +271,7 @@ namespace Notesnook.API.Services
|
||||
Repositories.Colors.DeleteByUserId(userId);
|
||||
Repositories.Tags.DeleteByUserId(userId);
|
||||
Repositories.Vaults.DeleteByUserId(userId);
|
||||
Repositories.InboxItemsHistory.DeleteByUserId(userId);
|
||||
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
||||
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
||||
if (!await unit.Commit()) return false;
|
||||
@@ -267,6 +282,8 @@ namespace Notesnook.API.Services
|
||||
|
||||
userSettings.AttachmentsKey = null;
|
||||
userSettings.MonographPasswordsKey = null;
|
||||
userSettings.DataEncryptionKey = null;
|
||||
userSettings.LegacyDataEncryptionKey = null;
|
||||
userSettings.VaultKey = null;
|
||||
userSettings.InboxKeys = null;
|
||||
userSettings.LastSynced = 0;
|
||||
|
||||
@@ -25,6 +25,7 @@ using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.Runtime;
|
||||
using StackExchange.Redis;
|
||||
using IdentityModel.AspNetCore.OAuth2Introspection;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
@@ -196,7 +197,8 @@ namespace Notesnook.API
|
||||
.AddMongoCollection(Collections.ColorsKey)
|
||||
.AddMongoCollection(Collections.VaultsKey)
|
||||
.AddMongoCollection(Collections.InboxItemsKey)
|
||||
.AddMongoCollection(Collections.InboxApiKeysKey);
|
||||
.AddMongoCollection(Collections.InboxApiKeysKey)
|
||||
.AddMongoCollection(Collections.InboxItemsHistoryKey);
|
||||
|
||||
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
|
||||
services.AddScoped<SyncDeviceService>();
|
||||
@@ -210,13 +212,30 @@ namespace Notesnook.API
|
||||
|
||||
services.AddHealthChecks();
|
||||
|
||||
services.AddSignalR((hub) =>
|
||||
var signalR = services.AddSignalR((hub) =>
|
||||
{
|
||||
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
|
||||
hub.KeepAliveInterval = TimeSpan.FromSeconds(15);
|
||||
hub.ClientTimeoutInterval = TimeSpan.FromMinutes(10);
|
||||
hub.EnableDetailedErrors = true;
|
||||
}).AddMessagePackProtocol().AddJsonProtocol();
|
||||
|
||||
if (!string.IsNullOrEmpty(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 =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
@@ -267,6 +286,12 @@ namespace Notesnook.API
|
||||
app.UseOpenTelemetryPrometheusScrapingEndpoint((context) => context.Request.Path == "/metrics" && context.Connection.LocalPort == 5067);
|
||||
app.UseResponseCompression();
|
||||
|
||||
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||
KeepAliveTimeout = TimeSpan.FromSeconds(60),
|
||||
});
|
||||
|
||||
app.UseCors("notesnook");
|
||||
app.UseVersion(Servers.NotesnookAPI);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM oven/bun:1.2.21-slim
|
||||
FROM oven/bun:1.3.5-slim
|
||||
|
||||
RUN mkdir -p /home/bun/app && chown -R bun:bun /home/bun/app
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Notesnook Inbox API
|
||||
|
||||
## Running locally
|
||||
|
||||
### Requirements
|
||||
|
||||
- Bun (v1.3.0 or higher)
|
||||
|
||||
### Commands
|
||||
|
||||
- `bun install` - Install dependencies
|
||||
- `bun run dev` - Start the development server
|
||||
- `bun run build` - Build the project for production
|
||||
- `bun run start` - Start the production server
|
||||
|
||||
## Self-hosting
|
||||
|
||||
The easiest way to self-host is with Docker or Docker Compose.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- `docker` (Engine) installed
|
||||
- `docker-compose` (optional, for multi-service setups)
|
||||
|
||||
Build and run with Docker:
|
||||
|
||||
```bash
|
||||
# build the image from the current folder
|
||||
docker build -t notesnook-inbox-api .
|
||||
|
||||
# run the container (example)
|
||||
docker run --rm -p 3000:3000 \
|
||||
-e PORT=3000 \
|
||||
-e NOTESNOOK_API_SERVER_URL="https://api.notesnook.com" \
|
||||
notesnook-inbox-api
|
||||
```
|
||||
|
||||
Docker Compose (example):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
inbox-api:
|
||||
image: notesnook-inbox-api
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PORT: 3000
|
||||
NOTESNOOK_API_SERVER_URL: "https://api.notesnook.com"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `PORT` — port the service listens on (default: `5181`)
|
||||
- `NOTESNOOK_API_SERVER_URL` — base URL of the Notesnook API used to fetch public inbox keys
|
||||
|
||||
_If you prefer running without Docker, use `bun install` and `bun run start` with the environment variables set._
|
||||
|
||||
## Writing from scratch
|
||||
|
||||
The inbox API server is pretty simple to write from scratch in any programming language and/or framework. There's only one endpoint that needs to be implemented, which does these three steps:
|
||||
|
||||
1. Fetch the user's public inbox API key from the Notesnook API.
|
||||
2. Encrypt the payload using `openpgp` or any other `openpgp` compatible library.
|
||||
3. Post the encrypted payload to the Notesnook API.
|
||||
|
||||
You can refer to the [source code](./src/index.ts) for implementation details.
|
||||
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.1.0",
|
||||
"libsodium-wrappers-sumo": "^0.7.15",
|
||||
"openpgp": "^6.2.2",
|
||||
"zod": "^4.1.9",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -116,10 +116,6 @@
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"libsodium-sumo": ["libsodium-sumo@0.7.15", "", {}, "sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw=="],
|
||||
|
||||
"libsodium-wrappers-sumo": ["libsodium-wrappers-sumo@0.7.15", "", { "dependencies": { "libsodium-sumo": "^0.7.15" } }, "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
@@ -140,6 +136,8 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openpgp": ["openpgp@6.2.2", "", {}, "sha512-P/dyEqQ3gfwOCo+xsqffzXjmUhGn4AZTOJ1LCcN21S23vAk+EAvMJOQTsb/C8krL6GjOSBxqGYckhik7+hneNw=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"dependencies": {
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.1.0",
|
||||
"libsodium-wrappers-sumo": "^0.7.15",
|
||||
"openpgp": "^6.2.2",
|
||||
"zod": "^4.1.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
GNUPGHOME=$(mktemp -d)
|
||||
|
||||
curl -s http://localhost:5264/inbox/public-encryption-key -H "Authorization: $API_KEY" | jq -r .key > "$GNUPGHOME"/pubkey.asc && gpg --batch --homedir "$GNUPGHOME" --import "$GNUPGHOME"/pubkey.asc >/dev/null 2>&1 && KEYID=$(gpg --homedir "$GNUPGHOME" --list-keys --with-colons | awk -F: '/^pub:/ {print $5; exit}') && printf '%s' '{"title":"Test title CLIE S","type":"note","source":"cli","version":1}' | gpg --batch --homedir "$GNUPGHOME" --trust-model always --armor --encrypt -r "$KEYID" | jq -Rs --arg alg "pgp-aes256" '{v:1, cipher:., alg:$alg}' | curl -s -X POST http://localhost:5264/inbox/items -H "Content-Type: application/json" -H "Authorization: $API_KEY" -d @- && rm -rf "$GNUPGHOME"
|
||||
@@ -0,0 +1,18 @@
|
||||
const response = await fetch("http://localhost:5181/inbox", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: process.env.API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: "This is test note 4",
|
||||
type: "note",
|
||||
source: "script",
|
||||
version: 1,
|
||||
content: {
|
||||
type: "html",
|
||||
data: "<p>This is test note content 3</p>",
|
||||
},
|
||||
}),
|
||||
});
|
||||
console.log(await response.text());
|
||||
@@ -1,15 +1,13 @@
|
||||
import express from "express";
|
||||
import _sodium, { base64_variants } from "libsodium-wrappers-sumo";
|
||||
import { z } from "zod";
|
||||
import { rateLimit } from "express-rate-limit";
|
||||
import * as openpgp from "openpgp";
|
||||
|
||||
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
|
||||
if (!NOTESNOOK_API_SERVER_URL) {
|
||||
throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
|
||||
}
|
||||
|
||||
let sodium: typeof _sodium;
|
||||
|
||||
const RawInboxItemSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
pinned: z.boolean().optional(),
|
||||
@@ -19,7 +17,7 @@ const RawInboxItemSchema = z.object({
|
||||
notebookIds: z.array(z.string()).optional(),
|
||||
tagIds: z.array(z.string()).optional(),
|
||||
type: z.enum(["note"]),
|
||||
source: z.string(),
|
||||
source: z.string().min(1, "Source is required"),
|
||||
version: z.literal(1),
|
||||
content: z
|
||||
.object({
|
||||
@@ -31,86 +29,60 @@ const RawInboxItemSchema = z.object({
|
||||
|
||||
interface EncryptedInboxItem {
|
||||
v: 1;
|
||||
key: Omit<EncryptedInboxItem, "key" | "iv" | "v" | "salt">;
|
||||
iv: string;
|
||||
alg: string;
|
||||
cipher: string;
|
||||
length: number;
|
||||
salt: string;
|
||||
alg: string;
|
||||
}
|
||||
|
||||
function encrypt(rawData: string, publicKey: string): EncryptedInboxItem {
|
||||
try {
|
||||
const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
||||
const saltBytes = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
||||
const key = sodium.crypto_pwhash(
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
|
||||
password,
|
||||
saltBytes,
|
||||
3, // operations limit
|
||||
1024 * 1024 * 8, // memory limit (8MB)
|
||||
sodium.crypto_pwhash_ALG_ARGON2I13
|
||||
);
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
||||
);
|
||||
const data = sodium.from_string(rawData);
|
||||
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
data,
|
||||
null,
|
||||
null,
|
||||
nonce,
|
||||
key
|
||||
);
|
||||
const inboxPublicKey = sodium.from_base64(
|
||||
publicKey,
|
||||
base64_variants.URLSAFE_NO_PADDING
|
||||
);
|
||||
const encryptedKey = sodium.crypto_box_seal(key, inboxPublicKey);
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
key: {
|
||||
cipher: sodium.to_base64(
|
||||
encryptedKey,
|
||||
base64_variants.URLSAFE_NO_PADDING
|
||||
),
|
||||
alg: `xsal-x25519-${base64_variants.URLSAFE_NO_PADDING}`,
|
||||
length: password.length,
|
||||
},
|
||||
iv: sodium.to_base64(nonce, base64_variants.URLSAFE_NO_PADDING),
|
||||
alg: `xcha-argon2i13-${base64_variants.URLSAFE_NO_PADDING}`,
|
||||
cipher: sodium.to_base64(cipher, base64_variants.URLSAFE_NO_PADDING),
|
||||
length: data.length,
|
||||
salt: sodium.to_base64(saltBytes, base64_variants.URLSAFE_NO_PADDING),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`encryption failed: ${error}`);
|
||||
}
|
||||
/**
|
||||
* Encrypts raw data using OpenPGP with the recipient's public key
|
||||
*
|
||||
* @param {string} rawData - The plaintext data to encrypt
|
||||
* @param {string} rawPublicKey - The recipient's OpenPGP public key
|
||||
*/
|
||||
async function encrypt(
|
||||
rawData: string,
|
||||
rawPublicKey: string,
|
||||
): Promise<EncryptedInboxItem> {
|
||||
const publicKey = await openpgp.readKey({ armoredKey: rawPublicKey });
|
||||
const message = await openpgp.createMessage({ text: rawData });
|
||||
const encrypted = await openpgp.encrypt({
|
||||
message,
|
||||
encryptionKeys: publicKey,
|
||||
});
|
||||
return {
|
||||
v: 1,
|
||||
cipher: encrypted,
|
||||
alg: "pgp-aes256",
|
||||
};
|
||||
}
|
||||
|
||||
async function getInboxPublicEncryptionKey(apiKey: string) {
|
||||
async function getInboxPublicEncryptionKey(
|
||||
apiKey: string,
|
||||
): Promise<{ status: "unauthorized" } | { status: "ok"; key: string | null }> {
|
||||
const response = await fetch(
|
||||
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: apiKey,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
if (response.status === 401) {
|
||||
return { status: "unauthorized" };
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`failed to fetch inbox public encryption key: ${await response.text()}`
|
||||
`failed to fetch inbox public encryption key: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
apiKey: string,
|
||||
item: EncryptedInboxItem
|
||||
item: EncryptedInboxItem,
|
||||
) {
|
||||
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, {
|
||||
method: "POST",
|
||||
@@ -131,19 +103,26 @@ app.use(
|
||||
rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1 minute
|
||||
limit: 60,
|
||||
})
|
||||
}),
|
||||
);
|
||||
app.post("/inbox", async (req, res) => {
|
||||
app.get("/health", (_, res) => {
|
||||
return res.status(200).json({ status: "ok" });
|
||||
});
|
||||
app.post("/", async (req, res) => {
|
||||
try {
|
||||
const apiKey = req.headers["authorization"];
|
||||
if (!apiKey) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey);
|
||||
if (!inboxPublicKey) {
|
||||
return res.status(403).json({ error: "inbox public key not found" });
|
||||
const encryptionKeyResult = await getInboxPublicEncryptionKey(apiKey);
|
||||
if (encryptionKeyResult.status === "unauthorized") {
|
||||
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");
|
||||
|
||||
const validationResult = RawInboxItemSchema.safeParse(req.body);
|
||||
@@ -154,9 +133,9 @@ app.post("/inbox", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const encryptedItem = encrypt(
|
||||
const encryptedItem = await encrypt(
|
||||
JSON.stringify(validationResult.data),
|
||||
inboxPublicKey
|
||||
inboxPublicKey,
|
||||
);
|
||||
console.log("[info] encrypted item");
|
||||
|
||||
@@ -180,14 +159,9 @@ app.post("/inbox", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await _sodium.ready;
|
||||
sodium = _sodium;
|
||||
|
||||
const PORT = Number(process.env.PORT || "5181");
|
||||
app.listen(PORT, () => {
|
||||
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
|
||||
});
|
||||
})();
|
||||
const PORT = Number(process.env.PORT || "5181");
|
||||
app.listen(PORT, () => {
|
||||
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -39,6 +39,7 @@ namespace Streetwriters.Common
|
||||
AppId = ApplicationType.NOTESNOOK,
|
||||
AccountRecoveryRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/recovery",
|
||||
EmailConfirmedRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/verified",
|
||||
PackageName = "com.streetwriters.notesnook",
|
||||
OnEmailConfirmed = async (userId) =>
|
||||
{
|
||||
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||
|
||||
@@ -79,6 +79,8 @@ namespace Streetwriters.Common
|
||||
public static string? SUBSCRIPTIONS_CERT_PATH => ReadSecret("SUBSCRIPTIONS_CERT_PATH");
|
||||
public static string? SUBSCRIPTIONS_CERT_KEY_PATH => ReadSecret("SUBSCRIPTIONS_CERT_KEY_PATH");
|
||||
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS")?.Split(",") ?? [];
|
||||
public static string? SIGNALR_REDIS_CONNECTION_STRING => ReadSecret("SIGNALR_REDIS_CONNECTION_STRING");
|
||||
public static string MONOGRAPH_PUBLIC_URL => ReadSecret("MONOGRAPH_PUBLIC_URL") ?? "https://monogr.ph";
|
||||
|
||||
public static string? ReadSecret(string name)
|
||||
{
|
||||
|
||||
@@ -52,7 +52,8 @@ namespace Streetwriters.Common.Extensions
|
||||
b.WithOrigins(Constants.NOTESNOOK_CORS_ORIGINS);
|
||||
|
||||
b.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
.AllowAnyHeader()
|
||||
.WithExposedHeaders(["X-Object-Size", "Content-Length"]);
|
||||
});
|
||||
});
|
||||
return services;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Mail;
|
||||
using System.Threading.Tasks;
|
||||
using MimeKit;
|
||||
using MimeKit.Cryptography;
|
||||
@@ -11,7 +12,7 @@ namespace Streetwriters.Common.Interfaces
|
||||
Task SendEmailAsync(
|
||||
string email,
|
||||
EmailTemplate template,
|
||||
IClient client,
|
||||
MailAddress from,
|
||||
GnuPGContext? gpgContext = null,
|
||||
Dictionary<string, byte[]>? attachments = null
|
||||
);
|
||||
|
||||
@@ -10,7 +10,13 @@ namespace Streetwriters.Common.Interfaces
|
||||
Task<UserModel?> GetUserAsync(string clientId, string userId);
|
||||
[WampProcedure("co.streetwriters.identity.users.delete_user")]
|
||||
Task DeleteUserAsync(string clientId, string userId, string password);
|
||||
// [WampProcedure("co.streetwriters.identity.users.create_user")]
|
||||
// Task<UserModel> CreateUserAsync();
|
||||
[WampProcedure("co.streetwriters.identity.users.change_password")]
|
||||
Task<bool> ChangePasswordAsync(string userId, string oldPassword, string newPassword);
|
||||
[WampProcedure("co.streetwriters.identity.users.reset_password")]
|
||||
Task<bool> ResetPasswordAsync(string userId, string newPassword);
|
||||
[WampProcedure("co.streetwriters.identity.users.clear_sessions")]
|
||||
Task<bool> ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken);
|
||||
[WampProcedure("co.streetwriters.identity.users.create_user")]
|
||||
Task<SignupResponse> CreateUserAsync(string clientId, string email, string password, string? userAgent = null);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ namespace Streetwriters.Common.Interfaces
|
||||
{
|
||||
[WampProcedure("co.streetwriters.subscriptions.subscriptions.get_user_subscription")]
|
||||
Task<Subscription?> GetUserSubscriptionAsync(string clientId, string userId);
|
||||
[WampProcedure("co.streetwriters.subscriptions.subscriptions.is_user_subscribed")]
|
||||
Task<bool> IsUserSubscribedAsync(string clientId, string userId);
|
||||
Subscription TransformUserSubscription(Subscription subscription);
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ namespace Streetwriters.Common.Models
|
||||
public required string SenderName { get; set; }
|
||||
public required string EmailConfirmedRedirectURL { get; set; }
|
||||
public required string AccountRecoveryRedirectURL { get; set; }
|
||||
public required string PackageName { get; set; }
|
||||
|
||||
public Func<string, Task>? OnEmailConfirmed { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public partial class GetCustomerResponse : PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public PaddleCustomer? Customer { get; set; }
|
||||
}
|
||||
|
||||
public class PaddleCustomer
|
||||
{
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public partial class GetSubscriptionResponse : PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public Data? Data { get; set; }
|
||||
}
|
||||
|
||||
public partial class Data
|
||||
{
|
||||
// [JsonPropertyName("id")]
|
||||
// public string Id { get; set; }
|
||||
|
||||
// [JsonPropertyName("status")]
|
||||
// public string Status { get; set; }
|
||||
|
||||
[JsonPropertyName("customer_id")]
|
||||
public string? CustomerId { get; set; }
|
||||
|
||||
// [JsonPropertyName("address_id")]
|
||||
// public string AddressId { get; set; }
|
||||
|
||||
// [JsonPropertyName("business_id")]
|
||||
// public object BusinessId { get; set; }
|
||||
|
||||
// [JsonPropertyName("currency_code")]
|
||||
// public string CurrencyCode { get; set; }
|
||||
|
||||
// [JsonPropertyName("created_at")]
|
||||
// public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("updated_at")]
|
||||
// public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("started_at")]
|
||||
// public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("first_billed_at")]
|
||||
public DateTimeOffset? FirstBilledAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("next_billed_at")]
|
||||
// public DateTimeOffset NextBilledAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("paused_at")]
|
||||
// public object PausedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("canceled_at")]
|
||||
// public object CanceledAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("collection_mode")]
|
||||
// public string CollectionMode { get; set; }
|
||||
|
||||
// [JsonPropertyName("billing_details")]
|
||||
// public object BillingDetails { get; set; }
|
||||
|
||||
// [JsonPropertyName("current_billing_period")]
|
||||
// public CurrentBillingPeriod CurrentBillingPeriod { get; set; }
|
||||
|
||||
[JsonPropertyName("billing_cycle")]
|
||||
public BillingCycle? BillingCycle { get; set; }
|
||||
|
||||
// [JsonPropertyName("scheduled_change")]
|
||||
// public object ScheduledChange { get; set; }
|
||||
|
||||
// [JsonPropertyName("items")]
|
||||
// public Item[] Items { get; set; }
|
||||
|
||||
// [JsonPropertyName("custom_data")]
|
||||
// public object CustomData { get; set; }
|
||||
|
||||
[JsonPropertyName("management_urls")]
|
||||
public ManagementUrls? ManagementUrls { get; set; }
|
||||
|
||||
// [JsonPropertyName("discount")]
|
||||
// public object Discount { get; set; }
|
||||
|
||||
// [JsonPropertyName("import_meta")]
|
||||
// public object ImportMeta { get; set; }
|
||||
}
|
||||
|
||||
public partial class BillingCycle
|
||||
{
|
||||
[JsonPropertyName("frequency")]
|
||||
public long Frequency { get; set; }
|
||||
|
||||
[JsonPropertyName("interval")]
|
||||
public string? Interval { get; set; }
|
||||
}
|
||||
|
||||
// public partial class CurrentBillingPeriod
|
||||
// {
|
||||
// [JsonPropertyName("starts_at")]
|
||||
// public DateTimeOffset StartsAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("ends_at")]
|
||||
// public DateTimeOffset EndsAt { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class Item
|
||||
// {
|
||||
// [JsonPropertyName("status")]
|
||||
// public string Status { get; set; }
|
||||
|
||||
// [JsonPropertyName("quantity")]
|
||||
// public long Quantity { get; set; }
|
||||
|
||||
// [JsonPropertyName("recurring")]
|
||||
// public bool Recurring { get; set; }
|
||||
|
||||
// [JsonPropertyName("created_at")]
|
||||
// public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("updated_at")]
|
||||
// public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("previously_billed_at")]
|
||||
// public DateTimeOffset PreviouslyBilledAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("next_billed_at")]
|
||||
// public DateTimeOffset NextBilledAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("trial_dates")]
|
||||
// public object TrialDates { get; set; }
|
||||
|
||||
// [JsonPropertyName("price")]
|
||||
// public Price Price { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class Price
|
||||
// {
|
||||
// [JsonPropertyName("id")]
|
||||
// public string Id { get; set; }
|
||||
|
||||
// [JsonPropertyName("product_id")]
|
||||
// public string ProductId { get; set; }
|
||||
|
||||
// [JsonPropertyName("type")]
|
||||
// public string Type { get; set; }
|
||||
|
||||
// [JsonPropertyName("description")]
|
||||
// public string Description { get; set; }
|
||||
|
||||
// [JsonPropertyName("name")]
|
||||
// public string Name { get; set; }
|
||||
|
||||
// [JsonPropertyName("tax_mode")]
|
||||
// public string TaxMode { get; set; }
|
||||
|
||||
// [JsonPropertyName("billing_cycle")]
|
||||
// public BillingCycle BillingCycle { get; set; }
|
||||
|
||||
// [JsonPropertyName("trial_period")]
|
||||
// public object TrialPeriod { get; set; }
|
||||
|
||||
// [JsonPropertyName("unit_price")]
|
||||
// public UnitPrice UnitPrice { get; set; }
|
||||
|
||||
// [JsonPropertyName("unit_price_overrides")]
|
||||
// public object[] UnitPriceOverrides { get; set; }
|
||||
|
||||
// [JsonPropertyName("custom_data")]
|
||||
// public object CustomData { get; set; }
|
||||
|
||||
// [JsonPropertyName("status")]
|
||||
// public string Status { get; set; }
|
||||
|
||||
// [JsonPropertyName("quantity")]
|
||||
// public Quantity Quantity { get; set; }
|
||||
|
||||
// [JsonPropertyName("import_meta")]
|
||||
// public object ImportMeta { get; set; }
|
||||
|
||||
// [JsonPropertyName("created_at")]
|
||||
// public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("updated_at")]
|
||||
// public DateTimeOffset UpdatedAt { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class Quantity
|
||||
// {
|
||||
// [JsonPropertyName("minimum")]
|
||||
// public long Minimum { get; set; }
|
||||
|
||||
// [JsonPropertyName("maximum")]
|
||||
// public long Maximum { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class UnitPrice
|
||||
// {
|
||||
// [JsonPropertyName("amount")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Amount { get; set; }
|
||||
|
||||
// [JsonPropertyName("currency_code")]
|
||||
// public string CurrencyCode { get; set; }
|
||||
// }
|
||||
|
||||
public partial class ManagementUrls
|
||||
{
|
||||
[JsonPropertyName("update_payment_method")]
|
||||
public Uri? UpdatePaymentMethod { get; set; }
|
||||
|
||||
[JsonPropertyName("cancel")]
|
||||
public Uri? Cancel { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public class GetTransactionInvoiceResponse : PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public Invoice? Invoice { get; set; }
|
||||
}
|
||||
|
||||
public partial class Invoice
|
||||
{
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public partial class GetTransactionResponse : PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public TransactionV2? Transaction { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
public partial class ListPaymentsResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("response")]
|
||||
public Payment[]? Payments { get; set; }
|
||||
}
|
||||
|
||||
public partial class Payment
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("subscription_id")]
|
||||
public long SubscriptionId { get; set; }
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public double Amount { get; set; }
|
||||
|
||||
[JsonPropertyName("currency")]
|
||||
public string? Currency { get; set; }
|
||||
|
||||
[JsonPropertyName("payout_date")]
|
||||
public string? PayoutDate { get; set; }
|
||||
|
||||
[JsonPropertyName("is_paid")]
|
||||
public short IsPaid { get; set; }
|
||||
|
||||
[JsonPropertyName("is_one_off_charge")]
|
||||
public bool IsOneOffCharge { get; set; }
|
||||
|
||||
[JsonPropertyName("receipt_url")]
|
||||
public string? ReceiptUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
public partial class ListTransactionsResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("response")]
|
||||
public Transaction[]? Transactions { get; set; }
|
||||
}
|
||||
|
||||
public partial class Transaction
|
||||
{
|
||||
[JsonPropertyName("order_id")]
|
||||
public string? OrderId { get; set; }
|
||||
|
||||
[JsonPropertyName("checkout_id")]
|
||||
public string? CheckoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public string? Amount { get; set; }
|
||||
|
||||
[JsonPropertyName("currency")]
|
||||
public string? Currency { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public string? CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("passthrough")]
|
||||
public object? Passthrough { get; set; }
|
||||
|
||||
[JsonPropertyName("product_id")]
|
||||
public long ProductId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_subscription")]
|
||||
public bool IsSubscription { get; set; }
|
||||
|
||||
[JsonPropertyName("is_one_off")]
|
||||
public bool IsOneOff { get; set; }
|
||||
|
||||
[JsonPropertyName("subscription")]
|
||||
public PaddleSubscription? Subscription { get; set; }
|
||||
|
||||
[JsonPropertyName("user")]
|
||||
public PaddleTransactionUser? User { get; set; }
|
||||
|
||||
[JsonPropertyName("receipt_url")]
|
||||
public string? ReceiptUrl { get; set; }
|
||||
}
|
||||
|
||||
public partial class PaddleSubscription
|
||||
{
|
||||
[JsonPropertyName("subscription_id")]
|
||||
public long SubscriptionId { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; }
|
||||
}
|
||||
|
||||
public partial class PaddleTransactionUser
|
||||
{
|
||||
[JsonPropertyName("user_id")]
|
||||
public long UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[JsonPropertyName("marketing_consent")]
|
||||
public bool MarketingConsent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public partial class ListTransactionsResponseV2 : PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public TransactionV2[]? Transactions { get; set; }
|
||||
}
|
||||
|
||||
public partial class TransactionV2
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string? Status { get; set; }
|
||||
|
||||
[JsonPropertyName("customer_id")]
|
||||
public string? CustomerId { get; set; }
|
||||
|
||||
// [JsonPropertyName("address_id")]
|
||||
// public string AddressId { get; set; }
|
||||
|
||||
// [JsonPropertyName("business_id")]
|
||||
// public object BusinessId { get; set; }
|
||||
|
||||
[JsonPropertyName("custom_data")]
|
||||
public Dictionary<string, string>? CustomData { get; set; }
|
||||
|
||||
[JsonPropertyName("origin")]
|
||||
public string? Origin { get; set; }
|
||||
|
||||
// [JsonPropertyName("collection_mode")]
|
||||
// public string CollectionMode { get; set; }
|
||||
|
||||
// [JsonPropertyName("subscription_id")]
|
||||
// public string SubscriptionId { get; set; }
|
||||
|
||||
// [JsonPropertyName("invoice_id")]
|
||||
// public string InvoiceId { get; set; }
|
||||
|
||||
// [JsonPropertyName("invoice_number")]
|
||||
// public string InvoiceNumber { get; set; }
|
||||
|
||||
[JsonPropertyName("billing_details")]
|
||||
public BillingDetails? BillingDetails { get; set; }
|
||||
|
||||
[JsonPropertyName("billing_period")]
|
||||
public BillingPeriod? BillingPeriod { get; set; }
|
||||
|
||||
// [JsonPropertyName("currency_code")]
|
||||
// public string CurrencyCode { get; set; }
|
||||
|
||||
// [JsonPropertyName("discount_id")]
|
||||
// public string DiscountId { get; set; }
|
||||
|
||||
[JsonPropertyName("created_at")]
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("updated_at")]
|
||||
// public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("billed_at")]
|
||||
public DateTimeOffset? BilledAt { get; set; }
|
||||
|
||||
[JsonPropertyName("items")]
|
||||
public Item[]? Items { get; set; }
|
||||
|
||||
[JsonPropertyName("details")]
|
||||
public Details? Details { get; set; }
|
||||
|
||||
// [JsonPropertyName("payments")]
|
||||
// public Payment[] Payments { get; set; }
|
||||
|
||||
// [JsonPropertyName("checkout")]
|
||||
// public Checkout Checkout { get; set; }
|
||||
}
|
||||
|
||||
public partial class BillingDetails
|
||||
{
|
||||
// [JsonPropertyName("enable_checkout")]
|
||||
// public bool EnableCheckout { get; set; }
|
||||
|
||||
[JsonPropertyName("payment_terms")]
|
||||
public PaymentTerms? PaymentTerms { get; set; }
|
||||
|
||||
// [JsonPropertyName("purchase_order_number")]
|
||||
// public string PurchaseOrderNumber { get; set; }
|
||||
|
||||
// [JsonPropertyName("additional_information")]
|
||||
// public object AdditionalInformation { get; set; }
|
||||
}
|
||||
|
||||
public partial class PaymentTerms
|
||||
{
|
||||
[JsonPropertyName("interval")]
|
||||
public string? Interval { get; set; }
|
||||
|
||||
[JsonPropertyName("frequency")]
|
||||
public long Frequency { get; set; }
|
||||
}
|
||||
|
||||
public partial class BillingPeriod
|
||||
{
|
||||
[JsonPropertyName("starts_at")]
|
||||
public DateTimeOffset StartsAt { get; set; }
|
||||
|
||||
[JsonPropertyName("ends_at")]
|
||||
public DateTimeOffset EndsAt { get; set; }
|
||||
}
|
||||
|
||||
// public partial class Checkout
|
||||
// {
|
||||
// [JsonPropertyName("url")]
|
||||
// public Uri Url { get; set; }
|
||||
// }
|
||||
|
||||
public partial class Details
|
||||
{
|
||||
// [JsonPropertyName("tax_rates_used")]
|
||||
// public TaxRatesUsed[] TaxRatesUsed { get; set; }
|
||||
|
||||
[JsonPropertyName("totals")]
|
||||
public Totals? Totals { get; set; }
|
||||
|
||||
// [JsonPropertyName("adjusted_totals")]
|
||||
// public AdjustedTotals AdjustedTotals { get; set; }
|
||||
|
||||
// [JsonPropertyName("payout_totals")]
|
||||
// public Dictionary<string, string> PayoutTotals { get; set; }
|
||||
|
||||
// [JsonPropertyName("adjusted_payout_totals")]
|
||||
// public AdjustedTotals AdjustedPayoutTotals { get; set; }
|
||||
|
||||
[JsonPropertyName("line_items")]
|
||||
public LineItem[]? LineItems { get; set; }
|
||||
}
|
||||
|
||||
public partial class Totals
|
||||
{
|
||||
[JsonPropertyName("subtotal")]
|
||||
public long Subtotal { get; set; }
|
||||
|
||||
[JsonPropertyName("tax")]
|
||||
public long Tax { get; set; }
|
||||
|
||||
[JsonPropertyName("discount")]
|
||||
public long Discount { get; set; }
|
||||
|
||||
[JsonPropertyName("total")]
|
||||
public long Total { get; set; }
|
||||
|
||||
[JsonPropertyName("grand_total")]
|
||||
public long GrandTotal { get; set; }
|
||||
|
||||
// [JsonPropertyName("fee")]
|
||||
// public object Fee { get; set; }
|
||||
|
||||
// [JsonPropertyName("credit")]
|
||||
// public long Credit { get; set; }
|
||||
|
||||
// [JsonPropertyName("credit_to_balance")]
|
||||
// public long CreditToBalance { get; set; }
|
||||
|
||||
[JsonPropertyName("balance")]
|
||||
public long Balance { get; set; }
|
||||
|
||||
// [JsonPropertyName("earnings")]
|
||||
// public object Earnings { get; set; }
|
||||
|
||||
[JsonPropertyName("currency_code")]
|
||||
public string? CurrencyCode { get; set; }
|
||||
}
|
||||
// public partial class AdjustedTotals
|
||||
// {
|
||||
// [JsonPropertyName("subtotal")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Subtotal { get; set; }
|
||||
|
||||
// [JsonPropertyName("tax")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Tax { get; set; }
|
||||
|
||||
// [JsonPropertyName("total")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Total { get; set; }
|
||||
|
||||
// [JsonPropertyName("fee")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Fee { get; set; }
|
||||
|
||||
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
// [JsonPropertyName("chargeback_fee")]
|
||||
// public ChargebackFee ChargebackFee { get; set; }
|
||||
|
||||
// [JsonPropertyName("earnings")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Earnings { get; set; }
|
||||
|
||||
// [JsonPropertyName("currency_code")]
|
||||
// public string CurrencyCode { get; set; }
|
||||
|
||||
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
// [JsonPropertyName("grand_total")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long? GrandTotal { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class ChargebackFee
|
||||
// {
|
||||
// [JsonPropertyName("amount")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Amount { get; set; }
|
||||
|
||||
// [JsonPropertyName("original")]
|
||||
// public object Original { get; set; }
|
||||
// }
|
||||
|
||||
public partial class LineItem
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("price_id")]
|
||||
public string? PriceId { get; set; }
|
||||
|
||||
// [JsonPropertyName("quantity")]
|
||||
// public long Quantity { get; set; }
|
||||
|
||||
// [JsonPropertyName("totals")]
|
||||
// public Totals Totals { get; set; }
|
||||
|
||||
// [JsonPropertyName("product")]
|
||||
// public Product Product { get; set; }
|
||||
|
||||
// [JsonPropertyName("tax_rate")]
|
||||
// public string TaxRate { get; set; }
|
||||
|
||||
// [JsonPropertyName("unit_totals")]
|
||||
// public Totals UnitTotals { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonPropertyName("proration")]
|
||||
public Proration? Proration { get; set; }
|
||||
}
|
||||
|
||||
// public partial class Product
|
||||
// {
|
||||
// [JsonPropertyName("id")]
|
||||
// public string Id { get; set; }
|
||||
|
||||
// [JsonPropertyName("name")]
|
||||
// public string Name { get; set; }
|
||||
|
||||
// [JsonPropertyName("description")]
|
||||
// public string Description { get; set; }
|
||||
|
||||
// [JsonPropertyName("type")]
|
||||
// public TypeEnum Type { get; set; }
|
||||
|
||||
// [JsonPropertyName("tax_category")]
|
||||
// public TypeEnum TaxCategory { get; set; }
|
||||
|
||||
// [JsonPropertyName("image_url")]
|
||||
// public Uri ImageUrl { get; set; }
|
||||
|
||||
// [JsonPropertyName("custom_data")]
|
||||
// public CustomData CustomData { get; set; }
|
||||
|
||||
// [JsonPropertyName("status")]
|
||||
// public Status Status { get; set; }
|
||||
|
||||
// [JsonPropertyName("created_at")]
|
||||
// public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("updated_at")]
|
||||
// public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("import_meta")]
|
||||
// public object ImportMeta { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class CustomData
|
||||
// {
|
||||
// [JsonPropertyName("features")]
|
||||
// public Features Features { get; set; }
|
||||
|
||||
// [JsonPropertyName("suggested_addons")]
|
||||
// public string[] SuggestedAddons { get; set; }
|
||||
|
||||
// [JsonPropertyName("upgrade_description")]
|
||||
// public string UpgradeDescription { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class Features
|
||||
// {
|
||||
// [JsonPropertyName("aircraft_performance")]
|
||||
// public bool AircraftPerformance { get; set; }
|
||||
|
||||
// [JsonPropertyName("compliance_monitoring")]
|
||||
// public bool ComplianceMonitoring { get; set; }
|
||||
|
||||
// [JsonPropertyName("flight_log_management")]
|
||||
// public bool FlightLogManagement { get; set; }
|
||||
|
||||
// [JsonPropertyName("payment_by_invoice")]
|
||||
// public bool PaymentByInvoice { get; set; }
|
||||
|
||||
// [JsonPropertyName("route_planning")]
|
||||
// public bool RoutePlanning { get; set; }
|
||||
|
||||
// [JsonPropertyName("sso")]
|
||||
// public bool Sso { get; set; }
|
||||
// }
|
||||
|
||||
public partial class Proration
|
||||
{
|
||||
[JsonPropertyName("billing_period")]
|
||||
public BillingPeriod? BillingPeriod { get; set; }
|
||||
}
|
||||
|
||||
// public partial class Totals
|
||||
// {
|
||||
// [JsonPropertyName("subtotal")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Subtotal { get; set; }
|
||||
|
||||
// [JsonPropertyName("discount")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Discount { get; set; }
|
||||
|
||||
// [JsonPropertyName("tax")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Tax { get; set; }
|
||||
|
||||
// [JsonPropertyName("total")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Total { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class TaxRatesUsed
|
||||
// {
|
||||
// [JsonPropertyName("tax_rate")]
|
||||
// public string TaxRate { get; set; }
|
||||
|
||||
// [JsonPropertyName("totals")]
|
||||
// public Totals Totals { get; set; }
|
||||
// }
|
||||
|
||||
public partial class Item
|
||||
{
|
||||
[JsonPropertyName("price")]
|
||||
public Price? Price { get; set; }
|
||||
|
||||
[JsonPropertyName("quantity")]
|
||||
public long Quantity { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
[JsonPropertyName("proration")]
|
||||
public Proration? Proration { get; set; }
|
||||
}
|
||||
|
||||
public partial class Price
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; set; }
|
||||
|
||||
// [JsonPropertyName("description")]
|
||||
// public string Description { get; set; }
|
||||
|
||||
// [JsonPropertyName("type")]
|
||||
// public TypeEnum Type { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
// [JsonPropertyName("product_id")]
|
||||
// public string ProductId { get; set; }
|
||||
|
||||
// [JsonPropertyName("billing_cycle")]
|
||||
// public PaymentTerms BillingCycle { get; set; }
|
||||
|
||||
// [JsonPropertyName("trial_period")]
|
||||
// public object TrialPeriod { get; set; }
|
||||
|
||||
// [JsonPropertyName("tax_mode")]
|
||||
// public TaxMode TaxMode { get; set; }
|
||||
|
||||
// [JsonPropertyName("unit_price")]
|
||||
// public UnitPrice UnitPrice { get; set; }
|
||||
|
||||
// [JsonPropertyName("unit_price_overrides")]
|
||||
// public object[] UnitPriceOverrides { get; set; }
|
||||
|
||||
// [JsonPropertyName("custom_data")]
|
||||
// public object CustomData { get; set; }
|
||||
|
||||
// [JsonPropertyName("quantity")]
|
||||
// public Quantity Quantity { get; set; }
|
||||
|
||||
// [JsonPropertyName("status")]
|
||||
// public Status Status { get; set; }
|
||||
|
||||
// [JsonPropertyName("created_at")]
|
||||
// public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("updated_at")]
|
||||
// public DateTimeOffset UpdatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("import_meta")]
|
||||
// public object ImportMeta { get; set; }
|
||||
}
|
||||
|
||||
// public partial class Quantity
|
||||
// {
|
||||
// [JsonPropertyName("minimum")]
|
||||
// public long Minimum { get; set; }
|
||||
|
||||
// [JsonPropertyName("maximum")]
|
||||
// public long Maximum { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class UnitPrice
|
||||
// {
|
||||
// [JsonPropertyName("amount")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Amount { get; set; }
|
||||
|
||||
// [JsonPropertyName("currency_code")]
|
||||
// public CurrencyCode CurrencyCode { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class Payment
|
||||
// {
|
||||
// [JsonPropertyName("payment_attempt_id")]
|
||||
// public Guid PaymentAttemptId { get; set; }
|
||||
|
||||
// [JsonPropertyName("stored_payment_method_id")]
|
||||
// public Guid StoredPaymentMethodId { get; set; }
|
||||
|
||||
// [JsonPropertyName("payment_method_id")]
|
||||
// public string PaymentMethodId { get; set; }
|
||||
|
||||
// [JsonPropertyName("amount")]
|
||||
// [JsonConverter(typeof(ParseStringConverter))]
|
||||
// public long Amount { get; set; }
|
||||
|
||||
// [JsonPropertyName("status")]
|
||||
// public string Status { get; set; }
|
||||
|
||||
// [JsonPropertyName("error_code")]
|
||||
// public string ErrorCode { get; set; }
|
||||
|
||||
// [JsonPropertyName("method_details")]
|
||||
// public MethodDetails MethodDetails { get; set; }
|
||||
|
||||
// [JsonPropertyName("created_at")]
|
||||
// public DateTimeOffset CreatedAt { get; set; }
|
||||
|
||||
// [JsonPropertyName("captured_at")]
|
||||
// public DateTimeOffset? CapturedAt { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class MethodDetails
|
||||
// {
|
||||
// [JsonPropertyName("type")]
|
||||
// public string Type { get; set; }
|
||||
|
||||
// [JsonPropertyName("card")]
|
||||
// public Card Card { get; set; }
|
||||
// }
|
||||
|
||||
// public partial class Card
|
||||
// {
|
||||
// [JsonPropertyName("type")]
|
||||
// public string Type { get; set; }
|
||||
|
||||
// [JsonPropertyName("last4")]
|
||||
// public string Last4 { get; set; }
|
||||
|
||||
// [JsonPropertyName("expiry_month")]
|
||||
// public long ExpiryMonth { get; set; }
|
||||
|
||||
// [JsonPropertyName("expiry_year")]
|
||||
// public long ExpiryYear { get; set; }
|
||||
|
||||
// [JsonPropertyName("cardholder_name")]
|
||||
// public string CardholderName { get; set; }
|
||||
// }
|
||||
|
||||
public partial class Pagination
|
||||
{
|
||||
[JsonPropertyName("per_page")]
|
||||
public long PerPage { get; set; }
|
||||
|
||||
[JsonPropertyName("next")]
|
||||
public Uri? Next { get; set; }
|
||||
|
||||
[JsonPropertyName("has_more")]
|
||||
public bool HasMore { get; set; }
|
||||
|
||||
[JsonPropertyName("estimated_total")]
|
||||
public long EstimatedTotal { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
public partial class ListUsersResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("response")]
|
||||
public PaddleUser[]? Users { get; set; }
|
||||
}
|
||||
|
||||
public class PaddleUser
|
||||
{
|
||||
[JsonPropertyName("subscription_id")]
|
||||
public long SubscriptionId { get; set; }
|
||||
|
||||
[JsonPropertyName("plan_id")]
|
||||
public long PlanId { get; set; }
|
||||
|
||||
[JsonPropertyName("user_id")]
|
||||
public long UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("user_email")]
|
||||
public string? UserEmail { get; set; }
|
||||
|
||||
[JsonPropertyName("marketing_consent")]
|
||||
public bool MarketingConsent { get; set; }
|
||||
|
||||
[JsonPropertyName("update_url")]
|
||||
public string? UpdateUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("cancel_url")]
|
||||
public string? CancelUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("state")]
|
||||
public string? State { get; set; }
|
||||
|
||||
[JsonPropertyName("signup_date")]
|
||||
public string? SignupDate { get; set; }
|
||||
|
||||
[JsonPropertyName("quantity")]
|
||||
public long Quantity { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public partial class PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("error")]
|
||||
public PaddleError? Error { get; set; }
|
||||
}
|
||||
|
||||
public class PaddleError
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
[JsonPropertyName("documentation_url")]
|
||||
public string? DocumentationUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
public partial class RefundPaymentResponse
|
||||
{
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonPropertyName("response")]
|
||||
public required Refund Refund { get; set; }
|
||||
}
|
||||
|
||||
public partial class Refund
|
||||
{
|
||||
[JsonPropertyName("refund_request_id")]
|
||||
public long RefundRequestId { get; set; }
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ using System.ComponentModel.DataAnnotations;
|
||||
using System.Runtime.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Streetwriters.Identity.Models
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
public class SignupForm
|
||||
{
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
public class SignupResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; set; }
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int AccessTokenLifetime { get; set; }
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string RefreshToken { get; set; }
|
||||
[JsonPropertyName("scope")]
|
||||
public string Scope { get; set; }
|
||||
[JsonPropertyName("user_id")]
|
||||
public string UserId { get; set; }
|
||||
public string[]? Errors { get; set; }
|
||||
|
||||
public static SignupResponse Error(IEnumerable<string> errors)
|
||||
{
|
||||
return new SignupResponse
|
||||
{
|
||||
Errors = [.. errors]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,9 @@ namespace Streetwriters.Common.Models
|
||||
[JsonPropertyName("trialsAvailed")]
|
||||
public SubscriptionPlan[]? TrialsAvailed { get; set; }
|
||||
|
||||
[JsonPropertyName("extensionsAvailed")]
|
||||
public SubscriptionExtension[]? ExtensionsAvailed { get; set; }
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public long UpdatedAt { get; set; }
|
||||
|
||||
@@ -104,4 +107,16 @@ namespace Streetwriters.Common.Models
|
||||
[JsonPropertyName("status")]
|
||||
public SubscriptionStatus Status { get; set; }
|
||||
}
|
||||
|
||||
public class SubscriptionExtension
|
||||
{
|
||||
[JsonPropertyName("timestamp")]
|
||||
public required long Timestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("expiry")]
|
||||
public required long ExpiryDate { get; set; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public required string Type { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
namespace Streetwriters.Common.Models
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Globalization;
|
||||
|
||||
public partial class SubscriptionPreviewResponse : PaddleResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public SubscriptionPreviewData? Data { get; set; }
|
||||
}
|
||||
|
||||
public partial class SubscriptionPreviewData
|
||||
{
|
||||
[JsonPropertyName("currency_code")]
|
||||
public string? CurrencyCode { get; set; }
|
||||
|
||||
[JsonPropertyName("billing_cycle")]
|
||||
public BillingCycle? BillingCycle { get; set; }
|
||||
|
||||
[JsonPropertyName("update_summary")]
|
||||
public UpdateSummary? UpdateSummary { get; set; }
|
||||
|
||||
[JsonPropertyName("immediate_transaction")]
|
||||
public TransactionV2? ImmediateTransaction { get; set; }
|
||||
|
||||
[JsonPropertyName("next_transaction")]
|
||||
public TransactionV2? NextTransaction { get; set; }
|
||||
|
||||
[JsonPropertyName("recurring_transaction_details")]
|
||||
public Details? RecurringTransactionDetails { get; set; }
|
||||
}
|
||||
|
||||
public partial class UpdateSummary
|
||||
{
|
||||
[JsonPropertyName("charge")]
|
||||
public UpdateSummaryItem? Charge { get; set; }
|
||||
|
||||
[JsonPropertyName("credit")]
|
||||
public UpdateSummaryItem? Credit { get; set; }
|
||||
|
||||
[JsonPropertyName("result")]
|
||||
public UpdateSummaryItem? Result { get; set; }
|
||||
}
|
||||
|
||||
public partial class UpdateSummaryItem
|
||||
{
|
||||
[JsonPropertyName("amount")]
|
||||
public long Amount { get; set; }
|
||||
|
||||
[JsonPropertyName("action")]
|
||||
public string? Action { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ namespace Streetwriters.Common.Services
|
||||
public async Task SendEmailAsync(
|
||||
string email,
|
||||
EmailTemplate template,
|
||||
IClient client,
|
||||
System.Net.Mail.MailAddress from,
|
||||
GnuPGContext? gpgContext = null,
|
||||
Dictionary<string, byte[]>? attachments = null
|
||||
)
|
||||
@@ -55,8 +55,7 @@ namespace Streetwriters.Common.Services
|
||||
);
|
||||
|
||||
var message = new MimeMessage();
|
||||
var sender = new MailboxAddress(client.SenderName, client.SenderEmail);
|
||||
message.From.Add(sender);
|
||||
message.From.Add(new MailboxAddress(from.DisplayName, from.Address));
|
||||
message.To.Add(new MailboxAddress("", email));
|
||||
message.Subject = await Template.Parse(template.Subject).RenderAsync(template.Data);
|
||||
|
||||
@@ -65,8 +64,7 @@ namespace Streetwriters.Common.Services
|
||||
|
||||
message.Body = await GetEmailBodyAsync(
|
||||
template,
|
||||
client,
|
||||
sender,
|
||||
new MailboxAddress(from.DisplayName, from.Address),
|
||||
gpgContext,
|
||||
attachments
|
||||
);
|
||||
@@ -76,7 +74,6 @@ namespace Streetwriters.Common.Services
|
||||
|
||||
private async Task<MimeEntity> GetEmailBodyAsync(
|
||||
EmailTemplate template,
|
||||
IClient client,
|
||||
MailboxAddress sender,
|
||||
GnuPGContext? gpgContext = null,
|
||||
Dictionary<string, byte[]>? attachments = null
|
||||
@@ -107,7 +104,7 @@ namespace Streetwriters.Common.Services
|
||||
}
|
||||
outputStream.Seek(0, SeekOrigin.Begin);
|
||||
builder.Attachments.Add(
|
||||
$"{client.Id}_pub.asc",
|
||||
$"pub.asc",
|
||||
Encoding.ASCII.GetBytes(
|
||||
Encoding.ASCII.GetString(outputStream.ToArray())
|
||||
)
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Streetwriters.Common.Services
|
||||
{
|
||||
public class PaddleBillingService
|
||||
{
|
||||
#if DEBUG
|
||||
private const string PADDLE_BASE_URI = "https://sandbox-api.paddle.com";
|
||||
#else
|
||||
private const string PADDLE_BASE_URI = "https://api.paddle.com";
|
||||
#endif
|
||||
private readonly HttpClient httpClient = new();
|
||||
public PaddleBillingService(string paddleApiKey)
|
||||
{
|
||||
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", paddleApiKey);
|
||||
}
|
||||
|
||||
public async Task<GetSubscriptionResponse?> GetSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}";
|
||||
var response = await httpClient.GetAsync(url);
|
||||
return await response.Content.ReadFromJsonAsync<GetSubscriptionResponse>();
|
||||
}
|
||||
|
||||
public async Task<GetTransactionResponse?> GetTransactionAsync(string transactionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/transactions/{transactionId}";
|
||||
var response = await httpClient.GetAsync(url);
|
||||
return await response.Content.ReadFromJsonAsync<GetTransactionResponse>();
|
||||
}
|
||||
|
||||
public async Task<GetTransactionInvoiceResponse?> GetTransactionInvoiceAsync(string transactionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/transactions/{transactionId}/invoice";
|
||||
var response = await httpClient.GetAsync(url);
|
||||
return await response.Content.ReadFromJsonAsync<GetTransactionInvoiceResponse>();
|
||||
}
|
||||
|
||||
public async Task<ListTransactionsResponseV2?> ListTransactionsAsync(string? subscriptionId = null, string? customerId = null, string[]? status = null, string[]? origin = null)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/transactions";
|
||||
var parameters = new Dictionary<string, string?>()
|
||||
{
|
||||
{ "subscription_id", subscriptionId },
|
||||
{ "customer_id", customerId },
|
||||
{ "status", string.Join(',', status ?? ["billed","completed"]) },
|
||||
{ "order_by", "billed_at[DESC]" }
|
||||
};
|
||||
if (origin is not null) parameters.Add("origin", string.Join(',', origin));
|
||||
var response = await httpClient.GetAsync(QueryHelpers.AddQueryString(url, parameters));
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ListTransactionsResponseV2>();
|
||||
}
|
||||
|
||||
public async Task<PaddleResponse?> RefundTransactionAsync(string transactionId, string transactionItemId, string reason = "")
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/adjustments";
|
||||
var response = await httpClient.PostAsync(url, JsonContent.Create(new Dictionary<string, object>
|
||||
{
|
||||
{ "action", "refund" },
|
||||
{
|
||||
"items",
|
||||
new object[]
|
||||
{
|
||||
new Dictionary<string, string> {
|
||||
{"item_id", transactionItemId},
|
||||
{"type", "full"}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ "reason", reason },
|
||||
{ "transaction_id", transactionId }
|
||||
}));
|
||||
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
|
||||
}
|
||||
|
||||
public async Task<SubscriptionPreviewResponse?> PreviewSubscriptionChangeAsync(string subscriptionId, string newProductId, bool isTrialing)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}/preview";
|
||||
var response = await httpClient.PatchAsync(url, JsonContent.Create(new
|
||||
{
|
||||
proration_billing_mode = isTrialing ? "do_not_bill" : "prorated_immediately",
|
||||
items = new[] { new { price_id = newProductId, quantity = 1 } }
|
||||
}));
|
||||
return await response.Content.ReadFromJsonAsync<SubscriptionPreviewResponse>();
|
||||
}
|
||||
|
||||
public async Task<PaddleResponse?> ChangeSubscriptionAsync(string subscriptionId, string newProductId, bool isTrialing)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}";
|
||||
var response = await httpClient.PatchAsync(url, JsonContent.Create(new
|
||||
{
|
||||
proration_billing_mode = isTrialing ? "do_not_bill" : "prorated_immediately",
|
||||
items = new[] { new { price_id = newProductId, quantity = 1 } }
|
||||
}));
|
||||
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
|
||||
}
|
||||
|
||||
public async Task<PaddleResponse?> CancelSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}/cancel";
|
||||
var response = await httpClient.PostAsync(url, JsonContent.Create(new { effective_from = "immediately" }));
|
||||
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
|
||||
}
|
||||
|
||||
public async Task<PaddleResponse?> PauseSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}/pause";
|
||||
var response = await httpClient.PostAsync(url, JsonContent.Create(new { }));
|
||||
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
|
||||
}
|
||||
|
||||
public async Task<PaddleResponse?> ResumeSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}";
|
||||
var response = await httpClient.PatchAsync(url, JsonContent.Create(new Dictionary<string, string?>
|
||||
{
|
||||
{"scheduled_change", null}
|
||||
}));
|
||||
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
|
||||
}
|
||||
|
||||
public async Task<GetCustomerResponse?> FindCustomerFromTransactionAsync(string transactionId)
|
||||
{
|
||||
var transaction = await GetTransactionAsync(transactionId);
|
||||
if (transaction?.Transaction?.CustomerId == null) return null;
|
||||
var url = $"{PADDLE_BASE_URI}/customers/{transaction.Transaction.CustomerId}";
|
||||
var response = await httpClient.GetFromJsonAsync<GetCustomerResponse>(url);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
namespace Streetwriters.Common.Services
|
||||
{
|
||||
public class PaddleService(string vendorId, string vendorAuthCode)
|
||||
{
|
||||
#if (DEBUG || STAGING)
|
||||
const string PADDLE_BASE_URI = "https://sandbox-vendors.paddle.com/api";
|
||||
#else
|
||||
const string PADDLE_BASE_URI = "https://vendors.paddle.com/api";
|
||||
#endif
|
||||
|
||||
HttpClient httpClient = new HttpClient();
|
||||
|
||||
public async Task<ListUsersResponse?> ListUsersAsync(
|
||||
string subscriptionId,
|
||||
int results
|
||||
)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
{ "subscription_id", subscriptionId },
|
||||
{ "results_per_page", results.ToString() },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ListUsersResponse>();
|
||||
}
|
||||
|
||||
public async Task<ListPaymentsResponse?> ListPaymentsAsync(
|
||||
string subscriptionId,
|
||||
long planId
|
||||
)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/subscription/payments";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
{ "subscription_id", subscriptionId },
|
||||
{ "is_paid", "1" },
|
||||
{ "plan", planId.ToString() },
|
||||
{ "is_one_off_charge", "0" },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ListPaymentsResponse>();
|
||||
}
|
||||
|
||||
public async Task<ListTransactionsResponse?> ListTransactionsAsync(
|
||||
string subscriptionId
|
||||
)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/subscription/{subscriptionId}/transactions";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<ListTransactionsResponse>();
|
||||
}
|
||||
|
||||
public async Task<PaddleTransactionUser?> FindUserFromOrderAsync(string orderId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/order/{orderId}/transactions";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
}
|
||||
)
|
||||
);
|
||||
var transactions = await response.Content.ReadFromJsonAsync<ListTransactionsResponse>();
|
||||
if (transactions?.Transactions == null || transactions.Transactions.Length == 0) return null;
|
||||
return transactions.Transactions[0].User;
|
||||
}
|
||||
|
||||
public async Task<bool> RefundPaymentAsync(string paymentId, string reason = "")
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/payment/refund";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
{ "order_id", paymentId },
|
||||
{ "reason", reason },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
var refundResponse = await response.Content.ReadFromJsonAsync<RefundPaymentResponse>();
|
||||
return refundResponse?.Success ?? false;
|
||||
}
|
||||
|
||||
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users_cancel";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
{ "subscription_id", subscriptionId },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> PauseSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users/update";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
{ "subscription_id", subscriptionId },
|
||||
{ "pause", "true" },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
public async Task<bool> ResumeSubscriptionAsync(string subscriptionId)
|
||||
{
|
||||
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users/update";
|
||||
var httpClient = new HttpClient();
|
||||
var response = await httpClient.PostAsync(
|
||||
url,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "vendor_id", vendorId },
|
||||
{ "vendor_auth_code", vendorAuthCode },
|
||||
{ "subscription_id", subscriptionId },
|
||||
{ "pause", "false" },
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
|
||||
@@ -22,7 +25,8 @@ namespace Streetwriters.Common.Services
|
||||
public async Task<bool> IsURLSafeAsync(string uri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Constants.WEBRISK_API_URI)) return true;
|
||||
var response = await httpClient.PostAsJsonAsync(Constants.WEBRISK_API_URI, new { uri });
|
||||
var body = new StringContent(JsonSerializer.Serialize(new { uri }), Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
|
||||
var response = await httpClient.PostAsync(Constants.WEBRISK_API_URI, body);
|
||||
if (!response.IsSuccessStatusCode) return true;
|
||||
var json = await response.Content.ReadFromJsonAsync<WebRiskAPIResponse>();
|
||||
return json.Threat.ThreatTypes == null || json.Threat.ThreatTypes.Length == 0;
|
||||
|
||||
@@ -33,9 +33,6 @@ namespace Streetwriters.Data.DbContexts
|
||||
public static IMongoClient CreateMongoDbClient(IDbSettings dbSettings)
|
||||
{
|
||||
var settings = MongoClientSettings.FromConnectionString(dbSettings.ConnectionString);
|
||||
settings.MaxConnectionPoolSize = 500;
|
||||
settings.MinConnectionPoolSize = 0;
|
||||
settings.HeartbeatInterval = TimeSpan.FromSeconds(60);
|
||||
return new MongoClient(settings);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AspNetCore.Identity.Mongo.Model;
|
||||
using IdentityServer4.Extensions;
|
||||
using IdentityServer4.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -33,10 +34,12 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Enums;
|
||||
using Streetwriters.Common.Helpers;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
using Streetwriters.Common.Messages;
|
||||
using Streetwriters.Common.Models;
|
||||
using Streetwriters.Identity.Enums;
|
||||
using Streetwriters.Identity.Extensions;
|
||||
using Streetwriters.Identity.Interfaces;
|
||||
using Streetwriters.Identity.Models;
|
||||
using Streetwriters.Identity.Services;
|
||||
@@ -50,17 +53,32 @@ namespace Streetwriters.Identity.Controllers
|
||||
[Authorize(LocalApi.PolicyName)]
|
||||
public class AccountController : IdentityControllerBase
|
||||
{
|
||||
private static readonly string emailConfirmedPageHtml = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailConfirmedPage.html");
|
||||
private static readonly string emailConfirmErrorPageHtml = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailConfirmErrorPage.html");
|
||||
|
||||
private IPersistedGrantStore PersistedGrantStore { get; set; }
|
||||
private ITokenGenerationService TokenGenerationService { get; set; }
|
||||
private IUserAccountService UserAccountService { get; set; }
|
||||
private EmailAddressValidator EmailValidator { get; set; }
|
||||
private readonly ILogger<AccountController> logger;
|
||||
public AccountController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
|
||||
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IPersistedGrantStore store,
|
||||
ITokenGenerationService tokenGenerationService, IMFAService _mfaService, IUserAccountService userAccountService, ILogger<AccountController> logger) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
|
||||
|
||||
public AccountController(
|
||||
UserManager<User> _userManager,
|
||||
ITemplatedEmailSender _emailSender,
|
||||
SignInManager<User> _signInManager,
|
||||
RoleManager<MongoRole> _roleManager,
|
||||
IPersistedGrantStore store,
|
||||
ITokenGenerationService tokenGenerationService,
|
||||
IMFAService _mfaService,
|
||||
IUserAccountService userAccountService,
|
||||
ILogger<AccountController> logger,
|
||||
EmailAddressValidator emailValidator
|
||||
) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
|
||||
{
|
||||
PersistedGrantStore = store;
|
||||
TokenGenerationService = tokenGenerationService;
|
||||
UserAccountService = userAccountService;
|
||||
EmailValidator = emailValidator;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@@ -79,10 +97,22 @@ namespace Streetwriters.Identity.Controllers
|
||||
{
|
||||
case TokenType.CONFRIM_EMAIL:
|
||||
{
|
||||
if (await UserManager.IsEmailConfirmedAsync(user)) return Ok("Email already verified.");
|
||||
if (await UserManager.IsEmailConfirmedAsync(user))
|
||||
{
|
||||
return Content(
|
||||
emailConfirmedPageHtml.Replace("{{subheading}}", "Your email is already verified."),
|
||||
"text/html"
|
||||
);
|
||||
}
|
||||
|
||||
var result = await UserManager.ConfirmEmailAsync(user, code);
|
||||
if (!result.Succeeded) return BadRequest(result.Errors.ToErrors());
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return Content(
|
||||
emailConfirmErrorPageHtml.Replace("{{errors}}", string.Join(" ", result.Errors.ToErrors())),
|
||||
"text/html"
|
||||
);
|
||||
}
|
||||
|
||||
if (await UserManager.IsInRoleAsync(user, client.Id) && client.OnEmailConfirmed != null)
|
||||
{
|
||||
@@ -92,8 +122,10 @@ namespace Streetwriters.Identity.Controllers
|
||||
if (!await UserManager.GetTwoFactorEnabledAsync(user))
|
||||
await MFAService.EnableMFAAsync(user, MFAMethods.Email);
|
||||
|
||||
var redirectUrl = $"{client.EmailConfirmedRedirectURL}?userId={userId}";
|
||||
return RedirectPermanent(redirectUrl);
|
||||
return Content(
|
||||
emailConfirmedPageHtml.Replace("{{subheading}}", "Your email has been confirmed."),
|
||||
"text/html"
|
||||
);
|
||||
}
|
||||
case TokenType.RESET_PASSWORD:
|
||||
{
|
||||
@@ -124,11 +156,16 @@ namespace Streetwriters.Identity.Controllers
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user.Email);
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
|
||||
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
|
||||
await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!await EmailValidator.IsEmailAddressValidAsync(newEmail.ToLowerInvariant()))
|
||||
{
|
||||
return BadRequest("Invalid email address.");
|
||||
}
|
||||
|
||||
var code = await UserManager.GenerateChangeEmailTokenAsync(user, newEmail);
|
||||
await EmailSender.SendChangeEmailConfirmationAsync(newEmail, code, client);
|
||||
}
|
||||
@@ -149,20 +186,21 @@ namespace Streetwriters.Identity.Controllers
|
||||
[EnableRateLimiting("strict")]
|
||||
public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form)
|
||||
{
|
||||
|
||||
var client = Clients.FindClientById(form.ClientId);
|
||||
if (client == null) return BadRequest("Invalid client_id.");
|
||||
|
||||
var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
|
||||
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
|
||||
var user = await UserManager.FindByEmailAsync(form.Email);
|
||||
if (user == null || !await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
|
||||
|
||||
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
|
||||
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
|
||||
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
|
||||
#if (DEBUG || STAGING)
|
||||
return Ok(callbackUrl);
|
||||
#else
|
||||
logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
|
||||
await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
|
||||
return Ok();
|
||||
logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
|
||||
await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
|
||||
return Ok();
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -248,34 +286,6 @@ namespace Streetwriters.Identity.Controllers
|
||||
}
|
||||
return BadRequest(result.Errors.ToErrors());
|
||||
}
|
||||
case "change_password":
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(form.OldPassword);
|
||||
ArgumentNullException.ThrowIfNull(form.NewPassword);
|
||||
var result = await UserManager.ChangePasswordAsync(user, form.OldPassword, form.NewPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendLogoutMessageAsync(user.Id.ToString(), "Password changed.");
|
||||
return Ok();
|
||||
}
|
||||
return BadRequest(result.Errors.ToErrors());
|
||||
}
|
||||
case "reset_password":
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(form.NewPassword);
|
||||
var result = await UserManager.RemovePasswordAsync(user);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await MFAService.ResetMFAAsync(user);
|
||||
result = await UserManager.AddPasswordAsync(user, form.NewPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await SendLogoutMessageAsync(user.Id.ToString(), "Password reset.");
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
return BadRequest(result.Errors.ToErrors());
|
||||
}
|
||||
case "change_marketing_consent":
|
||||
{
|
||||
var claimType = $"{client.Id}:marketing_consent";
|
||||
@@ -294,40 +304,14 @@ namespace Streetwriters.Identity.Controllers
|
||||
[HttpPost("sessions/clear")]
|
||||
public async Task<IActionResult> ClearUserSessions([FromQuery] bool all, [FromForm] string? refresh_token)
|
||||
{
|
||||
var client = Clients.FindClientById(User.FindFirstValue("client_id"));
|
||||
if (client == null) return BadRequest("Invalid client_id.");
|
||||
|
||||
var user = await UserManager.GetUserAsync(User) ?? throw new Exception("User not found.");
|
||||
if (!await UserService.IsUserValidAsync(UserManager, user, client.Id)) return BadRequest($"Unable to find user with ID '{user.Id}'.");
|
||||
|
||||
var jti = User.FindFirstValue("jti");
|
||||
|
||||
var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter
|
||||
{
|
||||
ClientId = client.Id,
|
||||
SubjectId = user.Id.ToString()
|
||||
});
|
||||
string? refreshTokenKey = refresh_token != null ? GetHashedKey(refresh_token, PersistedGrantTypes.RefreshToken) : null;
|
||||
var removedKeys = new List<string>();
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
if (!all && (grant.Data.Contains(jti) || grant.Key == refreshTokenKey)) continue;
|
||||
await PersistedGrantStore.RemoveAsync(grant.Key);
|
||||
removedKeys.Add(grant.Key);
|
||||
}
|
||||
|
||||
await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
|
||||
await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
|
||||
await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
|
||||
await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked.");
|
||||
var userId = User.GetSubjectId();
|
||||
var clientId = User.FindFirstValue("client_id");
|
||||
if (await UserAccountService.ClearSessionsAsync(userId, clientId, all, refresh_token, jti))
|
||||
await SendLogoutMessageAsync(userId, "Session revoked.");
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private static string GetHashedKey(string value, string grantType)
|
||||
{
|
||||
return (value + ":" + grantType).Sha256();
|
||||
}
|
||||
|
||||
private async Task SendLogoutMessageAsync(string userId, string reason)
|
||||
{
|
||||
await SendMessageAsync(userId, new Message
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
/*
|
||||
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
|
||||
|
||||
Copyright (C) 2023 Streetwriters (Private) Limited
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the Affero GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
Affero GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the Affero GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using AspNetCore.Identity.Mongo.Model;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Enums;
|
||||
using Streetwriters.Common.Models;
|
||||
using Streetwriters.Identity.Enums;
|
||||
using Streetwriters.Identity.Interfaces;
|
||||
using Streetwriters.Identity.Models;
|
||||
using Streetwriters.Identity.Services;
|
||||
|
||||
namespace Streetwriters.Identity.Controllers
|
||||
{
|
||||
[ApiController]
|
||||
[Route("signup")]
|
||||
public class SignupController : IdentityControllerBase
|
||||
{
|
||||
private readonly ILogger<SignupController> logger;
|
||||
private readonly EmailAddressValidator emailValidator;
|
||||
|
||||
public SignupController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
|
||||
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IMFAService _mfaService,
|
||||
ILogger<SignupController> logger, EmailAddressValidator emailValidator) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
|
||||
{
|
||||
this.logger = logger;
|
||||
this.emailValidator = emailValidator;
|
||||
}
|
||||
|
||||
private async Task AddClientRoleAsync(string clientId)
|
||||
{
|
||||
if (await RoleManager.FindByNameAsync(clientId) == null)
|
||||
await RoleManager.CreateAsync(new MongoRole(clientId));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("strict")]
|
||||
public async Task<IActionResult> Signup([FromForm] SignupForm form)
|
||||
{
|
||||
if (Constants.DISABLE_SIGNUPS)
|
||||
return BadRequest(new string[] { "Creating new accounts is not allowed." });
|
||||
try
|
||||
{
|
||||
var client = Clients.FindClientById(form.ClientId);
|
||||
if (client == null) return BadRequest(new string[] { "Invalid client id." });
|
||||
|
||||
await AddClientRoleAsync(client.Id);
|
||||
|
||||
// email addresses must be case-insensitive
|
||||
form.Email = form.Email.ToLowerInvariant();
|
||||
form.Username = form.Username?.ToLowerInvariant();
|
||||
|
||||
if (!await emailValidator.IsEmailAddressValidAsync(form.Email)) return BadRequest(new string[] { "Invalid email address." });
|
||||
|
||||
var result = await UserManager.CreateAsync(new User
|
||||
{
|
||||
Email = form.Email,
|
||||
EmailConfirmed = Constants.IS_SELF_HOSTED,
|
||||
UserName = form.Username ?? form.Email,
|
||||
}, form.Password);
|
||||
|
||||
if (result.Errors.Any((e) => e.Code == "DuplicateEmail"))
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(form.Email);
|
||||
if (user == null) return BadRequest(new string[] { "User not found." });
|
||||
|
||||
if (!await UserManager.IsInRoleAsync(user, client.Id))
|
||||
{
|
||||
if (!await UserManager.CheckPasswordAsync(user, form.Password))
|
||||
{
|
||||
// TODO
|
||||
await UserManager.RemovePasswordAsync(user);
|
||||
await UserManager.AddPasswordAsync(user, form.Password);
|
||||
}
|
||||
await MFAService.DisableMFAAsync(user);
|
||||
await UserManager.AddToRoleAsync(user, client.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest(new string[] { "Invalid email address.." });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
userId = user.Id.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var user = await UserManager.FindByEmailAsync(form.Email);
|
||||
if (user == null) return BadRequest(new string[] { "User not found after creation." });
|
||||
|
||||
await UserManager.AddToRoleAsync(user, client.Id);
|
||||
if (Constants.IS_SELF_HOSTED)
|
||||
{
|
||||
await UserManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
|
||||
}
|
||||
else
|
||||
{
|
||||
await UserManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(base.HttpContext.Request.Headers.UserAgent)));
|
||||
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
|
||||
if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null)
|
||||
{
|
||||
await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
|
||||
}
|
||||
}
|
||||
return Ok(new
|
||||
{
|
||||
userId = user.Id.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return BadRequest(result.Errors.ToErrors());
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create user account for email: {Email}", form.Email);
|
||||
return BadRequest("Failed to create an account.");
|
||||
}
|
||||
}
|
||||
|
||||
static string PlatformFromUserAgent(string? userAgent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userAgent)) return "unknown";
|
||||
return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,6 @@ FROM build AS publish
|
||||
RUN dotnet publish -c Release -o /app/publish \
|
||||
#--runtime alpine-x64 \
|
||||
--self-contained true \
|
||||
/p:TrimMode=partial \
|
||||
/p:PublishTrimmed=true \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:JsonSerializerIsReflectionEnabledByDefault=true \
|
||||
-a $TARGETARCH
|
||||
|
||||
@@ -25,25 +25,24 @@ using Streetwriters.Common;
|
||||
using Streetwriters.Identity.Controllers;
|
||||
using Streetwriters.Identity.Enums;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc
|
||||
namespace Streetwriters.Identity.Extensions
|
||||
{
|
||||
public static class UrlHelperExtensions
|
||||
public static class UrlExtensions
|
||||
{
|
||||
public static string? TokenLink(this IUrlHelper urlHelper, string userId, string code, string clientId, TokenType type)
|
||||
public static string? TokenLink(string userId, string code, string clientId, TokenType type)
|
||||
{
|
||||
|
||||
return urlHelper.ActionLink(
|
||||
var url = new UriBuilder();
|
||||
#if (DEBUG || STAGING)
|
||||
host: $"{Servers.IdentityServer.Hostname}:{Servers.IdentityServer.Port}",
|
||||
protocol: "http",
|
||||
url.Host = $"{Servers.IdentityServer.Hostname}";
|
||||
url.Port = Servers.IdentityServer.Port;
|
||||
url.Scheme = "http";
|
||||
#else
|
||||
host: Servers.IdentityServer.PublicURL.Host,
|
||||
protocol: Servers.IdentityServer.PublicURL.Scheme,
|
||||
url.Host = Servers.IdentityServer.PublicURL.Host;
|
||||
url.Scheme = Servers.IdentityServer.PublicURL.Scheme;
|
||||
#endif
|
||||
action: nameof(AccountController.ConfirmToken),
|
||||
controller: "Account",
|
||||
values: new { userId, code, clientId, type });
|
||||
|
||||
url.Path = "account/confirm";
|
||||
url.Query = $"userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(code)}&clientId={Uri.EscapeDataString(clientId)}&type={Uri.EscapeDataString(type.ToString())}";
|
||||
return url.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ namespace Streetwriters.Identity.Interfaces
|
||||
{
|
||||
public interface ISMSSender
|
||||
{
|
||||
Task<string> SendOTPAsync(string number, IClient client);
|
||||
Task<string?> SendOTPAsync(string number, IClient client);
|
||||
Task<bool> VerifyOTPAsync(string id, string code);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using IdentityServer4.ResponseHandling;
|
||||
using IdentityServer4.Validation;
|
||||
using Streetwriters.Common.Models;
|
||||
|
||||
@@ -26,8 +27,9 @@ namespace Streetwriters.Identity.Interfaces
|
||||
{
|
||||
public interface ITokenGenerationService
|
||||
{
|
||||
Task<string> CreateAccessTokenAsync(User user, string clientId);
|
||||
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 60);
|
||||
Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60);
|
||||
Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800);
|
||||
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 1200);
|
||||
Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 1200);
|
||||
Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,8 @@ namespace Streetwriters.Identity.Services
|
||||
ArgumentNullException.ThrowIfNull(form.PhoneNumber);
|
||||
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
|
||||
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);
|
||||
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
|
||||
break;
|
||||
|
||||
@@ -23,36 +23,56 @@ using Streetwriters.Common;
|
||||
using Twilio.Rest.Verify.V2.Service;
|
||||
using Twilio;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Streetwriters.Identity.Services
|
||||
{
|
||||
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))
|
||||
{
|
||||
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(
|
||||
to: number,
|
||||
channel: "sms",
|
||||
pathServiceSid: Constants.TWILIO_SERVICE_SID
|
||||
);
|
||||
return verification.Sid;
|
||||
try
|
||||
{
|
||||
var verification = await VerificationResource.CreateAsync(
|
||||
to: number,
|
||||
channel: "sms",
|
||||
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)
|
||||
{
|
||||
return (await VerificationCheckResource.CreateAsync(
|
||||
verificationSid: id,
|
||||
pathServiceSid: Constants.TWILIO_SERVICE_SID,
|
||||
code: code
|
||||
)).Status == "approved";
|
||||
try
|
||||
{
|
||||
return (await VerificationCheckResource.CreateAsync(
|
||||
verificationSid: id,
|
||||
pathServiceSid: Constants.TWILIO_SERVICE_SID,
|
||||
code: code
|
||||
)).Status == "approved";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error verifying OTP with Twilio");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ namespace Streetwriters.Identity.Services
|
||||
Subject = Email2FATemplate.Subject,
|
||||
Data = new { app_name = client.Name, code },
|
||||
};
|
||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
||||
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||
}
|
||||
|
||||
public async Task SendConfirmationEmailAsync(
|
||||
@@ -120,7 +120,7 @@ namespace Streetwriters.Identity.Services
|
||||
Subject = ConfirmEmailTemplate.Subject,
|
||||
Data = new { app_name = client.Name, confirm_link = callbackUrl },
|
||||
};
|
||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
||||
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||
}
|
||||
|
||||
public async Task SendChangeEmailConfirmationAsync(
|
||||
@@ -136,7 +136,7 @@ namespace Streetwriters.Identity.Services
|
||||
Subject = ConfirmChangeEmailTemplate.Subject,
|
||||
Data = new { app_name = client.Name, code },
|
||||
};
|
||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
||||
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||
}
|
||||
|
||||
public async Task SendPasswordResetEmailAsync(
|
||||
@@ -152,7 +152,7 @@ namespace Streetwriters.Identity.Services
|
||||
Subject = PasswordResetEmailTemplate.Subject,
|
||||
Data = new { app_name = client.Name, reset_link = callbackUrl },
|
||||
};
|
||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
||||
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||
}
|
||||
|
||||
public async Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client)
|
||||
@@ -168,7 +168,7 @@ namespace Streetwriters.Identity.Services
|
||||
device_info = deviceInfo.Replace("\n", "<br>"),
|
||||
},
|
||||
};
|
||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
||||
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ using IdentityModel;
|
||||
using IdentityServer4;
|
||||
using IdentityServer4.Configuration;
|
||||
using IdentityServer4.Models;
|
||||
using IdentityServer4.ResponseHandling;
|
||||
using IdentityServer4.Services;
|
||||
using IdentityServer4.Stores;
|
||||
using IdentityServer4.Validation;
|
||||
@@ -41,12 +42,14 @@ namespace Streetwriters.Identity.Helpers
|
||||
private IdentityServerOptions ISOptions { get; set; }
|
||||
private IdentityServerTools Tools { get; set; }
|
||||
private IResourceStore ResourceStore { get; set; }
|
||||
private readonly IRefreshTokenService refreshTokenService;
|
||||
public TokenGenerationService(ITokenService tokenService,
|
||||
IUserClaimsPrincipalFactory<User> principalFactory,
|
||||
IdentityServerOptions identityServerOptions,
|
||||
IPersistedGrantStore persistedGrantStore,
|
||||
IdentityServerTools tools,
|
||||
IResourceStore resourceStore)
|
||||
IResourceStore resourceStore,
|
||||
IRefreshTokenService _refreshTokenService)
|
||||
{
|
||||
TokenService = tokenService;
|
||||
PrincipalFactory = principalFactory;
|
||||
@@ -54,16 +57,25 @@ namespace Streetwriters.Identity.Helpers
|
||||
PersistedGrantStore = persistedGrantStore;
|
||||
Tools = tools;
|
||||
ResourceStore = resourceStore;
|
||||
refreshTokenService = _refreshTokenService;
|
||||
}
|
||||
|
||||
public async Task<string> CreateAccessTokenAsync(User user, string clientId)
|
||||
public async Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800)
|
||||
{
|
||||
var client = Config.Clients.FirstOrDefault((c) => c.ClientId == clientId);
|
||||
if (client == null)
|
||||
{
|
||||
throw new System.ArgumentException($"Client with ID '{clientId}' not found", nameof(clientId));
|
||||
}
|
||||
|
||||
var IdentityPricipal = await PrincipalFactory.CreateAsync(user);
|
||||
var IdentityUser = new IdentityServerUser(user.Id.ToString());
|
||||
IdentityUser.AdditionalClaims = IdentityPricipal.Claims.ToArray();
|
||||
IdentityUser.DisplayName = user.UserName;
|
||||
IdentityUser.AuthenticationTime = System.DateTime.UtcNow;
|
||||
IdentityUser.IdentityProvider = IdentityServerConstants.LocalIdentityProvider;
|
||||
var IdentityUser = new IdentityServerUser(user.Id.ToString())
|
||||
{
|
||||
AdditionalClaims = [.. IdentityPricipal.Claims],
|
||||
DisplayName = user.UserName,
|
||||
AuthenticationTime = System.DateTime.UtcNow,
|
||||
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
|
||||
};
|
||||
var Request = new TokenCreationRequest
|
||||
{
|
||||
Subject = IdentityUser.CreatePrincipal(),
|
||||
@@ -71,16 +83,61 @@ namespace Streetwriters.Identity.Helpers
|
||||
ValidatedRequest = new ValidatedRequest()
|
||||
};
|
||||
Request.ValidatedRequest.Subject = Request.Subject;
|
||||
Request.ValidatedRequest.SetClient(Config.Clients.FirstOrDefault((c) => c.ClientId == clientId));
|
||||
Request.ValidatedRequest.SetClient(client);
|
||||
Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference;
|
||||
Request.ValidatedRequest.AccessTokenLifetime = 18000;
|
||||
Request.ValidatedResources = new ResourceValidationResult(new Resources(Config.IdentityResources, Config.ApiResources, Config.ApiScopes));
|
||||
Request.ValidatedRequest.AccessTokenLifetime = lifetime;
|
||||
var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
|
||||
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
|
||||
{
|
||||
ParsedScopes = [.. requestedScopes]
|
||||
});
|
||||
Request.ValidatedRequest.Options = ISOptions;
|
||||
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
|
||||
var accessToken = await TokenService.CreateAccessTokenAsync(Request);
|
||||
return await TokenService.CreateSecurityTokenAsync(accessToken);
|
||||
}
|
||||
|
||||
public async Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800)
|
||||
{
|
||||
var client = Config.Clients.FirstOrDefault((c) => c.ClientId == clientId);
|
||||
var principal = await PrincipalFactory.CreateAsync(user);
|
||||
if (client == null || principal == null) return null;
|
||||
var IdentityUser = new IdentityServerUser(user.Id.ToString())
|
||||
{
|
||||
AdditionalClaims = [.. principal.Claims],
|
||||
DisplayName = user.UserName,
|
||||
AuthenticationTime = System.DateTime.UtcNow,
|
||||
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
|
||||
};
|
||||
var Request = new TokenCreationRequest
|
||||
{
|
||||
Subject = IdentityUser.CreatePrincipal(),
|
||||
IncludeAllIdentityClaims = true,
|
||||
ValidatedRequest = new ValidatedRequest()
|
||||
};
|
||||
Request.ValidatedRequest.Subject = Request.Subject;
|
||||
Request.ValidatedRequest.SetClient(client);
|
||||
Request.ValidatedRequest.AccessTokenType = AccessTokenType.Reference;
|
||||
Request.ValidatedRequest.AccessTokenLifetime = lifetime;
|
||||
var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
|
||||
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
|
||||
{
|
||||
ParsedScopes = [.. requestedScopes]
|
||||
});
|
||||
Request.ValidatedRequest.Options = ISOptions;
|
||||
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
|
||||
var accessToken = await TokenService.CreateAccessTokenAsync(Request);
|
||||
var refreshToken = await refreshTokenService.CreateRefreshTokenAsync(principal, accessToken, client);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = await TokenService.CreateSecurityTokenAsync(accessToken),
|
||||
AccessTokenLifetime = lifetime,
|
||||
RefreshToken = refreshToken,
|
||||
Scope = string.Join(" ", accessToken.Scopes)
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60)
|
||||
{
|
||||
var principal = await PrincipalFactory.CreateAsync(user);
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AspNetCore.Identity.Mongo.Model;
|
||||
using IdentityServer4;
|
||||
using IdentityServer4.Stores;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Streetwriters.Common;
|
||||
using Streetwriters.Common.Enums;
|
||||
using Streetwriters.Common.Interfaces;
|
||||
using Streetwriters.Common.Messages;
|
||||
using Streetwriters.Common.Models;
|
||||
using Streetwriters.Common.Services;
|
||||
using Streetwriters.Identity.Enums;
|
||||
using Streetwriters.Identity.Extensions;
|
||||
using Streetwriters.Identity.Interfaces;
|
||||
using Streetwriters.Identity.Models;
|
||||
|
||||
namespace Streetwriters.Identity.Services
|
||||
{
|
||||
public class UserAccountService(UserManager<User> userManager, IMFAService mfaService) : IUserAccountService
|
||||
public class UserAccountService(UserManager<User> userManager, IMFAService mfaService, IPersistedGrantStore persistedGrantStore, RoleManager<MongoRole> roleManager, EmailAddressValidator emailValidator, ITemplatedEmailSender emailSender, ITokenGenerationService tokenGenerationService, ILogger<UserAccountService> logger) : IUserAccountService
|
||||
{
|
||||
public async Task<UserModel?> GetUserAsync(string clientId, string userId)
|
||||
{
|
||||
@@ -54,5 +67,155 @@ namespace Streetwriters.Identity.Services
|
||||
|
||||
await userManager.DeleteAsync(user);
|
||||
}
|
||||
|
||||
public async Task<bool> ChangePasswordAsync(string userId, string oldPassword, string newPassword)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found.");
|
||||
|
||||
var result = await userManager.ChangePasswordAsync(user, oldPassword, newPassword);
|
||||
return result.Succeeded;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string userId, string newPassword)
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found.");
|
||||
|
||||
var result = await userManager.RemovePasswordAsync(user);
|
||||
if (!result.Succeeded) return false;
|
||||
|
||||
await mfaService.ResetMFAAsync(user);
|
||||
result = await userManager.AddPasswordAsync(user, newPassword);
|
||||
|
||||
// force change email to lowercase if it is not already
|
||||
if (user.Email != null && user.Email != user.Email.ToLower())
|
||||
{
|
||||
var token = await userManager.GenerateChangeEmailTokenAsync(user, user.Email.ToLower());
|
||||
result = await userManager.ChangeEmailAsync(user, user.Email.ToLower(), token);
|
||||
}
|
||||
return result.Succeeded;
|
||||
}
|
||||
|
||||
public async Task<bool> ClearSessionsAsync(string userId, string clientId, bool all, string jti, string? refreshToken)
|
||||
{
|
||||
var client = Clients.FindClientById(clientId) ?? throw new Exception("Invalid client_id.");
|
||||
|
||||
var user = await userManager.FindByIdAsync(userId) ?? throw new Exception("User not found.");
|
||||
if (!await UserService.IsUserValidAsync(userManager, user, client.Id)) throw new Exception($"Unable to find user with ID '{user.Id}'.");
|
||||
|
||||
var grants = await persistedGrantStore.GetAllAsync(new PersistedGrantFilter
|
||||
{
|
||||
ClientId = client.Id,
|
||||
SubjectId = user.Id.ToString()
|
||||
});
|
||||
string? refreshTokenKey = refreshToken != null ? GetHashedKey(refreshToken, IdentityServerConstants.PersistedGrantTypes.RefreshToken) : null;
|
||||
List<string> removedKeys = [];
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
if (!all && (grant.Data.Contains(jti) || grant.Key == refreshTokenKey)) continue;
|
||||
await persistedGrantStore.RemoveAsync(grant.Key);
|
||||
removedKeys.Add(grant.Key);
|
||||
}
|
||||
|
||||
await WampServers.NotesnookServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
|
||||
await WampServers.MessengerServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
|
||||
await WampServers.SubscriptionServer.PublishMessageAsync(IdentityServerTopics.ClearCacheTopic, new ClearCacheMessage(removedKeys));
|
||||
// await SendLogoutMessageAsync(user.Id.ToString(), "Session revoked.");
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<SignupResponse> CreateUserAsync(string clientId, string email, string password, string? userAgent = null)
|
||||
{
|
||||
if (Constants.DISABLE_SIGNUPS)
|
||||
return new SignupResponse
|
||||
{
|
||||
Errors = ["Creating new accounts is not allowed."]
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var client = Clients.FindClientById(clientId);
|
||||
if (client == null) return new SignupResponse
|
||||
{
|
||||
Errors = ["Invalid client id."]
|
||||
};
|
||||
|
||||
if (await roleManager.FindByNameAsync(clientId) == null)
|
||||
await roleManager.CreateAsync(new MongoRole(clientId));
|
||||
|
||||
// email addresses must be case-insensitive
|
||||
email = email.ToLowerInvariant();
|
||||
|
||||
if (!await emailValidator.IsEmailAddressValidAsync(email))
|
||||
return new SignupResponse
|
||||
{
|
||||
Errors = ["Invalid email address."]
|
||||
};
|
||||
|
||||
var result = await userManager.CreateAsync(new User
|
||||
{
|
||||
Email = email,
|
||||
EmailConfirmed = Constants.IS_SELF_HOSTED,
|
||||
UserName = email,
|
||||
}, password);
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var user = await userManager.FindByEmailAsync(email);
|
||||
if (user == null) return SignupResponse.Error(["User not found after creation."]);
|
||||
|
||||
await userManager.AddToRoleAsync(user, client.Id);
|
||||
if (Constants.IS_SELF_HOSTED)
|
||||
{
|
||||
await userManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (userAgent != null) await userManager.AddClaimAsync(user, new Claim("platform", PlatformFromUserAgent(userAgent)));
|
||||
var code = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.CONFRIM_EMAIL);
|
||||
if (!string.IsNullOrEmpty(user.Email) && callbackUrl != null)
|
||||
{
|
||||
await emailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
|
||||
}
|
||||
}
|
||||
|
||||
var response = await tokenGenerationService.CreateUserTokensAsync(user, client.Id, 3600);
|
||||
if (response == null) return SignupResponse.Error(["Failed to generate access token."]);
|
||||
|
||||
return new SignupResponse
|
||||
{
|
||||
AccessToken = response.AccessToken,
|
||||
AccessTokenLifetime = response.AccessTokenLifetime,
|
||||
RefreshToken = response.RefreshToken,
|
||||
Scope = response.Scope,
|
||||
UserId = user.Id.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
var otherErrors = result.Errors
|
||||
.Where(e => e.Code != "DuplicateUserName" && e.Code != "DuplicateEmail")
|
||||
.ToErrors();
|
||||
var hasDuplicate = result.Errors.Any(e => e.Code == "DuplicateUserName" || e.Code == "DuplicateEmail");
|
||||
var errors = hasDuplicate
|
||||
? ["Unable to create an account on this email.", .. otherErrors]
|
||||
: otherErrors;
|
||||
return SignupResponse.Error(errors);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to create user account for email: {Email}", email);
|
||||
return SignupResponse.Error(["Failed to create an account."]);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PlatformFromUserAgent(string? userAgent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(userAgent)) return "unknown";
|
||||
return userAgent.Contains("okhttp/") ? "android" : userAgent.Contains("Darwin/") || userAgent.Contains("CFNetwork/") ? "ios" : "web";
|
||||
}
|
||||
private static string GetHashedKey(string value, string grantType)
|
||||
{
|
||||
return (value + ":" + grantType).Sha256();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ using Streetwriters.Identity.Interfaces;
|
||||
using Streetwriters.Identity.Jobs;
|
||||
using Streetwriters.Identity.Services;
|
||||
using Streetwriters.Identity.Validation;
|
||||
using IdentityServer4.MongoDB.Configuration;
|
||||
|
||||
namespace Streetwriters.Identity
|
||||
{
|
||||
@@ -107,11 +108,6 @@ namespace Streetwriters.Identity
|
||||
options.UsersCollection = "users";
|
||||
// options.MigrationCollection = "migration";
|
||||
options.ConnectionString = connectionString;
|
||||
options.ClusterConfigurator = builder =>
|
||||
{
|
||||
builder.ConfigureConnectionPool((c) => c.With(maxConnections: 500, minConnections: 0));
|
||||
builder.ConfigureServer(s => s.With(heartbeatInterval: TimeSpan.FromSeconds(60)));
|
||||
};
|
||||
}).AddDefaultTokenProviders();
|
||||
|
||||
services.AddIdentityServer(
|
||||
@@ -137,6 +133,11 @@ namespace Streetwriters.Identity
|
||||
.AddKeyManagement()
|
||||
.AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore"));
|
||||
|
||||
services.Configure<MongoDBConfiguration>(options =>
|
||||
{
|
||||
options.ConnectionString = connectionString;
|
||||
});
|
||||
|
||||
services.Configure<DataProtectionTokenProviderOptions>(options =>
|
||||
{
|
||||
options.TokenLifespan = TimeSpan.FromHours(2);
|
||||
|
||||
@@ -504,7 +504,6 @@
|
||||
text-align: start;
|
||||
text-indent: 0px;
|
||||
text-transform: none;
|
||||
white-space: pre-wrap;
|
||||
widows: 2;
|
||||
word-spacing: 0px;
|
||||
-webkit-text-stroke-width: 0px;
|
||||
@@ -536,7 +535,6 @@
|
||||
text-align: start;
|
||||
text-indent: 0px;
|
||||
text-transform: none;
|
||||
white-space: pre-wrap;
|
||||
widows: 2;
|
||||
word-spacing: 0px;
|
||||
-webkit-text-stroke-width: 0px;
|
||||
@@ -567,7 +565,6 @@
|
||||
text-align: start;
|
||||
text-indent: 0px;
|
||||
text-transform: none;
|
||||
white-space: pre-wrap;
|
||||
widows: 2;
|
||||
word-spacing: 0px;
|
||||
-webkit-text-stroke-width: 0px;
|
||||
|
||||
@@ -309,7 +309,6 @@
|
||||
text-align: start;
|
||||
text-indent: 0px;
|
||||
text-transform: none;
|
||||
white-space: pre-wrap;
|
||||
widows: 2;
|
||||
word-spacing: 0px;
|
||||
-webkit-text-stroke-width: 0px;
|
||||
|
||||
@@ -401,7 +401,6 @@
|
||||
text-align: start;
|
||||
text-indent: 0px;
|
||||
text-transform: none;
|
||||
white-space: pre-wrap;
|
||||
word-spacing: 0px;
|
||||
-webkit-text-stroke-width: 0px;
|
||||
background-color: rgb(
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Email Confirmation Failed - Notesnook</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
background: #ffffff;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background: #fdecea;
|
||||
border-radius: 100px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-wrapper svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
fill: #c0392b;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
.body-text {
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #808080;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f0f0f0;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer-heading {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-subtext {
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<div class="icon-wrapper">
|
||||
<!-- Mail X Icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M13 19C13 18.66 13.04 18.33 13.09 18H4V8L12 13L20 8V13.09C20.72 13.21 21.39 13.46 22 13.81V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H13.09C13.04 19.67 13 19.34 13 19M20 6L12 11L4 6H20M21.12 15.46L19 17.59L16.88 15.46L15.47 16.88L17.59 19L15.47 21.12L16.88 22.54L19 20.41L21.12 22.54L22.54 21.12L20.41 19L22.54 16.88L21.12 15.46Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">Uh oh!</h1>
|
||||
<p class="subheading">Email confirmation failed. Please try again!</p>
|
||||
<p class="body-text">{{errors}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<h2 class="footer-heading">Notesnook</h2>
|
||||
<p class="footer-subtext">Privacy for everyone</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Email Confirmed - Notesnook</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
background: #ffffff;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
background: #e8f5e9;
|
||||
border-radius: 100px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-wrapper svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
fill: #008837;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
.body-text {
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #808080;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f0f0f0;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer-heading {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer-subtext {
|
||||
font-size: 1em;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
.social-icons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.social-icons a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #5b5b5b;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.social-icons a:hover svg {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.social-icons svg {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.promo-text {
|
||||
font-size: 0.85em;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
color: #5b5b5b;
|
||||
}
|
||||
|
||||
.promo-text .hashtag {
|
||||
font-weight: bold;
|
||||
color: #008837;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main">
|
||||
<div class="icon-wrapper">
|
||||
<!-- Mail Check Icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M13 19C13 18.66 13.04 18.33 13.09 18H4V8L12 13L20 8V13.09C20.72 13.21 21.39 13.46 22 13.81V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H13.09C13.04 19.67 13 19.34 13 19M20 6L12 11L4 6H20M17.75 22.16L15 19.16L16.16 18L17.75 19.59L21.34 16L22.5 17.41L17.75 22.16"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="heading">Huzzah!</h1>
|
||||
<p class="subheading">{{subheading}}</p>
|
||||
<p class="body-text">
|
||||
Thank you for choosing end-to-end encrypted note taking. Now you can
|
||||
sync your notes to unlimited devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<h2 class="footer-heading">Share Notesnook with friends!</h2>
|
||||
<p class="footer-subtext">Because where's the fun in nookin' alone?</p>
|
||||
<div class="social-icons">
|
||||
<!-- Discord -->
|
||||
<a
|
||||
href="https://discord.com/invite/zQBK97EE22"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Discord"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Twitter -->
|
||||
<a
|
||||
href="https://twitter.com/notesnook"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Twitter"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M23.953 4.57a10 10 0 0 1-2.825.775 4.958 4.958 0 0 0 2.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 0 0-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 0 0-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 0 1-2.228-.616v.06a4.923 4.923 0 0 0 3.946 4.827 4.996 4.996 0 0 1-2.212.085 4.936 4.936 0 0 0 4.604 3.417 9.867 9.867 0 0 1-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 0 0 7.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0 0 24 4.59z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<!-- Reddit -->
|
||||
<a
|
||||
href="https://reddit.com/r/Notesnook"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Reddit"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<p class="promo-text">
|
||||
Use <span class="hashtag">#notesnook</span> and get a chance to win free
|
||||
promo codes.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -467,7 +467,6 @@
|
||||
text-align: start;
|
||||
text-indent: 0px;
|
||||
text-transform: none;
|
||||
white-space: pre-wrap;
|
||||
widows: 2;
|
||||
word-spacing: 0px;
|
||||
-webkit-text-stroke-width: 0px;
|
||||
@@ -483,9 +482,7 @@
|
||||
display: inline;
|
||||
"
|
||||
><em
|
||||
>If you did not request to reset
|
||||
your account password, you can
|
||||
safely ignore this email.</em
|
||||
>If you did not request to reset your account password, you can safely ignore this email.</em
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
@@ -554,7 +551,6 @@
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
.
|
||||
<td
|
||||
style="
|
||||
padding: 18px 0px 18px 0px;
|
||||
|
||||
@@ -27,8 +27,6 @@ FROM build AS publish
|
||||
RUN dotnet publish -c Release -o /app/publish \
|
||||
#--runtime alpine-x64 \
|
||||
--self-contained true \
|
||||
/p:TrimMode=partial \
|
||||
/p:PublishTrimmed=true \
|
||||
/p:PublishSingleFile=true \
|
||||
/p:JsonSerializerIsReflectionEnabledByDefault=true \
|
||||
-a $TARGETARCH
|
||||
|
||||
@@ -18,28 +18,49 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Lib.AspNetCore.ServerSentEvents;
|
||||
using System.Security.Claims;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Streetwriters.Messenger.Helpers
|
||||
{
|
||||
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);
|
||||
foreach (var client in clients)
|
||||
{
|
||||
if (originTokenId != null && client.User.FindFirstValue("jti") == originTokenId) continue;
|
||||
if (!client.IsConnected) continue;
|
||||
await client.SendEventAsync(data);
|
||||
}
|
||||
var clients = sseService.GetClients()
|
||||
.Where(c => c.User?.FindFirstValue("sub") == userId)
|
||||
.Where(c => originTokenId == null || c.User?.FindFirstValue("jti") != originTokenId);
|
||||
|
||||
await SendEventToClientsAsync(clients, data, cancellationToken);
|
||||
}
|
||||
|
||||
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.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Lib.AspNetCore.ServerSentEvents;
|
||||
using Streetwriters.Messenger.Helpers;
|
||||
using System.Text.Json;
|
||||
@@ -33,12 +34,14 @@ namespace Streetwriters.Messenger.Services
|
||||
private const string HEARTBEAT_MESSAGE_FORMAT = "Streetwriters Heartbeat ({0} UTC)";
|
||||
|
||||
private readonly IServerSentEventsService _serverSentEventsService;
|
||||
private readonly ILogger<HeartbeatService> _logger;
|
||||
#endregion
|
||||
|
||||
#region Constructor
|
||||
public HeartbeatService(IServerSentEventsService serverSentEventsService)
|
||||
public HeartbeatService(IServerSentEventsService serverSentEventsService, ILogger<HeartbeatService> logger)
|
||||
{
|
||||
_serverSentEventsService = serverSentEventsService;
|
||||
_logger = logger;
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -47,15 +50,28 @@ namespace Streetwriters.Messenger.Services
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var message = JsonSerializer.Serialize(new
|
||||
try
|
||||
{
|
||||
type = "heartbeat",
|
||||
data = JsonSerializer.Serialize(new
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||
})
|
||||
});
|
||||
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService);
|
||||
type = "heartbeat",
|
||||
data = JsonSerializer.Serialize(new
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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"
|
||||
NoWarn="NU1605" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.0"
|
||||
|
||||
+96
-5
@@ -39,7 +39,7 @@ function isValidUrl(urlString: string): boolean {
|
||||
// Handle proxied request with redirect support
|
||||
async function proxyRequest(
|
||||
targetUrl: string,
|
||||
redirectCount = 0
|
||||
redirectCount = 0,
|
||||
): Promise<Response> {
|
||||
if (redirectCount >= MAX_REDIRECTS) {
|
||||
return new Response("Too many redirects", {
|
||||
@@ -147,7 +147,7 @@ const server = Bun.serve({
|
||||
method2: "GET /?url=<encoded-url>",
|
||||
example1: `${url.origin}/https://example.com/image.jpg`,
|
||||
example2: `${url.origin}/?url=${encodeURIComponent(
|
||||
"https://example.com/image.jpg"
|
||||
"https://example.com/image.jpg",
|
||||
)}`,
|
||||
},
|
||||
endpoints: {
|
||||
@@ -190,7 +190,7 @@ const server = Bun.serve({
|
||||
{
|
||||
status: 400,
|
||||
headers: corsHeaders,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,21 @@ const server = Bun.serve({
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy the request
|
||||
// Check if it's a YouTube URL and redirect instead of proxying
|
||||
if (isYouTubeEmbed(targetUrl)) {
|
||||
// YouTube URL detected, redirect to youtube-nocookie.com
|
||||
logRequest(req.method, targetUrl, 200);
|
||||
return new Response(serveYouTubeEmbed(targetUrl), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
// "Content-Security-Policy": "frame-ancestors *",
|
||||
// "X-Frame-Options": "ALLOWALL",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Proxy the request for non-YouTube URLs
|
||||
const response = await proxyRequest(targetUrl);
|
||||
logRequest(req.method, targetUrl, response.status);
|
||||
return response;
|
||||
@@ -225,7 +239,84 @@ const server = Bun.serve({
|
||||
});
|
||||
|
||||
console.log(
|
||||
`🚀 CORS Proxy Server running on http://${server.hostname}:${server.port}`
|
||||
`🚀 CORS Proxy Server running on http://${server.hostname}:${server.port}`,
|
||||
);
|
||||
console.log(`📋 Health check: http://${server.hostname}:${server.port}/health`);
|
||||
console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`);
|
||||
|
||||
/**
|
||||
* This is required to bypass YouTube's Referrer Policy restrictions when
|
||||
* embedding videos on the mobile app. It basically "proxies" the Referrer and
|
||||
* allows any YouTube video to be embedded anywhere without restrictions.
|
||||
*/
|
||||
function serveYouTubeEmbed(url: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="strict-origin-when-cross-origin">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>YouTube Video Embed</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing:border-box
|
||||
}
|
||||
|
||||
body, html {
|
||||
overflow: hidden;
|
||||
background:#000
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="${transformYouTubeUrl(
|
||||
url,
|
||||
)}" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin" title="Video player"></iframe>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Check if URL is a YouTube embed (including youtube-nocookie.com)
|
||||
function isYouTubeEmbed(urlString: string) {
|
||||
const url = new URL(urlString);
|
||||
return (
|
||||
(url.hostname === "www.youtube.com" ||
|
||||
url.hostname === "youtube.com" ||
|
||||
url.hostname === "m.youtube.com" ||
|
||||
url.hostname === "www.youtube-nocookie.com" ||
|
||||
url.hostname === "youtube-nocookie.com") &&
|
||||
url.pathname.startsWith("/embed/")
|
||||
);
|
||||
}
|
||||
|
||||
// Transform YouTube URLs to use youtube-nocookie.com for enhanced privacy
|
||||
function transformYouTubeUrl(urlString: string): string {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
|
||||
// Check if it's a YouTube domain
|
||||
if (
|
||||
url.hostname === "www.youtube.com" ||
|
||||
url.hostname === "youtube.com" ||
|
||||
url.hostname === "m.youtube.com"
|
||||
) {
|
||||
// Replace with youtube-nocookie.com
|
||||
url.hostname = "www.youtube-nocookie.com";
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return urlString;
|
||||
} catch {
|
||||
return urlString;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user