Compare commits

...
Author SHA1 Message Date
Abdullah Atta 294d885dbf common: expose x-object-size & content-length header via cors 2026-06-06 10:18:30 +05:00
Abdullah Atta bdd5017394 s3: add x-object-size header alongwith content-length header 2026-06-06 08:57:53 +05:00
01zulfiandGitHub 7ad70c63ee api: move inboxitemhistory collection sync in RequestFetchV4 (#101) 2026-05-19 13:39:52 +05:00
Abdullah Atta 0367ab6f80 sync: get rid of ._id fallback for inbox items 2026-05-15 08:58:21 +05:00
01zulfiandGitHub f3bfe0957b inbox: create InboxItemsHistory synced collection (#96) 2026-05-14 11:20:17 +05:00
01zulfiandGitHub 7f614f6954 inbox: remove 'Default' api key creation (#100) 2026-05-14 11:09:28 +05:00
Abdullah Atta 6663778e3e identity: fix email templates 2026-05-14 10:45:10 +05:00
14f0a3b37e inbox: improve http response status codes (#99)
* inbox: improve http response status codes
* inbox api: respond with 401 unauthorized for invalid inbox api key
* notesnook api (get public encryption key): respond with 404 if not found

* Update Notesnook.Inbox.API/src/index.ts

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-14 09:37:28 +05:00
01zulfiandGitHub 82a1152f9f inbox: fix expiry date validation check (#97) 2026-05-13 09:51:24 +05:00
01zulfiandGitHub 580524b855 inbox: require non-empty source in inbox item (#98) 2026-05-13 09:50:18 +05:00
Abdullah Atta 159fe0e376 identity: handle errors when sending/verifying sms otp codes 2026-05-08 11:08:55 +05:00
01zulfiandGitHub 30fdaae36c identity: return ok if user not found in recover endpoint (#95) 2026-05-02 22:19:26 +05:00
Abdullah Atta 815c8fb84c sync: log on disconnect 2026-05-02 22:18:39 +05:00
Abdullah Atta 04b0c305ed api: add redis to healthchecks 2026-05-02 22:18:34 +05:00
Abdullah Atta 31c57f95b2 sse: improve reliability 2026-04-23 09:54:59 +05:00
Abdullah Atta 33413b0a5c inbox: use separate model for CreateInboxApiKeyRequest 2026-04-15 09:05:41 +05:00
Abdullah Atta 4278b0624e sync: set ws keepalive interval to 15s 2026-04-11 10:55:05 +05:00
Abdullah Atta a56ef1fe11 sync: reduce chunk size to 3MB
this will increase the number of round trips but will make the sync
more reliable on slow devices that take longer to process 7MB
chunks.
2026-04-11 10:55:05 +05:00
01zulfiandGitHub d9c282fcf8 identity: replace account already exists errors on signup with one message (#90) 2026-04-05 11:36:15 +05:00
Abdullah Atta 73750613c4 monograph: move slug handling behind v2 endpoint 2026-04-02 15:01:14 +05:00
01zulfiandGitHub bb008c032d identity: render HTML pages for email confirmation outcomes (#89) 2026-04-01 19:27:42 +05:00
Abdullah Atta 5715f4c9ca monograph: fix 404 errors due to slug restriction on new monographs 2026-03-31 22:36:48 +05:00
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
45 changed files with 1112 additions and 336 deletions
+4
View File
@@ -36,6 +36,10 @@ jobs:
- image: streetwriters/sse
file: ./Streetwriters.Messenger/Dockerfile
context: .
- image: streetwriters/notesnook-inbox
file: ./Notesnook.Inbox.API/Dockerfile
context: ./Notesnook.Inbox.API/
permissions:
packages: write
contents: read
@@ -42,6 +42,7 @@ namespace Notesnook.API.Accessors
public SyncItemsRepository Colors { get; }
public SyncItemsRepository Vaults { get; }
public SyncItemsRepository Tags { get; }
public SyncItemsRepository InboxItemsHistory { get; }
public Repository<UserSettings> UsersSettings { get; }
public Repository<Monograph> Monographs { get; }
public Repository<InboxApiKey> InboxApiKey { get; }
@@ -75,6 +76,8 @@ namespace Notesnook.API.Accessors
IMongoCollection<SyncItem> vaults,
[FromKeyedServices(Collections.TagsKey)]
IMongoCollection<SyncItem> tags,
[FromKeyedServices(Collections.InboxItemsHistoryKey)]
IMongoCollection<SyncItem> inboxItemsHistory,
Repository<UserSettings> usersSettings,
Repository<Monograph> monographs,
@@ -102,6 +105,7 @@ namespace Notesnook.API.Accessors
Colors = new SyncItemsRepository(dbContext, colors, logger);
Vaults = new SyncItemsRepository(dbContext, vaults, logger);
Tags = new SyncItemsRepository(dbContext, tags, logger);
InboxItemsHistory = new SyncItemsRepository(dbContext, inboxItemsHistory, logger);
}
}
}
@@ -78,7 +78,7 @@ namespace Notesnook.API.Authorization
return AuthenticateResult.Fail("API key has expired");
}
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey);
var claims = new[]
+1
View File
@@ -18,5 +18,6 @@ namespace Notesnook.API
public const string InboxApiKeysKey = "inbox_api_keys";
public const string SyncDevicesKey = "sync_devices";
public const string DeviceIdsChunksKey = "device_ids_chunks";
public const string InboxItemsHistoryKey = "inbox_items_history";
}
}
+9 -24
View File
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
@@ -63,7 +64,7 @@ namespace Notesnook.API.Controllers
[HttpPost("api-keys")]
[Authorize(Policy = "Notesnook")]
public async Task<IActionResult> CreateApiKeyAsync([FromBody] InboxApiKey request)
public async Task<IActionResult> CreateApiKeyAsync([FromBody] CreateInboxApiKeyRequest request)
{
var userId = User.GetUserId();
try
@@ -72,9 +73,9 @@ namespace Notesnook.API.Controllers
{
return BadRequest(new { error = "Api key name is required." });
}
if (request.ExpiryDate <= -1)
if (request.ExpiryDate == null)
{
return BadRequest(new { error = "Valid expiry date is required." });
return BadRequest(new { error = "Expiry date is required." });
}
var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId);
@@ -133,7 +134,7 @@ namespace Notesnook.API.Controllers
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
{
return BadRequest(new { error = "Inbox public key is not configured." });
return NotFound(new { error = "Inbox public key is not configured." });
}
return Ok(new { key = userSetting.InboxKeys.Public });
}
@@ -151,34 +152,18 @@ namespace Notesnook.API.Controllers
var userId = User.GetUserId();
try
{
if (request.Key.Algorithm != Algorithms.XSAL_X25519_7)
if (string.IsNullOrWhiteSpace(request.Cipher))
{
return BadRequest(new { error = $"Only {Algorithms.XSAL_X25519_7} is supported for inbox item password." });
return BadRequest(new { error = "Inbox item is required." });
}
if (string.IsNullOrWhiteSpace(request.Key.Cipher))
if (string.IsNullOrWhiteSpace(request.Algorithm))
{
return BadRequest(new { error = "Inbox item password cipher is required." });
}
if (request.Key.Length <= 0)
{
return BadRequest(new { error = "Valid inbox item password length is required." });
}
if (request.Algorithm != Algorithms.Default)
{
return BadRequest(new { error = $"Only {Algorithms.Default} is supported for inbox item." });
return BadRequest(new { error = "Inbox item algorithm is required." });
}
if (request.Version <= 0)
{
return BadRequest(new { error = "Valid inbox item version is required." });
}
if (string.IsNullOrWhiteSpace(request.Cipher) || string.IsNullOrWhiteSpace(request.IV))
{
return BadRequest(new { error = "Inbox item cipher and iv is required." });
}
if (request.Length <= 0)
{
return BadRequest(new { error = "Valid inbox item length is required." });
}
request.UserId = userId;
request.ItemId = ObjectId.GenerateNewId().ToString();
+213 -75
View File
@@ -18,20 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using AngleSharp;
using AngleSharp.Dom;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using Notesnook.API.Authorization;
using NanoidDotNet;
using Notesnook.API.Extensions;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Streetwriters.Common;
@@ -40,7 +39,6 @@ using Streetwriters.Common.Enums;
using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Controllers
@@ -97,6 +95,29 @@ namespace Notesnook.API.Controllers
return await result.FirstOrDefaultAsync();
}
private async Task<Monograph> FindMonographBySlugAsync(string slug)
{
var result = await monographs.Collection.FindAsync(
Builders<Monograph>.Filter.Eq("Slug", slug), new FindOptions<Monograph>
{
Limit = 1
});
return await result.FirstOrDefaultAsync();
}
private async Task<string> GenerateUniqueSlugAsync(int length = 10, int maxAttempts = 5)
{
for (var i = 0; i < maxAttempts; i++)
{
var slug = Nanoid.Generate(size: length);
var exists = await monographs.Collection.Find(Builders<Monograph>.Filter.Eq("Slug", slug))
.Limit(1)
.AnyAsync();
if (!exists) return slug;
}
throw new Exception("Failed to generate unique slug");
}
[HttpPost]
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
{
@@ -108,24 +129,12 @@ namespace Notesnook.API.Controllers
var existingMonograph = await FindMonographAsync(userId, monograph);
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
if (monograph.EncryptedContent == null)
{
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
monograph.ContentSanitizationLevel = sanitizationLevel;
}
monograph.UserId = userId;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
monograph = await CreateMonographAsync(monograph, userId);
if (existingMonograph != null)
{
monograph.Id = existingMonograph.Id;
}
monograph.Deleted = false;
monograph.ViewCount = 0;
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
monograph,
@@ -137,13 +146,54 @@ namespace Notesnook.API.Controllers
return Ok(new
{
id = monograph.ItemId,
datePublished = monograph.DatePublished
datePublished = monograph.DatePublished,
});
}
catch (Exception e)
{
logger.LogError(e, "Failed to publish monograph");
return BadRequest();
return BadRequest(new { error = e.Message });
}
}
[HttpPost("v2")]
public async Task<IActionResult> PublishV2Async([FromQuery] string? deviceId, [FromBody] Monograph monograph)
{
try
{
var userId = this.User.GetUserId();
var jti = this.User.FindFirstValue("jti");
var existingMonograph = await FindMonographAsync(userId, monograph);
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
monograph = await CreateMonographAsync(monograph, userId);
monograph.Slug = await GenerateUniqueSlugAsync();
if (existingMonograph != null)
{
monograph.Id = existingMonograph.Id;
}
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
monograph,
new ReplaceOptions { IsUpsert = true }
);
await MarkMonographForSyncAsync(userId, monograph.ItemId ?? monograph.Id, deviceId, jti);
return Ok(new
{
id = monograph.ItemId,
datePublished = monograph.DatePublished,
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph)
});
}
catch (Exception e)
{
logger.LogError(e, "Failed to publish monograph");
return BadRequest(new { error = e.Message });
}
}
@@ -192,13 +242,14 @@ namespace Notesnook.API.Controllers
return Ok(new
{
id = monograph.ItemId,
datePublished = monograph.DatePublished
datePublished = monograph.DatePublished,
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(existingMonograph)
});
}
catch (Exception e)
{
logger.LogError(e, "Failed to update monograph");
return BadRequest();
return BadRequest(new { error = e.Message });
}
}
@@ -233,25 +284,7 @@ namespace Notesnook.API.Controllers
});
}
if (monograph.EncryptedContent == null)
{
var isContentUnsanitized = monograph.ContentSanitizationLevel == ContentSanitizationLevel.Partial || monograph.ContentSanitizationLevel == ContentSanitizationLevel.Unknown;
if (!Constants.IS_SELF_HOSTED && isContentUnsanitized && serviceAccessor.UserSubscriptionService != null && !await serviceAccessor.UserSubscriptionService.IsUserSubscribedAsync(Clients.Notesnook.Id, monograph.UserId!))
{
var cleaned = await SanitizeContentAsync(monograph.CompressedContent?.DecompressBrotli(), ContentSanitizationLevel.Full);
monograph.CompressedContent = cleaned.CompressBrotli();
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update
.Set(m => m.CompressedContent, monograph.CompressedContent)
.Set(m => m.ContentSanitizationLevel, ContentSanitizationLevel.Full)
);
}
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
}
monograph.ItemId ??= monograph.Id;
return Ok(monograph);
return Ok(await ProcessMonographAsync(monograph));
}
[HttpGet("{id}/view")]
@@ -259,47 +292,47 @@ namespace Notesnook.API.Controllers
public async Task<IActionResult> TrackView([FromRoute] string id)
{
var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted) return Content(SVG_PIXEL, "image/svg+xml");
if (monograph == null || monograph.Deleted)
return Content(SVG_PIXEL, "image/svg+xml");
var cookieName = $"viewed_{id}";
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
if (monograph.SelfDestruct)
{
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
new Monograph
{
ItemId = id,
Id = monograph.Id,
Deleted = true,
UserId = monograph.UserId,
ViewCount = 0
}
);
await MarkMonographForSyncAsync(monograph.UserId!, id);
}
else if (!hasVisitedBefore)
{
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
);
var cookieOptions = new CookieOptions
{
Path = $"/monographs/{id}",
HttpOnly = true,
Secure = Request.IsHttps,
Expires = DateTimeOffset.UtcNow.AddMonths(1)
};
Response.Cookies.Append(cookieName, "1", cookieOptions);
}
await TrackViewAsync(monograph, cookieName, $"/monographs/{id}");
return Content(SVG_PIXEL, "image/svg+xml");
}
[HttpGet("v2/{slug}/view")]
[AllowAnonymous]
public async Task<IActionResult> TrackViewV2([FromRoute] string slug)
{
var monograph = await FindMonographBySlugAsync(slug);
if (monograph == null || monograph.Deleted)
return Content(SVG_PIXEL, "image/svg+xml");
var cookieName = $"viewed_{slug}";
await TrackViewAsync(monograph, cookieName, $"/monographs/v2/{slug}");
return Content(SVG_PIXEL, "image/svg+xml");
}
[HttpGet("v2/{slug}")]
[AllowAnonymous]
public async Task<IActionResult> GetMonographBySlugAsync([FromRoute] string slug)
{
var monograph = await FindMonographBySlugAsync(slug);
if (monograph == null || monograph.Deleted)
{
return NotFound(new
{
error = "invalid_id",
error_description = $"No such monograph found."
});
}
return Ok(await ProcessMonographAsync(monograph));
}
[HttpGet("{id}/analytics")]
[Obsolete("This endpoint is deprecated and will be removed in future versions. Use GET /monographs/{id}/metadata instead.")]
public async Task<IActionResult> GetMonographAnalyticsAsync([FromRoute] string id)
{
if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User))
@@ -343,6 +376,29 @@ namespace Notesnook.API.Controllers
return Ok();
}
[HttpGet("{id}/metadata")]
public async Task<IActionResult> GetMetadataAsync([FromRoute] string id)
{
var userId = this.User.GetUserId();
var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted || monograph.UserId != userId)
{
return NotFound();
}
var isPro = FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User);
var totalViews = isPro ? monograph.ViewCount : 0;
return Ok(new
{
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph),
analytics = new
{
totalViews
}
});
}
private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
{
if (deviceId == null) return;
@@ -368,6 +424,88 @@ namespace Notesnook.API.Controllers
("audio", "src"),
];
private async Task<Monograph> CreateMonographAsync(Monograph monograph, string userId)
{
if (monograph.EncryptedContent == null)
{
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
monograph.ContentSanitizationLevel = sanitizationLevel;
}
monograph.UserId = userId;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
throw new Exception("Monograph is too big. Max allowed size is 15mb.");
monograph.Deleted = false;
monograph.ViewCount = 0;
return monograph;
}
private async Task TrackViewAsync(Monograph monograph, string cookieName, string cookiePath)
{
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
if (monograph.SelfDestruct)
{
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
new Monograph
{
ItemId = monograph.ItemId,
Id = monograph.Id,
Deleted = true,
UserId = monograph.UserId,
ViewCount = 0
}
);
await MarkMonographForSyncAsync(monograph.UserId!, monograph.ItemId ?? monograph.Id);
}
else if (!hasVisitedBefore)
{
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
);
var cookieOptions = new CookieOptions
{
Path = cookiePath,
HttpOnly = true,
Secure = Request.IsHttps,
Expires = DateTimeOffset.UtcNow.AddMonths(1)
};
Response.Cookies.Append(cookieName, "1", cookieOptions);
}
}
private async Task<Monograph> ProcessMonographAsync(Monograph monograph)
{
if (monograph.EncryptedContent == null)
{
var isContentUnsanitized = monograph.ContentSanitizationLevel == ContentSanitizationLevel.Partial || monograph.ContentSanitizationLevel == ContentSanitizationLevel.Unknown;
if (!Constants.IS_SELF_HOSTED && isContentUnsanitized && serviceAccessor.UserSubscriptionService != null && !await serviceAccessor.UserSubscriptionService.IsUserSubscribedAsync(Clients.Notesnook.Id, monograph.UserId!))
{
var cleaned = await SanitizeContentAsync(monograph.CompressedContent?.DecompressBrotli(), ContentSanitizationLevel.Full);
monograph.CompressedContent = cleaned.CompressBrotli();
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update
.Set(m => m.CompressedContent, monograph.CompressedContent)
.Set(m => m.ContentSanitizationLevel, ContentSanitizationLevel.Full)
);
}
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
}
monograph.ItemId ??= monograph.Id;
return monograph;
}
private async Task<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
{
if (string.IsNullOrEmpty(content)) return string.Empty;
+2 -2
View File
@@ -183,8 +183,8 @@ namespace Notesnook.API.Controllers
try
{
var userId = this.User.GetUserId();
var size = await s3Service.GetObjectSizeAsync(userId, name);
HttpContext.Response.Headers.ContentLength = size;
var size = await s3Service.GetObjectSizeAsync(userId, name); Response.Headers.ContentLength = size;
Response.Headers["X-Object-Size"] = size.ToString();
return Ok();
}
catch (Exception ex)
+50
View File
@@ -0,0 +1,50 @@
/*
This file is part of the Notesnook Sync Server project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the Affero GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Affero GNU General Public License for more details.
You should have received a copy of the Affero GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using Notesnook.API.Models;
using Streetwriters.Common;
namespace Notesnook.API.Helpers
{
public class UrlHelper
{
public static string ConstructPublishUrl(string slug)
{
var baseUrl = Constants.MONOGRAPH_PUBLIC_URL;
return $"{baseUrl}/{slug}";
}
public static string ConstructPublishUrl(Monograph monograph)
{
if (!string.IsNullOrEmpty(monograph.Slug))
{
return ConstructPublishUrl("s/" + monograph.Slug);
}
return ConstructPublishUrl(monograph.ItemId ?? monograph.Id);
}
public static string ConstructPublishUrl(MonographMetadata metadata)
{
if (!string.IsNullOrEmpty(metadata.PublishUrl))
{
return ConstructPublishUrl("s/" + metadata.PublishUrl);
}
return ConstructPublishUrl(metadata.PublishUrl ?? metadata.ItemId);
}
}
}
+90 -45
View File
@@ -32,6 +32,8 @@ using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using Notesnook.API.Authorization;
using Notesnook.API.Extensions;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Services;
@@ -55,21 +57,9 @@ namespace Notesnook.API.Hubs
private ISyncItemsRepositoryAccessor Repositories { get; }
private SyncDeviceService SyncDeviceService { get; }
private readonly IUnitOfWork unit;
private static readonly string[] CollectionKeys = [
"settingitem",
"attachment",
"note",
"notebook",
"content",
"shortcut",
"reminder",
"color",
"tag",
"vault",
"relation", // relations must sync at the end to prevent invalid state
];
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
private readonly Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections;
private readonly CollectionDef[] BaseCollectionDefs;
private readonly CollectionDef[] V4CollectionDefs;
ILogger<SyncV2Hub> Logger { get; }
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger)
@@ -79,18 +69,32 @@ namespace Notesnook.API.Hubs
unit = unitOfWork;
SyncDeviceService = syncDeviceService;
Collections = [
Repositories.Settings.FindItemsById,
Repositories.Attachments.FindItemsById,
Repositories.Notes.FindItemsById,
Repositories.Notebooks.FindItemsById,
Repositories.Contents.FindItemsById,
Repositories.Shortcuts.FindItemsById,
Repositories.Reminders.FindItemsById,
Repositories.Colors.FindItemsById,
Repositories.Tags.FindItemsById,
Repositories.Vaults.FindItemsById,
Repositories.Relations.FindItemsById,
BaseCollectionDefs = [
new("settingitem", Repositories.Settings.FindItemsById),
new("attachment", Repositories.Attachments.FindItemsById),
new("note", Repositories.Notes.FindItemsById),
new("notebook", Repositories.Notebooks.FindItemsById),
new("content", Repositories.Contents.FindItemsById),
new("shortcut", Repositories.Shortcuts.FindItemsById),
new("reminder", Repositories.Reminders.FindItemsById),
new("color", Repositories.Colors.FindItemsById),
new("tag", Repositories.Tags.FindItemsById),
new("vault", Repositories.Vaults.FindItemsById),
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
];
V4CollectionDefs = [
new("settingitem", Repositories.Settings.FindItemsById),
new("attachment", Repositories.Attachments.FindItemsById),
new("note", Repositories.Notes.FindItemsById),
new("notebook", Repositories.Notebooks.FindItemsById),
new("content", Repositories.Contents.FindItemsById),
new("shortcut", Repositories.Shortcuts.FindItemsById),
new("reminder", Repositories.Reminders.FindItemsById),
new("color", Repositories.Colors.FindItemsById),
new("tag", Repositories.Tags.FindItemsById),
new("vault", Repositories.Vaults.FindItemsById),
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
];
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
{ "settingitem", Repositories.Settings.UpsertMany },
@@ -104,6 +108,7 @@ namespace Notesnook.API.Hubs
{ "color", Repositories.Colors.UpsertMany },
{ "vault", Repositories.Vaults.UpsertMany },
{ "tag", Repositories.Tags.UpsertMany },
{ "inboxitemhistory", Repositories.InboxItemsHistory.UpsertMany },
}.ToFrozenDictionary();
}
@@ -120,6 +125,19 @@ namespace Notesnook.API.Hubs
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (exception != null)
{
Logger.LogWarning(exception, "Connection {ConnectionId} disconnected with error (server-side drop)", Context.ConnectionId);
}
else
{
Logger.LogInformation("Connection {ConnectionId} disconnected cleanly (client-initiated)", Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
public async Task<int> PushItems(string deviceId, SyncTransferItemV2 pushItem)
{
@@ -130,13 +148,19 @@ namespace Notesnook.API.Hubs
var stopwatch = Stopwatch.StartNew();
try
{
var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}.");
UpsertItems(pushItem.Items, userId, 1);
if (!await unit.Commit()) return 0;
await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type)));
// we need to delete the inbox items from the inbox collection
// after syncing to prevent them from being sent again in the
// next fetch.
var itemIds = pushItem.Items.Select(i => i.ItemId).ToList();
await Repositories.InboxItems.DeleteManyAsync(i => i.UserId == userId && itemIds.Contains(i.ItemId));
return 1;
}
finally
@@ -160,17 +184,17 @@ namespace Notesnook.API.Hubs
return true;
}
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes)
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes, CollectionDef[] collectionDefs)
{
var itemsProcessed = 0;
for (int i = 0; i < Collections.Length; i++)
foreach (var def in collectionDefs)
{
var type = CollectionKeys[i];
var type = def.Key;
var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray();
if (!resetSync && filteredIds.Length == 0) continue;
using var cursor = await Collections[i](userId, filteredIds, resetSync, size);
using var cursor = await def.FindItems(userId, filteredIds, resetSync, size);
var chunk = new List<SyncItem>();
long totalBytes = 0;
@@ -212,20 +236,25 @@ namespace Notesnook.API.Hubs
public async Task<SyncV2Metadata> RequestFetch(string deviceId)
{
return await HandleRequestFetch(deviceId, false, false);
return await HandleRequestFetch(deviceId, false, false, BaseCollectionDefs);
}
public async Task<SyncV2Metadata> RequestFetchV2(string deviceId)
{
return await HandleRequestFetch(deviceId, true, false);
return await HandleRequestFetch(deviceId, true, false, BaseCollectionDefs);
}
public async Task<SyncV2Metadata> RequestFetchV3(string deviceId)
{
return await HandleRequestFetch(deviceId, true, true);
return await HandleRequestFetch(deviceId, true, true, BaseCollectionDefs);
}
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems)
public async Task<SyncV2Metadata> RequestFetchV4(string deviceId)
{
return await HandleRequestFetch(deviceId, true, true, V4CollectionDefs);
}
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems, CollectionDef[] collectionDefs)
{
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
@@ -249,7 +278,8 @@ namespace Notesnook.API.Hubs
ids,
size: 100,
resetSync: device.IsSyncReset,
maxBytes: 7 * 1024 * 1024
maxBytes: 3 * 1024 * 1024,
collectionDefs
);
await foreach (var chunk in chunks)
@@ -275,15 +305,25 @@ namespace Notesnook.API.Hubs
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds)
)
);
var userMonographs = await Repositories.Monographs.Collection.Find(filter).Project((m) => new MonographMetadata
var userMonographs = await Repositories.Monographs.Collection
.Find(filter)
.Project((m) => new MonographMetadata
{
DatePublished = m.DatePublished,
Deleted = m.Deleted,
Password = m.Password,
SelfDestruct = m.SelfDestruct,
Title = m.Title,
ItemId = m.ItemId ?? m.Id.ToString(),
PublishUrl = m.Slug // this will be converted to full url in the end, but we only need slug for now
})
.ToListAsync();
userMonographs = userMonographs.Select((p) =>
{
DatePublished = m.DatePublished,
Deleted = m.Deleted,
Password = m.Password,
SelfDestruct = m.SelfDestruct,
Title = m.Title,
ItemId = m.ItemId ?? m.Id.ToString()
}).ToListAsync();
p.PublishUrl = UrlHelper.ConstructPublishUrl(p);
return p;
}).ToList();
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
throw new HubException("Client rejected monographs.");
@@ -294,7 +334,7 @@ namespace Notesnook.API.Hubs
var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId);
var userInboxItems = device.IsSyncReset
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId ?? m.Id.ToString()));
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId));
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
{
throw new HubException("Client rejected inbox items.");
@@ -313,6 +353,11 @@ namespace Notesnook.API.Hubs
SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds);
}
}
private record CollectionDef(
string Key,
Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>> FindItems
);
}
[MessagePack.MessagePackObject]
@@ -38,6 +38,7 @@ namespace Notesnook.API.Interfaces
SyncItemsRepository Colors { get; }
SyncItemsRepository Vaults { get; }
SyncItemsRepository Tags { get; }
SyncItemsRepository InboxItemsHistory { get; }
Repository<UserSettings> UsersSettings { get; }
Repository<Monograph> Monographs { get; }
Repository<InboxApiKey> InboxApiKey { get; }
-1
View File
@@ -22,6 +22,5 @@ namespace Notesnook.API.Models
public class Algorithms
{
public static string Default => "xcha-argon2i13-7";
public static string XSAL_X25519_7 => "xsal-x25519-7";
}
}
+9
View File
@@ -24,6 +24,15 @@ using NanoidDotNet;
namespace Notesnook.API.Models
{
public class CreateInboxApiKeyRequest
{
[JsonPropertyName("name")]
public required string Name { get; set; }
[JsonPropertyName("expiryDate")]
public long ExpiryDate { get; set; }
}
public class InboxApiKey
{
public InboxApiKey()
+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.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Notesnook.API.Models
{
[MessagePack.MessagePackObject]
public class InboxSyncItem : SyncItem
public class InboxSyncItem
{
[DataMember(Name = "key")]
[JsonPropertyName("key")]
[MessagePack.Key("key")]
[Required]
public required EncryptedKey Key { get; set; }
[DataMember(Name = "salt")]
[JsonPropertyName("salt")]
[MessagePack.Key("salt")]
[Required]
public required string Salt { get; set; }
}
[MessagePack.MessagePackObject]
public class EncryptedKey
{
[DataMember(Name = "alg")]
[JsonPropertyName("alg")]
[MessagePack.Key("alg")]
[Required]
public required string Algorithm { get; set; }
[DataMember(Name = "cipher")]
[JsonPropertyName("cipher")]
[MessagePack.Key("cipher")]
[Required]
public required string Cipher { get; set; }
public string Cipher
{
get; set;
}
[JsonPropertyName("length")]
[DataMember(Name = "length")]
[MessagePack.Key("length")]
[DataMember(Name = "userId")]
[JsonPropertyName("userId")]
[MessagePack.Key("userId")]
public string? UserId
{
get; set;
}
[DataMember(Name = "id")]
[JsonPropertyName("id")]
[MessagePack.Key("id")]
public string? ItemId
{
get; set;
}
[BsonId]
[BsonIgnoreIfDefault]
[BsonRepresentation(BsonType.ObjectId)]
[JsonIgnore]
[MessagePack.IgnoreMember]
public ObjectId Id
{
get; set;
}
[JsonPropertyName("v")]
[DataMember(Name = "v")]
[MessagePack.Key("v")]
[Required]
public long Length
public double Version
{
get; set;
}
[JsonPropertyName("alg")]
[DataMember(Name = "alg")]
[MessagePack.Key("alg")]
[Required]
public string Algorithm
{
get; set;
}
+3
View File
@@ -56,6 +56,9 @@ namespace Notesnook.API.Models
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("slug")]
public string? Slug { get; set; }
[JsonPropertyName("userId")]
public string? UserId { get; set; }
+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.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Notesnook.API.Models
{
@@ -37,6 +35,9 @@ namespace Notesnook.API.Models
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("publishUrl")]
public string? PublishUrl { get; set; }
[JsonPropertyName("selfDestruct")]
public bool SelfDestruct { get; set; }
+1
View File
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="AspNetCore.HealthChecks.Aws.S3" Version="9.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
<PackageReference Include="AWSSDK.Core" Version="3.7.304.31" />
<PackageReference Include="DotNetEnv" Version="2.3.0" />
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
@@ -49,7 +49,7 @@ namespace Notesnook.API.Repositories
this.logger = logger;
}
private readonly List<string> ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7];
private readonly List<string> ALGORITHMS = [Algorithms.Default];
private bool IsValidAlgorithm(string algorithm)
{
return ALGORITHMS.Contains(algorithm);
+4 -9
View File
@@ -174,16 +174,9 @@ namespace Notesnook.API.Services
else
{
userSettings.InboxKeys = keys.InboxKeys;
var defaultInboxKey = new InboxApiKey
{
UserId = userId,
Name = "Default",
DateCreated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ExpiryDate = DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds(),
LastUsedAt = 0
};
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
}
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
}
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
@@ -206,6 +199,7 @@ namespace Notesnook.API.Services
Repositories.Colors.DeleteByUserId(userId);
Repositories.Tags.DeleteByUserId(userId);
Repositories.Vaults.DeleteByUserId(userId);
Repositories.InboxItemsHistory.DeleteByUserId(userId);
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
@@ -267,6 +261,7 @@ namespace Notesnook.API.Services
Repositories.Colors.DeleteByUserId(userId);
Repositories.Tags.DeleteByUserId(userId);
Repositories.Vaults.DeleteByUserId(userId);
Repositories.InboxItemsHistory.DeleteByUserId(userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
if (!await unit.Commit()) return false;
+24 -2
View File
@@ -25,6 +25,7 @@ using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Amazon.Runtime;
using StackExchange.Redis;
using IdentityModel.AspNetCore.OAuth2Introspection;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -196,7 +197,8 @@ namespace Notesnook.API
.AddMongoCollection(Collections.ColorsKey)
.AddMongoCollection(Collections.VaultsKey)
.AddMongoCollection(Collections.InboxItemsKey)
.AddMongoCollection(Collections.InboxApiKeysKey);
.AddMongoCollection(Collections.InboxApiKeysKey)
.AddMongoCollection(Collections.InboxItemsHistoryKey);
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
services.AddScoped<SyncDeviceService>();
@@ -213,12 +215,26 @@ namespace Notesnook.API
var signalR = services.AddSignalR((hub) =>
{
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
hub.KeepAliveInterval = TimeSpan.FromSeconds(15);
hub.ClientTimeoutInterval = TimeSpan.FromMinutes(10);
hub.EnableDetailedErrors = true;
}).AddMessagePackProtocol().AddJsonProtocol();
if (!string.IsNullOrEmpty(Constants.SIGNALR_REDIS_CONNECTION_STRING))
signalR.AddStackExchangeRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING);
{
services.AddHealthChecks()
.AddRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING, tags: ["ready"]);
signalR.AddStackExchangeRedis(options =>
{
options.Configuration = ConfigurationOptions.Parse(Constants.SIGNALR_REDIS_CONNECTION_STRING);
options.Configuration.AbortOnConnectFail = false;
options.Configuration.ConnectRetry = 5;
options.Configuration.ReconnectRetryPolicy = new ExponentialRetry(5000, 30000);
options.Configuration.KeepAlive = 60;
options.Configuration.ConnectTimeout = 5000;
options.Configuration.SyncTimeout = 5000;
});
}
services.AddResponseCompression(options =>
{
@@ -270,6 +286,12 @@ namespace Notesnook.API
app.UseOpenTelemetryPrometheusScrapingEndpoint((context) => context.Request.Path == "/metrics" && context.Connection.LocalPort == 5067);
app.UseResponseCompression();
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30),
KeepAliveTimeout = TimeSpan.FromSeconds(60),
});
app.UseCors("notesnook");
app.UseVersion(Servers.NotesnookAPI);
+1 -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
+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": {
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"libsodium-wrappers-sumo": "^0.7.15",
"openpgp": "^6.2.2",
"zod": "^4.1.9",
},
"devDependencies": {
@@ -116,10 +116,6 @@
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"libsodium-sumo": ["libsodium-sumo@0.7.15", "", {}, "sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw=="],
"libsodium-wrappers-sumo": ["libsodium-wrappers-sumo@0.7.15", "", { "dependencies": { "libsodium-sumo": "^0.7.15" } }, "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
@@ -140,6 +136,8 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"openpgp": ["openpgp@6.2.2", "", {}, "sha512-P/dyEqQ3gfwOCo+xsqffzXjmUhGn4AZTOJ1LCcN21S23vAk+EAvMJOQTsb/C8krL6GjOSBxqGYckhik7+hneNw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
+1 -1
View File
@@ -22,7 +22,7 @@
"dependencies": {
"express": "^5.1.0",
"express-rate-limit": "^8.1.0",
"libsodium-wrappers-sumo": "^0.7.15",
"openpgp": "^6.2.2",
"zod": "^4.1.9"
},
"devDependencies": {
+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());
+52 -78
View File
@@ -1,15 +1,13 @@
import express from "express";
import _sodium, { base64_variants } from "libsodium-wrappers-sumo";
import { z } from "zod";
import { rateLimit } from "express-rate-limit";
import * as openpgp from "openpgp";
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
if (!NOTESNOOK_API_SERVER_URL) {
throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
}
let sodium: typeof _sodium;
const RawInboxItemSchema = z.object({
title: z.string().min(1, "Title is required"),
pinned: z.boolean().optional(),
@@ -19,7 +17,7 @@ const RawInboxItemSchema = z.object({
notebookIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
type: z.enum(["note"]),
source: z.string(),
source: z.string().min(1, "Source is required"),
version: z.literal(1),
content: z
.object({
@@ -31,86 +29,60 @@ const RawInboxItemSchema = z.object({
interface EncryptedInboxItem {
v: 1;
key: Omit<EncryptedInboxItem, "key" | "iv" | "v" | "salt">;
iv: string;
alg: string;
cipher: string;
length: number;
salt: string;
alg: string;
}
function encrypt(rawData: string, publicKey: string): EncryptedInboxItem {
try {
const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
const saltBytes = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const key = sodium.crypto_pwhash(
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
password,
saltBytes,
3, // operations limit
1024 * 1024 * 8, // memory limit (8MB)
sodium.crypto_pwhash_ALG_ARGON2I13
);
const nonce = sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
);
const data = sodium.from_string(rawData);
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
data,
null,
null,
nonce,
key
);
const inboxPublicKey = sodium.from_base64(
publicKey,
base64_variants.URLSAFE_NO_PADDING
);
const encryptedKey = sodium.crypto_box_seal(key, inboxPublicKey);
return {
v: 1,
key: {
cipher: sodium.to_base64(
encryptedKey,
base64_variants.URLSAFE_NO_PADDING
),
alg: `xsal-x25519-${base64_variants.URLSAFE_NO_PADDING}`,
length: password.length,
},
iv: sodium.to_base64(nonce, base64_variants.URLSAFE_NO_PADDING),
alg: `xcha-argon2i13-${base64_variants.URLSAFE_NO_PADDING}`,
cipher: sodium.to_base64(cipher, base64_variants.URLSAFE_NO_PADDING),
length: data.length,
salt: sodium.to_base64(saltBytes, base64_variants.URLSAFE_NO_PADDING),
};
} catch (error) {
throw new Error(`encryption failed: ${error}`);
}
/**
* Encrypts raw data using OpenPGP with the recipient's public key
*
* @param {string} rawData - The plaintext data to encrypt
* @param {string} rawPublicKey - The recipient's OpenPGP public key
*/
async function encrypt(
rawData: string,
rawPublicKey: string,
): Promise<EncryptedInboxItem> {
const publicKey = await openpgp.readKey({ armoredKey: rawPublicKey });
const message = await openpgp.createMessage({ text: rawData });
const encrypted = await openpgp.encrypt({
message,
encryptionKeys: publicKey,
});
return {
v: 1,
cipher: encrypted,
alg: "pgp-aes256",
};
}
async function getInboxPublicEncryptionKey(apiKey: string) {
async function getInboxPublicEncryptionKey(
apiKey: string,
): Promise<{ status: "unauthorized" } | { status: "ok"; key: string | null }> {
const response = await fetch(
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
{
headers: {
Authorization: apiKey,
},
}
},
);
if (response.status === 401) {
return { status: "unauthorized" };
}
if (!response.ok) {
throw new Error(
`failed to fetch inbox public encryption key: ${await response.text()}`
`failed to fetch inbox public encryption key: ${await response.text()}`,
);
}
const data = (await response.json()) as unknown as any;
return (data?.key as string) || null;
return { status: "ok", key: (data?.key as string) || null };
}
async function postEncryptedInboxItem(
apiKey: string,
item: EncryptedInboxItem
item: EncryptedInboxItem,
) {
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, {
method: "POST",
@@ -131,19 +103,26 @@ app.use(
rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
limit: 60,
})
}),
);
app.post("/inbox", async (req, res) => {
app.get("/health", (_, res) => {
return res.status(200).json({ status: "ok" });
});
app.post("/", async (req, res) => {
try {
const apiKey = req.headers["authorization"];
if (!apiKey) {
return res.status(401).json({ error: "unauthorized" });
}
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey);
if (!inboxPublicKey) {
return res.status(403).json({ error: "inbox public key not found" });
const encryptionKeyResult = await getInboxPublicEncryptionKey(apiKey);
if (encryptionKeyResult.status === "unauthorized") {
return res.status(401).json({ error: "unauthorized" });
}
if (!encryptionKeyResult.key) {
return res.status(404).json({ error: "inbox public key not found" });
}
const inboxPublicKey = encryptionKeyResult.key;
console.log("[info] fetched inbox public key");
const validationResult = RawInboxItemSchema.safeParse(req.body);
@@ -154,9 +133,9 @@ app.post("/inbox", async (req, res) => {
});
}
const encryptedItem = encrypt(
const encryptedItem = await encrypt(
JSON.stringify(validationResult.data),
inboxPublicKey
inboxPublicKey,
);
console.log("[info] encrypted item");
@@ -180,14 +159,9 @@ app.post("/inbox", async (req, res) => {
}
});
(async () => {
await _sodium.ready;
sodium = _sodium;
const PORT = Number(process.env.PORT || "5181");
app.listen(PORT, () => {
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
});
})();
const PORT = Number(process.env.PORT || "5181");
app.listen(PORT, () => {
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
});
export default app;
+1
View File
@@ -39,6 +39,7 @@ namespace Streetwriters.Common
AppId = ApplicationType.NOTESNOOK,
AccountRecoveryRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/recovery",
EmailConfirmedRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/verified",
PackageName = "com.streetwriters.notesnook",
OnEmailConfirmed = async (userId) =>
{
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
+1
View File
@@ -80,6 +80,7 @@ namespace Streetwriters.Common
public static string? SUBSCRIPTIONS_CERT_KEY_PATH => ReadSecret("SUBSCRIPTIONS_CERT_KEY_PATH");
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS")?.Split(",") ?? [];
public static string? SIGNALR_REDIS_CONNECTION_STRING => ReadSecret("SIGNALR_REDIS_CONNECTION_STRING");
public static string MONOGRAPH_PUBLIC_URL => ReadSecret("MONOGRAPH_PUBLIC_URL") ?? "https://monogr.ph";
public static string? ReadSecret(string name)
{
@@ -52,7 +52,8 @@ namespace Streetwriters.Common.Extensions
b.WithOrigins(Constants.NOTESNOOK_CORS_ORIGINS);
b.AllowAnyMethod()
.AllowAnyHeader();
.AllowAnyHeader()
.WithExposedHeaders(["X-Object-Size", "Content-Length"]);
});
});
return services;
+1
View File
@@ -39,6 +39,7 @@ namespace Streetwriters.Common.Models
public required string SenderName { get; set; }
public required string EmailConfirmedRedirectURL { get; set; }
public required string AccountRecoveryRedirectURL { get; set; }
public required string PackageName { get; set; }
public Func<string, Task>? OnEmailConfirmed { get; set; }
}
@@ -34,6 +34,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
@@ -52,17 +53,32 @@ namespace Streetwriters.Identity.Controllers
[Authorize(LocalApi.PolicyName)]
public class AccountController : IdentityControllerBase
{
private static readonly string emailConfirmedPageHtml = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailConfirmedPage.html");
private static readonly string emailConfirmErrorPageHtml = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailConfirmErrorPage.html");
private IPersistedGrantStore PersistedGrantStore { get; set; }
private ITokenGenerationService TokenGenerationService { get; set; }
private IUserAccountService UserAccountService { get; set; }
private EmailAddressValidator EmailValidator { get; set; }
private readonly ILogger<AccountController> logger;
public AccountController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IPersistedGrantStore store,
ITokenGenerationService tokenGenerationService, IMFAService _mfaService, IUserAccountService userAccountService, ILogger<AccountController> logger) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
public AccountController(
UserManager<User> _userManager,
ITemplatedEmailSender _emailSender,
SignInManager<User> _signInManager,
RoleManager<MongoRole> _roleManager,
IPersistedGrantStore store,
ITokenGenerationService tokenGenerationService,
IMFAService _mfaService,
IUserAccountService userAccountService,
ILogger<AccountController> logger,
EmailAddressValidator emailValidator
) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
{
PersistedGrantStore = store;
TokenGenerationService = tokenGenerationService;
UserAccountService = userAccountService;
EmailValidator = emailValidator;
this.logger = logger;
}
@@ -81,10 +97,22 @@ namespace Streetwriters.Identity.Controllers
{
case TokenType.CONFRIM_EMAIL:
{
if (await UserManager.IsEmailConfirmedAsync(user)) return Ok("Email already verified.");
if (await UserManager.IsEmailConfirmedAsync(user))
{
return Content(
emailConfirmedPageHtml.Replace("{{subheading}}", "Your email is already verified."),
"text/html"
);
}
var result = await UserManager.ConfirmEmailAsync(user, code);
if (!result.Succeeded) return BadRequest(result.Errors.ToErrors());
if (!result.Succeeded)
{
return Content(
emailConfirmErrorPageHtml.Replace("{{errors}}", string.Join(" ", result.Errors.ToErrors())),
"text/html"
);
}
if (await UserManager.IsInRoleAsync(user, client.Id) && client.OnEmailConfirmed != null)
{
@@ -94,8 +122,10 @@ namespace Streetwriters.Identity.Controllers
if (!await UserManager.GetTwoFactorEnabledAsync(user))
await MFAService.EnableMFAAsync(user, MFAMethods.Email);
var redirectUrl = $"{client.EmailConfirmedRedirectURL}?userId={userId}";
return RedirectPermanent(redirectUrl);
return Content(
emailConfirmedPageHtml.Replace("{{subheading}}", "Your email has been confirmed."),
"text/html"
);
}
case TokenType.RESET_PASSWORD:
{
@@ -131,6 +161,11 @@ namespace Streetwriters.Identity.Controllers
}
else
{
if (!await EmailValidator.IsEmailAddressValidAsync(newEmail.ToLowerInvariant()))
{
return BadRequest("Invalid email address.");
}
var code = await UserManager.GenerateChangeEmailTokenAsync(user, newEmail);
await EmailSender.SendChangeEmailConfirmationAsync(newEmail, code, client);
}
@@ -155,8 +190,8 @@ namespace Streetwriters.Identity.Controllers
var client = Clients.FindClientById(form.ClientId);
if (client == null) return BadRequest("Invalid client_id.");
var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
var user = await UserManager.FindByEmailAsync(form.Email);
if (user == null || !await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
@@ -24,7 +24,7 @@ namespace Streetwriters.Identity.Interfaces
{
public interface ISMSSender
{
Task<string> SendOTPAsync(string number, IClient client);
Task<string?> SendOTPAsync(string number, IClient client);
Task<bool> VerifyOTPAsync(string id, string code);
}
}
@@ -186,6 +186,8 @@ namespace Streetwriters.Identity.Services
ArgumentNullException.ThrowIfNull(form.PhoneNumber);
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client);
if (string.IsNullOrEmpty(id)) throw new Exception("Failed to send SMS. Please try again.");
logger.LogInformation("SMS OTP sent for user: {UserId}, SMS ID: {SmsId}", user.Id, id);
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
break;
+33 -13
View File
@@ -23,36 +23,56 @@ using Streetwriters.Common;
using Twilio.Rest.Verify.V2.Service;
using Twilio;
using System.Threading.Tasks;
using System;
using Microsoft.Extensions.Logging;
namespace Streetwriters.Identity.Services
{
public class SMSSender : ISMSSender
{
public SMSSender()
private readonly ILogger<SMSSender> Logger;
public SMSSender(ILogger<SMSSender> logger)
{
Logger = logger;
if (!string.IsNullOrEmpty(Constants.TWILIO_ACCOUNT_SID) && !string.IsNullOrEmpty(Constants.TWILIO_AUTH_TOKEN))
{
TwilioClient.Init(Constants.TWILIO_ACCOUNT_SID, Constants.TWILIO_AUTH_TOKEN);
}
}
public async Task<string> SendOTPAsync(string number, IClient app)
public async Task<string?> SendOTPAsync(string number, IClient app)
{
var verification = await VerificationResource.CreateAsync(
to: number,
channel: "sms",
pathServiceSid: Constants.TWILIO_SERVICE_SID
);
return verification.Sid;
try
{
var verification = await VerificationResource.CreateAsync(
to: number,
channel: "sms",
pathServiceSid: Constants.TWILIO_SERVICE_SID
);
return verification.Sid;
}
catch (Exception ex)
{
Logger.LogError(ex, "Error sending OTP with Twilio");
return null;
}
}
public async Task<bool> VerifyOTPAsync(string id, string code)
{
return (await VerificationCheckResource.CreateAsync(
verificationSid: id,
pathServiceSid: Constants.TWILIO_SERVICE_SID,
code: code
)).Status == "approved";
try
{
return (await VerificationCheckResource.CreateAsync(
verificationSid: id,
pathServiceSid: Constants.TWILIO_SERVICE_SID,
code: code
)).Status == "approved";
}
catch (Exception ex)
{
Logger.LogError(ex, "Error verifying OTP with Twilio");
return false;
}
}
}
}
@@ -185,7 +185,14 @@ namespace Streetwriters.Identity.Services
};
}
return SignupResponse.Error(result.Errors.ToErrors());
var otherErrors = result.Errors
.Where(e => e.Code != "DuplicateUserName" && e.Code != "DuplicateEmail")
.ToErrors();
var hasDuplicate = result.Errors.Any(e => e.Code == "DuplicateUserName" || e.Code == "DuplicateEmail");
var errors = hasDuplicate
? ["Unable to create an account on this email.", .. otherErrors]
: otherErrors;
return SignupResponse.Error(errors);
}
catch (System.Exception ex)
{
@@ -504,7 +504,6 @@
text-align: start;
text-indent: 0px;
text-transform: none;
white-space: pre-wrap;
widows: 2;
word-spacing: 0px;
-webkit-text-stroke-width: 0px;
@@ -536,7 +535,6 @@
text-align: start;
text-indent: 0px;
text-transform: none;
white-space: pre-wrap;
widows: 2;
word-spacing: 0px;
-webkit-text-stroke-width: 0px;
@@ -567,7 +565,6 @@
text-align: start;
text-indent: 0px;
text-transform: none;
white-space: pre-wrap;
widows: 2;
word-spacing: 0px;
-webkit-text-stroke-width: 0px;
@@ -309,7 +309,6 @@
text-align: start;
text-indent: 0px;
text-transform: none;
white-space: pre-wrap;
widows: 2;
word-spacing: 0px;
-webkit-text-stroke-width: 0px;
@@ -401,7 +401,6 @@
text-align: start;
text-indent: 0px;
text-transform: none;
white-space: pre-wrap;
word-spacing: 0px;
-webkit-text-stroke-width: 0px;
background-color: rgb(
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Confirmation Failed - Notesnook</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background: #ffffff;
height: 100vh;
display: flex;
flex-direction: column;
overflow-y: auto;
font-size: 16px;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.icon-wrapper {
background: #fdecea;
border-radius: 100px;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-wrapper svg {
width: 72px;
height: 72px;
fill: #c0392b;
}
.heading {
font-size: 2.5em;
font-weight: 700;
text-align: center;
margin-top: 20px;
color: #000;
}
.subheading {
font-size: 1.5em;
font-weight: 600;
text-align: center;
margin-top: 8px;
color: #5b5b5b;
}
.body-text {
font-size: 1.2em;
text-align: center;
margin-top: 8px;
color: #808080;
word-wrap: break-word;
}
.footer {
background: #f0f0f0;
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.footer-heading {
font-size: 1.2em;
font-weight: bold;
text-align: center;
}
.footer-subtext {
font-size: 1em;
text-align: center;
margin-top: 8px;
color: #5b5b5b;
}
@media (max-width: 480px) {
body {
font-size: 14px;
}
}
@media (min-width: 769px) {
body {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="main">
<div class="icon-wrapper">
<!-- Mail X Icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M13 19C13 18.66 13.04 18.33 13.09 18H4V8L12 13L20 8V13.09C20.72 13.21 21.39 13.46 22 13.81V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H13.09C13.04 19.67 13 19.34 13 19M20 6L12 11L4 6H20M21.12 15.46L19 17.59L16.88 15.46L15.47 16.88L17.59 19L15.47 21.12L16.88 22.54L19 20.41L21.12 22.54L22.54 21.12L20.41 19L22.54 16.88L21.12 15.46Z"
/>
</svg>
</div>
<h1 class="heading">Uh oh!</h1>
<p class="subheading">Email confirmation failed. Please try again!</p>
<p class="body-text">{{errors}}</p>
</div>
<div class="footer">
<h2 class="footer-heading">Notesnook</h2>
<p class="footer-subtext">Privacy for everyone</p>
</div>
</body>
</html>
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Email Confirmed - Notesnook</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
background: #ffffff;
height: 100vh;
display: flex;
flex-direction: column;
overflow-y: auto;
font-size: 16px;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.icon-wrapper {
background: #e8f5e9;
border-radius: 100px;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.icon-wrapper svg {
width: 72px;
height: 72px;
fill: #008837;
}
.heading {
font-size: 2.5em;
font-weight: 700;
text-align: center;
margin-top: 20px;
color: #000;
}
.subheading {
font-size: 1.5em;
font-weight: 600;
text-align: center;
margin-top: 8px;
color: #5b5b5b;
}
.body-text {
font-size: 1.2em;
text-align: center;
margin-top: 8px;
color: #808080;
word-wrap: break-word;
}
.footer {
background: #f0f0f0;
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.footer-heading {
font-size: 1.2em;
font-weight: bold;
text-align: center;
}
.footer-subtext {
font-size: 1em;
text-align: center;
margin-top: 8px;
color: #5b5b5b;
}
.social-icons {
display: flex;
gap: 8px;
margin-top: 20px;
}
.social-icons a {
display: flex;
align-items: center;
justify-content: center;
color: #5b5b5b;
cursor: pointer;
text-decoration: none;
}
.social-icons a:hover svg {
opacity: 0.8;
}
.social-icons svg {
width: 30px;
height: 30px;
}
.promo-text {
font-size: 0.85em;
text-align: center;
margin-top: 8px;
color: #5b5b5b;
}
.promo-text .hashtag {
font-weight: bold;
color: #008837;
}
@media (max-width: 480px) {
body {
font-size: 14px;
}
}
@media (min-width: 769px) {
body {
font-size: 18px;
}
}
</style>
</head>
<body>
<div class="main">
<div class="icon-wrapper">
<!-- Mail Check Icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M13 19C13 18.66 13.04 18.33 13.09 18H4V8L12 13L20 8V13.09C20.72 13.21 21.39 13.46 22 13.81V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H13.09C13.04 19.67 13 19.34 13 19M20 6L12 11L4 6H20M17.75 22.16L15 19.16L16.16 18L17.75 19.59L21.34 16L22.5 17.41L17.75 22.16"
/>
</svg>
</div>
<h1 class="heading">Huzzah!</h1>
<p class="subheading">{{subheading}}</p>
<p class="body-text">
Thank you for choosing end-to-end encrypted note taking. Now you can
sync your notes to unlimited devices.
</p>
</div>
<div class="footer">
<h2 class="footer-heading">Share Notesnook with friends!</h2>
<p class="footer-subtext">Because where's the fun in nookin' alone?</p>
<div class="social-icons">
<!-- Discord -->
<a
href="https://discord.com/invite/zQBK97EE22"
target="_blank"
rel="noopener noreferrer"
title="Discord"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</a>
<!-- Twitter -->
<a
href="https://twitter.com/notesnook"
target="_blank"
rel="noopener noreferrer"
title="Twitter"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M23.953 4.57a10 10 0 0 1-2.825.775 4.958 4.958 0 0 0 2.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 0 0-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 0 0-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 0 1-2.228-.616v.06a4.923 4.923 0 0 0 3.946 4.827 4.996 4.996 0 0 1-2.212.085 4.936 4.936 0 0 0 4.604 3.417 9.867 9.867 0 0 1-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 0 0 7.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0 0 24 4.59z"
/>
</svg>
</a>
<!-- Reddit -->
<a
href="https://reddit.com/r/Notesnook"
target="_blank"
rel="noopener noreferrer"
title="Reddit"
>
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
/>
</svg>
</a>
</div>
<p class="promo-text">
Use <span class="hashtag">#notesnook</span> and get a chance to win free
promo codes.
</p>
</div>
</body>
</html>
@@ -467,7 +467,6 @@
text-align: start;
text-indent: 0px;
text-transform: none;
white-space: pre-wrap;
widows: 2;
word-spacing: 0px;
-webkit-text-stroke-width: 0px;
@@ -483,9 +482,7 @@
display: inline;
"
><em
>If you did not request to reset
your account password, you can
safely ignore this email.</em
>If you did not request to reset your account password, you can safely ignore this email.</em
></span
>
</div>
@@ -554,7 +551,6 @@
>
<tbody>
<tr>
.
<td
style="
padding: 18px 0px 18px 0px;
+31 -10
View File
@@ -18,28 +18,49 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System.Linq;
using System;
using System.Threading;
using System.Threading.Tasks;
using Lib.AspNetCore.ServerSentEvents;
using System.Security.Claims;
using System.Collections.Generic;
namespace Streetwriters.Messenger.Helpers
{
public class SSEHelper
{
public static async Task SendEventToUserAsync(string data, IServerSentEventsService sseService, string userId, string? originTokenId = null)
public static async Task SendEventToUserAsync(string data, IServerSentEventsService sseService, string userId, string? originTokenId = null, CancellationToken cancellationToken = default)
{
var clients = sseService.GetClients().Where(c => c.User.FindFirstValue("sub") == userId);
foreach (var client in clients)
{
if (originTokenId != null && client.User.FindFirstValue("jti") == originTokenId) continue;
if (!client.IsConnected) continue;
await client.SendEventAsync(data);
}
var clients = sseService.GetClients()
.Where(c => c.User?.FindFirstValue("sub") == userId)
.Where(c => originTokenId == null || c.User?.FindFirstValue("jti") != originTokenId);
await SendEventToClientsAsync(clients, data, cancellationToken);
}
public static async Task SendEventToAllUsersAsync(string data, IServerSentEventsService sseService)
public static async Task SendEventToAllUsersAsync(string data, IServerSentEventsService sseService, CancellationToken cancellationToken = default)
{
await sseService.SendEventAsync(data);
await SendEventToClientsAsync(sseService.GetClients(), data, cancellationToken);
}
private static async Task SendEventToClientsAsync(IEnumerable<IServerSentEventsClient> clients, string data, CancellationToken cancellationToken)
{
foreach (var client in clients)
{
if (!client.IsConnected) continue;
try
{
await client.SendEventAsync(data, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
}
catch
{
}
}
}
}
}
@@ -21,6 +21,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Lib.AspNetCore.ServerSentEvents;
using Streetwriters.Messenger.Helpers;
using System.Text.Json;
@@ -33,12 +34,14 @@ namespace Streetwriters.Messenger.Services
private const string HEARTBEAT_MESSAGE_FORMAT = "Streetwriters Heartbeat ({0} UTC)";
private readonly IServerSentEventsService _serverSentEventsService;
private readonly ILogger<HeartbeatService> _logger;
#endregion
#region Constructor
public HeartbeatService(IServerSentEventsService serverSentEventsService)
public HeartbeatService(IServerSentEventsService serverSentEventsService, ILogger<HeartbeatService> logger)
{
_serverSentEventsService = serverSentEventsService;
_logger = logger;
}
#endregion
@@ -47,15 +50,28 @@ namespace Streetwriters.Messenger.Services
{
while (!stoppingToken.IsCancellationRequested)
{
var message = JsonSerializer.Serialize(new
try
{
type = "heartbeat",
data = JsonSerializer.Serialize(new
var message = JsonSerializer.Serialize(new
{
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
})
});
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService);
type = "heartbeat",
data = JsonSerializer.Serialize(new
{
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
})
});
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send SSE heartbeat to one or more clients.");
}
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="2.3.0" />
<PackageReference Include="Lib.AspNetCore.ServerSentEvents" Version="6.0.0" />
<PackageReference Include="Lib.AspNetCore.ServerSentEvents" Version="9.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.0"
NoWarn="NU1605" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.0"
+7 -7
View File
@@ -39,7 +39,7 @@ function isValidUrl(urlString: string): boolean {
// Handle proxied request with redirect support
async function proxyRequest(
targetUrl: string,
redirectCount = 0
redirectCount = 0,
): Promise<Response> {
if (redirectCount >= MAX_REDIRECTS) {
return new Response("Too many redirects", {
@@ -147,7 +147,7 @@ const server = Bun.serve({
method2: "GET /?url=<encoded-url>",
example1: `${url.origin}/https://example.com/image.jpg`,
example2: `${url.origin}/?url=${encodeURIComponent(
"https://example.com/image.jpg"
"https://example.com/image.jpg",
)}`,
},
endpoints: {
@@ -190,7 +190,7 @@ const server = Bun.serve({
{
status: 400,
headers: corsHeaders,
}
},
);
}
@@ -218,8 +218,8 @@ const server = Bun.serve({
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "frame-ancestors *",
"X-Frame-Options": "ALLOWALL",
// "Content-Security-Policy": "frame-ancestors *",
// "X-Frame-Options": "ALLOWALL",
},
});
}
@@ -239,7 +239,7 @@ const server = Bun.serve({
});
console.log(
`🚀 CORS Proxy Server running on http://${server.hostname}:${server.port}`
`🚀 CORS Proxy Server running on http://${server.hostname}:${server.port}`,
);
console.log(`📋 Health check: http://${server.hostname}:${server.port}/health`);
console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`);
@@ -280,7 +280,7 @@ function serveYouTubeEmbed(url: string) {
</head>
<body>
<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>
</body>
</html>`;