sync: migrate sync devices from fs to mongodb

This commit is contained in:
Abdullah Atta
2025-12-22 20:11:43 +05:00
parent c7bb053cea
commit b98612be7a
19 changed files with 341 additions and 350 deletions

View File

@@ -46,6 +46,8 @@ namespace Notesnook.API.Accessors
public Repository<Monograph> Monographs { get; } public Repository<Monograph> Monographs { get; }
public Repository<InboxApiKey> InboxApiKey { get; } public Repository<InboxApiKey> InboxApiKey { get; }
public Repository<InboxSyncItem> InboxItems { get; } public Repository<InboxSyncItem> InboxItems { get; }
public Repository<SyncDevice> SyncDevices { get; }
public Repository<DeviceIdsChunk> DeviceIdsChunks { get; }
public SyncItemsRepositoryAccessor(IDbContext dbContext, public SyncItemsRepositoryAccessor(IDbContext dbContext,
@@ -74,13 +76,20 @@ namespace Notesnook.API.Accessors
[FromKeyedServices(Collections.TagsKey)] [FromKeyedServices(Collections.TagsKey)]
IMongoCollection<SyncItem> tags, IMongoCollection<SyncItem> tags,
Repository<UserSettings> usersSettings, Repository<Monograph> monographs, Repository<UserSettings> usersSettings,
Repository<InboxApiKey> inboxApiKey, Repository<InboxSyncItem> inboxItems, ILogger<SyncItemsRepository> logger) Repository<Monograph> monographs,
Repository<InboxApiKey> inboxApiKey,
Repository<InboxSyncItem> inboxItems,
Repository<SyncDevice> syncDevices,
Repository<DeviceIdsChunk> deviceIdsChunks,
ILogger<SyncItemsRepository> logger)
{ {
UsersSettings = usersSettings; UsersSettings = usersSettings;
Monographs = monographs; Monographs = monographs;
InboxApiKey = inboxApiKey; InboxApiKey = inboxApiKey;
InboxItems = inboxItems; InboxItems = inboxItems;
SyncDevices = syncDevices;
DeviceIdsChunks = deviceIdsChunks;
Notebooks = new SyncItemsRepository(dbContext, notebooks, logger); Notebooks = new SyncItemsRepository(dbContext, notebooks, logger);
Notes = new SyncItemsRepository(dbContext, notes, logger); Notes = new SyncItemsRepository(dbContext, notes, logger);
Contents = new SyncItemsRepository(dbContext, content, logger); Contents = new SyncItemsRepository(dbContext, content, logger);

View File

@@ -14,7 +14,9 @@ namespace Notesnook.API
public const string TagsKey = "tags"; public const string TagsKey = "tags";
public const string ColorsKey = "colors"; public const string ColorsKey = "colors";
public const string VaultsKey = "vaults"; public const string VaultsKey = "vaults";
public const string InboxItems = "inbox_items"; public const string InboxItemsKey = "inbox_items";
public const string InboxApiKeysKey = "inbox_api_keys"; public const string InboxApiKeysKey = "inbox_api_keys";
public const string SyncDevicesKey = "sync_devices";
public const string DeviceIdsChunksKey = "device_ids_chunks";
} }
} }

View File

@@ -21,11 +21,14 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Text;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Models; using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Interfaces; using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
using Streetwriters.Data.Repositories; using Streetwriters.Data.Repositories;
@@ -35,25 +38,26 @@ namespace Notesnook.API.Controllers
// TODO: this should be moved out into its own microservice // TODO: this should be moved out into its own microservice
[ApiController] [ApiController]
[Route("announcements")] [Route("announcements")]
public class AnnouncementController : ControllerBase public class AnnouncementController(Repository<Announcement> announcements, WampServiceAccessor serviceAccessor) : ControllerBase
{ {
private Repository<Announcement> Announcements { get; set; }
public AnnouncementController(Repository<Announcement> announcements)
{
Announcements = announcements;
}
[HttpGet("active")] [HttpGet("active")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> GetActiveAnnouncements([FromQuery] string? userId) public async Task<IActionResult> GetActiveAnnouncements([FromQuery] string? userId)
{ {
var totalActive = await Announcements.Collection.CountDocumentsAsync(Builders<Announcement>.Filter.Eq("IsActive", true)); var filter = Builders<Announcement>.Filter.Eq(x => x.IsActive, true);
if (totalActive <= 0) return Ok(Array.Empty<Announcement>()); if (!string.IsNullOrEmpty(userId))
var announcements = (await Announcements.FindAsync((a) => a.IsActive)).Where((a) => a.UserIds == null || a.UserIds.Length == 0 || a.UserIds.Contains(userId));
foreach (var announcement in announcements)
{ {
if (announcement.UserIds != null && !announcement.UserIds.Contains(userId)) continue; var userFilter = Builders<Announcement>.Filter.Or(
Builders<Announcement>.Filter.Eq(x => x.UserIds, null),
Builders<Announcement>.Filter.Size(x => x.UserIds, 0),
Builders<Announcement>.Filter.AnyEq(x => x.UserIds, userId)
);
filter = Builders<Announcement>.Filter.And(filter, userFilter);
}
var userAnnouncements = await announcements.Collection.Find(filter).ToListAsync();
foreach (var announcement in userAnnouncements)
{
if (userId != null && announcement.UserIds != null && !announcement.UserIds.Contains(userId)) continue;
foreach (var item in announcement.Body) foreach (var item in announcement.Body)
{ {
@@ -66,13 +70,13 @@ namespace Notesnook.API.Controllers
if (action.Data.Contains("{{Email}}")) if (action.Data.Contains("{{Email}}"))
{ {
var user = string.IsNullOrEmpty(userId) ? null : await (await WampServers.IdentityServer.GetServiceAsync<IUserAccountService>(IdentityServerTopics.UserAccountServiceTopic)).GetUserAsync(Clients.Notesnook.Id, userId); var user = string.IsNullOrEmpty(userId) ? null : await serviceAccessor.UserAccountService.GetUserAsync(Clients.Notesnook.Id, userId);
action.Data = action.Data.Replace("{{Email}}", user?.Email ?? ""); action.Data = action.Data.Replace("{{Email}}", user?.Email ?? "");
} }
} }
} }
} }
return Ok(announcements); return Ok(userAnnouncements);
} }
} }
} }

