/* 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 . */ 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 Notesnook.API.Models; using Notesnook.API.Services; using Streetwriters.Common; using Streetwriters.Common.Helpers; using Streetwriters.Common.Interfaces; using Streetwriters.Common.Messages; using Streetwriters.Data.Interfaces; using Streetwriters.Data.Repositories; namespace Notesnook.API.Controllers { [ApiController] [Route("monographs")] [Authorize("Sync")] public class MonographsController(Repository monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, ILogger logger) : ControllerBase { const string SVG_PIXEL = ""; private const int MAX_DOC_SIZE = 15 * 1024 * 1024; private static FilterDefinition CreateMonographFilter(string userId, Monograph monograph) { var userIdFilter = Builders.Filter.Eq("UserId", userId); monograph.ItemId ??= monograph.Id; return ObjectId.TryParse(monograph.ItemId, out ObjectId id) ? Builders.Filter .And(userIdFilter, Builders.Filter.Or( Builders.Filter.Eq("_id", id), Builders.Filter.Eq("ItemId", monograph.ItemId) ) ) : Builders.Filter .And(userIdFilter, Builders.Filter.Eq("ItemId", monograph.ItemId) ); } private static FilterDefinition CreateMonographFilter(string itemId) { return ObjectId.TryParse(itemId, out ObjectId id) ? Builders.Filter.Or( Builders.Filter.Eq("_id", id), Builders.Filter.Eq("ItemId", itemId)) : Builders.Filter.Eq("ItemId", itemId); } private async Task FindMonographAsync(string userId, Monograph monograph) { var result = await monographs.Collection.FindAsync(CreateMonographFilter(userId, monograph), new FindOptions { Limit = 1 }); return await result.FirstOrDefaultAsync(); } private async Task FindMonographAsync(string itemId) { var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions { Limit = 1 }); return await result.FirstOrDefaultAsync(); } [HttpPost] public async Task PublishAsync([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); if (monograph.EncryptedContent == null) monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli(); 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."); if (existingMonograph != null) { monograph.Id = existingMonograph.Id; } monograph.Deleted = false; monograph.ViewCount = 0; 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(); } } [HttpPatch] public async Task UpdateAsync([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 NotFound(); } 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."); if (monograph.EncryptedContent == null) monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli(); else monograph.Content = null; monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); var result = await monographs.Collection.UpdateOneAsync( CreateMonographFilter(userId, monograph), Builders.Update .Set(m => m.DatePublished, monograph.DatePublished) .Set(m => m.CompressedContent, monograph.CompressedContent) .Set(m => m.EncryptedContent, monograph.EncryptedContent) .Set(m => m.SelfDestruct, monograph.SelfDestruct) .Set(m => m.Title, monograph.Title) .Set(m => m.Password, monograph.Password) ); if (!result.IsAcknowledged) return BadRequest(); 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 update monograph"); return BadRequest(); } } [HttpGet] public async Task GetUserMonographsAsync() { var userId = this.User.GetUserId(); var userMonographs = (await monographs.Collection.FindAsync( Builders.Filter.And( Builders.Filter.Eq("UserId", userId), Builders.Filter.Ne("Deleted", true) ) , new FindOptions { Projection = Builders.Projection.Include("_id").Include("ItemId"), })).ToEnumerable(); return Ok(userMonographs.Select((m) => m.ItemId ?? m.Id)); } [HttpGet("{id}")] [AllowAnonymous] public async Task GetMonographAsync([FromRoute] string id) { var monograph = await FindMonographAsync(id); if (monograph == null || monograph.Deleted) { return NotFound(new { error = "invalid_id", error_description = $"No such monograph found." }); } if (monograph.EncryptedContent == null) monograph.Content = monograph.CompressedContent?.DecompressBrotli(); monograph.ItemId ??= monograph.Id; return Ok(monograph); } [HttpGet("{id}/view")] [AllowAnonymous] public async Task TrackView([FromRoute] string id) { var monograph = await FindMonographAsync(id); 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.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); } return Content(SVG_PIXEL, "image/svg+xml"); } [HttpGet("{id}/analytics")] public async Task GetMonographAnalyticsAsync([FromRoute] string id) { if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User)) return BadRequest(new { error = "Monograph analytics are only available on the Pro & Believer plans." }); var userId = this.User.GetUserId(); var monograph = await FindMonographAsync(id); if (monograph == null || monograph.Deleted || monograph.UserId != userId) { return NotFound(); } return Ok(new { totalViews = monograph.ViewCount }); } [HttpDelete("{id}")] public async Task DeleteAsync([FromQuery] string? deviceId, [FromRoute] string id) { var userId = this.User.GetUserId(); var monograph = await FindMonographAsync(id); if (monograph == null || monograph.Deleted) return Ok(); var jti = this.User.FindFirstValue("jti"); await monographs.Collection.ReplaceOneAsync( CreateMonographFilter(userId, monograph), new Monograph { ItemId = id, Id = monograph.Id, Deleted = true, UserId = monograph.UserId, ViewCount = 0 } ); await MarkMonographForSyncAsync(userId, id, deviceId, jti); return Ok(); } private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti) { if (deviceId == null) return; await syncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, [new(monographId, "monograph")]); } private async Task MarkMonographForSyncAsync(string userId, string monographId) { await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(monographId, "monograph")]); } private async Task CleanupContentAsync(ClaimsPrincipal user, string? content) { if (string.IsNullOrEmpty(content)) return string.Empty; if (Constants.IS_SELF_HOSTED) return content; try { var json = JsonSerializer.Deserialize(content) ?? throw new Exception("Invalid monograph content."); var html = json.Data; if (user.IsUserSubscribed()) { 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")) { var href = element.GetAttribute("href"); if (string.IsNullOrEmpty(href)) continue; if (!await analyzer.IsURLSafeAsync(href)) { logger.LogInformation("Malicious URL detected: {Url}", href); element.RemoveAttribute("href"); } } html = document.ToHtml(); } else { 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) element.RemoveAttribute(attr.Name); } html = document.ToHtml(); } return JsonSerializer.Serialize(new MonographContent { Type = json.Type, Data = html }); } catch (Exception ex) { logger.LogError(ex, "Failed to cleanup monograph content"); return content; } } } }