monograph: add sync support (#39)

* monograph: add sync support

* monograph: fix password field && improve syncing logic && fix delete endpoint

* sync: get rid of unnecessary .ToList & ToListAsync

* sync: AddIdsToAllDevices is no longer asynchronous

* monograph: simplify and fix several bugs

- we were sending the triggerSync event to all users instead of all devices
- asynchronous methods did not have the `Async` suffix
- we weren't properly replacing the deleted monograph

* monograph: fix minor issues
* fix publishing
* don't return deleted monograph in monographs/:id endpoint
* persist UserId when soft deleting monograph

* monograph: check soft delete status in several endpoints

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
This commit is contained in:
01zulfi
2025-08-04 11:51:15 +05:00
committed by GitHub
parent 1e2ef0685d
commit bf2e6efeff
4 changed files with 151 additions and 20 deletions

View File

@@ -18,16 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using MongoDB.Driver;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Streetwriters.Common;
using Streetwriters.Common.Messages;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
@@ -92,14 +95,18 @@ namespace Notesnook.API.Controllers
}
[HttpPost]
public async Task<IActionResult> PublishAsync([FromBody] Monograph monograph)
public async Task<IActionResult> PublishAsync([FromQuery] string deviceId, [FromBody] Monograph monograph)
{
try
{
var userId = this.User.FindFirstValue("sub");
if (userId == null) return Unauthorized();
if (await FindMonographAsync(userId, monograph) != null) return base.Conflict("This monograph is already published.");
var existingMonograph = await FindMonographAsync(userId, monograph);
if (existingMonograph != null && !existingMonograph.Deleted)
{
return base.Conflict("This monograph is already published.");
}
if (monograph.EncryptedContent == null)
monograph.CompressedContent = monograph.Content.CompressBrotli();
@@ -109,11 +116,23 @@ 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.");
await Monographs.InsertAsync(monograph);
if (existingMonograph != null)
{
monograph.Id = existingMonograph?.Id;
}
monograph.Deleted = false;
await Monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
monograph,
new ReplaceOptions { IsUpsert = true }
);
await MarkMonographForSyncAsync(monograph.ItemId ?? monograph.Id, deviceId);
return Ok(new
{
id = monograph.ItemId
id = monograph.ItemId,
datePublished = monograph.DatePublished,
});
}
catch (Exception e)
@@ -124,14 +143,18 @@ namespace Notesnook.API.Controllers
}
[HttpPatch]
public async Task<IActionResult> UpdateAsync([FromBody] Monograph monograph)
public async Task<IActionResult> UpdateAsync([FromQuery] string deviceId, [FromBody] Monograph monograph)
{
try
{
var userId = this.User.FindFirstValue("sub");
if (userId == null) return Unauthorized();
if (await FindMonographAsync(userId, monograph) == null) return NotFound();
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.");
@@ -150,12 +173,16 @@ namespace Notesnook.API.Controllers
.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(monograph.ItemId ?? monograph.Id, deviceId);
return Ok(new
{
id = monograph.ItemId
id = monograph.ItemId,
datePublished = monograph.DatePublished,
});
}
catch (Exception e)
@@ -171,10 +198,15 @@ namespace Notesnook.API.Controllers
var userId = this.User.FindFirstValue("sub");
if (userId == null) return Unauthorized();
var monographs = (await Monographs.Collection.FindAsync(Builders<Monograph>.Filter.Eq("UserId", userId), new FindOptions<Monograph, ObjectWithId>
{
Projection = Builders<Monograph>.Projection.Include("_id").Include("ItemId"),
})).ToEnumerable();
var monographs = (await Monographs.Collection.FindAsync(
Builders<Monograph>.Filter.And(
Builders<Monograph>.Filter.Eq("UserId", userId),
Builders<Monograph>.Filter.Eq("Deleted", false)
)
, new FindOptions<Monograph, ObjectWithId>
{
Projection = Builders<Monograph>.Projection.Include("_id").Include("ItemId"),
})).ToEnumerable();
return Ok(monographs.Select((m) => m.ItemId ?? m.Id));
}
@@ -183,7 +215,7 @@ namespace Notesnook.API.Controllers
public async Task<IActionResult> GetMonographAsync([FromRoute] string id)
{
var monograph = await FindMonographAsync(id);
if (monograph == null)
if (monograph == null || monograph.Deleted)
{
return NotFound(new
{
@@ -203,19 +235,89 @@ namespace Notesnook.API.Controllers
public async Task<IActionResult> TrackView([FromRoute] string id)
{
var monograph = await FindMonographAsync(id);
if (monograph == null) return Content(SVG_PIXEL, "image/svg+xml");
if (monograph == null || monograph.Deleted) return Content(SVG_PIXEL, "image/svg+xml");
if (monograph.SelfDestruct)
await Monographs.DeleteByIdAsync(monograph.Id);
{
var userId = this.User.FindFirstValue("sub");
await Monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
new Monograph
{
ItemId = id,
Id = monograph.Id,
Deleted = true
}
);
await MarkMonographForSyncAsync(id);
}
return Content(SVG_PIXEL, "image/svg+xml");
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAsync([FromRoute] string id)
public async Task<IActionResult> DeleteAsync([FromQuery] string deviceId, [FromRoute] string id)
{
await Monographs.Collection.DeleteOneAsync(CreateMonographFilter(id));
var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted)
{
return NotFound(new
{
error = "invalid_id",
error_description = $"No such monograph found."
});
}
var userId = this.User.FindFirstValue("sub");
await Monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
new Monograph
{
ItemId = id,
Id = monograph.Id,
Deleted = true,
UserId = monograph.UserId
}
);
await MarkMonographForSyncAsync(id, deviceId);
return Ok();
}
private async Task MarkMonographForSyncAsync(string monographId, string deviceId)
{
if (deviceId == null) return;
var userId = this.User.FindFirstValue("sub");
new SyncDeviceService(new SyncDevice(userId, deviceId)).AddIdsToOtherDevices([$"{monographId}:monograph"]);
await SendTriggerSyncEventAsync();
}
private async Task MarkMonographForSyncAsync(string monographId)
{
var userId = this.User.FindFirstValue("sub");
new SyncDeviceService(new SyncDevice(userId, string.Empty)).AddIdsToAllDevices([$"{monographId}:monograph"]);
await SendTriggerSyncEventAsync(sendToAllDevices: true);
}
private async Task SendTriggerSyncEventAsync(bool sendToAllDevices = false)
{
var userId = this.User.FindFirstValue("sub");
var jti = this.User.FindFirstValue("jti");
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
{
OriginTokenId = sendToAllDevices ? null : jti,
UserId = userId,
Message = new Message
{
Type = "triggerSync",
Data = JsonSerializer.Serialize(new { reason = "Monographs updated." })
}
});
}
}
}