View File

@@ -40,6 +40,7 @@ namespace Notesnook.API.Controllers
Repository<InboxApiKey> inboxApiKeysRepository, Repository<InboxApiKey> inboxApiKeysRepository,
Repository<UserSettings> userSettingsRepository, Repository<UserSettings> userSettingsRepository,
Repository<InboxSyncItem> inboxItemsRepository, Repository<InboxSyncItem> inboxItemsRepository,
SyncDeviceService syncDeviceService,
ILogger<InboxController> logger) : ControllerBase ILogger<InboxController> logger) : ControllerBase
{ {
@@ -182,8 +183,7 @@ namespace Notesnook.API.Controllers
request.UserId = userId; request.UserId = userId;
request.ItemId = ObjectId.GenerateNewId().ToString(); request.ItemId = ObjectId.GenerateNewId().ToString();
await inboxItemsRepository.InsertAsync(request); await inboxItemsRepository.InsertAsync(request);
new SyncDeviceService(new SyncDevice(userId, string.Empty)) await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(request.ItemId, "inbox_item")]);
.AddIdsToAllDevices([$"{request.ItemId}:inboxItems"]);
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
{ {
OriginTokenId = null, OriginTokenId = null,

View File

@@ -46,7 +46,7 @@ namespace Notesnook.API.Controllers
[ApiController] [ApiController]
[Route("monographs")] [Route("monographs")]
[Authorize("Sync")] [Authorize("Sync")]
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, ILogger<MonographsController> logger) : ControllerBase public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer, SyncDeviceService syncDeviceService, ILogger<MonographsController> logger) : ControllerBase
{ {
const string SVG_PIXEL = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><circle r='9'/></svg>"; 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; private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
@@ -317,32 +317,16 @@ namespace Notesnook.API.Controllers
return Ok(); return Ok();
} }
private static async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti) private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
{ {
if (deviceId == null) return; if (deviceId == null) return;
new SyncDeviceService(new SyncDevice(userId, deviceId)).AddIdsToOtherDevices([$"{monographId}:monograph"]); await syncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, [new(monographId, "monograph")]);
await SendTriggerSyncEventAsync(userId, jti);
} }
private static async Task MarkMonographForSyncAsync(string userId, string monographId) private async Task MarkMonographForSyncAsync(string userId, string monographId)
{ {
new SyncDeviceService(new SyncDevice(userId, string.Empty)).AddIdsToAllDevices([$"{monographId}:monograph"]); await syncDeviceService.AddIdsToAllDevicesAsync(userId, [new(monographId, "monograph")]);
await SendTriggerSyncEventAsync(userId, sendToAllDevices: true);
}
private static async Task SendTriggerSyncEventAsync(string userId, string? jti = null, bool sendToAllDevices = false)
{
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." })
}
});
} }
private async Task<string> CleanupContentAsync(ClaimsPrincipal user, string? content) private async Task<string> CleanupContentAsync(ClaimsPrincipal user, string? content)

View File

