From 73750613c435bcf411dd21b3f75a9d11e1356ab6 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Thu, 2 Apr 2026 15:00:56 +0500 Subject: [PATCH] monograph: move slug handling behind v2 endpoint --- .../Controllers/MonographsController.cs | 259 ++++++++++++------ Notesnook.API/Helpers/UrlHelper.cs | 10 +- 2 files changed, 191 insertions(+), 78 deletions(-) diff --git a/Notesnook.API/Controllers/MonographsController.cs b/Notesnook.API/Controllers/MonographsController.cs index 7aefa0c..a26b8b5 100644 --- a/Notesnook.API/Controllers/MonographsController.cs +++ b/Notesnook.API/Controllers/MonographsController.cs @@ -68,15 +68,13 @@ namespace Notesnook.API.Controllers ); } - private static FilterDefinition CreateMonographFilter(string itemIdOrSlug) + private static FilterDefinition CreateMonographFilter(string itemId) { - return ObjectId.TryParse(itemIdOrSlug, out ObjectId id) + return ObjectId.TryParse(itemId, out ObjectId id) ? Builders.Filter.Or( Builders.Filter.Eq("_id", id), - Builders.Filter.Eq("ItemId", itemIdOrSlug)) - : Builders.Filter.Or( - Builders.Filter.Eq("Slug", itemIdOrSlug), - Builders.Filter.Eq("ItemId", itemIdOrSlug)); + Builders.Filter.Eq("ItemId", itemId)) + : Builders.Filter.Eq("ItemId", itemId); } private async Task FindMonographAsync(string userId, Monograph monograph) @@ -88,19 +86,37 @@ namespace Notesnook.API.Controllers return await result.FirstOrDefaultAsync(); } - private async Task FindMonographAsync(string itemIdOrSlug) + private async Task FindMonographAsync(string itemId) { - var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemIdOrSlug), new FindOptions + var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions { Limit = 1 }); return await result.FirstOrDefaultAsync(); } - // private static string GenerateSlug() - // { - // return Nanoid.Generate(size: 24); - // } + private async Task FindMonographBySlugAsync(string slug) + { + var result = await monographs.Collection.FindAsync( + Builders.Filter.Eq("Slug", slug), new FindOptions + { + Limit = 1 + }); + return await result.FirstOrDefaultAsync(); + } + + private async Task 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.Filter.Eq("Slug", slug)) + .Limit(1) + .AnyAsync(); + if (!exists) return slug; + } + throw new Exception("Failed to generate unique slug"); + } [HttpPost] public async Task 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 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.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.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 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 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 GetMonographAnalyticsAsync([FromRoute] string id) @@ -401,6 +424,88 @@ namespace Notesnook.API.Controllers ("audio", "src"), ]; + private async Task 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.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 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.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 SanitizeContentAsync(string? content, ContentSanitizationLevel level) { if (string.IsNullOrEmpty(content)) return string.Empty; diff --git a/Notesnook.API/Helpers/UrlHelper.cs b/Notesnook.API/Helpers/UrlHelper.cs index 5f93afe..daafd94 100644 --- a/Notesnook.API/Helpers/UrlHelper.cs +++ b/Notesnook.API/Helpers/UrlHelper.cs @@ -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); } }