Compare commits

..
Author SHA1 Message Date
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
13 changed files with 636 additions and 104 deletions
+1 -1
View File
@@ -63,7 +63,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
+182 -77
View File
@@ -68,15 +68,13 @@ namespace Notesnook.API.Controllers
);
}
private static FilterDefinition<Monograph> CreateMonographFilter(string itemIdOrSlug)
private static FilterDefinition<Monograph> CreateMonographFilter(string itemId)
{
return ObjectId.TryParse(itemIdOrSlug, out ObjectId id)
return ObjectId.TryParse(itemId, out ObjectId id)
? Builders<Monograph>.Filter.Or(
Builders<Monograph>.Filter.Eq("_id", id),
Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug))
: Builders<Monograph>.Filter.Or(
Builders<Monograph>.Filter.Eq("Slug", itemIdOrSlug),
Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug));
Builders<Monograph>.Filter.Eq("ItemId", itemId))
: Builders<Monograph>.Filter.Eq("ItemId", itemId);
}
private async Task<Monograph> FindMonographAsync(string userId, Monograph monograph)
@@ -88,19 +86,37 @@ namespace Notesnook.API.Controllers
return await result.FirstOrDefaultAsync();
}
private async Task<Monograph> FindMonographAsync(string itemIdOrSlug)
private async Task<Monograph> FindMonographAsync(string itemId)
{
var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemIdOrSlug), new FindOptions<Monograph>
var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions<Monograph>
{
Limit = 1
});
return await result.FirstOrDefaultAsync();
}
// private static string GenerateSlug()
// {
// return Nanoid.Generate(size: 24);
// }
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)
@@ -113,25 +129,52 @@ namespace Notesnook.API.Controllers
var existingMonograph = await FindMonographAsync(userId, monograph);
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
if (monograph.EncryptedContent == null)
monograph = await CreateMonographAsync(monograph, userId);
if (existingMonograph != null)
{
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
monograph.ContentSanitizationLevel = sanitizationLevel;
monograph.Id = existingMonograph.Id;
}
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.");
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,
});
}
catch (Exception e)
{
logger.LogError(e, "Failed to publish monograph");
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;
}
monograph.Deleted = false;
monograph.ViewCount = 0;
// monograph.Slug = GenerateSlug();
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
monograph,
@@ -150,7 +193,7 @@ namespace Notesnook.API.Controllers
catch (Exception e)
{
logger.LogError(e, "Failed to publish monograph");
return BadRequest();
return BadRequest(new { error = e.Message });
}
}
@@ -206,7 +249,7 @@ namespace Notesnook.API.Controllers
catch (Exception e)
{
logger.LogError(e, "Failed to update monograph");
return BadRequest();
return BadRequest(new { error = e.Message });
}
}
@@ -241,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")]
@@ -271,43 +296,41 @@ namespace Notesnook.API.Controllers
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)
@@ -401,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;
+9 -1
View File
@@ -31,11 +31,19 @@ namespace Notesnook.API.Helpers
}
public static string ConstructPublishUrl(Monograph monograph)
{
return ConstructPublishUrl(monograph.Slug ?? monograph.ItemId ?? monograph.Id);
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);
}
}
+1 -1
View File
@@ -257,7 +257,7 @@ namespace Notesnook.API.Hubs
ids,
size: 100,
resetSync: device.IsSyncReset,
maxBytes: 7 * 1024 * 1024
maxBytes: 3 * 1024 * 1024
);
await foreach (var chunk in chunks)
+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()
+7
View File
@@ -213,6 +213,7 @@ 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();
@@ -270,6 +271,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);
@@ -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,6 +53,9 @@ 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; }
@@ -93,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)
{
@@ -106,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:
{
@@ -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)
{
@@ -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>
+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"