@@ -17,23 +17,25 @@ 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Amazon.S3.Model;
using System.Threading.Tasks;
using System.Security.Claims;
using Notesnook.API.Interfaces;
using System; using System;
using System.Net.Http; using System.Net.Http;
using Streetwriters.Common.Extensions; using System.Security.Claims;
using Streetwriters.Common.Models; using System.Threading.Tasks;
using Notesnook.API.Helpers; using Amazon.S3.Model;
using Streetwriters.Common; using Microsoft.AspNetCore.Authorization;
using Streetwriters.Common.Interfaces;
using Notesnook.API.Models;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
{ {
@@ -41,7 +43,7 @@ namespace Notesnook.API.Controllers
[Route("s3")] [Route("s3")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[Authorize("Sync")] [Authorize("Sync")]
public class S3Controller(IS3Service s3Service, ISyncItemsRepositoryAccessor repositories, ILogger<S3Controller> logger) : ControllerBase public class S3Controller(IS3Service s3Service, ISyncItemsRepositoryAccessor repositories, WampServiceAccessor serviceAccessor, ILogger<S3Controller> logger) : ControllerBase
{ {
[HttpPut] [HttpPut]
public async Task<IActionResult> Upload([FromQuery] string name) public async Task<IActionResult> Upload([FromQuery] string name)
@@ -74,8 +76,7 @@ namespace Notesnook.API.Controllers
{ {
var userSettings = await repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId); var userSettings = await repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic); var subscription = await serviceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
var subscription = await subscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
if (StorageHelper.IsFileSizeExceeded(subscription, fileSize)) if (StorageHelper.IsFileSizeExceeded(subscription, fileSize))
throw new Exception("Max file size exceeded."); throw new Exception("Max file size exceeded.");

View File

@@ -37,15 +37,15 @@ namespace Notesnook.API.Controllers
[ApiController] [ApiController]
[Authorize] [Authorize]
[Route("devices")] [Route("devices")]
public class SyncDeviceController(ILogger<SyncDeviceController> logger) : ControllerBase public class SyncDeviceController(SyncDeviceService syncDeviceService, ILogger<SyncDeviceController> logger) : ControllerBase
{ {
[HttpPost] [HttpPost]
public IActionResult RegisterDevice([FromQuery] string deviceId) public async Task<IActionResult> RegisterDevice([FromQuery] string deviceId)
{ {
try try
{ {
var userId = this.User.GetUserId(); var userId = this.User.GetUserId();
new SyncDeviceService(new SyncDevice(userId, deviceId)).RegisterDevice(); await syncDeviceService.RegisterDeviceAsync(userId, deviceId);
return Ok(); return Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -57,12 +57,12 @@ namespace Notesnook.API.Controllers
[HttpDelete] [HttpDelete]
public IActionResult UnregisterDevice([FromQuery] string deviceId) public async Task<IActionResult> UnregisterDevice([FromQuery] string deviceId)
{ {
try try
{ {
var userId = this.User.GetUserId(); var userId = this.User.GetUserId();
new SyncDeviceService(new SyncDevice(userId, deviceId)).UnregisterDevice(); await syncDeviceService.UnregisterDeviceAsync(userId, deviceId);
return Ok(); return Ok();
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -46,12 +46,14 @@ namespace Notesnook.API.Hubs
Task<bool> SendMonographs(IEnumerable<MonographMetadata> monographs); Task<bool> SendMonographs(IEnumerable<MonographMetadata> monographs);
Task<bool> SendInboxItems(IEnumerable<InboxSyncItem> inboxItems); Task<bool> SendInboxItems(IEnumerable<InboxSyncItem> inboxItems);
Task PushCompleted(); Task PushCompleted();
Task PushCompletedV2(string deviceId);
} }
[Authorize] [Authorize]
public class SyncV2Hub : Hub<ISyncV2HubClient> public class SyncV2Hub : Hub<ISyncV2HubClient>
{ {
private ISyncItemsRepositoryAccessor Repositories { get; } private ISyncItemsRepositoryAccessor Repositories { get; }
private SyncDeviceService SyncDeviceService { get; }
private readonly IUnitOfWork unit; private readonly IUnitOfWork unit;
private static readonly string[] CollectionKeys = [ private static readonly string[] CollectionKeys = [
"settingitem", "settingitem",
@@ -67,14 +69,15 @@ namespace Notesnook.API.Hubs
"relation", // relations must sync at the end to prevent invalid state "relation", // relations must sync at the end to prevent invalid state
]; ];
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap; private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
private readonly Func<string, string[], bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections; private readonly Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections;
ILogger<SyncV2Hub> Logger { get; } ILogger<SyncV2Hub> Logger { get; }
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, ILogger<SyncV2Hub> logger) public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger)
{ {
Logger = logger; Logger = logger;
Repositories = syncItemsRepositoryAccessor; Repositories = syncItemsRepositoryAccessor;
unit = unitOfWork; unit = unitOfWork;
SyncDeviceService = syncDeviceService;
Collections = [ Collections = [
Repositories.Settings.FindItemsById, Repositories.Settings.FindItemsById,
@@ -133,7 +136,7 @@ namespace Notesnook.API.Hubs
if (!await unit.Commit()) return 0; if (!await unit.Commit()) return 0;
new SyncDeviceService(new SyncDevice(userId, deviceId)).AddIdsToOtherDevices(pushItem.Items.Select((i) => $"{i.ItemId}:{pushItem.Type}").ToList()); await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type)));
return 1; return 1;
} }
finally finally
@@ -149,14 +152,22 @@ namespace Notesnook.API.Hubs
return true; return true;
} }
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, string[] ids, int size, bool resetSync, long maxBytes) public async Task<bool> PushCompletedV2(string deviceId)
{
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("User not found.");
await Clients.OthersInGroup(userId).PushCompleted();
await Clients.OthersInGroup(userId).PushCompletedV2(deviceId);
return true;
}
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes)
{ {
var itemsProcessed = 0; var itemsProcessed = 0;
for (int i = 0; i < Collections.Length; i++) for (int i = 0; i < Collections.Length; i++)
{ {
var type = CollectionKeys[i]; var type = CollectionKeys[i];
var filteredIds = ids.Where((id) => id.EndsWith($":{type}")).Select((id) => id.Split(":")[0]).ToArray(); var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray();
if (!resetSync && filteredIds.Length == 0) continue; if (!resetSync && filteredIds.Length == 0) continue;
using var cursor = await Collections[i](userId, filteredIds, resetSync, size); using var cursor = await Collections[i](userId, filteredIds, resetSync, size);
@@ -220,61 +231,47 @@ namespace Notesnook.API.Hubs
SyncEventCounterSource.Log.FetchV2(); SyncEventCounterSource.Log.FetchV2();
var device = new SyncDevice(userId, deviceId); var device = await SyncDeviceService.GetDeviceAsync(userId, deviceId);
var deviceService = new SyncDeviceService(device); if (device == null)
if (!deviceService.IsDeviceRegistered()) deviceService.RegisterDevice(); device = await SyncDeviceService.RegisterDeviceAsync(userId, deviceId);
else
device.LastAccessTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); await SyncDeviceService.UpdateLastAccessTimeAsync(userId, deviceId);
var isResetSync = deviceService.IsSyncReset();
if (!deviceService.IsUnsynced() &&
!deviceService.IsSyncPending() &&
!isResetSync)
return new SyncV2Metadata { Synced = true };
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
try try
{ {
string[] ids = deviceService.FetchUnsyncedIds(); var ids = await SyncDeviceService.FetchUnsyncedIdsAsync(userId, deviceId);
if (!device.IsSyncReset && ids.Count == 0)
return new SyncV2Metadata { Synced = true };
var chunks = PrepareChunks( var chunks = PrepareChunks(
userId, userId,
ids, ids,
size: 1000, size: 1000,
resetSync: isResetSync, resetSync: device.IsSyncReset,
maxBytes: 7 * 1024 * 1024 maxBytes: 7 * 1024 * 1024
); );
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId.Equals(userId));
if (userSettings.VaultKey != null)
{
if (!await Clients.Caller.SendVaultKey(userSettings.VaultKey).WaitAsync(TimeSpan.FromMinutes(10))) throw new HubException("Client rejected vault key.");
}
await foreach (var chunk in chunks) await foreach (var chunk in chunks)
{ {
if (!await Clients.Caller.SendItems(chunk).WaitAsync(TimeSpan.FromMinutes(10))) throw new HubException("Client rejected sent items."); if (!await Clients.Caller.SendItems(chunk).WaitAsync(TimeSpan.FromMinutes(10))) throw new HubException("Client rejected sent items.");
if (!isResetSync) if (!device.IsSyncReset)
{ {
var syncedIds = chunk.Items.Select((i) => $"{i.ItemId}:{chunk.Type}").ToHashSet(); ids.ExceptWith(chunk.Items.Select(i => new ItemKey(i.ItemId, chunk.Type)));
ids = ids.Where((id) => !syncedIds.Contains(id)).ToArray(); await SyncDeviceService.WritePendingIdsAsync(userId, deviceId, ids);
deviceService.WritePendingIds(ids);
} }
} }
if (includeMonographs) if (includeMonographs)
{ {
var isSyncingMonographsForFirstTime = !device.HasInitialMonographsSync; var unsyncedMonographIds = ids.Where(k => k.Type == "monograph").Select(k => k.ItemId);
var unsyncedMonographs = ids.Where((id) => id.EndsWith(":monograph")).ToHashSet(); FilterDefinition<Monograph> filter = device.IsSyncReset
var unsyncedMonographIds = unsyncedMonographs.Select((id) => id.Split(":")[0]).ToArray(); ? Builders<Monograph>.Filter.Eq(m => m.UserId, userId)
FilterDefinition<Monograph> filter = isResetSync || isSyncingMonographsForFirstTime
? Builders<Monograph>.Filter.Eq("UserId", userId)
: Builders<Monograph>.Filter.And( : Builders<Monograph>.Filter.And(
Builders<Monograph>.Filter.Eq("UserId", userId), Builders<Monograph>.Filter.Eq(m => m.UserId, userId),
Builders<Monograph>.Filter.Or( Builders<Monograph>.Filter.Or(
Builders<Monograph>.Filter.In("ItemId", unsyncedMonographIds), Builders<Monograph>.Filter.In(m => m.ItemId, unsyncedMonographIds),
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds) Builders<Monograph>.Filter.In("_id", unsyncedMonographIds)
) )
); );
@@ -285,30 +282,26 @@ namespace Notesnook.API.Hubs
Password = m.Password, Password = m.Password,
SelfDestruct = m.SelfDestruct, SelfDestruct = m.SelfDestruct,
Title = m.Title, Title = m.Title,
ItemId = m.ItemId ?? m.Id.ToString(), ItemId = m.ItemId ?? m.Id.ToString()
ViewCount = m.ViewCount
}).ToListAsync(); }).ToListAsync();
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10))) if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
throw new HubException("Client rejected monographs."); throw new HubException("Client rejected monographs.");
device.HasInitialMonographsSync = true;
} }
if (includeInboxItems) if (includeInboxItems)
{ {
var unsyncedInboxItems = ids.Where((id) => id.EndsWith(":inboxItems")).ToHashSet(); var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId);
var unsyncedInboxItemIds = unsyncedInboxItems.Select((id) => id.Split(":")[0]).ToArray(); var userInboxItems = device.IsSyncReset
var userInboxItems = isResetSync
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId) ? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId)); : await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId ?? m.Id.ToString()));
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10))) if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
{ {
throw new HubException("Client rejected inbox items."); throw new HubException("Client rejected inbox items.");
} }
} }
deviceService.Reset(); await SyncDeviceService.ResetAsync(userId, deviceId);
return new SyncV2Metadata return new SyncV2Metadata
{ {

View File

@@ -42,5 +42,7 @@ namespace Notesnook.API.Interfaces
Repository<Monograph> Monographs { get; } Repository<Monograph> Monographs { get; }
Repository<InboxApiKey> InboxApiKey { get; } Repository<InboxApiKey> InboxApiKey { get; }
Repository<InboxSyncItem> InboxItems { get; } Repository<InboxSyncItem> InboxItems { get; }
Repository<SyncDevice> SyncDevices { get; }
Repository<DeviceIdsChunk> DeviceIdsChunks { get; }
} }
} }