View File

@@ -20,7 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Linq;
using System.Security.Claims;
using System.Text.Json.Serialization;
@@ -28,8 +27,6 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Notesnook.API.Authorization;
using Notesnook.API.Interfaces;
@@ -43,6 +40,7 @@ namespace Notesnook.API.Hubs
{
Task<bool> SendItems(SyncTransferItemV2 transferItem);
Task<bool> SendVaultKey(EncryptedData vaultKey);
Task<bool> SendMonographs(IEnumerable<Monograph> monographs);
Task PushCompleted();
}
@@ -259,6 +257,7 @@ namespace Notesnook.API.Hubs
if (!await Clients.Caller.SendVaultKey(userSettings.VaultKey).WaitAsync(TimeSpan.FromMinutes(10))) throw new HubException("Client rejected vault key.");
}
await foreach (var chunk in chunks)
{
if (!await Clients.Caller.SendItems(chunk).WaitAsync(TimeSpan.FromMinutes(10))) throw new HubException("Client rejected sent items.");
@@ -271,6 +270,15 @@ namespace Notesnook.API.Hubs
}
}
var unsyncedMonographs = ids.Where((id) => id.EndsWith(":monograph")).ToHashSet();
var unsyncedMonographIds = unsyncedMonographs.Select((id) => id.Split(":")[0]).ToArray();
var userMonographs = isResetSync
? await Repositories.Monographs.FindAsync(m => m.UserId == userId)
: await Repositories.Monographs.FindAsync(m => m.UserId == userId && unsyncedMonographIds.Contains(m.ItemId));
if (userMonographs.Any() && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
throw new HubException("Client rejected monographs.");
deviceService.Reset();
return new SyncV2Metadata

View File

@@ -86,5 +86,11 @@ namespace Notesnook.API.Models
[JsonIgnore]
public byte[] CompressedContent { get; set; }
[JsonPropertyName("password")]
public EncryptedData Password { get; set; }
[JsonPropertyName("deleted")]
public bool Deleted { get; set; }
}
}

View File

@@ -194,6 +194,21 @@ namespace Notesnook.API.Services
}
}
public void AddIdsToAllDevices(List<string> ids)
{
foreach (var id in ListDevices())
{
if (IsSyncReset(id)) return;
lock (id)
{
if (!IsDeviceRegistered(id)) Directory.CreateDirectory(Path.Join(device.UserSyncDirectoryPath, id));
var oldIds = GetUnsyncedIds(id);
File.WriteAllLinesAsync(Path.Join(device.UserSyncDirectoryPath, id, "unsynced"), ids.Union(oldIds));
}
}
}
public void RegisterDevice()
{
lock (device.UserId)