Compare commits

...
Author SHA1 Message Date
Abdullah Atta 8116ce70e4 ci: publish inbox api docker image 2026-03-31 13:58:58 +05:00
01zulfiandGitHub 2f8b0ad607 identity: validate disposable email before sending 'email change' mail (#87) 2026-03-30 14:06:11 +05:00
1c5bcd6eff inbox: add health endpoint & docs (#64)
* inbox: add health endpoint && docs

* inbox: update post endpoint uri

* inbox: update docs & dockerfile

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-03-27 14:20:42 +05:00
Abdullah Atta 3a2a04317f inbox: add scripts for testing inbox api locally 2026-03-27 14:11:15 +05:00
8d92aff8cd inbox: use pgp encryption (#70)
* inbox: use pgp encryption && other fixes
* fix inbox key last used at time
* remove inbox items if keys change or same item id syncs

* inbox:update inbox sync item
* rename item field to sync
* add alg field

* sync: delete inbox items after commit succeeds

* user: merge if conditions

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-03-27 14:06:29 +05:00
4bc1469dfe monographs: add slug field which regenerates on republish (#72)
* monographs: add slug field which regenerates on update

* monographs: don't regenerate slug on update

* common: fix monograph public url constant

* monographs: improve APIs && use .Project when fetching monographs
* create separate endpoint for fetching monographs by slug
* combine analytics and publish-url endpoint into a publish-info endpoint

* monographs: reinstate analytics endpoint

* common: add missing monograph constant

* monograph: refactoring

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-03-26 23:14:20 +05:00
Abdullah Atta da58262afb cors: remove XFO header 2026-03-25 20:46:54 +05:00
Abdullah Atta 864baa702b common: add package name to clients 2026-03-25 11:11:07 +05:00
Abdullah Atta 55c5cd0a7c cors: fix youtube embeds not working on mobile 2026-03-20 12:35:36 +05:00
Abdullah Atta 077d411fc7 sync: use w1 write concern for device_ids_chunks collection 2026-03-15 22:09:42 +05:00
Abdullah Atta 1cecfe4b3c s3: disable bulk deletion (temporarily) 2026-03-12 10:24:24 +05:00
Abdullah Atta b8a7bd16a6 sync: add redis backplane for signalr 2026-03-09 11:49:48 +05:00
Abdullah AttaandAbdullah Atta fe7c546d9b monograph: fix typo 2026-02-25 15:43:35 +05:00
Abdullah AttaandAbdullah Atta 8d4336d1bc monograph: fix monograph content sanitization 2026-02-25 15:43:35 +05:00
Abdullah AttaandAbdullah Atta 9ae5db378d identity: simplify user sign up 2026-02-16 13:43:04 +05:00
Abdullah AttaandAbdullah Atta d5790d8785 api: minor refactor 2026-02-16 13:43:04 +05:00
Abdullah AttaandAbdullah Atta 9424afed68 api: move to atomic password reset 2026-02-16 13:43:04 +05:00
01zulfiandGitHub b9385ae112 s3: add bulk delete api (#82) 2026-02-13 11:29:15 +05:00
51 changed files with 995 additions and 489 deletions
+4
View File
@@ -36,6 +36,10 @@ jobs:
- image: streetwriters/sse - image: streetwriters/sse
file: ./Streetwriters.Messenger/Dockerfile file: ./Streetwriters.Messenger/Dockerfile
context: . context: .
- image: streetwriters/notesnook-inbox
file: ./Notesnook.Inbox.API/Dockerfile
context: ./Notesnook.Inbox.API/
permissions: permissions:
packages: write packages: write
contents: read contents: read
@@ -78,7 +78,7 @@ namespace Notesnook.API.Authorization
return AuthenticateResult.Fail("API key has expired"); 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); await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey);
var claims = new[] var claims = new[]
+4 -20
View File
@@ -151,34 +151,18 @@ namespace Notesnook.API.Controllers
var userId = User.GetUserId(); var userId = User.GetUserId();
try 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." }); return BadRequest(new { error = "Inbox item algorithm 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." });
} }
if (request.Version <= 0) if (request.Version <= 0)
{ {
return BadRequest(new { error = "Valid inbox item version is required." }); 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.UserId = userId;
request.ItemId = ObjectId.GenerateNewId().ToString(); request.ItemId = ObjectId.GenerateNewId().ToString();
+107 -30
View File
@@ -18,27 +18,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp; using AngleSharp;
using AngleSharp.Dom;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Authorization; using NanoidDotNet;
using Notesnook.API.Extensions;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Services; using Notesnook.API.Services;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Helpers; using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces; using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages; using Streetwriters.Common.Messages;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories; using Streetwriters.Data.Repositories;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
@@ -46,7 +46,7 @@ namespace Notesnook.API.Controllers
[ApiController] [ApiController]
[Route("monographs")] [Route("monographs")]
[Authorize("Sync")] [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>"; 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; private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
@@ -68,13 +68,15 @@ namespace Notesnook.API.Controllers
); );
} }
private static FilterDefinition<Monograph> CreateMonographFilter(string itemId) private static FilterDefinition<Monograph> CreateMonographFilter(string itemIdOrSlug)
{ {
return ObjectId.TryParse(itemId, out ObjectId id) return ObjectId.TryParse(itemIdOrSlug, out ObjectId id)
? Builders<Monograph>.Filter.Or( ? Builders<Monograph>.Filter.Or(
Builders<Monograph>.Filter.Eq("_id", id), Builders<Monograph>.Filter.Eq("_id", id),
Builders<Monograph>.Filter.Eq("ItemId", itemId)) Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug))
: Builders<Monograph>.Filter.Eq("ItemId", itemId); : Builders<Monograph>.Filter.Or(
Builders<Monograph>.Filter.Eq("Slug", itemIdOrSlug),
Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug));
} }
private async Task<Monograph> FindMonographAsync(string userId, Monograph monograph) private async Task<Monograph> FindMonographAsync(string userId, Monograph monograph)
@@ -86,15 +88,20 @@ namespace Notesnook.API.Controllers
return await result.FirstOrDefaultAsync(); return await result.FirstOrDefaultAsync();
} }
private async Task<Monograph> FindMonographAsync(string itemId) private async Task<Monograph> FindMonographAsync(string itemIdOrSlug)
{ {
var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions<Monograph> var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemIdOrSlug), new FindOptions<Monograph>
{ {
Limit = 1 Limit = 1
}); });
return await result.FirstOrDefaultAsync(); return await result.FirstOrDefaultAsync();
} }
private static string GenerateSlug()
{
return Nanoid.Generate(size: 24);
}
[HttpPost] [HttpPost]
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph) public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
{ {
@@ -107,7 +114,11 @@ namespace Notesnook.API.Controllers
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph); if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
if (monograph.EncryptedContent == null) if (monograph.EncryptedContent == null)
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli(); {
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
monograph.ContentSanitizationLevel = sanitizationLevel;
}
monograph.UserId = userId; monograph.UserId = userId;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -120,6 +131,7 @@ namespace Notesnook.API.Controllers
} }
monograph.Deleted = false; monograph.Deleted = false;
monograph.ViewCount = 0; monograph.ViewCount = 0;
monograph.Slug = GenerateSlug();
await monographs.Collection.ReplaceOneAsync( await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph), CreateMonographFilter(userId, monograph),
monograph, monograph,
@@ -131,7 +143,8 @@ namespace Notesnook.API.Controllers
return Ok(new return Ok(new
{ {
id = monograph.ItemId, id = monograph.ItemId,
datePublished = monograph.DatePublished datePublished = monograph.DatePublished,
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph)
}); });
} }
catch (Exception e) catch (Exception e)
@@ -158,8 +171,12 @@ namespace Notesnook.API.Controllers
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE) 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."); return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
var sanitizationLevel = ContentSanitizationLevel.Unknown;
if (monograph.EncryptedContent == null) 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 else
monograph.Content = null; monograph.Content = null;
@@ -173,6 +190,7 @@ namespace Notesnook.API.Controllers
.Set(m => m.SelfDestruct, monograph.SelfDestruct) .Set(m => m.SelfDestruct, monograph.SelfDestruct)
.Set(m => m.Title, monograph.Title) .Set(m => m.Title, monograph.Title)
.Set(m => m.Password, monograph.Password) .Set(m => m.Password, monograph.Password)
.Set(m => m.ContentSanitizationLevel, sanitizationLevel)
); );
if (!result.IsAcknowledged) return BadRequest(); if (!result.IsAcknowledged) return BadRequest();
@@ -181,7 +199,8 @@ namespace Notesnook.API.Controllers
return Ok(new return Ok(new
{ {
id = monograph.ItemId, id = monograph.ItemId,
datePublished = monograph.DatePublished datePublished = monograph.DatePublished,
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(existingMonograph)
}); });
} }
catch (Exception e) catch (Exception e)
@@ -213,7 +232,7 @@ namespace Notesnook.API.Controllers
public async Task<IActionResult> GetMonographAsync([FromRoute] string id) public async Task<IActionResult> GetMonographAsync([FromRoute] string id)
{ {
var monograph = await FindMonographAsync(id); var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted) if (monograph == null || monograph.Deleted || (monograph.Slug != null && monograph.Slug != id))
{ {
return NotFound(new return NotFound(new
{ {
@@ -223,7 +242,22 @@ namespace Notesnook.API.Controllers
} }
if (monograph.EncryptedContent == null) 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.Content = monograph.CompressedContent?.DecompressBrotli();
}
monograph.ItemId ??= monograph.Id; monograph.ItemId ??= monograph.Id;
return Ok(monograph); return Ok(monograph);
} }
@@ -233,7 +267,8 @@ namespace Notesnook.API.Controllers
public async Task<IActionResult> TrackView([FromRoute] string id) public async Task<IActionResult> TrackView([FromRoute] string id)
{ {
var monograph = await FindMonographAsync(id); var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted) return Content(SVG_PIXEL, "image/svg+xml"); if (monograph == null || monograph.Deleted || (monograph.Slug != null && monograph.Slug != id))
return Content(SVG_PIXEL, "image/svg+xml");
var cookieName = $"viewed_{id}"; var cookieName = $"viewed_{id}";
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName); var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
@@ -241,7 +276,7 @@ namespace Notesnook.API.Controllers
if (monograph.SelfDestruct) if (monograph.SelfDestruct)
{ {
await monographs.Collection.ReplaceOneAsync( await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(monograph.UserId, monograph), CreateMonographFilter(monograph.UserId!, monograph),
new Monograph new Monograph
{ {
ItemId = id, ItemId = id,
@@ -251,12 +286,12 @@ namespace Notesnook.API.Controllers
ViewCount = 0 ViewCount = 0
} }
); );
await MarkMonographForSyncAsync(monograph.UserId, id); await MarkMonographForSyncAsync(monograph.UserId!, id);
} }
else if (!hasVisitedBefore) else if (!hasVisitedBefore)
{ {
await monographs.Collection.UpdateOneAsync( await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId, monograph), CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1) Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
); );
@@ -274,6 +309,7 @@ namespace Notesnook.API.Controllers
} }
[HttpGet("{id}/analytics")] [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) public async Task<IActionResult> GetMonographAnalyticsAsync([FromRoute] string id)
{ {
if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User)) if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User))
@@ -317,6 +353,29 @@ namespace Notesnook.API.Controllers
return Ok(); 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) private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
{ {
if (deviceId == null) return; if (deviceId == null) return;
@@ -329,7 +388,20 @@ namespace Notesnook.API.Controllers
await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(monographId, "monograph")]); 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<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
{ {
if (string.IsNullOrEmpty(content)) return string.Empty; if (string.IsNullOrEmpty(content)) return string.Empty;
if (Constants.IS_SELF_HOSTED) return content; if (Constants.IS_SELF_HOSTED) return content;
@@ -338,31 +410,36 @@ namespace Notesnook.API.Controllers
var json = JsonSerializer.Deserialize<MonographContent>(content) ?? throw new Exception("Invalid monograph content."); var json = JsonSerializer.Deserialize<MonographContent>(content) ?? throw new Exception("Invalid monograph content.");
var html = json.Data; var html = json.Data;
if (user.IsUserSubscribed()) if (level == ContentSanitizationLevel.Partial)
{ {
var config = Configuration.Default.WithDefaultLoader(); var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config); var context = BrowsingContext.New(config);
var document = await context.OpenAsync(r => r.Content(html)); 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"); foreach (var element in document.QuerySelectorAll(selector))
if (string.IsNullOrEmpty(href)) continue;
if (!await analyzer.IsURLSafeAsync(href))
{ {
logger.LogInformation("Malicious URL detected: {Url}", href); var url = element.GetAttribute(attribute);
element.RemoveAttribute("href"); 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(); html = document.ToHtml();
} }
else else if (level == ContentSanitizationLevel.Full)
{ {
var config = Configuration.Default.WithDefaultLoader(); var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config); var context = BrowsingContext.New(config);
var document = await context.OpenAsync(r => r.Content(html)); var document = await context.OpenAsync(r => r.Content(html));
foreach (var element in document.QuerySelectorAll("a,iframe,img,object,svg,button,link")) 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); element.RemoveAttribute(attr.Name);
} }
html = document.ToHtml(); html = document.ToHtml();
+21 -3
View File
@@ -21,20 +21,17 @@ using System;
using System.Net.Http; using System.Net.Http;
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3.Model;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers; using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors; using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions; using Streetwriters.Common.Extensions;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
@@ -212,5 +209,26 @@ namespace Notesnook.API.Controllers
return BadRequest(new { error = "Failed to delete attachment." }); 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." });
// }
// }
} }
} }
+46 -4
View File
@@ -18,7 +18,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System;
using System.Net.Http;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Timeouts; using Microsoft.AspNetCore.Http.Timeouts;
@@ -28,22 +30,25 @@ using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses; using Notesnook.API.Models.Responses;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
{ {
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("users")] [Route("users")]
public class UsersController(IUserService UserService, ILogger<UsersController> logger) : ControllerBase public class UsersController(IUserService UserService, WampServiceAccessor serviceAccessor, ILogger<UsersController> logger) : ControllerBase
{ {
[HttpPost] [HttpPost]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> Signup() public async Task<IActionResult> Signup([FromForm] SignupForm form)
{ {
try try
{ {
await UserService.CreateUserAsync(); return Ok(await UserService.CreateUserAsync(form));
return Ok();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -85,6 +90,43 @@ namespace Notesnook.API.Controllers
} }
} }
[HttpPatch("password/{type}")]
public async Task<IActionResult> ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form)
{
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")] [HttpPost("reset")]
public async Task<IActionResult> Reset([FromForm] bool removeAttachments) public async Task<IActionResult> Reset([FromForm] bool removeAttachments)
{ {
+42
View File
@@ -0,0 +1,42 @@
/*
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)
{
return ConstructPublishUrl(monograph.Slug ?? monograph.ItemId ?? monograph.Id);
}
public static string ConstructPublishUrl(MonographMetadata metadata)
{
return ConstructPublishUrl(metadata.PublishUrl ?? metadata.ItemId);
}
}
}
+27 -9
View File
@@ -32,6 +32,8 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Authorization; using Notesnook.API.Authorization;
using Notesnook.API.Extensions;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Services; using Notesnook.API.Services;
@@ -130,13 +132,19 @@ namespace Notesnook.API.Hubs
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
try try
{ {
var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}."); var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}.");
UpsertItems(pushItem.Items, userId, 1); UpsertItems(pushItem.Items, userId, 1);
if (!await unit.Commit()) return 0; if (!await unit.Commit()) return 0;
await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type))); 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; return 1;
} }
finally finally
@@ -275,15 +283,25 @@ namespace Notesnook.API.Hubs
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds) 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, p.PublishUrl = UrlHelper.ConstructPublishUrl(p);
Deleted = m.Deleted, return p;
Password = m.Password, }).ToList();
SelfDestruct = m.SelfDestruct,
Title = m.Title,
ItemId = m.ItemId ?? m.Id.ToString()
}).ToListAsync();
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10))) if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
throw new HubException("Client rejected monographs."); throw new HubException("Client rejected monographs.");
+1 -3
View File
@@ -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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3.Model; using Amazon.S3.Model;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces namespace Notesnook.API.Interfaces
{ {
public interface IS3Service public interface IS3Service
{ {
Task DeleteObjectAsync(string userId, string name); Task DeleteObjectAsync(string userId, string name);
Task DeleteObjectsAsync(string userId, string[] names);
Task DeleteDirectoryAsync(string userId); Task DeleteDirectoryAsync(string userId);
Task<long> GetObjectSizeAsync(string userId, string name); Task<long> GetObjectSizeAsync(string userId, string name);
Task<string?> GetUploadObjectUrlAsync(string userId, string name); Task<string?> GetUploadObjectUrlAsync(string userId, string name);
+2 -1
View File
@@ -20,12 +20,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Threading.Tasks; using System.Threading.Tasks;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses; using Notesnook.API.Models.Responses;
using Streetwriters.Common.Models;
namespace Notesnook.API.Interfaces namespace Notesnook.API.Interfaces
{ {
public interface IUserService public interface IUserService
{ {
Task CreateUserAsync(); Task<SignupResponse> CreateUserAsync(SignupForm form);
Task DeleteUserAsync(string userId); Task DeleteUserAsync(string userId);
Task DeleteUserAsync(string userId, string? jti, string password); Task DeleteUserAsync(string userId, string? jti, string password);
Task<bool> ResetUserAsync(string userId, bool removeAttachments); Task<bool> ResetUserAsync(string userId, bool removeAttachments);
-1
View File
@@ -22,6 +22,5 @@ namespace Notesnook.API.Models
public class Algorithms public class Algorithms
{ {
public static string Default => "xcha-argon2i13-7"; 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
}
}
+25
View File
@@ -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; }
}
+46 -28
View File
@@ -20,46 +20,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Notesnook.API.Models namespace Notesnook.API.Models
{ {
[MessagePack.MessagePackObject] [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")] [DataMember(Name = "cipher")]
[JsonPropertyName("cipher")] [JsonPropertyName("cipher")]
[MessagePack.Key("cipher")] [MessagePack.Key("cipher")]
[Required] [Required]
public required string Cipher { get; set; } public string Cipher
{
get; set;
}
[JsonPropertyName("length")] [DataMember(Name = "userId")]
[DataMember(Name = "length")] [JsonPropertyName("userId")]
[MessagePack.Key("length")] [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] [Required]
public long Length public double Version
{
get; set;
}
[JsonPropertyName("alg")]
[DataMember(Name = "alg")]
[MessagePack.Key("alg")]
[Required]
public string Algorithm
{ {
get; set; get; set;
} }
+6
View File
@@ -56,6 +56,9 @@ namespace Notesnook.API.Models
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string? Title { get; set; } public string? Title { get; set; }
[JsonPropertyName("slug")]
public string? Slug { get; set; }
[JsonPropertyName("userId")] [JsonPropertyName("userId")]
public string? UserId { get; set; } public string? UserId { get; set; }
@@ -83,5 +86,8 @@ namespace Notesnook.API.Models
[JsonPropertyName("viewCount")] [JsonPropertyName("viewCount")]
public int ViewCount { get; set; } public int ViewCount { get; set; }
[JsonIgnore]
public ContentSanitizationLevel ContentSanitizationLevel { get; set; }
} }
} }
+3 -2
View File
@@ -19,8 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Runtime.Serialization; using System.Runtime.Serialization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Notesnook.API.Models namespace Notesnook.API.Models
{ {
@@ -37,6 +35,9 @@ namespace Notesnook.API.Models
[JsonPropertyName("title")] [JsonPropertyName("title")]
public string? Title { get; set; } public string? Title { get; set; }
[JsonPropertyName("publishUrl")]
public string? PublishUrl { get; set; }
[JsonPropertyName("selfDestruct")] [JsonPropertyName("selfDestruct")]
public bool SelfDestruct { get; set; } public bool SelfDestruct { get; set; }
+19
View File
@@ -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")] [JsonPropertyName("monographPasswordsKey")]
public EncryptedData? MonographPasswordsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; }
[JsonPropertyName("dataEncryptionKey")]
public EncryptedData? DataEncryptionKey { get; set; }
[JsonPropertyName("legacyDataEncryptionKey")]
public EncryptedData? LegacyDataEncryptionKey { get; set; }
[JsonPropertyName("inboxKeys")] [JsonPropertyName("inboxKeys")]
public InboxKeys? InboxKeys { get; set; } public InboxKeys? InboxKeys { get; set; }
+8
View File
@@ -98,6 +98,14 @@ namespace Notesnook.API.Models
get; set; get; set;
} }
[JsonPropertyName("keyVersion")]
[DataMember(Name = "keyVersion")]
[MessagePack.Key("keyVersion")]
public int? KeyVersion
{
get; set;
}
[JsonPropertyName("alg")] [JsonPropertyName("alg")]
[DataMember(Name = "alg")] [DataMember(Name = "alg")]
[MessagePack.Key("alg")] [MessagePack.Key("alg")]
+2
View File
@@ -24,6 +24,8 @@ namespace Notesnook.API.Models
public EncryptedData? AttachmentsKey { get; set; } public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; }
public InboxKeys? InboxKeys { get; set; } public InboxKeys? InboxKeys { get; set; }
public EncryptedData? DataEncryptionKey { get; set; }
public EncryptedData? LegacyDataEncryptionKey { get; set; }
} }
public class InboxKeys public class InboxKeys
+2
View File
@@ -55,6 +55,8 @@ namespace Notesnook.API.Models
public EncryptedData? VaultKey { get; set; } public EncryptedData? VaultKey { get; set; }
public EncryptedData? AttachmentsKey { get; set; } public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; } public EncryptedData? MonographPasswordsKey { get; set; }
public EncryptedData? DataEncryptionKey { get; set; }
public EncryptedData? LegacyDataEncryptionKey { get; set; }
public InboxKeys? InboxKeys { get; set; } public InboxKeys? InboxKeys { get; set; }
public Limit? StorageLimit { get; set; } public Limit? StorageLimit { get; set; }
+1
View File
@@ -17,6 +17,7 @@
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="6.0.1-rc2.2" /> <PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="6.0.1-rc2.2" />
<PackageReference Include="AWSSDK.S3" Version="3.7.310.8" /> <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.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="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.2.0" />
<PackageReference Include="Nanoid" Version="3.1.0" /> <PackageReference Include="Nanoid" Version="3.1.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-alpha.2" /> <PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-alpha.2" />
@@ -49,7 +49,7 @@ namespace Notesnook.API.Repositories
this.logger = logger; 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) private bool IsValidAlgorithm(string algorithm)
{ {
return ALGORITHMS.Contains(algorithm); return ALGORITHMS.Contains(algorithm);
+64 -6
View File
@@ -24,21 +24,15 @@ using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon; using Amazon;
using Amazon.Runtime;
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers; using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors; using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Services namespace Notesnook.API.Services
{ {
@@ -110,6 +104,70 @@ namespace Notesnook.API.Services
throw new Exception("Could not delete object."); 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) public async Task DeleteDirectoryAsync(string userId)
{ {
var request = new ListObjectsV2Request var request = new ListObjectsV2Request
+4 -4
View File
@@ -82,7 +82,7 @@ namespace Notesnook.API.Services
if (chunk != null) if (chunk != null)
{ {
var update = Builders<DeviceIdsChunk>.Update.AddToSetEach(x => x.Ids, ids.Select(i => i.ToString())); var update = Builders<DeviceIdsChunk>.Update.AddToSetEach(x => x.Ids, ids.Select(i => i.ToString()));
await repositories.DeviceIdsChunks.Collection.UpdateOneAsync( await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).UpdateOneAsync(
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id), Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
update update
); );
@@ -96,11 +96,11 @@ namespace Notesnook.API.Services
Key = key, Key = key,
Ids = [.. ids.Select(i => i.ToString())] 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); 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) 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)); 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) public async Task<HashSet<ItemKey>> FetchUnsyncedIdsAsync(string userId, string deviceId)
+18 -5
View File
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -50,15 +51,16 @@ namespace Notesnook.API.Services
private IS3Service S3Service { get; set; } = s3Service; private IS3Service S3Service { get; set; } = s3Service;
private readonly IUnitOfWork unit = unitOfWork; 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); SignupResponse response = await serviceAccessor.UserAccountService.CreateUserAsync(form.ClientId, form.Email, form.Password, HttpContextAccessor.HttpContext?.Request.Headers["User-Agent"].ToString());
if (!response.Success || (response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
if ((response.Errors != null && response.Errors.Length > 0) || response.UserId == null)
{ {
logger.LogError("Failed to sign up user: {Response}", JsonSerializer.Serialize(response)); logger.LogError("Failed to sign up user: {Response}", JsonSerializer.Serialize(response));
if (response.Errors != null && response.Errors.Length > 0) if (response.Errors != null && response.Errors.Length > 0)
throw new Exception(string.Join(" ", response.Errors)); 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 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) public async Task<UserResponse> GetUserAsync(string userId)
@@ -133,6 +135,8 @@ namespace Notesnook.API.Services
PhoneNumber = user.PhoneNumber, PhoneNumber = user.PhoneNumber,
AttachmentsKey = userSettings.AttachmentsKey, AttachmentsKey = userSettings.AttachmentsKey,
MonographPasswordsKey = userSettings.MonographPasswordsKey, MonographPasswordsKey = userSettings.MonographPasswordsKey,
DataEncryptionKey = userSettings.DataEncryptionKey,
LegacyDataEncryptionKey = userSettings.LegacyDataEncryptionKey,
InboxKeys = userSettings.InboxKeys, InboxKeys = userSettings.InboxKeys,
Salt = userSettings.Salt, Salt = userSettings.Salt,
Subscription = subscription, Subscription = subscription,
@@ -155,6 +159,11 @@ namespace Notesnook.API.Services
{ {
userSettings.MonographPasswordsKey = keys.MonographPasswordsKey; 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 != null)
{ {
if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null) if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null)
@@ -175,6 +184,8 @@ namespace Notesnook.API.Services
}; };
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey); await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
} }
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
} }
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings); await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
@@ -268,6 +279,8 @@ namespace Notesnook.API.Services
userSettings.AttachmentsKey = null; userSettings.AttachmentsKey = null;
userSettings.MonographPasswordsKey = null; userSettings.MonographPasswordsKey = null;
userSettings.DataEncryptionKey = null;
userSettings.LegacyDataEncryptionKey = null;
userSettings.VaultKey = null; userSettings.VaultKey = null;
userSettings.InboxKeys = null; userSettings.InboxKeys = null;
userSettings.LastSynced = 0; userSettings.LastSynced = 0;
+4 -1
View File
@@ -210,13 +210,16 @@ namespace Notesnook.API
services.AddHealthChecks(); services.AddHealthChecks();
services.AddSignalR((hub) => var signalR = services.AddSignalR((hub) =>
{ {
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024; hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
hub.ClientTimeoutInterval = TimeSpan.FromMinutes(10); hub.ClientTimeoutInterval = TimeSpan.FromMinutes(10);
hub.EnableDetailedErrors = true; hub.EnableDetailedErrors = true;
}).AddMessagePackProtocol().AddJsonProtocol(); }).AddMessagePackProtocol().AddJsonProtocol();
if (!string.IsNullOrEmpty(Constants.SIGNALR_REDIS_CONNECTION_STRING))
signalR.AddStackExchangeRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING);
services.AddResponseCompression(options => services.AddResponseCompression(options =>
{ {
options.EnableForHttps = true; options.EnableForHttps = true;
+1 -1
View File
@@ -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 RUN mkdir -p /home/bun/app && chown -R bun:bun /home/bun/app
+68
View File
@@ -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.
+3 -5
View File
@@ -6,7 +6,7 @@
"dependencies": { "dependencies": {
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.1.0", "express-rate-limit": "^8.1.0",
"libsodium-wrappers-sumo": "^0.7.15", "openpgp": "^6.2.2",
"zod": "^4.1.9", "zod": "^4.1.9",
}, },
"devDependencies": { "devDependencies": {
@@ -116,10 +116,6 @@
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "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=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "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=="], "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=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
+1 -1
View File
@@ -22,7 +22,7 @@
"dependencies": { "dependencies": {
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.1.0", "express-rate-limit": "^8.1.0",
"libsodium-wrappers-sumo": "^0.7.15", "openpgp": "^6.2.2",
"zod": "^4.1.9" "zod": "^4.1.9"
}, },
"devDependencies": { "devDependencies": {
+5
View File
@@ -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"
+18
View File
@@ -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());
+37 -72
View File
@@ -1,15 +1,13 @@
import express from "express"; import express from "express";
import _sodium, { base64_variants } from "libsodium-wrappers-sumo";
import { z } from "zod"; import { z } from "zod";
import { rateLimit } from "express-rate-limit"; import { rateLimit } from "express-rate-limit";
import * as openpgp from "openpgp";
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL; const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
if (!NOTESNOOK_API_SERVER_URL) { if (!NOTESNOOK_API_SERVER_URL) {
throw new Error("NOTESNOOK_API_SERVER_URL is not defined"); throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
} }
let sodium: typeof _sodium;
const RawInboxItemSchema = z.object({ const RawInboxItemSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
pinned: z.boolean().optional(), pinned: z.boolean().optional(),
@@ -31,62 +29,31 @@ const RawInboxItemSchema = z.object({
interface EncryptedInboxItem { interface EncryptedInboxItem {
v: 1; v: 1;
key: Omit<EncryptedInboxItem, "key" | "iv" | "v" | "salt">;
iv: string;
alg: string;
cipher: string; cipher: string;
length: number; alg: string;
salt: string;
} }
function encrypt(rawData: string, publicKey: string): EncryptedInboxItem { /**
try { * Encrypts raw data using OpenPGP with the recipient's public key
const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen(); *
const saltBytes = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); * @param {string} rawData - The plaintext data to encrypt
const key = sodium.crypto_pwhash( * @param {string} rawPublicKey - The recipient's OpenPGP public key
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES, */
password, async function encrypt(
saltBytes, rawData: string,
3, // operations limit rawPublicKey: string,
1024 * 1024 * 8, // memory limit (8MB) ): Promise<EncryptedInboxItem> {
sodium.crypto_pwhash_ALG_ARGON2I13 const publicKey = await openpgp.readKey({ armoredKey: rawPublicKey });
); const message = await openpgp.createMessage({ text: rawData });
const nonce = sodium.randombytes_buf( const encrypted = await openpgp.encrypt({
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES message,
); encryptionKeys: publicKey,
const data = sodium.from_string(rawData); });
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( return {
data, v: 1,
null, cipher: encrypted,
null, alg: "pgp-aes256",
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}`);
}
} }
async function getInboxPublicEncryptionKey(apiKey: string) { async function getInboxPublicEncryptionKey(apiKey: string) {
@@ -96,11 +63,11 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
headers: { headers: {
Authorization: apiKey, Authorization: apiKey,
}, },
} },
); );
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(
`failed to fetch inbox public encryption key: ${await response.text()}` `failed to fetch inbox public encryption key: ${await response.text()}`,
); );
} }
@@ -110,7 +77,7 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
async function postEncryptedInboxItem( async function postEncryptedInboxItem(
apiKey: string, apiKey: string,
item: EncryptedInboxItem item: EncryptedInboxItem,
) { ) {
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, { const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, {
method: "POST", method: "POST",
@@ -131,9 +98,12 @@ app.use(
rateLimit({ rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute windowMs: 1 * 60 * 1000, // 1 minute
limit: 60, 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 { try {
const apiKey = req.headers["authorization"]; const apiKey = req.headers["authorization"];
if (!apiKey) { if (!apiKey) {
@@ -154,9 +124,9 @@ app.post("/inbox", async (req, res) => {
}); });
} }
const encryptedItem = encrypt( const encryptedItem = await encrypt(
JSON.stringify(validationResult.data), JSON.stringify(validationResult.data),
inboxPublicKey inboxPublicKey,
); );
console.log("[info] encrypted item"); console.log("[info] encrypted item");
@@ -180,14 +150,9 @@ app.post("/inbox", async (req, res) => {
} }
}); });
(async () => { const PORT = Number(process.env.PORT || "5181");
await _sodium.ready; app.listen(PORT, () => {
sodium = _sodium; 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; export default app;
+1
View File
@@ -39,6 +39,7 @@ namespace Streetwriters.Common
AppId = ApplicationType.NOTESNOOK, AppId = ApplicationType.NOTESNOOK,
AccountRecoveryRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/recovery", AccountRecoveryRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/recovery",
EmailConfirmedRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/verified", EmailConfirmedRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/verified",
PackageName = "com.streetwriters.notesnook",
OnEmailConfirmed = async (userId) => OnEmailConfirmed = async (userId) =>
{ {
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
+2
View File
@@ -79,6 +79,8 @@ namespace Streetwriters.Common
public static string? SUBSCRIPTIONS_CERT_PATH => ReadSecret("SUBSCRIPTIONS_CERT_PATH"); 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? SUBSCRIPTIONS_CERT_KEY_PATH => ReadSecret("SUBSCRIPTIONS_CERT_KEY_PATH");
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS")?.Split(",") ?? []; 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) public static string? ReadSecret(string name)
{ {
@@ -10,7 +10,13 @@ namespace Streetwriters.Common.Interfaces
Task<UserModel?> GetUserAsync(string clientId, string userId); Task<UserModel?> GetUserAsync(string clientId, string userId);
[WampProcedure("co.streetwriters.identity.users.delete_user")] [WampProcedure("co.streetwriters.identity.users.delete_user")]
Task DeleteUserAsync(string clientId, string userId, string password); Task DeleteUserAsync(string clientId, string userId, string password);
// [WampProcedure("co.streetwriters.identity.users.create_user")] [WampProcedure("co.streetwriters.identity.users.change_password")]
// Task<UserModel> CreateUserAsync(); 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")] [WampProcedure("co.streetwriters.subscriptions.subscriptions.get_user_subscription")]
Task<Subscription?> GetUserSubscriptionAsync(string clientId, string userId); 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); Subscription TransformUserSubscription(Subscription subscription);
} }
} }
+1
View File
@@ -39,6 +39,7 @@ namespace Streetwriters.Common.Models
public required string SenderName { get; set; } public required string SenderName { get; set; }
public required string EmailConfirmedRedirectURL { get; set; } public required string EmailConfirmedRedirectURL { get; set; }
public required string AccountRecoveryRedirectURL { get; set; } public required string AccountRecoveryRedirectURL { get; set; }
public required string PackageName { get; set; }
public Func<string, Task>? OnEmailConfirmed { get; set; } public Func<string, Task>? OnEmailConfirmed { get; set; }
} }
@@ -21,7 +21,7 @@ using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization; using System.Runtime.Serialization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Streetwriters.Identity.Models namespace Streetwriters.Common.Models
{ {
public class SignupForm 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]
};
}
}
}
+5 -1
View File
@@ -1,6 +1,9 @@
using System; using System;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Streetwriters.Common.Interfaces; using Streetwriters.Common.Interfaces;
@@ -22,7 +25,8 @@ namespace Streetwriters.Common.Services
public async Task<bool> IsURLSafeAsync(string uri) public async Task<bool> IsURLSafeAsync(string uri)
{ {
if (string.IsNullOrEmpty(Constants.WEBRISK_API_URI)) return true; 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; if (!response.IsSuccessStatusCode) return true;
var json = await response.Content.ReadFromJsonAsync<WebRiskAPIResponse>(); var json = await response.Content.ReadFromJsonAsync<WebRiskAPIResponse>();
return json.Threat.ThreatTypes == null || json.Threat.ThreatTypes.Length == 0; return json.Threat.ThreatTypes == null || json.Threat.ThreatTypes.Length == 0;
@@ -25,6 +25,7 @@ using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model; using AspNetCore.Identity.Mongo.Model;
using IdentityServer4.Extensions;
using IdentityServer4.Stores; using IdentityServer4.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -37,6 +38,7 @@ using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages; using Streetwriters.Common.Messages;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
using Streetwriters.Identity.Enums; using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Extensions;
using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models; using Streetwriters.Identity.Models;
using Streetwriters.Identity.Services; using Streetwriters.Identity.Services;
@@ -53,14 +55,26 @@ namespace Streetwriters.Identity.Controllers
private IPersistedGrantStore PersistedGrantStore { get; set; } private IPersistedGrantStore PersistedGrantStore { get; set; }
private ITokenGenerationService TokenGenerationService { get; set; } private ITokenGenerationService TokenGenerationService { get; set; }
private IUserAccountService UserAccountService { get; set; } private IUserAccountService UserAccountService { get; set; }
private EmailAddressValidator EmailValidator { get; set; }
private readonly ILogger<AccountController> logger; private readonly ILogger<AccountController> logger;
public AccountController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IPersistedGrantStore store, public AccountController(
ITokenGenerationService tokenGenerationService, IMFAService _mfaService, IUserAccountService userAccountService, ILogger<AccountController> logger) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService) 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; PersistedGrantStore = store;
TokenGenerationService = tokenGenerationService; TokenGenerationService = tokenGenerationService;
UserAccountService = userAccountService; UserAccountService = userAccountService;
EmailValidator = emailValidator;
this.logger = logger; this.logger = logger;
} }
@@ -97,12 +111,12 @@ namespace Streetwriters.Identity.Controllers
} }
case TokenType.RESET_PASSWORD: case TokenType.RESET_PASSWORD:
{ {
// if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code)) if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code))
return BadRequest("Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience."); return BadRequest("Invalid token.");
// var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode"); var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode");
// var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}"; var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}";
// return RedirectPermanent(redirectUrl); return RedirectPermanent(redirectUrl);
} }
default: default:
return BadRequest("Invalid type."); return BadRequest("Invalid type.");
@@ -124,11 +138,16 @@ namespace Streetwriters.Identity.Controllers
{ {
ArgumentNullException.ThrowIfNull(user.Email); ArgumentNullException.ThrowIfNull(user.Email);
var code = await UserManager.GenerateEmailConfirmationTokenAsync(user); 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); await EmailSender.SendConfirmationEmailAsync(user.Email, callbackUrl, client);
} }
else else
{ {
if (!await EmailValidator.IsEmailAddressValidAsync(newEmail.ToLowerInvariant()))
{
return BadRequest("Invalid email address.");
}
var code = await UserManager.GenerateChangeEmailTokenAsync(user, newEmail); var code = await UserManager.GenerateChangeEmailTokenAsync(user, newEmail);
await EmailSender.SendChangeEmailConfirmationAsync(newEmail, code, client); await EmailSender.SendChangeEmailConfirmationAsync(newEmail, code, client);
} }
@@ -149,22 +168,22 @@ namespace Streetwriters.Identity.Controllers
[EnableRateLimiting("strict")] [EnableRateLimiting("strict")]
public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form) public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form)
{ {
return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
// 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."); var client = Clients.FindClientById(form.ClientId);
// if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok(); if (client == null) return BadRequest("Invalid client_id.");
// var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword"); var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
// var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD); if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
// #if (DEBUG || STAGING)
// return Ok(callbackUrl); var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
// #else var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
// logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl); #if (DEBUG || STAGING)
// await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client); return Ok(callbackUrl);
// return Ok(); #else
// #endif logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
return Ok();
#endif
} }
[HttpPost("logout")] [HttpPost("logout")]
@@ -249,36 +268,6 @@ namespace Streetwriters.Identity.Controllers
} }
return BadRequest(result.Errors.ToErrors()); return BadRequest(result.Errors.ToErrors());
} }
case "change_password":
{
return BadRequest(new { error = "Password change is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
// 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":
{
return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
// 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": case "change_marketing_consent":
{ {
var claimType = $"{client.Id}:marketing_consent"; var claimType = $"{client.Id}:marketing_consent";
@@ -297,40 +286,14 @@ namespace Streetwriters.Identity.Controllers
[HttpPost("sessions/clear")] [HttpPost("sessions/clear")]
public async Task<IActionResult> ClearUserSessions([FromQuery] bool all, [FromForm] string? refresh_token) 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 jti = User.FindFirstValue("jti");
var userId = User.GetSubjectId();
var grants = await PersistedGrantStore.GetAllAsync(new PersistedGrantFilter var clientId = User.FindFirstValue("client_id");
{ if (await UserAccountService.ClearSessionsAsync(userId, clientId, all, refresh_token, jti))
ClientId = client.Id, await SendLogoutMessageAsync(userId, "Session revoked.");
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.");
return Ok(); return Ok();
} }
private static string GetHashedKey(string value, string grantType)
{
return (value + ":" + grantType).Sha256();
}
private async Task SendLogoutMessageAsync(string userId, string reason) private async Task SendLogoutMessageAsync(string userId, string reason)
{ {
await SendMessageAsync(userId, new Message 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";
}
}
}
@@ -25,25 +25,24 @@ using Streetwriters.Common;
using Streetwriters.Identity.Controllers; using Streetwriters.Identity.Controllers;
using Streetwriters.Identity.Enums; 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)
{ {
var url = new UriBuilder();
return urlHelper.ActionLink(
#if (DEBUG || STAGING) #if (DEBUG || STAGING)
host: $"{Servers.IdentityServer.Hostname}:{Servers.IdentityServer.Port}", url.Host = $"{Servers.IdentityServer.Hostname}";
protocol: "http", url.Port = Servers.IdentityServer.Port;
url.Scheme = "http";
#else #else
host: Servers.IdentityServer.PublicURL.Host, url.Host = Servers.IdentityServer.PublicURL.Host;
protocol: Servers.IdentityServer.PublicURL.Scheme, url.Scheme = Servers.IdentityServer.PublicURL.Scheme;
#endif #endif
action: nameof(AccountController.ConfirmToken), url.Path = "account/confirm";
controller: "Account", url.Query = $"userId={Uri.EscapeDataString(userId)}&code={Uri.EscapeDataString(code)}&clientId={Uri.EscapeDataString(clientId)}&type={Uri.EscapeDataString(type.ToString())}";
values: new { userId, code, clientId, type }); return url.ToString();
} }
} }
} }
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System.Security.Claims; using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Validation; using IdentityServer4.Validation;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
@@ -26,8 +27,9 @@ namespace Streetwriters.Identity.Interfaces
{ {
public interface ITokenGenerationService public interface ITokenGenerationService
{ {
Task<string> CreateAccessTokenAsync(User user, string clientId); Task<string> CreateAccessTokenAsync(User user, string clientId, int lifetime = 1800);
Task<string> CreateAccessTokenFromValidatedRequestAsync(ValidatedTokenRequest validatedRequest, User user, string[] scopes, int lifetime = 60); 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 = 20 * 60); Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 1200);
Task<TokenResponse?> CreateUserTokensAsync(User user, string clientId, int lifetime = 1800);
} }
} }
@@ -24,6 +24,7 @@ using IdentityModel;
using IdentityServer4; using IdentityServer4;
using IdentityServer4.Configuration; using IdentityServer4.Configuration;
using IdentityServer4.Models; using IdentityServer4.Models;
using IdentityServer4.ResponseHandling;
using IdentityServer4.Services; using IdentityServer4.Services;
using IdentityServer4.Stores; using IdentityServer4.Stores;
using IdentityServer4.Validation; using IdentityServer4.Validation;
@@ -41,12 +42,14 @@ namespace Streetwriters.Identity.Helpers
private IdentityServerOptions ISOptions { get; set; } private IdentityServerOptions ISOptions { get; set; }
private IdentityServerTools Tools { get; set; } private IdentityServerTools Tools { get; set; }
private IResourceStore ResourceStore { get; set; } private IResourceStore ResourceStore { get; set; }
private readonly IRefreshTokenService refreshTokenService;
public TokenGenerationService(ITokenService tokenService, public TokenGenerationService(ITokenService tokenService,
IUserClaimsPrincipalFactory<User> principalFactory, IUserClaimsPrincipalFactory<User> principalFactory,
IdentityServerOptions identityServerOptions, IdentityServerOptions identityServerOptions,
IPersistedGrantStore persistedGrantStore, IPersistedGrantStore persistedGrantStore,
IdentityServerTools tools, IdentityServerTools tools,
IResourceStore resourceStore) IResourceStore resourceStore,
IRefreshTokenService _refreshTokenService)
{ {
TokenService = tokenService; TokenService = tokenService;
PrincipalFactory = principalFactory; PrincipalFactory = principalFactory;
@@ -54,16 +57,25 @@ namespace Streetwriters.Identity.Helpers
PersistedGrantStore = persistedGrantStore; PersistedGrantStore = persistedGrantStore;
Tools = tools; Tools = tools;
ResourceStore = resourceStore; 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 IdentityPricipal = await PrincipalFactory.CreateAsync(user);
var IdentityUser = new IdentityServerUser(user.Id.ToString()); var IdentityUser = new IdentityServerUser(user.Id.ToString())
IdentityUser.AdditionalClaims = IdentityPricipal.Claims.ToArray(); {
IdentityUser.DisplayName = user.UserName; AdditionalClaims = [.. IdentityPricipal.Claims],
IdentityUser.AuthenticationTime = System.DateTime.UtcNow; DisplayName = user.UserName,
IdentityUser.IdentityProvider = IdentityServerConstants.LocalIdentityProvider; AuthenticationTime = System.DateTime.UtcNow,
IdentityProvider = IdentityServerConstants.LocalIdentityProvider
};
var Request = new TokenCreationRequest var Request = new TokenCreationRequest
{ {
Subject = IdentityUser.CreatePrincipal(), Subject = IdentityUser.CreatePrincipal(),
@@ -71,16 +83,61 @@ namespace Streetwriters.Identity.Helpers
ValidatedRequest = new ValidatedRequest() ValidatedRequest = new ValidatedRequest()
}; };
Request.ValidatedRequest.Subject = Request.Subject; 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.AccessTokenType = AccessTokenType.Reference;
Request.ValidatedRequest.AccessTokenLifetime = 18000; Request.ValidatedRequest.AccessTokenLifetime = lifetime;
Request.ValidatedResources = new ResourceValidationResult(new Resources(Config.IdentityResources, Config.ApiResources, Config.ApiScopes)); var requestedScopes = client.AllowedScopes.Select(s => new ParsedScopeValue(s));
Request.ValidatedResources = await ResourceStore.CreateResourceValidationResult(new ParsedScopesResult
{
ParsedScopes = [.. requestedScopes]
});
Request.ValidatedRequest.Options = ISOptions; Request.ValidatedRequest.Options = ISOptions;
Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims; Request.ValidatedRequest.ClientClaims = IdentityUser.AdditionalClaims;
var accessToken = await TokenService.CreateAccessTokenAsync(Request); var accessToken = await TokenService.CreateAccessTokenAsync(Request);
return await TokenService.CreateSecurityTokenAsync(accessToken); 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) public async Task<ClaimsPrincipal> TransformTokenRequestAsync(ValidatedTokenRequest request, User user, string grantType, string[] scopes, int lifetime = 20 * 60)
{ {
var principal = await PrincipalFactory.CreateAsync(user); var principal = await PrincipalFactory.CreateAsync(user);
@@ -1,16 +1,29 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using AspNetCore.Identity.Mongo.Model;
using IdentityServer4;
using IdentityServer4.Stores;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums; using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces; using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
using Streetwriters.Common.Services;
using Streetwriters.Identity.Enums;
using Streetwriters.Identity.Extensions;
using Streetwriters.Identity.Interfaces; using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Models; using Streetwriters.Identity.Models;
namespace Streetwriters.Identity.Services 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) public async Task<UserModel?> GetUserAsync(string clientId, string userId)
{ {
@@ -54,5 +67,141 @@ namespace Streetwriters.Identity.Services
await userManager.DeleteAsync(user); 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);
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()
};
}
return SignupResponse.Error(result.Errors.ToErrors());
}
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();
}
} }
} }
+7 -7
View File
@@ -39,7 +39,7 @@ function isValidUrl(urlString: string): boolean {
// Handle proxied request with redirect support // Handle proxied request with redirect support
async function proxyRequest( async function proxyRequest(
targetUrl: string, targetUrl: string,
redirectCount = 0 redirectCount = 0,
): Promise<Response> { ): Promise<Response> {
if (redirectCount >= MAX_REDIRECTS) { if (redirectCount >= MAX_REDIRECTS) {
return new Response("Too many redirects", { return new Response("Too many redirects", {
@@ -147,7 +147,7 @@ const server = Bun.serve({
method2: "GET /?url=<encoded-url>", method2: "GET /?url=<encoded-url>",
example1: `${url.origin}/https://example.com/image.jpg`, example1: `${url.origin}/https://example.com/image.jpg`,
example2: `${url.origin}/?url=${encodeURIComponent( example2: `${url.origin}/?url=${encodeURIComponent(
"https://example.com/image.jpg" "https://example.com/image.jpg",
)}`, )}`,
}, },
endpoints: { endpoints: {
@@ -190,7 +190,7 @@ const server = Bun.serve({
{ {
status: 400, status: 400,
headers: corsHeaders, headers: corsHeaders,
} },
); );
} }
@@ -218,8 +218,8 @@ const server = Bun.serve({
status: 200, status: 200,
headers: { headers: {
"Content-Type": "text/html; charset=utf-8", "Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "frame-ancestors *", // "Content-Security-Policy": "frame-ancestors *",
"X-Frame-Options": "ALLOWALL", // "X-Frame-Options": "ALLOWALL",
}, },
}); });
} }
@@ -239,7 +239,7 @@ const server = Bun.serve({
}); });
console.log( 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(`📋 Health check: http://${server.hostname}:${server.port}/health`);
console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`); console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`);
@@ -280,7 +280,7 @@ function serveYouTubeEmbed(url: string) {
</head> </head>
<body> <body>
<iframe src="${transformYouTubeUrl( <iframe src="${transformYouTubeUrl(
url 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> )}" 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> </body>
</html>`; </html>`;