View File

@@ -1,65 +1,41 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Quartz; using Quartz;
namespace Notesnook.API.Jobs namespace Notesnook.API.Jobs
{ {
public class DeviceCleanupJob : IJob public class DeviceCleanupJob(ISyncItemsRepositoryAccessor repositories) : IJob
{ {
public Task Execute(IJobExecutionContext context) public async Task Execute(IJobExecutionContext context)
{ {
ParallelOptions parallelOptions = new() var cutoffDate = DateTimeOffset.UtcNow.AddMonths(-1).ToUnixTimeMilliseconds();
var deviceFilter = Builders<SyncDevice>.Filter.Lt(x => x.LastAccessTime, cutoffDate);
using var cursor = await repositories.SyncDevices.Collection.Find(deviceFilter, new FindOptions { BatchSize = 1000 })
.Project(x => x.DeviceId)
.ToCursorAsync();
var deleteModels = new List<WriteModel<DeviceIdsChunk>>();
while (await cursor.MoveNextAsync())
{ {
MaxDegreeOfParallelism = 100, if (!cursor.Current.Any()) continue;
CancellationToken = context.CancellationToken, deleteModels.Add(new DeleteManyModel<DeviceIdsChunk>(Builders<DeviceIdsChunk>.Filter.In(x => x.DeviceId, cursor.Current)));
}; }
Parallel.ForEach(Directory.EnumerateDirectories("sync"), parallelOptions, (userDir, ct) =>
if (deleteModels.Count > 0)
{ {
foreach (var device in Directory.EnumerateDirectories(userDir)) var bulkOptions = new BulkWriteOptions { IsOrdered = false };
{ await repositories.DeviceIdsChunks.Collection.BulkWriteAsync(deleteModels, bulkOptions);
string lastAccessFile = Path.Combine(device, "LastAccessTime"); }
try await repositories.SyncDevices.Collection.DeleteManyAsync(deviceFilter);
{
if (!File.Exists(lastAccessFile))
{
Directory.Delete(device, true);
continue;
}
string content = File.ReadAllText(lastAccessFile);
if (!long.TryParse(content, out long lastAccessTime) || lastAccessTime <= 0)
{
Directory.Delete(device, true);
continue;
}
DateTimeOffset accessTime;
try
{
accessTime = DateTimeOffset.FromUnixTimeMilliseconds(lastAccessTime);
}
catch (Exception)
{
Directory.Delete(device, true);
continue;
}
// If the device hasn't been accessed for more than one month, delete it.
if (accessTime.AddMonths(1) < DateTimeOffset.UtcNow)
{
Directory.Delete(device, true);
}
}
catch (Exception ex)
{
// Log the error and continue processing other directories.
Console.Error.WriteLine($"Error processing device '{device}': {ex.Message}");
}
}
});
return Task.CompletedTask;
} }
} }
} }

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Notesnook.API.Models
{
public class DeviceIdsChunk
{
[BsonId]
public ObjectId Id { get; set; }
public required string UserId { get; set; }
public required string DeviceId { get; set; }
public required string Key { get; set; }
public required string[] Ids { get; set; } = [];
}
}

View File

