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
31 changed files with 480 additions and 206 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
@@ -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[]
+4 -20
View File
@@ -151,34 +151,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();
+107 -30
View File
@@ -18,27 +18,27 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using AngleSharp;
using AngleSharp.Dom;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using Notesnook.API.Authorization;
using NanoidDotNet;
using Notesnook.API.Extensions;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Controllers
@@ -46,7 +46,7 @@ namespace Notesnook.API.Controllers
[ApiController]
[Route("monographs")]
[Authorize("Sync")]
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, ILogger<MonographsController> logger) : ControllerBase
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, WampServiceAccessor serviceAccessor, ILogger<MonographsController> logger) : ControllerBase
{
const string SVG_PIXEL = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><circle r='9'/></svg>";
private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
@@ -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.Eq("_id", id),
Builders<Monograph>.Filter.Eq("ItemId", itemId))
: Builders<Monograph>.Filter.Eq("ItemId", itemId);
Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug))
: 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)
@@ -86,15 +88,20 @@ namespace Notesnook.API.Controllers
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
});
return await result.FirstOrDefaultAsync();
}
private static string GenerateSlug()
{
return Nanoid.Generate(size: 24);
}
[HttpPost]
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 (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.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -120,6 +131,7 @@ namespace Notesnook.API.Controllers
}
monograph.Deleted = false;
monograph.ViewCount = 0;
monograph.Slug = GenerateSlug();
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
monograph,
@@ -131,7 +143,8 @@ namespace Notesnook.API.Controllers
return Ok(new
{
id = monograph.ItemId,
datePublished = monograph.DatePublished
datePublished = monograph.DatePublished,
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph)
});
}
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)
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
var sanitizationLevel = ContentSanitizationLevel.Unknown;
if (monograph.EncryptedContent == null)
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
{
sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
}
else
monograph.Content = null;
@@ -173,6 +190,7 @@ namespace Notesnook.API.Controllers
.Set(m => m.SelfDestruct, monograph.SelfDestruct)
.Set(m => m.Title, monograph.Title)
.Set(m => m.Password, monograph.Password)
.Set(m => m.ContentSanitizationLevel, sanitizationLevel)
);
if (!result.IsAcknowledged) return BadRequest();
@@ -181,7 +199,8 @@ namespace Notesnook.API.Controllers
return Ok(new
{
id = monograph.ItemId,
datePublished = monograph.DatePublished
datePublished = monograph.DatePublished,
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(existingMonograph)
});
}
catch (Exception e)
@@ -213,7 +232,7 @@ namespace Notesnook.API.Controllers
public async Task<IActionResult> GetMonographAsync([FromRoute] string 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
{
@@ -223,7 +242,22 @@ 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);
}
@@ -233,7 +267,8 @@ 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 || (monograph.Slug != null && monograph.Slug != id))
return Content(SVG_PIXEL, "image/svg+xml");
var cookieName = $"viewed_{id}";
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
@@ -241,7 +276,7 @@ namespace Notesnook.API.Controllers
if (monograph.SelfDestruct)
{
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(monograph.UserId, monograph),
CreateMonographFilter(monograph.UserId!, monograph),
new Monograph
{
ItemId = id,
@@ -251,12 +286,12 @@ namespace Notesnook.API.Controllers
ViewCount = 0
}
);
await MarkMonographForSyncAsync(monograph.UserId, id);
await MarkMonographForSyncAsync(monograph.UserId!, id);
}
else if (!hasVisitedBefore)
{
await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(monograph.UserId, monograph),
CreateMonographFilter(monograph.UserId!, monograph),
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
);
@@ -274,6 +309,7 @@ namespace Notesnook.API.Controllers
}
[HttpGet("{id}/analytics")]
[Obsolete("This endpoint is deprecated and will be removed in future versions. Use GET /monographs/{id}/metadata instead.")]
public async Task<IActionResult> GetMonographAnalyticsAsync([FromRoute] string id)
{
if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User))
@@ -317,6 +353,29 @@ namespace Notesnook.API.Controllers
return Ok();
}
[HttpGet("{id}/metadata")]
public async Task<IActionResult> GetMetadataAsync([FromRoute] string id)
{
var userId = this.User.GetUserId();
var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted || monograph.UserId != userId)
{
return NotFound();
}
var isPro = FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User);
var totalViews = isPro ? monograph.ViewCount : 0;
return Ok(new
{
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph),
analytics = new
{
totalViews
}
});
}
private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
{
if (deviceId == null) return;
@@ -329,7 +388,20 @@ namespace Notesnook.API.Controllers
await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(monographId, "monograph")]);
}
private async Task<string> CleanupContentAsync(ClaimsPrincipal user, string? content)
// (selector, url-bearing attribute) pairs to inspect
private static readonly (string Selector, string Attribute)[] urlElements =
[
("a", "href"),
("img", "src"),
("iframe", "src"),
("embed", "src"),
("object", "data"),
("source", "src"),
("video", "src"),
("audio", "src"),
];
private async Task<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
{
if (string.IsNullOrEmpty(content)) return string.Empty;
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 html = json.Data;
if (user.IsUserSubscribed())
if (level == ContentSanitizationLevel.Partial)
{
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(r => r.Content(html));
foreach (var element in document.QuerySelectorAll("a"))
foreach (var (selector, attribute) in urlElements)
{
var href = element.GetAttribute("href");
if (string.IsNullOrEmpty(href)) continue;
if (!await analyzer.IsURLSafeAsync(href))
foreach (var element in document.QuerySelectorAll(selector))
{
logger.LogInformation("Malicious URL detected: {Url}", href);
element.RemoveAttribute("href");
var url = element.GetAttribute(attribute);
if (string.IsNullOrEmpty(url)) continue;
if (!await analyzer.IsURLSafeAsync(url))
{
logger.LogInformation("Malicious URL detected in <{Selector} {Attribute}>: {Url}", selector, attribute, url);
element.RemoveAttribute(attribute);
}
}
}
html = document.ToHtml();
}
else
else if (level == ContentSanitizationLevel.Full)
{
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(r => r.Content(html));
foreach (var element in document.QuerySelectorAll("a,iframe,img,object,svg,button,link"))
{
foreach (var attr in element.Attributes)
foreach (var attr in element.Attributes.ToList())
element.RemoveAttribute(attr.Name);
}
html = document.ToHtml();
+19 -19
View File
@@ -210,25 +210,25 @@ namespace Notesnook.API.Controllers
}
}
[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." });
}
// [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." });
}
}
// 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." });
// }
// }
}
}
+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 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;
@@ -130,13 +132,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
@@ -275,15 +283,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.");
-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";
}
}
@@ -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
}
}
+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;
}
+6
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; }
@@ -83,5 +86,8 @@ namespace Notesnook.API.Models
[JsonPropertyName("viewCount")]
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.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
@@ -17,6 +17,7 @@
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="6.0.1-rc2.2" />
<PackageReference Include="AWSSDK.S3" Version="3.7.310.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="6.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" Version="9.0.13" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.2.0" />
<PackageReference Include="Nanoid" Version="3.1.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-alpha.2" />
@@ -49,7 +49,7 @@ namespace Notesnook.API.Repositories
this.logger = logger;
}
private readonly List<string> ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7];
private readonly List<string> ALGORITHMS = [Algorithms.Default];
private bool IsValidAlgorithm(string algorithm)
{
return ALGORITHMS.Contains(algorithm);
+4 -4
View File
@@ -82,7 +82,7 @@ namespace Notesnook.API.Services
if (chunk != null)
{
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),
update
);
@@ -96,11 +96,11 @@ namespace Notesnook.API.Services
Key = key,
Ids = [.. ids.Select(i => i.ToString())]
};
await repositories.DeviceIdsChunks.Collection.InsertOneAsync(newChunk);
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).InsertOneAsync(newChunk);
}
var emptyChunksFilter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Size(x => x.Ids, 0);
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(emptyChunksFilter);
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).DeleteManyAsync(emptyChunksFilter);
}
public async Task WriteIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
@@ -121,7 +121,7 @@ namespace Notesnook.API.Services
};
writes.Add(new InsertOneModel<DeviceIdsChunk>(newChunk));
}
await repositories.DeviceIdsChunks.Collection.BulkWriteAsync(writes);
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).BulkWriteAsync(writes);
}
public async Task<HashSet<ItemKey>> FetchUnsyncedIdsAsync(string userId, string deviceId)
+2
View File
@@ -184,6 +184,8 @@ namespace Notesnook.API.Services
};
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
}
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
}
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
+4 -1
View File
@@ -210,13 +210,16 @@ namespace Notesnook.API
services.AddHealthChecks();
services.AddSignalR((hub) =>
var signalR = services.AddSignalR((hub) =>
{
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
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.AddResponseCompression(options =>
{
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
+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());
+37 -72
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(),
@@ -31,62 +29,31 @@ 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) {
@@ -96,11 +63,11 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
headers: {
Authorization: apiKey,
},
}
},
);
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()}`,
);
}
@@ -110,7 +77,7 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
async function postEncryptedInboxItem(
apiKey: string,
item: EncryptedInboxItem
item: EncryptedInboxItem,
) {
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, {
method: "POST",
@@ -131,9 +98,12 @@ 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) {
@@ -154,9 +124,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 +150,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
+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_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)
{
@@ -9,6 +9,8 @@ namespace Streetwriters.Common.Interfaces
{
[WampProcedure("co.streetwriters.subscriptions.subscriptions.get_user_subscription")]
Task<Subscription?> GetUserSubscriptionAsync(string clientId, string userId);
[WampProcedure("co.streetwriters.subscriptions.subscriptions.is_user_subscribed")]
Task<bool> IsUserSubscribedAsync(string clientId, string userId);
Subscription TransformUserSubscription(Subscription subscription);
}
}
+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; }
}
+5 -1
View File
@@ -1,6 +1,9 @@
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Streetwriters.Common.Interfaces;
@@ -22,7 +25,8 @@ namespace Streetwriters.Common.Services
public async Task<bool> IsURLSafeAsync(string uri)
{
if (string.IsNullOrEmpty(Constants.WEBRISK_API_URI)) return true;
var response = await httpClient.PostAsJsonAsync(Constants.WEBRISK_API_URI, new { uri });
var body = new StringContent(JsonSerializer.Serialize(new { uri }), Encoding.UTF8, new MediaTypeHeaderValue("application/json"));
var response = await httpClient.PostAsync(Constants.WEBRISK_API_URI, body);
if (!response.IsSuccessStatusCode) return true;
var json = await response.Content.ReadFromJsonAsync<WebRiskAPIResponse>();
return json.Threat.ThreatTypes == null || json.Threat.ThreatTypes.Length == 0;
@@ -55,14 +55,26 @@ namespace Streetwriters.Identity.Controllers
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;
}
@@ -131,6 +143,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);
}
+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>`;