@@ -17,10 +17,10 @@ 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System.Runtime.Serialization;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using MongoDB.Bson; using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Attributes;
using System.Runtime.Serialization;
namespace Notesnook.API.Models namespace Notesnook.API.Models
{ {
@@ -48,8 +48,5 @@ namespace Notesnook.API.Models
[JsonPropertyName("deleted")] [JsonPropertyName("deleted")]
public bool Deleted { get; set; } public bool Deleted { get; set; }
[JsonPropertyName("viewCount")]
public int ViewCount { get; set; }
} }
} }

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace Notesnook.API.Models
{
public class SyncDevice
{
[BsonId]
public ObjectId Id { get; set; }
public required string UserId { get; set; }
public required string DeviceId { get; set; }
public required long LastAccessTime { get; set; }
public required bool IsSyncReset { get; set; }
public string? AppVersion { get; set; }
public string? DatabaseVersion { get; set; }
}
}

View File

@@ -17,13 +17,12 @@ 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/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
using System; using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Streetwriters.Common; using Streetwriters.Common;
using System.Net;
namespace Notesnook.API namespace Notesnook.API
{ {

View File

@@ -30,10 +30,12 @@ using Amazon.S3.Model;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Accessors;
using Notesnook.API.Helpers; using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums; using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces; using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
@@ -52,6 +54,7 @@ namespace Notesnook.API.Services
private readonly string INTERNAL_BUCKET_NAME = Constants.S3_INTERNAL_BUCKET_NAME ?? Constants.S3_BUCKET_NAME; private readonly string INTERNAL_BUCKET_NAME = Constants.S3_INTERNAL_BUCKET_NAME ?? Constants.S3_BUCKET_NAME;
private readonly S3FailoverHelper S3Client; private readonly S3FailoverHelper S3Client;
private ISyncItemsRepositoryAccessor Repositories { get; } private ISyncItemsRepositoryAccessor Repositories { get; }
private WampServiceAccessor ServiceAccessor { get; }
// When running in a dockerized environment the sync server doesn't have access // When running in a dockerized environment the sync server doesn't have access
// to the host's S3 Service URL. It can only talk to S3 server via its own internal // to the host's S3 Service URL. It can only talk to S3 server via its own internal
@@ -64,9 +67,10 @@ namespace Notesnook.API.Services
private readonly S3FailoverHelper S3InternalClient; private readonly S3FailoverHelper S3InternalClient;
private readonly HttpClient httpClient = new(); private readonly HttpClient httpClient = new();
public S3Service(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, ILogger<S3Service> logger) public S3Service(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, WampServiceAccessor wampServiceAccessor, ILogger<S3Service> logger)
{ {
Repositories = syncItemsRepositoryAccessor; Repositories = syncItemsRepositoryAccessor;
ServiceAccessor = wampServiceAccessor;
S3Client = new S3FailoverHelper( S3Client = new S3FailoverHelper(
S3ClientFactory.CreateS3Clients( S3ClientFactory.CreateS3Clients(
Constants.S3_SERVICE_URL, Constants.S3_SERVICE_URL,
@@ -240,8 +244,7 @@ namespace Notesnook.API.Services
if (!Constants.IS_SELF_HOSTED) if (!Constants.IS_SELF_HOSTED)
{ {
var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic); var subscription = await ServiceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
var subscription = await subscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
long fileSize = await GetMultipartUploadSizeAsync(userId, uploadRequest.Key, uploadRequest.UploadId); long fileSize = await GetMultipartUploadSizeAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
if (StorageHelper.IsFileSizeExceeded(subscription, fileSize)) if (StorageHelper.IsFileSizeExceeded(subscription, fileSize))

View File

@@ -24,220 +24,201 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
namespace Notesnook.API.Services namespace Notesnook.API.Services
{ {
public struct SyncDevice(string userId, string deviceId) public readonly record struct ItemKey(string ItemId, string Type)
{ {
public readonly string DeviceId => deviceId; public override string ToString() => $"{ItemId}:{Type}";
public readonly string UserId => userId;
public string UserSyncDirectoryPath = CreateFilePath(userId);
public string UserDeviceDirectoryPath = CreateFilePath(userId, deviceId);
public string PendingIdsFilePath = CreateFilePath(userId, deviceId, "pending");
public string UnsyncedIdsFilePath = CreateFilePath(userId, deviceId, "unsynced");
public string ResetSyncFilePath = CreateFilePath(userId, deviceId, "reset-sync");
public readonly long LastAccessTime
{
get => long.Parse(GetMetadata("LastAccessTime") ?? "0");
set => SetMetadata("LastAccessTime", value.ToString());
}
/// <summary>
/// Indicates if the monographs have been synced for the first time
/// ever on a device.
/// </summary>
public readonly bool HasInitialMonographsSync
{
get => !string.IsNullOrEmpty(GetMetadata("HasInitialMonographsSync"));
set => SetMetadata("HasInitialMonographsSync", value.ToString());
}
private static string CreateFilePath(string userId, string? deviceId = null, string? metadataKey = null)
{
return Path.Join("sync", userId, deviceId, metadataKey);
}
private readonly string? GetMetadata(string metadataKey)
{
var path = CreateFilePath(userId, deviceId, metadataKey);
if (!File.Exists(path)) return null;
return File.ReadAllText(path);
}
private readonly void SetMetadata(string metadataKey, string value)
{
try
{
var path = CreateFilePath(userId, deviceId, metadataKey);
File.WriteAllText(path, value);
}
catch (DirectoryNotFoundException) { }
}
} }
public class SyncDeviceService(ISyncItemsRepositoryAccessor repositories, ILogger<SyncDeviceService> logger)
public class SyncDeviceService(SyncDevice device)
{ {
public string[] GetUnsyncedIds() private static FilterDefinition<SyncDevice> DeviceFilter(string userId, string deviceId) =>
{ Builders<SyncDevice>.Filter.Eq(x => x.UserId, userId) &
try Builders<SyncDevice>.Filter.Eq(x => x.DeviceId, deviceId);
{ private static FilterDefinition<DeviceIdsChunk> DeviceIdsChunkFilter(string userId, string deviceId, string key) =>
return File.ReadAllLines(device.UnsyncedIdsFilePath); Builders<DeviceIdsChunk>.Filter.Eq(x => x.UserId, userId) &
} Builders<DeviceIdsChunk>.Filter.Eq(x => x.DeviceId, deviceId) &
catch { return []; } Builders<DeviceIdsChunk>.Filter.Eq(x => x.Key, key);
}
public string[] GetUnsyncedIds(string deviceId) private static FilterDefinition<DeviceIdsChunk> DeviceIdsChunkFilter(string userId, string deviceId) =>
{ Builders<DeviceIdsChunk>.Filter.Eq(x => x.UserId, userId) &
try Builders<DeviceIdsChunk>.Filter.Eq(x => x.DeviceId, deviceId);
{
return File.ReadAllLines(Path.Join(device.UserSyncDirectoryPath, deviceId, "unsynced"));
}
catch { return []; }
}
public string[] FetchUnsyncedIds() private static FilterDefinition<DeviceIdsChunk> DeviceIdsChunkFilter(string userId) =>
Builders<DeviceIdsChunk>.Filter.Eq(x => x.UserId, userId);
private static FilterDefinition<SyncDevice> UserFilter(string userId) => Builders<SyncDevice>.Filter.Eq(x => x.UserId, userId);
public async Task<HashSet<ItemKey>> GetIdsAsync(string userId, string deviceId, string key)
{ {
if (IsSyncReset()) return []; var cursor = await repositories.DeviceIdsChunks.Collection.FindAsync(DeviceIdsChunkFilter(userId, deviceId, key));
try var result = new HashSet<ItemKey>();
while (await cursor.MoveNextAsync())
{ {
var unsyncedIds = GetUnsyncedIds(); foreach (var chunk in cursor.Current)
lock (device.DeviceId)
{ {
if (IsSyncPending()) foreach (var id in chunk.Ids)
{ {
unsyncedIds = unsyncedIds.Union(File.ReadAllLines(device.PendingIdsFilePath)).ToArray(); var parts = id.Split(':', 2);
result.Add(new ItemKey(parts[0], parts[1]));
} }
if (unsyncedIds.Length == 0) return [];
File.Delete(device.UnsyncedIdsFilePath);
File.WriteAllLines(device.PendingIdsFilePath, unsyncedIds);
} }
return unsyncedIds;
} }
catch return result;
}
const int MaxIdsPerChunk = 400_000;
public async Task AppendIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
{
var filter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Where(x => x.Ids.Length < MaxIdsPerChunk);
var chunk = await repositories.DeviceIdsChunks.Collection.Find(filter).FirstOrDefaultAsync();
if (chunk != null)
{ {
return []; var update = Builders<DeviceIdsChunk>.Update.PushEach(x => x.Ids, ids.Select(i => i.ToString()));
await repositories.DeviceIdsChunks.Collection.UpdateOneAsync(
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
update
);
} }
} else
public void WritePendingIds(IEnumerable<string> ids)
{
lock (device.DeviceId)
{ {
File.WriteAllLines(device.PendingIdsFilePath, ids); var newChunk = new DeviceIdsChunk
}
}
public bool IsSyncReset()
{
return File.Exists(device.ResetSyncFilePath);
}
public bool IsSyncReset(string deviceId)
{
return File.Exists(Path.Join(device.UserSyncDirectoryPath, deviceId, "reset-sync"));
}
public bool IsSyncPending()
{
return File.Exists(device.PendingIdsFilePath);
}
public bool IsUnsynced()
{
return File.Exists(device.UnsyncedIdsFilePath);
}
public void Reset()
{
try
{
lock (device.UserId)
{ {
File.Delete(device.ResetSyncFilePath); UserId = userId,
File.Delete(device.PendingIdsFilePath); DeviceId = deviceId,
} Key = key,
Ids = [.. ids.Select(i => i.ToString())]
};
await repositories.DeviceIdsChunks.Collection.InsertOneAsync(newChunk);
} }
catch (FileNotFoundException) { }
catch (DirectoryNotFoundException) { } var emptyChunksFilter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Size(x => x.Ids, 0);
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(emptyChunksFilter);
} }
public bool IsDeviceRegistered() public async Task WriteIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
{ {
return Directory.Exists(device.UserDeviceDirectoryPath); var writes = new List<WriteModel<DeviceIdsChunk>>
}
public bool IsDeviceRegistered(string deviceId)
{
return Directory.Exists(Path.Join(device.UserSyncDirectoryPath, deviceId));
}
public string[] ListDevices()
{
return Directory.GetDirectories(device.UserSyncDirectoryPath).Select((path) => path[(path.LastIndexOf(Path.DirectorySeparatorChar) + 1)..]).ToArray();
}
public void ResetDevices()
{
lock (device.UserId)
{ {
if (File.Exists(device.UserSyncDirectoryPath)) File.Delete(device.UserSyncDirectoryPath); new DeleteManyModel<DeviceIdsChunk>(DeviceIdsChunkFilter(userId, deviceId, key))
Directory.CreateDirectory(device.UserSyncDirectoryPath); };
} var chunks = ids.Chunk(MaxIdsPerChunk);
} foreach (var chunk in chunks)
public void AddIdsToOtherDevices(List<string> ids)
{
device.LastAccessTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (string id in ListDevices())
{ {
if (id == device.DeviceId || IsSyncReset(id)) continue; var newChunk = new DeviceIdsChunk
lock (id)
{ {
if (!IsDeviceRegistered(id)) Directory.CreateDirectory(Path.Join(device.UserSyncDirectoryPath, id)); UserId = userId,
DeviceId = deviceId,
var oldIds = GetUnsyncedIds(id); Key = key,
File.WriteAllLines(Path.Join(device.UserSyncDirectoryPath, id, "unsynced"), ids.Union(oldIds)); Ids = [.. chunk.Select(i => i.ToString())]
} };
writes.Add(new InsertOneModel<DeviceIdsChunk>(newChunk));
} }
await repositories.DeviceIdsChunks.Collection.BulkWriteAsync(writes);
} }
public void AddIdsToAllDevices(List<string> ids) public async Task<HashSet<ItemKey>> FetchUnsyncedIdsAsync(string userId, string deviceId)
{ {
foreach (var id in ListDevices()) var device = await GetDeviceAsync(userId, deviceId);
if (device == null || device.IsSyncReset) return [];
var unsyncedIds = await GetIdsAsync(userId, deviceId, "unsynced");
var pendingIds = await GetIdsAsync(userId, deviceId, "pending");
unsyncedIds = [.. unsyncedIds, .. pendingIds];
if (unsyncedIds.Count == 0) return [];
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(DeviceIdsChunkFilter(userId, deviceId, "unsynced"));
await WriteIdsAsync(userId, deviceId, "pending", unsyncedIds);
return unsyncedIds;
}
public async Task WritePendingIdsAsync(string userId, string deviceId, HashSet<ItemKey> ids)
{
await WriteIdsAsync(userId, deviceId, "pending", ids);
}
public async Task ResetAsync(string userId, string deviceId)
{
await repositories.SyncDevices.Collection.UpdateOneAsync(DeviceFilter(userId, deviceId), Builders<SyncDevice>.Update
.Set(x => x.IsSyncReset, false));
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(DeviceIdsChunkFilter(userId, deviceId, "pending"));
}
public async Task<SyncDevice?> GetDeviceAsync(string userId, string deviceId)
{
return await repositories.SyncDevices.Collection.Find(DeviceFilter(userId, deviceId)).FirstOrDefaultAsync();
}
public async IAsyncEnumerable<SyncDevice> ListDevicesAsync(string userId)
{
using var cursor = await repositories.SyncDevices.Collection.FindAsync(UserFilter(userId));
while (await cursor.MoveNextAsync())
{ {
if (IsSyncReset(id)) return; foreach (var device in cursor.Current)
lock (id)
{ {
if (!IsDeviceRegistered(id)) Directory.CreateDirectory(Path.Join(device.UserSyncDirectoryPath, id)); yield return device;
var oldIds = GetUnsyncedIds(id);
File.WriteAllLinesAsync(Path.Join(device.UserSyncDirectoryPath, id, "unsynced"), ids.Union(oldIds));
} }
} }
} }
public void RegisterDevice() public async Task ResetDevicesAsync(string userId)
{ {
lock (device.UserId) await repositories.SyncDevices.Collection.DeleteManyAsync(UserFilter(userId));
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(DeviceIdsChunkFilter(userId));
}
public async Task UpdateLastAccessTimeAsync(string userId, string deviceId)
{
await repositories.SyncDevices.Collection.UpdateOneAsync(DeviceFilter(userId, deviceId), Builders<SyncDevice>.Update
.Set(x => x.LastAccessTime, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()));
}
public async Task AddIdsToOtherDevicesAsync(string userId, string deviceId, IEnumerable<ItemKey> ids)
{
await UpdateLastAccessTimeAsync(userId, deviceId);
await foreach (var device in ListDevicesAsync(userId))
{ {
if (Directory.Exists(device.UserDeviceDirectoryPath)) if (device.DeviceId == deviceId || device.IsSyncReset) continue;
Directory.Delete(device.UserDeviceDirectoryPath, true); await AppendIdsAsync(userId, device.DeviceId, "unsynced", ids);
Directory.CreateDirectory(device.UserDeviceDirectoryPath);
File.Create(device.ResetSyncFilePath).Close();
device.LastAccessTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
} }
} }
public void UnregisterDevice() public async Task AddIdsToAllDevicesAsync(string userId, IEnumerable<ItemKey> ids)
{ {
lock (device.UserId) await foreach (var device in ListDevicesAsync(userId))
{ {
if (!Path.Exists(device.UserDeviceDirectoryPath)) return; if (device.IsSyncReset) continue;
Directory.Delete(device.UserDeviceDirectoryPath, true); await AppendIdsAsync(userId, device.DeviceId, "unsynced", ids);
} }
} }
public async Task<SyncDevice> RegisterDeviceAsync(string userId, string deviceId)
{
var newDevice = new SyncDevice
{
UserId = userId,
DeviceId = deviceId,
LastAccessTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
IsSyncReset = true
};
await repositories.SyncDevices.Collection.InsertOneAsync(newDevice);
return newDevice;
}
public async Task UnregisterDeviceAsync(string userId, string deviceId)
{
await repositories.SyncDevices.Collection.DeleteOneAsync(DeviceFilter(userId, deviceId));
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(DeviceIdsChunkFilter(userId, deviceId));
}
} }
} }

View File

@@ -30,9 +30,9 @@ using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses; using Notesnook.API.Models.Responses;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums; using Streetwriters.Common.Enums;
using Streetwriters.Common.Extensions; using Streetwriters.Common.Extensions;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages; using Streetwriters.Common.Messages;
using Streetwriters.Common.Models; using Streetwriters.Common.Models;
using Streetwriters.Data.Interfaces; using Streetwriters.Data.Interfaces;
@@ -41,7 +41,7 @@ namespace Notesnook.API.Services
{ {
public class UserService(IHttpContextAccessor accessor, public class UserService(IHttpContextAccessor accessor,
ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor,
IUnitOfWork unitOfWork, IS3Service s3Service, ILogger<UserService> logger) : IUserService IUnitOfWork unitOfWork, IS3Service s3Service, SyncDeviceService syncDeviceService, WampServiceAccessor serviceAccessor, ILogger<UserService> logger) : IUserService
{ {
private static readonly System.Security.Cryptography.RandomNumberGenerator Rng = System.Security.Cryptography.RandomNumberGenerator.Create(); private static readonly System.Security.Cryptography.RandomNumberGenerator Rng = System.Security.Cryptography.RandomNumberGenerator.Create();
private readonly HttpClient httpClient = new(); private readonly HttpClient httpClient = new();
@@ -88,9 +88,7 @@ namespace Notesnook.API.Services
public async Task<UserResponse> GetUserAsync(string userId) public async Task<UserResponse> GetUserAsync(string userId)
{ {
var userService = await WampServers.IdentityServer.GetServiceAsync<IUserAccountService>(IdentityServerTopics.UserAccountServiceTopic); var user = await serviceAccessor.UserAccountService.GetUserAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User not found.");
var user = await userService.GetUserAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User not found.");
Subscription? subscription = null; Subscription? subscription = null;
if (Constants.IS_SELF_HOSTED) if (Constants.IS_SELF_HOSTED)
@@ -109,8 +107,7 @@ namespace Notesnook.API.Services
} }
else else
{ {
var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic); subscription = await serviceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
subscription = await subscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
} }
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == user.UserId) ?? throw new Exception("User settings not found."); var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == user.UserId) ?? throw new Exception("User settings not found.");
@@ -185,8 +182,6 @@ namespace Notesnook.API.Services
public async Task DeleteUserAsync(string userId) public async Task DeleteUserAsync(string userId)
{ {
new SyncDeviceService(new SyncDevice(userId, userId)).ResetDevices();
var cc = new CancellationTokenSource(); var cc = new CancellationTokenSource();
Repositories.Notes.DeleteByUserId(userId); Repositories.Notes.DeleteByUserId(userId);
@@ -209,6 +204,8 @@ namespace Notesnook.API.Services
logger.LogInformation("User data deleted for user {UserId}: {Result}", userId, result); logger.LogInformation("User data deleted for user {UserId}: {Result}", userId, result);
if (!result) throw new Exception("Could not delete user data."); if (!result) throw new Exception("Could not delete user data.");
await syncDeviceService.ResetDevicesAsync(userId);
if (!Constants.IS_SELF_HOSTED) if (!Constants.IS_SELF_HOSTED)
{ {
await WampServers.SubscriptionServer.PublishMessageAsync(SubscriptionServerTopics.DeleteSubscriptionTopic, new DeleteSubscriptionMessage await WampServers.SubscriptionServer.PublishMessageAsync(SubscriptionServerTopics.DeleteSubscriptionTopic, new DeleteSubscriptionMessage
@@ -225,8 +222,7 @@ namespace Notesnook.API.Services
{ {
logger.LogInformation("Deleting user account: {UserId}", userId); logger.LogInformation("Deleting user account: {UserId}", userId);
var userService = await WampServers.IdentityServer.GetServiceAsync<IUserAccountService>(IdentityServerTopics.UserAccountServiceTopic); await serviceAccessor.UserAccountService.DeleteUserAsync(Clients.Notesnook.Id, userId, password);
await userService.DeleteUserAsync(Clients.Notesnook.Id, userId, password);
await DeleteUserAsync(userId); await DeleteUserAsync(userId);
@@ -246,7 +242,6 @@ namespace Notesnook.API.Services
public async Task<bool> ResetUserAsync(string userId, bool removeAttachments) public async Task<bool> ResetUserAsync(string userId, bool removeAttachments)
{ {
new SyncDeviceService(new SyncDevice(userId, userId)).ResetDevices();
var cc = new CancellationTokenSource(); var cc = new CancellationTokenSource();
@@ -266,6 +261,8 @@ namespace Notesnook.API.Services
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId); Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
if (!await unit.Commit()) return false; if (!await unit.Commit()) return false;
await syncDeviceService.ResetDevicesAsync(userId);
var userSettings = await Repositories.UsersSettings.FindOneAsync((s) => s.UserId == userId); var userSettings = await Repositories.UsersSettings.FindOneAsync((s) => s.UserId == userId);
userSettings.AttachmentsKey = null; userSettings.AttachmentsKey = null;

View File

@@ -169,14 +169,19 @@ namespace Notesnook.API
if (!BsonClassMap.IsClassMapRegistered(typeof(CallToAction))) if (!BsonClassMap.IsClassMapRegistered(typeof(CallToAction)))
BsonClassMap.RegisterClassMap<CallToAction>(); BsonClassMap.RegisterClassMap<CallToAction>();
if (!BsonClassMap.IsClassMapRegistered(typeof(SyncDevice)))
BsonClassMap.RegisterClassMap<SyncDevice>();
services.AddScoped<IDbContext, MongoDbContext>(); services.AddScoped<IDbContext, MongoDbContext>();
services.AddScoped<IUnitOfWork, UnitOfWork>(); services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddRepository<UserSettings>("user_settings", "notesnook") services.AddRepository<UserSettings>("user_settings", "notesnook")
.AddRepository<Monograph>("monographs", "notesnook") .AddRepository<Monograph>("monographs", "notesnook")
.AddRepository<Announcement>("announcements", "notesnook") .AddRepository<Announcement>("announcements", "notesnook")
.AddRepository<DeviceIdsChunk>(Collections.DeviceIdsChunksKey, "notesnook")
.AddRepository<SyncDevice>(Collections.SyncDevicesKey, "notesnook")
.AddRepository<InboxApiKey>(Collections.InboxApiKeysKey, "notesnook") .AddRepository<InboxApiKey>(Collections.InboxApiKeysKey, "notesnook")
.AddRepository<InboxSyncItem>(Collections.InboxItems, "notesnook"); .AddRepository<InboxSyncItem>(Collections.InboxItemsKey, "notesnook");
services.AddMongoCollection(Collections.SettingsKey) services.AddMongoCollection(Collections.SettingsKey)
.AddMongoCollection(Collections.AttachmentsKey) .AddMongoCollection(Collections.AttachmentsKey)
@@ -190,14 +195,17 @@ namespace Notesnook.API
.AddMongoCollection(Collections.TagsKey) .AddMongoCollection(Collections.TagsKey)
.AddMongoCollection(Collections.ColorsKey) .AddMongoCollection(Collections.ColorsKey)
.AddMongoCollection(Collections.VaultsKey) .AddMongoCollection(Collections.VaultsKey)
.AddMongoCollection(Collections.InboxItems) .AddMongoCollection(Collections.InboxItemsKey)
.AddMongoCollection(Collections.InboxApiKeysKey); .AddMongoCollection(Collections.InboxApiKeysKey);
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>(); services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
services.AddScoped<SyncDeviceService>();
services.AddScoped<IUserService, UserService>(); services.AddScoped<IUserService, UserService>();
services.AddScoped<IS3Service, S3Service>(); services.AddScoped<IS3Service, S3Service>();
services.AddScoped<IURLAnalyzer, URLAnalyzer>(); services.AddScoped<IURLAnalyzer, URLAnalyzer>();
services.AddWampServiceAccessor(Servers.NotesnookAPI);
services.AddControllers(); services.AddControllers();
services.AddHealthChecks(); services.AddHealthChecks();

View File

@@ -1,7 +1,7 @@
{ {
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Debug",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information", "Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.SignalR": "Trace", "Microsoft.AspNetCore.SignalR": "Trace",