47 Commits

Author SHA1 Message Date
Abdullah Atta
d1421d640f identity: fix user subscription claim value incorrect for legacy pro users 2025-10-13 11:27:07 +05:00
Abdullah Atta
131df3df04 s3: use internal upload object url for uploading files 2025-10-09 14:16:59 +05:00
Abdullah Atta
1ecd8adee1 s3: fix attachments not uploading on self hosted servers
this was due to s3 endpoints requesting user's subscription
which didn't exist in case of
self hosted setups
2025-10-09 14:13:11 +05:00
01zulfi
32b24dead2 inbox: sync inbox items 2025-10-08 12:49:41 +05:00
Abdullah Atta
4779a21134 ci: fix workflow 2025-10-07 17:05:10 +05:00
Abdullah Atta
6215c3c916 ci: run workflow on tag creation 2025-10-07 17:01:52 +05:00
Abdullah Atta
16ce7942be sync: fix error on monographs sync 2025-10-07 16:44:15 +05:00
Abdullah Atta
3a2e5f884b monograph: refactor to fix type errors 2025-10-07 16:44:15 +05:00
Abdullah Atta
2b6a58ff04 api: handle subscription fetch errors 2025-10-07 16:44:15 +05:00
Abdullah Atta
72e825a12c common: update subscription models 2025-10-07 16:44:15 +05:00
Abdullah Atta
8aeeba0eeb s3: fix ContentLength header is required error 2025-10-07 16:44:15 +05:00
Abdullah Atta
2952dd2c63 sync: fix monograph sync 2025-10-07 16:44:15 +05:00
Abdullah Atta
908d64bd4f api: fix minor issues 2025-10-07 16:44:15 +05:00
Abdullah Atta
41a185bd9f s3: disable download limits based on file size 2025-10-07 16:44:15 +05:00
Abdullah Atta
61dd2e1f74 s3: add limit on download file size 2025-10-07 16:44:15 +05:00
Abdullah Atta
3bb140aeb3 common: handle paddle billing errors 2025-10-07 16:44:15 +05:00
Abdullah Atta
7172510c9e api: fix sync not working on android 2025-10-07 16:44:15 +05:00
Abdullah Atta
cfe2875a67 api: refactor user subscription check for monograph embed & links 2025-10-07 16:44:15 +05:00
Abdullah Atta
9860df2379 identity: refactor sms mfa authorization check 2025-10-07 16:44:15 +05:00
Abdullah Atta
cc459f9fea common: add paddle v1 api client 2025-10-07 16:44:15 +05:00
Abdullah Atta
9e6a25ec1d common: fix plan change if subscription is trialing 2025-10-07 16:44:15 +05:00
Abdullah Atta
6304d8178f identity: fix delete subscription handling 2025-10-07 16:44:15 +05:00
Abdullah Atta
500a64de18 identity: use subscription v2 types & api 2025-10-07 16:44:15 +05:00
Abdullah Atta
55a2223198 common: add subscribe with semaphore to wamp to allow serial messaging 2025-10-07 16:44:15 +05:00
Abdullah Atta
3beb716b83 common: add paddle billing api client 2025-10-07 16:44:15 +05:00
Abdullah Atta
ed6e3c56f2 api: minor refactors aimed at improving memory consumption 2025-10-07 16:44:15 +05:00
Abdullah Atta
579e65b0be api: add support for storage limits 2025-10-07 16:44:15 +05:00
Abdullah Atta
b3dcdda697 api: improve sync hub auth 2025-10-07 16:44:15 +05:00
Abdullah Atta
44a9ff57e7 common: add new subscription types 2025-10-07 16:44:15 +05:00
Abdullah Atta
4361b90425 api: minor refactors 2025-10-07 16:44:15 +05:00
01zulfi
3471ecb21a feat: set up public inbox api server (#52)
* feat: set up public inbox api server

* feat: add zod validation for raw inbox item

* chore: update encrypted item type && raw inbox item schema

* feat: use symmetric & asymmetric combination for inbox encryption

* chore: improve error handling

* chore: update encrypted item type

* feat: add Dockerfile for Notesnook.Inbox.Api
2025-10-06 12:22:25 +05:00
01zulfi
5a9b98fd06 inbox: add GET public inbox key & POST inbox items endpoint (#51)
* inbox: add GET public inbox key && POST inbox items endpoint

* inbox: update SyncItem to support inbox items

* inbox: update post inbox items request payload

* inbox: update post inbox item endpoint
2025-10-06 12:21:31 +05:00
Abdullah Atta
34e5dc6a20 identity: fix super_strict rate limiting policy 2025-09-25 09:07:56 +05:00
Abdullah Atta
8f8c60e0b3 api: fix error on user patch 2025-09-17 11:38:33 +05:00
01zulfi
9b774d640c feat: get, add, & delete user's inbox api tokens (#50)
* feat: get, add, & delete user's inbox api tokens

* inbox: generate inbox api key on the server

* inbox: use nanoid to generate api key && set created date on server

* inbox: set api key in constructor && increase default expiry date to 1 year
2025-09-16 08:40:52 +05:00
Abdullah Atta
4a0aee1c44 monograph: fix json content serialized as html 2025-09-15 12:30:28 +05:00
Abdullah Atta
97fbd3226d monograph: add support for webrisk api for analyzing urls for pro users 2025-09-15 11:22:37 +05:00
Abdullah Atta
0f43b3ee66 monograph: remove links, embeds & images for non-pro users 2025-09-15 09:19:16 +05:00
Abdullah Atta
b469da70e8 api: only expose /metrics endpoint internally 2025-09-10 09:33:19 +05:00
01zulfi
10e33de897 api: include inbox keys in get user endpoint (#48) 2025-09-09 19:44:08 +05:00
01zulfi
6e8fb81ade sync: ensure monographs sync for first time ever on an existing device (#44) 2025-09-06 09:33:18 +05:00
01zulfi
34a09ad15d inbox: store user's inbox keys (#47) 2025-09-06 09:32:44 +05:00
Abdullah Atta
201a235357 identity: make otp rate limiting more strict 2025-08-25 10:46:31 +05:00
01zulfi
e68b8f7e7c sync: add RequestFetchV2 hub method (#43) 2025-08-20 11:20:50 +05:00
Abdullah Atta
a5b3a12914 monograph: fix some monographs can't be unpublished 2025-08-19 14:23:56 +05:00
Abdullah Atta
3ed30b206c api: run device cleanup job 1st of every month 2025-08-19 14:00:55 +05:00
Abdullah Atta
1344199807 api: add job to cleanup stale devices every 30 days 2025-08-19 12:24:43 +05:00
82 changed files with 3909 additions and 988 deletions

View File

@@ -10,8 +10,9 @@
name: Publish Docker images
on:
release:
types: [published]
push:
tags:
- "v*"
jobs:
push_to_registry:

View File

@@ -43,6 +43,8 @@ namespace Notesnook.API.Accessors
public SyncItemsRepository Tags { get; }
public Repository<UserSettings> UsersSettings { get; }
public Repository<Monograph> Monographs { get; }
public Repository<InboxApiKey> InboxApiKey { get; }
public Repository<InboxSyncItem> InboxItems { get; }
public SyncItemsRepositoryAccessor(IDbContext dbContext,
@@ -71,10 +73,13 @@ namespace Notesnook.API.Accessors
[FromKeyedServices(Collections.TagsKey)]
IMongoCollection<SyncItem> tags,
Repository<UserSettings> usersSettings, Repository<Monograph> monographs)
Repository<UserSettings> usersSettings, Repository<Monograph> monographs,
Repository<InboxApiKey> inboxApiKey, Repository<InboxSyncItem> inboxItems)
{
UsersSettings = usersSettings;
Monographs = monographs;
InboxApiKey = inboxApiKey;
InboxItems = inboxItems;
Notebooks = new SyncItemsRepository(dbContext, notebooks);
Notes = new SyncItemsRepository(dbContext, notes);
Contents = new SyncItemsRepository(dbContext, content);

View File

@@ -0,0 +1,101 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Notesnook.API.Models;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Authorization
{
public static class InboxApiKeyAuthenticationDefaults
{
public const string AuthenticationScheme = "InboxApiKey";
}
public class InboxApiKeyAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
}
public class InboxApiKeyAuthenticationHandler : AuthenticationHandler<InboxApiKeyAuthenticationSchemeOptions>
{
private readonly Repository<InboxApiKey> _inboxApiKeyRepository;
public InboxApiKeyAuthenticationHandler(
IOptionsMonitor<InboxApiKeyAuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
Repository<InboxApiKey> inboxApiKeyRepository)
: base(options, logger, encoder)
{
_inboxApiKeyRepository = inboxApiKeyRepository;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
{
return AuthenticateResult.Fail("Missing Authorization header");
}
var apiKey = Request.Headers["Authorization"].ToString().Trim();
if (string.IsNullOrEmpty(apiKey))
{
return AuthenticateResult.Fail("Missing API key");
}
try
{
var inboxApiKey = await _inboxApiKeyRepository.FindOneAsync(k => k.Key == apiKey);
if (inboxApiKey == null)
{
return AuthenticateResult.Fail("Invalid API key");
}
if (inboxApiKey.ExpiryDate > 0 && DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() > inboxApiKey.ExpiryDate)
{
return AuthenticateResult.Fail("API key has expired");
}
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey);
var claims = new[]
{
new Claim("sub", inboxApiKey.UserId),
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error validating inbox API key");
return AuthenticateResult.Fail("Error validating API key");
}
}
}
}

View File

@@ -1,63 +0,0 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
namespace Notesnook.API.Authorization
{
public class ProUserRequirement : AuthorizationHandler<ProUserRequirement>, IAuthorizationRequirement
{
private readonly Dictionary<string, string> pathErrorPhraseMap = new()
{
["/s3"] = "upload attachments",
["/s3/multipart"] = "upload attachments",
};
private readonly string[] allowedClaims = ["trial", "premium", "premium_canceled"];
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ProUserRequirement requirement)
{
PathString path = context.Resource is DefaultHttpContext httpContext ? httpContext.Request.Path : null;
var isProOrTrial = context.User.Claims.Any((c) => c.Type == "notesnook:status" && allowedClaims.Contains(c.Value));
if (isProOrTrial) context.Succeed(requirement);
else
{
var phrase = "continue";
foreach (var item in pathErrorPhraseMap)
{
if (path != null && path.StartsWithSegments(item.Key))
phrase = item.Value;
}
var error = $"Please upgrade to Pro to {phrase}.";
context.Fail(new AuthorizationFailureReason(this, error));
}
return Task.CompletedTask;
}
public override Task HandleAsync(AuthorizationHandlerContext context)
{
return this.HandleRequirementAsync(context, this);
}
}
}

View File

@@ -1,102 +1,102 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
namespace Notesnook.API.Authorization
{
public class SyncRequirement : AuthorizationHandler<SyncRequirement>, IAuthorizationRequirement
{
private readonly Dictionary<string, string> pathErrorPhraseMap = new()
{
["/sync/attachments"] = "use attachments",
["/sync"] = "sync your notes",
["/hubs/sync"] = "sync your notes",
["/hubs/sync/v2"] = "sync your notes",
["/monographs"] = "publish monographs"
};
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncRequirement requirement)
{
PathString path = context.Resource is DefaultHttpContext httpContext ? httpContext.Request.Path : null;
var result = this.IsAuthorized(context.User, path);
if (result.Succeeded) context.Succeed(requirement);
else if (result.AuthorizationFailure.FailureReasons.Any())
context.Fail(result.AuthorizationFailure.FailureReasons.First());
else context.Fail();
return Task.CompletedTask;
}
public PolicyAuthorizationResult IsAuthorized(ClaimsPrincipal User, PathString requestPath)
{
var id = User.FindFirstValue("sub");
if (string.IsNullOrEmpty(id))
{
var reason = new[]
{
new AuthorizationFailureReason(this, "Invalid token.")
};
return PolicyAuthorizationResult.Forbid(AuthorizationFailure.Failed(reason));
}
var hasSyncScope = User.HasClaim("scope", "notesnook.sync");
var isInAudience = User.HasClaim("aud", "notesnook");
var hasRole = User.HasClaim("role", "notesnook");
var isEmailVerified = User.HasClaim("verified", "true");
if (!isEmailVerified)
{
var phrase = "continue";
foreach (var item in pathErrorPhraseMap)
{
if (requestPath != null && requestPath.StartsWithSegments(item.Key))
phrase = item.Value;
}
var error = $"Please confirm your email to {phrase}.";
var reason = new[]
{
new AuthorizationFailureReason(this, error)
};
return PolicyAuthorizationResult.Forbid(AuthorizationFailure.Failed(reason));
// context.Fail(new AuthorizationFailureReason(this, error));
}
if (hasSyncScope && isInAudience && hasRole && isEmailVerified)
return PolicyAuthorizationResult.Success(); //(requirement);
return PolicyAuthorizationResult.Forbid();
}
public override Task HandleAsync(AuthorizationHandlerContext context)
{
return this.HandleRequirementAsync(context, this);
}
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
namespace Notesnook.API.Authorization
{
public class SyncRequirement : AuthorizationHandler<SyncRequirement>, IAuthorizationRequirement
{
private readonly Dictionary<string, string> pathErrorPhraseMap = new()
{
["/sync/attachments"] = "use attachments",
["/sync"] = "sync your notes",
["/hubs/sync"] = "sync your notes",
["/hubs/sync/v2"] = "sync your notes",
["/monographs"] = "publish monographs"
};
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncRequirement requirement)
{
PathString path = context.Resource is DefaultHttpContext httpContext ? httpContext.Request.Path : null;
var result = this.IsAuthorized(context.User, path);
if (result.Succeeded) context.Succeed(requirement);
else if (result.AuthorizationFailure.FailureReasons.Any())
context.Fail(result.AuthorizationFailure.FailureReasons.First());
else context.Fail();
return Task.CompletedTask;
}
public PolicyAuthorizationResult IsAuthorized(ClaimsPrincipal? User, PathString requestPath)
{
var id = User?.FindFirstValue("sub");
if (string.IsNullOrEmpty(id))
{
var reason = new[]
{
new AuthorizationFailureReason(this, "Invalid token.")
};
return PolicyAuthorizationResult.Forbid(AuthorizationFailure.Failed(reason));
}
var hasSyncScope = User.HasClaim("scope", "notesnook.sync");
var isInAudience = User.HasClaim("aud", "notesnook");
var hasRole = User.HasClaim("role", "notesnook");
var isEmailVerified = User.HasClaim("verified", "true");
if (!isEmailVerified)
{
var phrase = "continue";
foreach (var item in pathErrorPhraseMap)
{
if (requestPath != null && requestPath.StartsWithSegments(item.Key))
phrase = item.Value;
}
var error = $"Please confirm your email to {phrase}.";
var reason = new[]
{
new AuthorizationFailureReason(this, error)
};
return PolicyAuthorizationResult.Forbid(AuthorizationFailure.Failed(reason));
// context.Fail(new AuthorizationFailureReason(this, error));
}
if (hasSyncScope && isInAudience && hasRole && isEmailVerified)
return PolicyAuthorizationResult.Success(); //(requirement);
return PolicyAuthorizationResult.Forbid();
}
public override Task HandleAsync(AuthorizationHandlerContext context)
{
return this.HandleRequirementAsync(context, this);
}
}
}

View File

@@ -14,5 +14,7 @@ namespace Notesnook.API
public const string TagsKey = "tags";
public const string ColorsKey = "colors";
public const string VaultsKey = "vaults";
public const string InboxItems = "inbox_items";
public const string InboxApiKeysKey = "inbox_api_keys";
}
}

View File

@@ -42,10 +42,10 @@ namespace Notesnook.API.Controllers
[HttpGet("active")]
[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));
if (totalActive <= 0) return Ok(new Announcement[] { });
if (totalActive <= 0) return Ok(Array.Empty<Announcement>());
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)

View File

@@ -0,0 +1,214 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MongoDB.Bson;
using Notesnook.API.Authorization;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Streetwriters.Common;
using Streetwriters.Common.Messages;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Controllers
{
[ApiController]
[Route("inbox")]
public class InboxController : ControllerBase
{
private readonly Repository<InboxApiKey> InboxApiKey;
private readonly Repository<UserSettings> UserSetting;
private Repository<InboxSyncItem> InboxItems;
public InboxController(
Repository<InboxApiKey> inboxApiKeysRepository,
Repository<UserSettings> userSettingsRepository,
Repository<InboxSyncItem> inboxItemsRepository)
{
InboxApiKey = inboxApiKeysRepository;
UserSetting = userSettingsRepository;
InboxItems = inboxItemsRepository;
}
[HttpGet("api-keys")]
[Authorize(Policy = "Notesnook")]
public async Task<IActionResult> GetApiKeysAsync()
{
var userId = User.FindFirstValue("sub");
try
{
var apiKeys = await InboxApiKey.FindAsync(t => t.UserId == userId);
return Ok(apiKeys);
}
catch (Exception ex)
{
await Slogger<InboxController>.Error(nameof(GetApiKeysAsync), "Couldn't get inbox api keys.", userId, ex.ToString());
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("api-keys")]
[Authorize(Policy = "Notesnook")]
public async Task<IActionResult> CreateApiKeyAsync([FromBody] InboxApiKey request)
{
var userId = User.FindFirstValue("sub");
try
{
if (string.IsNullOrWhiteSpace(request.Name))
{
return BadRequest(new { error = "Api key name is required." });
}
if (request.ExpiryDate <= -1)
{
return BadRequest(new { error = "Valid expiry date is required." });
}
var count = await InboxApiKey.CountAsync(t => t.UserId == userId);
if (count >= 10)
{
return BadRequest(new { error = "Maximum of 10 inbox api keys allowed." });
}
var inboxApiKey = new InboxApiKey
{
UserId = userId,
Name = request.Name,
DateCreated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ExpiryDate = request.ExpiryDate,
LastUsedAt = 0
};
await InboxApiKey.InsertAsync(inboxApiKey);
return Ok(inboxApiKey);
}
catch (Exception ex)
{
await Slogger<InboxController>.Error(nameof(CreateApiKeyAsync), "Couldn't create inbox api key.", userId, ex.ToString());
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("api-keys/{apiKey}")]
[Authorize(Policy = "Notesnook")]
public async Task<IActionResult> DeleteApiKeyAsync(string apiKey)
{
var userId = User.FindFirstValue("sub");
try
{
if (string.IsNullOrWhiteSpace(apiKey))
{
return BadRequest(new { error = "Api key is required." });
}
await InboxApiKey.DeleteAsync(t => t.UserId == userId && t.Key == apiKey);
return Ok(new { message = "Api key deleted successfully." });
}
catch (Exception ex)
{
await Slogger<InboxController>.Error(nameof(DeleteApiKeyAsync), "Couldn't delete inbox api key.", userId, ex.ToString());
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("public-encryption-key")]
[Authorize(Policy = InboxApiKeyAuthenticationDefaults.AuthenticationScheme)]
public async Task<IActionResult> GetPublicKeyAsync()
{
var userId = User.FindFirstValue("sub");
try
{
var userSetting = await UserSetting.FindOneAsync(u => u.UserId == userId);
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
{
return BadRequest(new { error = "Inbox public key is not configured." });
}
return Ok(new { key = userSetting.InboxKeys.Public });
}
catch (Exception ex)
{
await Slogger<InboxController>.Error(nameof(GetPublicKeyAsync), "Couldn't get user's inbox's public key.", userId, ex.ToString());
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("items")]
[Authorize(Policy = InboxApiKeyAuthenticationDefaults.AuthenticationScheme)]
public async Task<IActionResult> CreateInboxItemAsync([FromBody] InboxSyncItem request)
{
var userId = User.FindFirstValue("sub");
try
{
if (request.Key.Algorithm != Algorithms.XSAL_X25519_7)
{
return BadRequest(new { error = $"Only {Algorithms.XSAL_X25519_7} is supported for inbox item password." });
}
if (string.IsNullOrWhiteSpace(request.Key.Cipher))
{
return BadRequest(new { error = "Inbox item password cipher is required." });
}
if (request.Key.Length <= 0)
{
return BadRequest(new { error = "Valid inbox item password length is required." });
}
if (request.Algorithm != Algorithms.Default)
{
return BadRequest(new { error = $"Only {Algorithms.Default} is supported for inbox item." });
}
if (request.Version <= 0)
{
return BadRequest(new { error = "Valid inbox item version is required." });
}
if (string.IsNullOrWhiteSpace(request.Cipher) || string.IsNullOrWhiteSpace(request.IV))
{
return BadRequest(new { error = "Inbox item cipher and iv is required." });
}
if (request.Length <= 0)
{
return BadRequest(new { error = "Valid inbox item length is required." });
}
request.UserId = userId;
request.ItemId = ObjectId.GenerateNewId().ToString();
await InboxItems.InsertAsync(request);
new SyncDeviceService(new SyncDevice(userId, string.Empty))
.AddIdsToAllDevices([$"{request.ItemId}:inboxItems"]);
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
{
OriginTokenId = null,
UserId = userId,
Message = new Message
{
Type = "triggerSync",
Data = JsonSerializer.Serialize(new { reason = "Inbox items updated." })
}
});
return Ok();
}
catch (Exception ex)
{
await Slogger<InboxController>.Error(nameof(CreateInboxItemAsync), "Couldn't create inbox item.", userId, ex.ToString());
return BadRequest(new { error = ex.Message });
}
}
}
}

View File

@@ -23,13 +23,17 @@ 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.Mvc;
using MongoDB.Bson;
using MongoDB.Driver;
using Notesnook.API.Authorization;
using Notesnook.API.Models;
using Notesnook.API.Services;
using Streetwriters.Common;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
@@ -39,21 +43,15 @@ namespace Notesnook.API.Controllers
[ApiController]
[Route("monographs")]
[Authorize("Sync")]
public class MonographsController : ControllerBase
public class MonographsController(Repository<Monograph> monographs, IURLAnalyzer analyzer) : ControllerBase
{
const string SVG_PIXEL = "<svg xmlns='http://www.w3.org/2000/svg' width='1' height='1'><circle r='9'/></svg>";
private Repository<Monograph> Monographs { get; set; }
private readonly IUnitOfWork unit;
private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
public MonographsController(Repository<Monograph> monographs, IUnitOfWork unitOfWork)
{
Monographs = monographs;
unit = unitOfWork;
}
private static FilterDefinition<Monograph> CreateMonographFilter(string userId, Monograph monograph)
{
var userIdFilter = Builders<Monograph>.Filter.Eq("UserId", userId);
monograph.ItemId ??= monograph.Id;
return ObjectId.TryParse(monograph.ItemId, out ObjectId id)
? Builders<Monograph>.Filter
.And(userIdFilter,
@@ -78,7 +76,7 @@ namespace Notesnook.API.Controllers
private async Task<Monograph> FindMonographAsync(string userId, Monograph monograph)
{
var result = await Monographs.Collection.FindAsync(CreateMonographFilter(userId, monograph), new FindOptions<Monograph>
var result = await monographs.Collection.FindAsync(CreateMonographFilter(userId, monograph), new FindOptions<Monograph>
{
Limit = 1
});
@@ -87,7 +85,7 @@ namespace Notesnook.API.Controllers
private async Task<Monograph> FindMonographAsync(string itemId)
{
var result = await Monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions<Monograph>
var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions<Monograph>
{
Limit = 1
});
@@ -95,21 +93,19 @@ namespace Notesnook.API.Controllers
}
[HttpPost]
public async Task<IActionResult> PublishAsync([FromQuery] string deviceId, [FromBody] Monograph monograph)
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
{
try
{
var userId = this.User.FindFirstValue("sub");
var jti = this.User.FindFirstValue("jti");
if (userId == null) return Unauthorized();
var existingMonograph = await FindMonographAsync(userId, monograph);
if (existingMonograph != null && !existingMonograph.Deleted)
{
return base.Conflict("This monograph is already published.");
}
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
if (monograph.EncryptedContent == null)
monograph.CompressedContent = monograph.Content.CompressBrotli();
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
monograph.UserId = userId;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -118,16 +114,16 @@ namespace Notesnook.API.Controllers
if (existingMonograph != null)
{
monograph.Id = existingMonograph?.Id;
monograph.Id = existingMonograph.Id;
}
monograph.Deleted = false;
await Monographs.Collection.ReplaceOneAsync(
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
monograph,
new ReplaceOptions { IsUpsert = true }
);
await MarkMonographForSyncAsync(monograph.ItemId ?? monograph.Id, deviceId);
await MarkMonographForSyncAsync(userId, monograph.ItemId ?? monograph.Id, deviceId, jti);
return Ok(new
{
@@ -143,11 +139,12 @@ namespace Notesnook.API.Controllers
}
[HttpPatch]
public async Task<IActionResult> UpdateAsync([FromQuery] string deviceId, [FromBody] Monograph monograph)
public async Task<IActionResult> UpdateAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
{
try
{
var userId = this.User.FindFirstValue("sub");
var jti = this.User.FindFirstValue("jti");
if (userId == null) return Unauthorized();
var existingMonograph = await FindMonographAsync(userId, monograph);
@@ -160,12 +157,12 @@ namespace Notesnook.API.Controllers
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
if (monograph.EncryptedContent == null)
monograph.CompressedContent = monograph.Content.CompressBrotli();
monograph.CompressedContent = (await CleanupContentAsync(User, monograph.Content)).CompressBrotli();
else
monograph.Content = null;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var result = await Monographs.Collection.UpdateOneAsync(
var result = await monographs.Collection.UpdateOneAsync(
CreateMonographFilter(userId, monograph),
Builders<Monograph>.Update
.Set(m => m.DatePublished, monograph.DatePublished)
@@ -177,7 +174,7 @@ namespace Notesnook.API.Controllers
);
if (!result.IsAcknowledged) return BadRequest();
await MarkMonographForSyncAsync(monograph.ItemId ?? monograph.Id, deviceId);
await MarkMonographForSyncAsync(userId, monograph.ItemId ?? monograph.Id, deviceId, jti);
return Ok(new
{
@@ -198,7 +195,7 @@ namespace Notesnook.API.Controllers
var userId = this.User.FindFirstValue("sub");
if (userId == null) return Unauthorized();
var monographs = (await Monographs.Collection.FindAsync(
var userMonographs = (await monographs.Collection.FindAsync(
Builders<Monograph>.Filter.And(
Builders<Monograph>.Filter.Eq("UserId", userId),
Builders<Monograph>.Filter.Ne("Deleted", true)
@@ -207,7 +204,7 @@ namespace Notesnook.API.Controllers
{
Projection = Builders<Monograph>.Projection.Include("_id").Include("ItemId"),
})).ToEnumerable();
return Ok(monographs.Select((m) => m.ItemId ?? m.Id));
return Ok(userMonographs.Select((m) => m.ItemId ?? m.Id));
}
[HttpGet("{id}")]
@@ -225,8 +222,8 @@ namespace Notesnook.API.Controllers
}
if (monograph.EncryptedContent == null)
monograph.Content = monograph.CompressedContent.DecompressBrotli();
if (monograph.ItemId == null) monograph.ItemId = monograph.Id;
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
monograph.ItemId ??= monograph.Id;
return Ok(monograph);
}
@@ -239,38 +236,36 @@ namespace Notesnook.API.Controllers
if (monograph.SelfDestruct)
{
var userId = this.User.FindFirstValue("sub");
await Monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(monograph.UserId, monograph),
new Monograph
{
ItemId = id,
Id = monograph.Id,
Deleted = true
Deleted = true,
UserId = monograph.UserId
}
);
await MarkMonographForSyncAsync(id);
await MarkMonographForSyncAsync(monograph.UserId, id);
}
return Content(SVG_PIXEL, "image/svg+xml");
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAsync([FromQuery] string deviceId, [FromRoute] string id)
public async Task<IActionResult> DeleteAsync([FromQuery] string? deviceId, [FromRoute] string id)
{
var userId = this.User.FindFirstValue("sub");
if (userId is null) return Unauthorized();
var monograph = await FindMonographAsync(id);
if (monograph == null || monograph.Deleted)
{
return NotFound(new
{
error = "invalid_id",
error_description = $"No such monograph found."
});
}
return Ok();
var userId = this.User.FindFirstValue("sub");
await Monographs.Collection.ReplaceOneAsync(
var jti = this.User.FindFirstValue("jti");
await monographs.Collection.ReplaceOneAsync(
CreateMonographFilter(userId, monograph),
new Monograph
{
@@ -281,33 +276,27 @@ namespace Notesnook.API.Controllers
}
);
await MarkMonographForSyncAsync(id, deviceId);
await MarkMonographForSyncAsync(userId, id, deviceId, jti);
return Ok();
}
private async Task MarkMonographForSyncAsync(string monographId, string deviceId)
private static async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
{
if (deviceId == null) return;
var userId = this.User.FindFirstValue("sub");
new SyncDeviceService(new SyncDevice(userId, deviceId)).AddIdsToOtherDevices([$"{monographId}:monograph"]);
await SendTriggerSyncEventAsync();
await SendTriggerSyncEventAsync(userId, jti);
}
private async Task MarkMonographForSyncAsync(string monographId)
private static async Task MarkMonographForSyncAsync(string userId, string monographId)
{
var userId = this.User.FindFirstValue("sub");
new SyncDeviceService(new SyncDevice(userId, string.Empty)).AddIdsToAllDevices([$"{monographId}:monograph"]);
await SendTriggerSyncEventAsync(sendToAllDevices: true);
await SendTriggerSyncEventAsync(userId, sendToAllDevices: true);
}
private async Task SendTriggerSyncEventAsync(bool sendToAllDevices = false)
private static async Task SendTriggerSyncEventAsync(string userId, string? jti = null, 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,
@@ -319,5 +308,56 @@ namespace Notesnook.API.Controllers
}
});
}
private async Task<string> CleanupContentAsync(ClaimsPrincipal user, string content)
{
if (Constants.IS_SELF_HOSTED) return content;
try
{
var json = JsonSerializer.Deserialize<MonographContent>(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))
{
await Slogger<MonographsController>.Info("CleanupContentAsync", "Malicious URL detected: " + 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<MonographContent>(new MonographContent
{
Type = json.Type,
Data = html
});
}
catch (Exception ex)
{
await Slogger<MonographsController>.Error("CleanupContentAsync", ex.ToString());
return content;
}
}
}
}

View File

@@ -24,6 +24,12 @@ using System.Threading.Tasks;
using System.Security.Claims;
using Notesnook.API.Interfaces;
using System;
using System.Net.Http;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Models;
using Notesnook.API.Helpers;
using Streetwriters.Common;
using Streetwriters.Common.Interfaces;
using Notesnook.API.Models;
namespace Notesnook.API.Controllers
@@ -31,28 +37,71 @@ namespace Notesnook.API.Controllers
[ApiController]
[Route("s3")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[Authorize("Sync")]
public class S3Controller : ControllerBase
{
private ISyncItemsRepositoryAccessor Repositories { get; }
private IS3Service S3Service { get; set; }
public S3Controller(IS3Service s3Service)
public S3Controller(IS3Service s3Service, ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor)
{
S3Service = s3Service;
Repositories = syncItemsRepositoryAccessor;
}
[HttpPut]
[Authorize("Pro")]
public IActionResult Upload([FromQuery] string name)
public async Task<IActionResult> Upload([FromQuery] string name)
{
var userId = this.User.FindFirstValue("sub");
var url = S3Service.GetUploadObjectUrl(userId, name);
if (url == null) return BadRequest("Could not create signed url.");
return Ok(url);
if (!HttpContext.Request.Headers.ContentLength.HasValue) return BadRequest(new { error = "No Content-Length header found." });
long fileSize = HttpContext.Request.Headers.ContentLength.Value;
if (fileSize == 0)
{
var uploadUrl = S3Service.GetUploadObjectUrl(userId, name);
if (uploadUrl == null) return BadRequest(new { error = "Could not create signed url." });
return Ok(uploadUrl);
}
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
if (!Constants.IS_SELF_HOSTED)
{
var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic);
var subscription = await subscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId);
if (subscription is null) return BadRequest(new { error = "User subscription not found." });
if (StorageHelper.IsFileSizeExceeded(subscription, fileSize))
{
return BadRequest(new { error = "Max file size exceeded." });
}
userSettings.StorageLimit ??= new Limit { Value = 0, UpdatedAt = 0 };
userSettings.StorageLimit.Value += fileSize;
if (StorageHelper.IsStorageLimitReached(subscription, userSettings.StorageLimit))
return BadRequest(new { error = "Storage limit exceeded." });
}
var url = S3Service.GetInternalUploadObjectUrl(userId, name);
if (url == null) return BadRequest(new { error = "Could not create signed url." });
var httpClient = new HttpClient();
var content = new StreamContent(HttpContext.Request.BodyReader.AsStream());
content.Headers.ContentLength = Request.ContentLength;
var response = await httpClient.SendRequestAsync<Response>(url, null, HttpMethod.Put, content);
if (!response.Success) return BadRequest(await response.Content.ReadAsStringAsync());
if (!Constants.IS_SELF_HOSTED)
{
userSettings.StorageLimit.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await Repositories.UsersSettings.UpsertAsync(userSettings, (u) => u.UserId == userId);
}
return Ok(response);
}
[HttpGet("multipart")]
[Authorize("Pro")]
public async Task<IActionResult> MultipartUpload([FromQuery] string name, [FromQuery] int parts, [FromQuery] string uploadId)
public async Task<IActionResult> MultipartUpload([FromQuery] string name, [FromQuery] int parts, [FromQuery] string? uploadId)
{
var userId = this.User.FindFirstValue("sub");
try
@@ -64,7 +113,6 @@ namespace Notesnook.API.Controllers
}
[HttpDelete("multipart")]
[Authorize("Pro")]
public async Task<IActionResult> AbortMultipartUpload([FromQuery] string name, [FromQuery] string uploadId)
{
var userId = this.User.FindFirstValue("sub");
@@ -77,7 +125,6 @@ namespace Notesnook.API.Controllers
}
[HttpPost("multipart")]
[Authorize("Pro")]
public async Task<IActionResult> CompleteMultipartUpload([FromBody] CompleteMultipartUploadRequestWrapper uploadRequestWrapper)
{
var userId = this.User.FindFirstValue("sub");
@@ -90,17 +137,19 @@ namespace Notesnook.API.Controllers
}
[HttpGet]
[Authorize("Sync")]
public IActionResult Download([FromQuery] string name)
public async Task<IActionResult> Download([FromQuery] string name)
{
var userId = this.User.FindFirstValue("sub");
var url = S3Service.GetDownloadObjectUrl(userId, name);
if (url == null) return BadRequest("Could not create signed url.");
return Ok(url);
try
{
var userId = this.User.FindFirstValue("sub");
var url = await S3Service.GetDownloadObjectUrl(userId, name);
if (url == null) return BadRequest("Could not create signed url.");
return Ok(url);
}
catch (Exception ex) { return BadRequest(ex.Message); }
}
[HttpHead]
[Authorize("Sync")]
public async Task<IActionResult> Info([FromQuery] string name)
{
var userId = this.User.FindFirstValue("sub");
@@ -110,7 +159,6 @@ namespace Notesnook.API.Controllers
}
[HttpDelete]
[Authorize("Sync")]
public async Task<IActionResult> DeleteAsync([FromQuery] string name)
{
try
@@ -125,4 +173,4 @@ namespace Notesnook.API.Controllers
}
}
}
}
}

View File

@@ -58,7 +58,7 @@ namespace Notesnook.API.Controllers
try
{
UserResponse response = await UserService.GetUserAsync(userId);
if (!response.Success) return BadRequest(response);
if (!response.Success) return BadRequest();
return Ok(response);
}
catch (Exception ex)
@@ -69,16 +69,11 @@ namespace Notesnook.API.Controllers
}
[HttpPatch]
public async Task<IActionResult> UpdateUser([FromBody] UserResponse user)
public async Task<IActionResult> UpdateUser([FromBody] UserKeys keys)
{
var userId = User.FindFirstValue("sub");
try
{
var keys = new UserKeys
{
AttachmentsKey = user.AttachmentsKey,
MonographPasswordsKey = user.MonographPasswordsKey
};
await UserService.SetUserKeysAsync(userId, keys);
return Ok();
}

View File

@@ -17,8 +17,11 @@ 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/>.
*/
using System;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
@@ -42,12 +45,9 @@ namespace Notesnook.API.Extensions
AuthorizationPolicy authorizationPolicy,
PolicyAuthorizationResult policyAuthorizationResult)
{
var isWebsocket = httpContext.Request.Headers.Upgrade == "websocket";
if (!isWebsocket && policyAuthorizationResult.Forbidden && policyAuthorizationResult.AuthorizationFailure != null)
if (policyAuthorizationResult.Forbidden && policyAuthorizationResult.AuthorizationFailure != null)
{
var error = string.Join("\n", policyAuthorizationResult.AuthorizationFailure.FailureReasons.Select((r) => r.Message));
if (!string.IsNullOrEmpty(error))
{
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
@@ -55,17 +55,8 @@ namespace Notesnook.API.Extensions
await httpContext.Response.WriteAsync(JsonSerializer.Serialize(new { error }));
return;
}
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, policyAuthorizationResult);
}
else if (isWebsocket)
{
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, PolicyAuthorizationResult.Success());
}
else
{
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, policyAuthorizationResult);
}
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, policyAuthorizationResult);
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Threading;
using System;
using System.Threading.Tasks;
using System.Linq;
namespace System.Security.Claims
{
public static class ClaimsPrincipalExtensions
{
private readonly static string[] SUBSCRIBED_CLAIMS = ["believer", "education", "essential", "pro", "legacy_pro"];
public static bool IsUserSubscribed(this ClaimsPrincipal user)
=> user.Claims.Any((c) => c.Type == "notesnook:status" && SUBSCRIBED_CLAIMS.Contains(c.Value));
}
}

View File

@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using Notesnook.API.Models;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
namespace Notesnook.API.Helpers
{
class StorageHelper
{
const long MB = 1024 * 1024;
const long GB = 1024 * MB;
public readonly static Dictionary<SubscriptionPlan, long> MAX_STORAGE_PER_MONTH = new()
{
{ SubscriptionPlan.FREE, 50L * MB },
{ SubscriptionPlan.ESSENTIAL, GB },
{ SubscriptionPlan.PRO, 10L * GB },
{ SubscriptionPlan.EDUCATION, 10L * GB },
{ SubscriptionPlan.BELIEVER, 25L * GB },
{ SubscriptionPlan.LEGACY_PRO, -1 }
};
public readonly static Dictionary<SubscriptionPlan, long> MAX_FILE_SIZE = new()
{
{ SubscriptionPlan.FREE, 10 * MB },
{ SubscriptionPlan.ESSENTIAL, 100 * MB },
{ SubscriptionPlan.PRO, 1L * GB },
{ SubscriptionPlan.EDUCATION, 1L * GB },
{ SubscriptionPlan.BELIEVER, 5L * GB },
{ SubscriptionPlan.LEGACY_PRO, 512 * MB }
};
public static long GetStorageLimitForPlan(Subscription subscription)
{
return MAX_STORAGE_PER_MONTH[subscription.Plan];
}
public static long GetFileSizeLimitForPlan(Subscription subscription)
{
return MAX_FILE_SIZE[subscription.Plan];
}
public static bool IsStorageLimitReached(Subscription subscription, Limit limit)
{
var storageLimit = GetStorageLimitForPlan(subscription);
if (storageLimit == -1) return false;
return limit.Value > storageLimit;
}
public static bool IsFileSizeExceeded(Subscription subscription, long fileSize)
{
var maxFileSize = MAX_FILE_SIZE[subscription.Plan];
return fileSize > maxFileSize;
}
private static readonly string[] sizes = ["B", "KB", "MB", "GB", "TB"];
public static string FormatBytes(long size)
{
int order = 0;
while (size >= 1024 && order < sizes.Length - 1)
{
order++;
size = size / 1024;
}
return String.Format("{0:0.##} {1}", size, sizes[order]);
}
}
}

View File

@@ -18,9 +18,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
@@ -40,16 +42,17 @@ namespace Notesnook.API.Hubs
{
Task<bool> SendItems(SyncTransferItemV2 transferItem);
Task<bool> SendVaultKey(EncryptedData vaultKey);
Task<bool> SendMonographs(IEnumerable<Monograph> monographs);
Task<bool> SendMonographs(IEnumerable<MonographMetadata> monographs);
Task<bool> SendInboxItems(IEnumerable<InboxSyncItem> inboxItems);
Task PushCompleted();
}
[Authorize("Sync")]
[Authorize]
public class SyncV2Hub : Hub<ISyncV2HubClient>
{
private ISyncItemsRepositoryAccessor Repositories { get; }
private readonly IUnitOfWork unit;
private readonly string[] CollectionKeys = [
private static readonly string[] CollectionKeys = [
"settingitem",
"attachment",
"note",
@@ -62,11 +65,40 @@ namespace Notesnook.API.Hubs
"vault",
"relation", // relations must sync at the end to prevent invalid state
];
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
private readonly Func<string, string[], bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections;
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork)
{
Repositories = syncItemsRepositoryAccessor;
unit = unitOfWork;
Collections = [
Repositories.Settings.FindItemsById,
Repositories.Attachments.FindItemsById,
Repositories.Notes.FindItemsById,
Repositories.Notebooks.FindItemsById,
Repositories.Contents.FindItemsById,
Repositories.Shortcuts.FindItemsById,
Repositories.Reminders.FindItemsById,
Repositories.Colors.FindItemsById,
Repositories.Tags.FindItemsById,
Repositories.Vaults.FindItemsById,
Repositories.Relations.FindItemsById,
];
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
{ "settingitem", Repositories.Settings.UpsertMany },
{ "attachment", Repositories.Attachments.UpsertMany },
{ "note", Repositories.Notes.UpsertMany },
{ "notebook", Repositories.Notebooks.UpsertMany },
{ "content", Repositories.Contents.UpsertMany },
{ "shortcut", Repositories.Shortcuts.UpsertMany },
{ "reminder", Repositories.Reminders.UpsertMany },
{ "relation", Repositories.Relations.UpsertMany },
{ "color", Repositories.Colors.UpsertMany },
{ "vault", Repositories.Vaults.UpsertMany },
{ "tag", Repositories.Tags.UpsertMany },
}.ToFrozenDictionary();
}
public override async Task OnConnectedAsync()
@@ -74,46 +106,26 @@ namespace Notesnook.API.Hubs
var result = new SyncRequirement().IsAuthorized(Context.User, new PathString("/hubs/sync/v2"));
if (!result.Succeeded)
{
var reason = result.AuthorizationFailure.FailureReasons.FirstOrDefault();
var reason = result.AuthorizationFailure?.FailureReasons.FirstOrDefault();
throw new HubException(reason?.Message ?? "Unauthorized");
}
var id = Context.User.FindFirstValue("sub");
var id = Context.User?.FindFirstValue("sub") ?? throw new HubException("User not found.");
await Groups.AddToGroupAsync(Context.ConnectionId, id);
await base.OnConnectedAsync();
}
private Action<IEnumerable<SyncItem>, string, long> MapTypeToUpsertAction(string type)
{
return type switch
{
"settingitem" => Repositories.Settings.UpsertMany,
"attachment" => Repositories.Attachments.UpsertMany,
"note" => Repositories.Notes.UpsertMany,
"notebook" => Repositories.Notebooks.UpsertMany,
"content" => Repositories.Contents.UpsertMany,
"shortcut" => Repositories.Shortcuts.UpsertMany,
"reminder" => Repositories.Reminders.UpsertMany,
"relation" => Repositories.Relations.UpsertMany,
"color" => Repositories.Colors.UpsertMany,
"vault" => Repositories.Vaults.UpsertMany,
"tag" => Repositories.Tags.UpsertMany,
_ => null,
};
}
public async Task<int> PushItems(string deviceId, SyncTransferItemV2 pushItem)
{
var userId = Context.User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId)) throw new HubException("Please login to sync.");
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
SyncEventCounterSource.Log.PushV2();
var stopwatch = new Stopwatch();
stopwatch.Start();
var stopwatch = Stopwatch.StartNew();
try
{
var UpsertItems = MapTypeToUpsertAction(pushItem.Type) ?? throw new Exception($"Invalid item type: {pushItem.Type}.");
var UpsertItems = UpsertActionsMap[pushItem.Type] ?? throw new Exception($"Invalid item type: {pushItem.Type}.");
UpsertItems(pushItem.Items, userId, 1);
if (!await unit.Commit()) return 0;
@@ -123,29 +135,28 @@ namespace Notesnook.API.Hubs
}
finally
{
stopwatch.Stop();
SyncEventCounterSource.Log.RecordPushDuration(stopwatch.ElapsedMilliseconds);
}
}
public async Task<bool> PushCompleted()
{
var userId = Context.User.FindFirstValue("sub");
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("User not found.");
await Clients.OthersInGroup(userId).PushCompleted();
return true;
}
private static async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(Func<string, string[], bool, int, Task<IAsyncCursor<SyncItem>>>[] collections, string[] types, string userId, string[] ids, int size, bool resetSync, long maxBytes)
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, string[] ids, int size, bool resetSync, long maxBytes)
{
var itemsProcessed = 0;
for (int i = 0; i < collections.Length; i++)
for (int i = 0; i < Collections.Length; i++)
{
var type = types[i];
var type = CollectionKeys[i];
var filteredIds = ids.Where((id) => id.EndsWith($":{type}")).Select((id) => id.Split(":")[0]).ToArray();
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);
var chunk = new List<SyncItem>();
long totalBytes = 0;
@@ -187,8 +198,22 @@ namespace Notesnook.API.Hubs
public async Task<SyncV2Metadata> RequestFetch(string deviceId)
{
var userId = Context.User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId)) throw new HubException("Please login to sync.");
return await HandleRequestFetch(deviceId, false, false);
}
public async Task<SyncV2Metadata> RequestFetchV2(string deviceId)
{
return await HandleRequestFetch(deviceId, true, false);
}
public async Task<SyncV2Metadata> RequestFetchV3(string deviceId)
{
return await HandleRequestFetch(deviceId, true, true);
}
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems)
{
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
SyncEventCounterSource.Log.FetchV2();
@@ -204,27 +229,12 @@ namespace Notesnook.API.Hubs
!isResetSync)
return new SyncV2Metadata { Synced = true };
var stopwatch = new Stopwatch();
stopwatch.Start();
var stopwatch = Stopwatch.StartNew();
try
{
string[] ids = deviceService.FetchUnsyncedIds();
var chunks = PrepareChunks(
collections: [
Repositories.Settings.FindItemsById,
Repositories.Attachments.FindItemsById,
Repositories.Notes.FindItemsById,
Repositories.Notebooks.FindItemsById,
Repositories.Contents.FindItemsById,
Repositories.Shortcuts.FindItemsById,
Repositories.Reminders.FindItemsById,
Repositories.Colors.FindItemsById,
Repositories.Tags.FindItemsById,
Repositories.Vaults.FindItemsById,
Repositories.Relations.FindItemsById,
],
types: CollectionKeys,
userId,
ids,
size: 1000,
@@ -251,14 +261,48 @@ 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 (includeMonographs)
{
var isSyncingMonographsForFirstTime = !device.HasInitialMonographsSync;
var unsyncedMonographs = ids.Where((id) => id.EndsWith(":monograph")).ToHashSet();
var unsyncedMonographIds = unsyncedMonographs.Select((id) => id.Split(":")[0]).ToArray();
FilterDefinition<Monograph> filter = isResetSync || isSyncingMonographsForFirstTime
? Builders<Monograph>.Filter.Eq("UserId", userId)
: Builders<Monograph>.Filter.And(
Builders<Monograph>.Filter.Eq("UserId", userId),
Builders<Monograph>.Filter.Or(
Builders<Monograph>.Filter.In("ItemId", unsyncedMonographIds),
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds)
)
);
var userMonographs = await Repositories.Monographs.Collection.Find(filter).Project((m) => new MonographMetadata
{
DatePublished = m.DatePublished,
Deleted = m.Deleted,
Password = m.Password,
SelfDestruct = m.SelfDestruct,
Title = m.Title,
ItemId = m.ItemId ?? m.Id.ToString(),
}).ToListAsync();
// if (userMonographs.Any() && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
// throw new HubException("Client rejected monographs.");
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
throw new HubException("Client rejected monographs.");
device.HasInitialMonographsSync = true;
}
if (includeInboxItems)
{
var unsyncedInboxItems = ids.Where((id) => id.EndsWith(":inboxItems")).ToHashSet();
var unsyncedInboxItemIds = unsyncedInboxItems.Select((id) => id.Split(":")[0]).ToArray();
var userInboxItems = isResetSync
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId));
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
{
throw new HubException("Client rejected inbox items.");
}
}
deviceService.Reset();
@@ -269,7 +313,6 @@ namespace Notesnook.API.Hubs
}
finally
{
stopwatch.Stop();
SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds);
}
}
@@ -282,24 +325,4 @@ namespace Notesnook.API.Hubs
[JsonPropertyName("synced")]
public bool Synced { get; set; }
}
[MessagePack.MessagePackObject]
public struct SyncV2TransferItem
{
[MessagePack.Key("items")]
[JsonPropertyName("items")]
public IEnumerable<SyncItem> Items { get; set; }
[MessagePack.Key("type")]
[JsonPropertyName("type")]
public string Type { get; set; }
[MessagePack.Key("final")]
[JsonPropertyName("final")]
public bool Final { get; set; }
[MessagePack.Key("vaultKey")]
[JsonPropertyName("vaultKey")]
public EncryptedData VaultKey { get; set; }
}
}

View File

@@ -1,40 +1,41 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3.Model;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
public interface IS3Service
{
Task DeleteObjectAsync(string userId, string name);
Task DeleteDirectoryAsync(string userId);
Task<long> GetObjectSizeAsync(string userId, string name);
string GetUploadObjectUrl(string userId, string name);
string GetDownloadObjectUrl(string userId, string name);
Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string uploadId = null);
Task AbortMultipartUploadAsync(string userId, string name, string uploadId);
Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest);
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3.Model;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
public interface IS3Service
{
Task DeleteObjectAsync(string userId, string name);
Task DeleteDirectoryAsync(string userId);
Task<long> GetObjectSizeAsync(string userId, string name);
string? GetUploadObjectUrl(string userId, string name);
string? GetInternalUploadObjectUrl(string userId, string name);
Task<string?> GetDownloadObjectUrl(string userId, string name);
Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string? uploadId = null);
Task AbortMultipartUploadAsync(string userId, string name, string uploadId);
Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest);
}
}

View File

@@ -40,5 +40,7 @@ namespace Notesnook.API.Interfaces
SyncItemsRepository Tags { get; }
Repository<UserSettings> UsersSettings { get; }
Repository<Monograph> Monographs { get; }
Repository<InboxApiKey> InboxApiKey { get; }
Repository<InboxSyncItem> InboxItems { get; }
}
}

View File

@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Quartz;
namespace Notesnook.API.Jobs
{
public class DeviceCleanupJob : IJob
{
public async Task Execute(IJobExecutionContext context)
{
ParallelOptions parallelOptions = new()
{
MaxDegreeOfParallelism = 100,
CancellationToken = context.CancellationToken,
};
Parallel.ForEach(Directory.EnumerateDirectories("sync"), parallelOptions, (userDir, ct) =>
{
foreach (var device in Directory.EnumerateDirectories(userDir))
{
string lastAccessFile = Path.Combine(device, "LastAccessTime");
try
{
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}");
}
}
});
}
}
}

View File

@@ -22,5 +22,6 @@ namespace Notesnook.API.Models
public class Algorithms
{
public static string Default => "xcha-argon2i13-7";
public static string XSAL_X25519_7 => "xsal-x25519-7";
}
}

View File

@@ -0,0 +1,60 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using NanoidDotNet;
namespace Notesnook.API.Models
{
public class InboxApiKey
{
public InboxApiKey()
{
var random = Nanoid.Generate(size: 64);
Key = "nn__" + random;
}
[BsonId]
[BsonIgnoreIfDefault]
[BsonRepresentation(BsonType.ObjectId)]
[JsonIgnore]
[MessagePack.IgnoreMember]
public string Id { get; set; }
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("key")]
public string Key { get; set; }
[JsonPropertyName("dateCreated")]
public long DateCreated { get; set; }
[JsonPropertyName("expiryDate")]
public long ExpiryDate { get; set; }
[JsonPropertyName("lastUsedAt")]
public long LastUsedAt { get; set; }
}
}

View File

@@ -0,0 +1,70 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
namespace Notesnook.API.Models
{
[MessagePack.MessagePackObject]
public class InboxSyncItem : SyncItem
{
[DataMember(Name = "key")]
[JsonPropertyName("key")]
[MessagePack.Key("key")]
[Required]
public EncryptedKey Key
{
get; set;
}
}
[MessagePack.MessagePackObject]
public class EncryptedKey
{
[DataMember(Name = "alg")]
[JsonPropertyName("alg")]
[MessagePack.Key("alg")]
[Required]
public string Algorithm
{
get; set;
}
[DataMember(Name = "cipher")]
[JsonPropertyName("cipher")]
[MessagePack.Key("cipher")]
[Required]
public string Cipher
{
get; set;
}
[JsonPropertyName("length")]
[DataMember(Name = "length")]
[MessagePack.Key("length")]
[Required]
public long Length
{
get; set;
}
}
}

View File

@@ -69,26 +69,26 @@ namespace Notesnook.API.Models
public string Title { get; set; }
[JsonPropertyName("userId")]
public string UserId { get; set; }
public string? UserId { get; set; }
[JsonPropertyName("selfDestruct")]
public bool SelfDestruct { get; set; }
[JsonPropertyName("encryptedContent")]
public EncryptedData EncryptedContent { get; set; }
public EncryptedData? EncryptedContent { get; set; }
[JsonPropertyName("datePublished")]
public long DatePublished { get; set; }
[JsonPropertyName("content")]
[BsonIgnore]
public string Content { get; set; }
public string? Content { get; set; }
[JsonIgnore]
public byte[] CompressedContent { get; set; }
public byte[]? CompressedContent { get; set; }
[JsonPropertyName("password")]
public EncryptedData Password { get; set; }
public EncryptedData? Password { get; set; }
[JsonPropertyName("deleted")]
public bool Deleted { get; set; }

View File

@@ -17,25 +17,19 @@ 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/>.
*/
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Runtime.Serialization;
using Streetwriters.Common.Attributes;
using Streetwriters.Common.Converters;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Interfaces
namespace Notesnook.API.Models
{
[JsonInterfaceConverter(typeof(InterfaceConverter<ISubscription, Subscription>))]
public interface ISubscription : IDocument
public class MonographContent
{
string UserId { get; set; }
ApplicationType AppId { get; set; }
SubscriptionProvider Provider { get; set; }
long StartDate { get; set; }
long ExpiryDate { get; set; }
SubscriptionType Type { get; set; }
string OrderId { get; set; }
string SubscriptionId { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
}
}
}

View File

@@ -0,0 +1,52 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using System.Runtime.Serialization;
namespace Notesnook.API.Models
{
public class MonographMetadata
{
[DataMember(Name = "id")]
[JsonPropertyName("id")]
[MessagePack.Key("id")]
public required string ItemId
{
get; set;
}
[JsonPropertyName("title")]
public required string Title { get; set; }
[JsonPropertyName("selfDestruct")]
public bool SelfDestruct { get; set; }
[JsonPropertyName("datePublished")]
public long DatePublished { get; set; }
[JsonPropertyName("password")]
public EncryptedData? Password { get; set; }
[JsonPropertyName("deleted")]
public bool Deleted { get; set; }
}
}

View File

@@ -1,3 +1,4 @@
using System.Net.Http;
using System.Text.Json.Serialization;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
@@ -7,22 +8,30 @@ namespace Notesnook.API.Models.Responses
public class UserResponse : UserModel, IResponse
{
[JsonPropertyName("salt")]
public string Salt { get; set; }
public string? Salt { get; set; }
[JsonPropertyName("attachmentsKey")]
public EncryptedData AttachmentsKey { get; set; }
public EncryptedData? AttachmentsKey { get; set; }
[JsonPropertyName("monographPasswordsKey")]
public EncryptedData MonographPasswordsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; }
[JsonPropertyName("inboxKeys")]
public InboxKeys? InboxKeys { get; set; }
[JsonPropertyName("subscription")]
public ISubscription Subscription { get; set; }
public Subscription? Subscription { get; set; }
[JsonPropertyName("profile")]
public EncryptedData Profile { get; set; }
[JsonPropertyName("storageUsed")]
public long StorageUsed { get; set; }
[JsonPropertyName("totalStorage")]
public long TotalStorage { get; set; }
[JsonIgnore]
public bool Success { get; set; }
public int StatusCode { get; set; }
[JsonIgnore]
public HttpContent? Content { get; set; }
}
}

View File

@@ -44,7 +44,7 @@ namespace Notesnook.API.Models
[DataMember(Name = "userId")]
[JsonPropertyName("userId")]
[MessagePack.Key("userId")]
public string UserId
public string? UserId
{
get; set;
}
@@ -71,7 +71,7 @@ namespace Notesnook.API.Models
[DataMember(Name = "id")]
[JsonPropertyName("id")]
[MessagePack.Key("id")]
public string ItemId
public string? ItemId
{
get; set;
}
@@ -111,7 +111,7 @@ namespace Notesnook.API.Models
public string Algorithm
{
get; set;
} = Algorithms.Default;
}
}
public class SyncItemBsonSerializer : SerializerBase<SyncItem>

View File

@@ -19,9 +19,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
namespace Notesnook.API.Models
{
public class UserKeys
{
public EncryptedData AttachmentsKey { get; set; }
public EncryptedData MonographPasswordsKey { get; set; }
}
public class UserKeys
{
public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; }
public InboxKeys? InboxKeys { get; set; }
}
public class InboxKeys
{
public string? Public { get; set; }
public EncryptedData? Private { get; set; }
}
}

View File

@@ -38,9 +38,10 @@ namespace Notesnook.API.Models
public string UserId { get; set; }
public long LastSynced { get; set; }
public string Salt { get; set; }
public EncryptedData VaultKey { get; set; }
public EncryptedData AttachmentsKey { get; set; }
public EncryptedData MonographPasswordsKey { get; set; }
public EncryptedData? VaultKey { get; set; }
public EncryptedData? AttachmentsKey { get; set; }
public EncryptedData? MonographPasswordsKey { get; set; }
public InboxKeys? InboxKeys { get; set; }
public Limit StorageLimit { get; set; }
[BsonId]

View File

@@ -3,9 +3,11 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<StartupObject>Notesnook.API.Program</StartupObject>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" />
<PackageReference Include="AWSSDK.Core" Version="3.7.304.31" />
<PackageReference Include="DotNetEnv" Version="2.3.0" />
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
@@ -15,8 +17,11 @@
<PackageReference Include="AWSSDK.S3" Version="3.7.310.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="6.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.2.0" />
<PackageReference Include="Nanoid" Version="3.1.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-alpha.2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.8.1" />
<PackageReference Include="Quartz" Version="3.5.0" />
<PackageReference Include="Quartz.AspNetCore" Version="3.5.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -46,7 +46,7 @@ namespace Notesnook.API.Repositories
this.collectionName = collection.CollectionNamespace.CollectionName;
}
private readonly List<string> ALGORITHMS = [Algorithms.Default];
private readonly List<string> ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7];
private bool IsValidAlgorithm(string algorithm)
{
return ALGORITHMS.Contains(algorithm);
@@ -114,6 +114,9 @@ namespace Notesnook.API.Repositories
throw new Exception($"Corrupted item \"{item.ItemId}\" in collection \"{this.collectionName}\". Please report this error to support@streetwriters.co.");
}
if (item.ItemId == null)
throw new Exception($"Item does not have an ItemId.");
item.DateSynced = dateSynced;
item.UserId = userId;
@@ -148,6 +151,9 @@ namespace Notesnook.API.Repositories
throw new Exception($"Corrupted item \"{item.ItemId}\" in collection \"{this.collectionName}\". Please report this error to support@streetwriters.co.");
}
if (item.ItemId == null)
throw new Exception($"Item does not have an ItemId.");
var filter = Builders<SyncItem>.Filter.And(
userIdFilter,
Builders<SyncItem>.Filter.Eq("ItemId", item.ItemId)

View File

@@ -28,9 +28,13 @@ using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Options;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Services
{
@@ -45,6 +49,7 @@ namespace Notesnook.API.Services
private readonly string BUCKET_NAME = Constants.S3_BUCKET_NAME ?? "";
private readonly string INTERNAL_BUCKET_NAME = Constants.S3_INTERNAL_BUCKET_NAME ?? "";
private AmazonS3Client S3Client { get; }
private ISyncItemsRepositoryAccessor Repositories { get; }
// 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
@@ -57,8 +62,9 @@ namespace Notesnook.API.Services
private AmazonS3Client S3InternalClient { get; }
private HttpClient httpClient = new HttpClient();
public S3Service()
public S3Service(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor)
{
Repositories = syncItemsRepositoryAccessor;
var config = new AmazonS3Config
{
#if (DEBUG || STAGING)
@@ -145,31 +151,38 @@ namespace Notesnook.API.Services
var request = new HttpRequestMessage(HttpMethod.Head, url);
var response = await httpClient.SendAsync(request);
const long MAX_SIZE = 513 * 1024 * 1024; // 512 MB
if (!Constants.IS_SELF_HOSTED && response.Content.Headers.ContentLength >= MAX_SIZE)
{
await this.DeleteObjectAsync(userId, name);
throw new Exception("File size exceeds the maximum allowed size.");
}
return response.Content.Headers.ContentLength ?? 0;
}
public string GetUploadObjectUrl(string userId, string name)
public string? GetUploadObjectUrl(string userId, string name)
{
var url = this.GetPresignedURL(userId, name, HttpVerb.PUT);
if (url == null) return null;
return url;
return this.GetPresignedURL(userId, name, HttpVerb.PUT);
}
public string GetDownloadObjectUrl(string userId, string name)
public string? GetInternalUploadObjectUrl(string userId, string name)
{
return this.GetPresignedURL(userId, name, HttpVerb.PUT, S3ClientMode.INTERNAL);
}
public async Task<string?> GetDownloadObjectUrl(string userId, string name)
{
// var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic);
// var subscription = await subscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId);
// var size = await GetObjectSizeAsync(userId, name);
// if (StorageHelper.IsFileSizeExceeded(subscription, size))
// {
// var fileSizeLimit = StorageHelper.GetFileSizeLimitForPlan(subscription);
// throw new Exception($"You cannot download files larger than {StorageHelper.FormatBytes(fileSizeLimit)} on this plan.");
// }
var url = this.GetPresignedURL(userId, name, HttpVerb.GET);
if (url == null) return null;
return url;
}
public async Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string uploadId = null)
public async Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string? uploadId = null)
{
var objectName = GetFullObjectName(userId, name);
if (userId == null || objectName == null) throw new Exception("Could not initiate multipart upload.");
@@ -204,18 +217,64 @@ namespace Notesnook.API.Services
if (!IsSuccessStatusCode(((int)response.HttpStatusCode))) throw new Exception("Failed to abort multipart upload.");
}
private async Task<long> GetMultipartUploadSizeAsync(string userId, string key, string uploadId)
{
var objectName = GetFullObjectName(userId, key);
var parts = await GetS3Client(S3ClientMode.INTERNAL).ListPartsAsync(GetBucketName(S3ClientMode.INTERNAL), objectName, uploadId);
long totalSize = 0;
foreach (var part in parts.Parts)
{
totalSize += part.Size;
}
return totalSize;
}
public async Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest)
{
var objectName = GetFullObjectName(userId, uploadRequest.Key);
if (userId == null || objectName == null) throw new Exception("Could not abort multipart upload.");
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
if (userSettings == null)
{
await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
throw new Exception("User settings not found.");
}
if (!Constants.IS_SELF_HOSTED)
{
var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic);
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);
if (StorageHelper.IsFileSizeExceeded(subscription, fileSize))
{
await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
throw new Exception("Max file size exceeded.");
}
userSettings.StorageLimit ??= new Limit { Value = 0, UpdatedAt = 0 };
userSettings.StorageLimit.Value += fileSize;
if (StorageHelper.IsStorageLimitReached(subscription, userSettings.StorageLimit))
{
await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
throw new Exception("Storage limit reached.");
}
}
uploadRequest.Key = objectName;
uploadRequest.BucketName = GetBucketName(S3ClientMode.INTERNAL);
var response = await GetS3Client(S3ClientMode.INTERNAL).CompleteMultipartUploadAsync(uploadRequest);
if (!IsSuccessStatusCode(((int)response.HttpStatusCode))) throw new Exception("Failed to complete multipart upload.");
if (!Constants.IS_SELF_HOSTED)
{
userSettings.StorageLimit.UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
await Repositories.UsersSettings.UpsertAsync(userSettings, (u) => u.UserId == userId);
}
}
private string GetPresignedURL(string userId, string name, HttpVerb httpVerb, S3ClientMode mode = S3ClientMode.EXTERNAL)
private string? GetPresignedURL(string userId, string name, HttpVerb httpVerb, S3ClientMode mode = S3ClientMode.EXTERNAL)
{
var objectName = GetFullObjectName(userId, name);
if (userId == null || objectName == null) return null;

View File

@@ -44,6 +44,16 @@ namespace Notesnook.API.Services
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);

View File

@@ -18,13 +18,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Notesnook.API.Helpers;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
@@ -73,19 +72,22 @@ namespace Notesnook.API.Services
await Repositories.UsersSettings.InsertAsync(new UserSettings
{
UserId = response.UserId,
StorageLimit = new Limit { UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Value = 0 },
LastSynced = 0,
Salt = GetSalt()
});
if (!Constants.IS_SELF_HOSTED)
{
await WampServers.SubscriptionServer.PublishMessageAsync(SubscriptionServerTopics.CreateSubscriptionTopic, new CreateSubscriptionMessage
await WampServers.SubscriptionServer.PublishMessageAsync(SubscriptionServerTopics.CreateSubscriptionV2Topic, new CreateSubscriptionMessageV2
{
AppId = ApplicationType.NOTESNOOK,
Provider = SubscriptionProvider.STREETWRITERS,
Type = SubscriptionType.BASIC,
Status = SubscriptionStatus.ACTIVE,
Plan = SubscriptionPlan.FREE,
UserId = response.UserId,
StartTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
@@ -98,14 +100,15 @@ namespace Notesnook.API.Services
var user = await userService.GetUserAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User not found.");
ISubscription subscription = null;
Subscription? subscription = null;
if (Constants.IS_SELF_HOSTED)
{
subscription = new Subscription
{
AppId = ApplicationType.NOTESNOOK,
Provider = SubscriptionProvider.STREETWRITERS,
Type = SubscriptionType.PREMIUM,
Plan = SubscriptionPlan.BELIEVER,
Status = SubscriptionStatus.ACTIVE,
UserId = user.UserId,
StartDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
// this date doesn't matter as the subscription is static.
@@ -115,10 +118,18 @@ namespace Notesnook.API.Services
else
{
var subscriptionService = await WampServers.SubscriptionServer.GetServiceAsync<IUserSubscriptionService>(SubscriptionServerTopics.UserSubscriptionServiceTopic);
subscription = await subscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId);
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.");
// reset user's attachment limit every month
if (userSettings.StorageLimit == null || DateTimeOffset.UtcNow.Month > DateTimeOffset.FromUnixTimeMilliseconds(userSettings.StorageLimit.UpdatedAt).Month)
{
userSettings.StorageLimit ??= new Limit { UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), Value = 0 };
await Repositories.UsersSettings.UpsertAsync(userSettings, (u) => u.UserId == user.UserId);
}
return new UserResponse
{
UserId = user.UserId,
@@ -129,8 +140,11 @@ namespace Notesnook.API.Services
PhoneNumber = user.PhoneNumber,
AttachmentsKey = userSettings.AttachmentsKey,
MonographPasswordsKey = userSettings.MonographPasswordsKey,
InboxKeys = userSettings.InboxKeys,
Salt = userSettings.Salt,
Subscription = subscription,
StorageUsed = userSettings.StorageLimit.Value,
TotalStorage = StorageHelper.GetStorageLimitForPlan(subscription),
Success = true,
StatusCode = 200
};
@@ -148,6 +162,27 @@ namespace Notesnook.API.Services
{
userSettings.MonographPasswordsKey = keys.MonographPasswordsKey;
}
if (keys.InboxKeys != null)
{
if (keys.InboxKeys.Public == null || keys.InboxKeys.Private == null)
{
userSettings.InboxKeys = null;
await Repositories.InboxApiKey.DeleteManyAsync(t => t.UserId == userId);
}
else
{
userSettings.InboxKeys = keys.InboxKeys;
var defaultInboxKey = new InboxApiKey
{
UserId = userId,
Name = "Default",
DateCreated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ExpiryDate = DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds(),
LastUsedAt = 0
};
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
}
}
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
}
@@ -172,6 +207,7 @@ namespace Notesnook.API.Services
Repositories.Vaults.DeleteByUserId(userId);
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
var result = await unit.Commit();
await Slogger<UserService>.Info(nameof(DeleteUserAsync), "User data deleted", userId, result.ToString());
@@ -231,6 +267,7 @@ namespace Notesnook.API.Services
Repositories.Tags.DeleteByUserId(userId);
Repositories.Vaults.DeleteByUserId(userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
if (!await unit.Commit()) return false;
var userSettings = await Repositories.UsersSettings.FindOneAsync((s) => s.UserId == userId);
@@ -238,6 +275,7 @@ namespace Notesnook.API.Services
userSettings.AttachmentsKey = null;
userSettings.MonographPasswordsKey = null;
userSettings.VaultKey = null;
userSettings.InboxKeys = null;
userSettings.LastSynced = 0;
await Repositories.UsersSettings.UpsertAsync(userSettings, (s) => s.UserId == userId);

View File

@@ -48,15 +48,19 @@ using Notesnook.API.Authorization;
using Notesnook.API.Extensions;
using Notesnook.API.Hubs;
using Notesnook.API.Interfaces;
using Notesnook.API.Jobs;
using Notesnook.API.Models;
using Notesnook.API.Repositories;
using Notesnook.API.Services;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using Quartz;
using Streetwriters.Common;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Common.Services;
using Streetwriters.Data;
using Streetwriters.Data.DbContexts;
using Streetwriters.Data.Interfaces;
@@ -108,12 +112,11 @@ namespace Notesnook.API
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new SyncRequirement());
});
options.AddPolicy("Pro", policy =>
options.AddPolicy(InboxApiKeyAuthenticationDefaults.AuthenticationScheme, policy =>
{
policy.AuthenticationSchemes.Add("introspection");
policy.AuthenticationSchemes.Add(InboxApiKeyAuthenticationDefaults.AuthenticationScheme);
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new SyncRequirement());
policy.Requirements.Add(new ProUserRequirement());
});
options.DefaultPolicy = options.GetPolicy("Notesnook");
@@ -148,7 +151,11 @@ namespace Notesnook.API
options.SaveToken = true;
options.EnableCaching = true;
options.CacheDuration = TimeSpan.FromMinutes(30);
});
})
.AddScheme<InboxApiKeyAuthenticationSchemeOptions, InboxApiKeyAuthenticationHandler>(
InboxApiKeyAuthenticationDefaults.AuthenticationScheme,
options => { }
);
// Serializer.RegisterSerializer(new SyncItemBsonSerializer());
if (!BsonClassMap.IsClassMapRegistered(typeof(UserSettings)))
@@ -165,7 +172,9 @@ namespace Notesnook.API
services.AddRepository<UserSettings>("user_settings", "notesnook")
.AddRepository<Monograph>("monographs", "notesnook")
.AddRepository<Announcement>("announcements", "notesnook");
.AddRepository<Announcement>("announcements", "notesnook")
.AddRepository<InboxApiKey>(Collections.InboxApiKeysKey, "notesnook")
.AddRepository<InboxSyncItem>(Collections.InboxItems, "notesnook");
services.AddMongoCollection(Collections.SettingsKey)
.AddMongoCollection(Collections.AttachmentsKey)
@@ -178,11 +187,14 @@ namespace Notesnook.API
.AddMongoCollection(Collections.ShortcutsKey)
.AddMongoCollection(Collections.TagsKey)
.AddMongoCollection(Collections.ColorsKey)
.AddMongoCollection(Collections.VaultsKey);
.AddMongoCollection(Collections.VaultsKey)
.AddMongoCollection(Collections.InboxItems)
.AddMongoCollection(Collections.InboxApiKeysKey);
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
services.AddScoped<IUserService, UserService>();
services.AddScoped<IS3Service, S3Service>();
services.AddScoped<IURLAnalyzer, URLAnalyzer>();
services.AddControllers();
@@ -216,6 +228,24 @@ namespace Notesnook.API
.WithMetrics((builder) => builder
.AddMeter("Notesnook.API.Metrics.Sync")
.AddPrometheusExporter());
services.AddQuartzHostedService(q =>
{
q.WaitForJobsToComplete = false;
q.AwaitApplicationStarted = true;
q.StartDelay = TimeSpan.FromMinutes(1);
}).AddQuartz(q =>
{
q.UseMicrosoftDependencyInjectionJobFactory();
var jobKey = new JobKey("DeviceCleanupJob");
q.AddJob<DeviceCleanupJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity("DeviceCleanup-trigger")
// first of every month
.WithCronSchedule("0 0 0 1 * ? *"));
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@@ -257,7 +287,6 @@ namespace Notesnook.API
app.UseEndpoints(endpoints =>
{
endpoints.MapPrometheusScrapingEndpoint();
endpoints.MapControllers();
endpoints.MapHealthChecks("/health");
endpoints.MapHub<SyncHub>("/hubs/sync", options =>

View File

@@ -0,0 +1,2 @@
PORT=5181
NOTESNOOK_API_SERVER_URL=http://localhost:5264/

3
Notesnook.Inbox.API/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
.env

View File

@@ -0,0 +1,19 @@
FROM oven/bun:1.2.21-slim
RUN mkdir -p /home/bun/app && chown -R bun:bun /home/bun/app
WORKDIR /home/bun/app
USER bun
COPY --chown=bun:bun package.json bun.lock .
RUN bun install --frozen-lockfile
COPY --chown=bun:bun . .
RUN bun run build
EXPOSE 5181
CMD ["bun", "run", "start"]

View File

@@ -0,0 +1,192 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "notesnook-inbox-api",
"dependencies": {
"express": "^5.1.0",
"libsodium-wrappers-sumo": "^0.7.15",
"zod": "^4.1.9",
},
"devDependencies": {
"@types/express": "^5.0.3",
"@types/libsodium-wrappers-sumo": "^0.7.8",
"@types/node": "^24.5.2",
"typescript": "^5.9.2",
},
},
},
"packages": {
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/express": ["@types/express@5.0.3", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "*" } }, "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw=="],
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.0.7", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ=="],
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="],
"@types/libsodium-wrappers-sumo": ["@types/libsodium-wrappers-sumo@0.7.8", "", { "dependencies": { "@types/libsodium-wrappers": "*" } }, "sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw=="],
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/node": ["@types/node@24.5.2", "", { "dependencies": { "undici-types": "~7.12.0" } }, "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ=="],
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="],
"@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"libsodium-sumo": ["libsodium-sumo@0.7.15", "", {}, "sha512-5tPmqPmq8T8Nikpm1Nqj0hBHvsLFCXvdhBFV7SGOitQPZAA6jso8XoL0r4L7vmfKXr486fiQInvErHtEvizFMw=="],
"libsodium-wrappers-sumo": ["libsodium-wrappers-sumo@0.7.15", "", { "dependencies": { "libsodium-sumo": "^0.7.15" } }, "sha512-aSWY8wKDZh5TC7rMvEdTHoyppVq/1dTSAeAR7H6pzd6QRT3vQWcT5pGwCotLcpPEOLXX6VvqihSPkpEhYAjANA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
"serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="],
"undici-types": ["undici-types@7.12.0", "", {}, "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@4.1.9", "", {}, "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ=="],
"http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
}
}

View File

@@ -0,0 +1,33 @@
{
"name": "notesnook-inbox-api",
"version": "1.0.0",
"description": "Notesnook Inbox API server",
"main": "dist/index.js",
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun",
"start": "bun run dist/index.js",
"dev": "bun --watch src/index.ts"
},
"keywords": [
"notesnook",
"inbox",
"api"
],
"license": "GPL-3.0-or-later",
"author": {
"name": "Streetwriters (Private) Limited",
"email": "support@streetwriters.co",
"url": "https://streetwriters.co"
},
"dependencies": {
"express": "^5.1.0",
"libsodium-wrappers-sumo": "^0.7.15",
"zod": "^4.1.9"
},
"devDependencies": {
"@types/libsodium-wrappers-sumo": "^0.7.8",
"@types/express": "^5.0.3",
"@types/node": "^24.5.2",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,173 @@
import express from "express";
import _sodium, { base64_variants } from "libsodium-wrappers-sumo";
import { z } from "zod";
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
if (!NOTESNOOK_API_SERVER_URL) {
throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
}
let sodium: typeof _sodium;
const RawInboxItemSchema = z.object({
title: z.string().min(1, "Title is required"),
pinned: z.boolean().optional(),
favorite: z.boolean().optional(),
readonly: z.boolean().optional(),
archived: z.boolean().optional(),
notebookIds: z.array(z.string()).optional(),
tagIds: z.array(z.string()).optional(),
type: z.enum(["note"]),
source: z.string(),
version: z.literal(1),
content: z
.object({
type: z.enum(["html"]),
data: z.string(),
})
.optional(),
});
interface EncryptedInboxItem {
v: 1;
key: Omit<EncryptedInboxItem, "key" | "iv" | "v">;
iv: string;
alg: string;
cipher: string;
length: number;
}
function encrypt(rawData: string, publicKey: string): EncryptedInboxItem {
try {
const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
const nonce = sodium.randombytes_buf(
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
);
const data = sodium.from_string(rawData);
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
data,
null,
null,
nonce,
password
);
const inboxPublicKey = sodium.from_base64(
publicKey,
base64_variants.URLSAFE_NO_PADDING
);
const encryptedPassword = sodium.crypto_box_seal(password, inboxPublicKey);
return {
v: 1,
key: {
cipher: sodium.to_base64(
encryptedPassword,
base64_variants.URLSAFE_NO_PADDING
),
alg: `xsal-x25519-${base64_variants.URLSAFE_NO_PADDING}`,
length: password.length,
},
iv: sodium.to_base64(nonce, base64_variants.URLSAFE_NO_PADDING),
alg: `xcha-argon2i13-${base64_variants.URLSAFE_NO_PADDING}`,
cipher: sodium.to_base64(cipher, base64_variants.URLSAFE_NO_PADDING),
length: data.length,
};
} catch (error) {
throw new Error(`encryption failed: ${error}`);
}
}
async function getInboxPublicEncryptionKey(apiKey: string) {
const response = await fetch(
`${NOTESNOOK_API_SERVER_URL}inbox/public-encryption-key`,
{
headers: {
Authorization: apiKey,
},
}
);
if (!response.ok) {
throw new Error(
`failed to fetch inbox public encryption key: ${await response.text()}`
);
}
const data = (await response.json()) as unknown as any;
return (data?.key as string) || null;
}
async function postEncryptedInboxItem(
apiKey: string,
item: EncryptedInboxItem
) {
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}inbox/items`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: apiKey,
},
body: JSON.stringify({ ...item }),
});
if (!response.ok) {
throw new Error(`failed to post inbox item: ${await response.text()}`);
}
}
const app = express();
app.use(express.json({ limit: "10mb" }));
app.post("/inbox", async (req, res) => {
try {
const apiKey = req.headers["authorization"];
if (!apiKey) {
return res.status(401).json({ error: "unauthorized" });
}
if (!req.body.item) {
return res.status(400).json({ error: "item is required" });
}
const validationResult = RawInboxItemSchema.safeParse(req.body.item);
if (!validationResult.success) {
return res.status(400).json({
error: "invalid item",
details: validationResult.error.issues,
});
}
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey);
if (!inboxPublicKey) {
return res.status(403).json({ error: "inbox public key not found" });
}
console.log("[info] fetched inbox public key:", inboxPublicKey);
const item = validationResult.data;
const encryptedItem = encrypt(JSON.stringify(item), inboxPublicKey);
console.log("[info] encrypted item:", encryptedItem);
await postEncryptedInboxItem(apiKey, encryptedItem);
return res.status(200).json({ message: "inbox item posted" });
} catch (error) {
if (error instanceof Error) {
console.log("[error]", error.message);
return res
.status(500)
.json({ error: "internal server error", description: error.message });
} else {
console.log("[error] unknown error occured:", error);
return res.status(500).json({
error: "internal server error",
description: `unknown error occured: ${error}`,
});
}
}
});
(async () => {
await _sodium.ready;
sodium = _sodium;
const PORT = Number(process.env.PORT || "5181");
app.listen(PORT, () => {
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
});
})();
export default app;

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"outDir": "./dist",
"strict": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -70,6 +70,7 @@ namespace Streetwriters.Common
public static string SSE_CERT_KEY_PATH => Environment.GetEnvironmentVariable("SSE_CERT_KEY_PATH");
// internal
public static string WEBRISK_API_URI => Environment.GetEnvironmentVariable("WEBRISK_API_URI");
public static string MONGODB_CONNECTION_STRING => Environment.GetEnvironmentVariable("MONGODB_CONNECTION_STRING");
public static string MONGODB_DATABASE_NAME => Environment.GetEnvironmentVariable("MONGODB_DATABASE_NAME");
public static int SUBSCRIPTIONS_SERVER_PORT => int.Parse(Environment.GetEnvironmentVariable("SUBSCRIPTIONS_SERVER_PORT") ?? "80");

View File

@@ -0,0 +1,33 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
namespace Streetwriters.Common.Enums
{
public enum SubscriptionPlan
{
FREE = 0,
ESSENTIAL = 1,
PRO = 2,
BELIEVER = 3,
EDUCATION = 4,
LEGACY_PRO = 5
}
}

View File

@@ -0,0 +1,30 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
namespace Streetwriters.Common.Enums
{
public enum SubscriptionStatus
{
ACTIVE,
TRIAL,
CANCELED,
PAUSED,
EXPIRED
}
}

View File

@@ -1,32 +1,32 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
namespace Streetwriters.Common.Enums
{
public enum SubscriptionType
{
BASIC = 0,
TRIAL = 1,
BETA = 2,
PREMIUM = 5,
PREMIUM_EXPIRED = 6,
PREMIUM_CANCELED = 7,
PREMIUM_PAUSED = 8
}
/*
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 <http://www.gnu.org/licenses/>.
*/
namespace Streetwriters.Common.Enums
{
public enum SubscriptionType
{
BASIC = 0,
TRIAL = 1,
BETA = 2,
PREMIUM = 5,
PREMIUM_EXPIRED = 6,
PREMIUM_CANCELED = 7,
PREMIUM_PAUSED = 8,
}
}

View File

@@ -37,19 +37,21 @@ namespace Streetwriters.Common.Extensions
request.Content = content;
}
foreach (var header in headers)
if (headers != null)
{
if (header.Key == "Content-Type" || header.Key == "Content-Length")
foreach (var header in headers)
{
if (request.Content != null)
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.AsEnumerable());
continue;
if (header.Key == "Content-Type" || header.Key == "Content-Length")
{
request.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.AsEnumerable());
continue;
}
request.Headers.TryAddWithoutValidation(header.Key, header.Value.AsEnumerable());
}
request.Headers.TryAddWithoutValidation(header.Key, header.Value.AsEnumerable());
}
var response = await httpClient.SendAsync(request);
if (response.Content.Headers.ContentLength > 0)
if (response.Content.Headers.ContentLength > 0 && response.Content.Headers.ContentType.ToString().Contains("application/json"))
{
var res = await response.Content.ReadFromJsonAsync<T>();
res.Success = response.IsSuccessStatusCode;
@@ -58,7 +60,7 @@ namespace Streetwriters.Common.Extensions
}
else
{
return new T { Success = response.IsSuccessStatusCode, StatusCode = (int)response.StatusCode };
return new T { Success = response.IsSuccessStatusCode, StatusCode = (int)response.StatusCode, Content = response.Content };
}
}

View File

@@ -18,6 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Reactive.Disposables;
using System.Threading;
using Microsoft.AspNetCore.Builder;
using Streetwriters.Common.Interfaces;
using WampSharp.AspNetCore.WebSockets.Server;
@@ -38,5 +40,27 @@ namespace Streetwriters.Common.Extensions
{
return realm.Services.GetSubject<T>(topicName).Subscribe<T>(async (message) => await handler.Process(message));
}
public static IDisposable SubscribeWithSemaphore<T>(this IWampHostedRealm realm, string topicName, IMessageHandler<T> handler)
{
var semaphore = new SemaphoreSlim(1, 1);
var subscriber = realm.Services.GetSubject<T>(topicName).Subscribe<T>(async (message) =>
{
await semaphore.WaitAsync();
try
{
await handler.Process(message);
}
finally
{
semaphore.Release();
}
});
return Disposable.Create(() =>
{
subscriber.Dispose();
semaphore.Dispose();
});
}
}
}

View File

@@ -1,47 +1,55 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Reactive.Subjects;
using System.Threading.Tasks;
using Streetwriters.Common.Messages;
using WampSharp.V2;
using WampSharp.V2.Client;
namespace Streetwriters.Common.Helpers
{
public class WampHelper
{
public static async Task<IWampRealmProxy> OpenWampChannelAsync(string server, string realmName)
{
DefaultWampChannelFactory channelFactory = new();
IWampChannel channel = channelFactory.CreateJsonChannel(server, realmName);
await channel.Open();
return channel.RealmProxy;
}
public static void PublishMessage<T>(IWampRealmProxy realm, string topicName, T message)
{
var subject = realm.Services.GetSubject<T>(topicName);
subject.OnNext(message);
}
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Collections.Generic;
using System.Reactive.Subjects;
using System.Threading.Tasks;
using Streetwriters.Common.Messages;
using WampSharp.V2;
using WampSharp.V2.Client;
namespace Streetwriters.Common.Helpers
{
public class WampHelper
{
public static async Task<IWampRealmProxy> OpenWampChannelAsync(string server, string realmName)
{
DefaultWampChannelFactory channelFactory = new();
IWampChannel channel = channelFactory.CreateJsonChannel(server, realmName);
await channel.Open();
return channel.RealmProxy;
}
public static void PublishMessage<T>(IWampRealmProxy realm, string topicName, T message)
{
var subject = realm.Services.GetSubject<T>(topicName);
subject.OnNext(message);
}
public static void PublishMessages<T>(IWampRealmProxy realm, string topicName, IEnumerable<T> messages)
{
var subject = realm.Services.GetSubject<T>(topicName);
foreach (var message in messages)
subject.OnNext(message);
}
}
}

View File

@@ -17,11 +17,14 @@ 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/>.
*/
using System.Net.Http;
namespace Streetwriters.Common.Interfaces
{
public interface IResponse
{
bool Success { get; set; }
int StatusCode { get; set; }
HttpContent Content { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using MimeKit;
using MimeKit.Cryptography;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Interfaces
{
public interface IURLAnalyzer
{
Task<bool> IsURLSafeAsync(string uri);
}
}

View File

@@ -7,7 +7,7 @@ namespace Streetwriters.Common.Interfaces
public interface IUserAccountService
{
[WampProcedure("co.streetwriters.identity.users.get_user")]
Task<UserModel> GetUserAsync(string clientId, string userId);
Task<UserModel?> GetUserAsync(string clientId, string userId);
[WampProcedure("co.streetwriters.identity.users.delete_user")]
Task DeleteUserAsync(string clientId, string userId, string password);
// [WampProcedure("co.streetwriters.identity.users.create_user")]

View File

@@ -9,5 +9,6 @@ namespace Streetwriters.Common.Interfaces
{
[WampProcedure("co.streetwriters.subscriptions.subscriptions.get_user_subscription")]
Task<Subscription> GetUserSubscriptionAsync(string clientId, string userId);
Subscription TransformUserSubscription(Subscription subscription);
}
}

View File

@@ -1,63 +1,66 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class CreateSubscriptionMessage
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("type")]
public SubscriptionType Type { get; set; }
[JsonPropertyName("start")]
public long StartTime { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryTime { get; set; }
[JsonPropertyName("orderId")]
public string OrderId { get; set; }
[JsonPropertyName("updateURL")]
public string UpdateURL { get; set; }
[JsonPropertyName("cancelURL")]
public string CancelURL { get; set; }
[JsonPropertyName("subscriptionId")]
public string SubscriptionId { get; set; }
[JsonPropertyName("productId")]
public string ProductId { get; set; }
public bool Extend { get; set; }
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class CreateSubscriptionMessage
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("type")]
public SubscriptionType Type { get; set; }
[JsonPropertyName("start")]
public long StartTime { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryTime { get; set; }
[JsonPropertyName("orderId")]
public string OrderId { get; set; }
[JsonPropertyName("updateURL")]
public string UpdateURL { get; set; }
[JsonPropertyName("cancelURL")]
public string CancelURL { get; set; }
[JsonPropertyName("subscriptionId")]
public string SubscriptionId { get; set; }
[JsonPropertyName("productId")]
public string ProductId { get; set; }
[JsonPropertyName("extend")]
public bool Extend { get; set; }
}
}

View File

@@ -0,0 +1,69 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class CreateSubscriptionMessageV2
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("plan")]
public SubscriptionPlan Plan { get; set; }
[JsonPropertyName("status")]
public SubscriptionStatus Status { get; set; }
[JsonPropertyName("start")]
public long StartTime { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryTime { get; set; }
[JsonPropertyName("orderId")]
public string OrderId { get; set; }
[JsonPropertyName("subscriptionId")]
public string SubscriptionId { get; set; }
[JsonPropertyName("productId")]
public string ProductId { get; set; }
[JsonPropertyName("timestamp")]
public long Timestamp { get; set; }
[JsonPropertyName("trialExpiry")]
public long TrialExpiryTime { get; set; }
[JsonPropertyName("googlePurchaseToken")]
public string? GooglePurchaseToken { get; set; }
}
}

View File

@@ -43,6 +43,6 @@ namespace Streetwriters.Common.Messages
public Message Message { get; set; }
[JsonPropertyName("originTokenId")]
public string OriginTokenId { get; set; }
public string? OriginTokenId { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public partial class GetCustomerResponse : PaddleResponse
{
[JsonPropertyName("data")]
public PaddleCustomer Customer { get; set; }
}
public class PaddleCustomer
{
[JsonPropertyName("email")]
public string Email { get; set; }
}
}

View File

@@ -1,31 +1,214 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class SubscriptionResponse : Response
{
[JsonPropertyName("subscription")]
public ISubscription Subscription { get; set; }
}
}
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public partial class GetSubscriptionResponse : PaddleResponse
{
[JsonPropertyName("data")]
public Data Data { get; set; }
}
public partial class Data
{
// [JsonPropertyName("id")]
// public string Id { get; set; }
// [JsonPropertyName("status")]
// public string Status { get; set; }
[JsonPropertyName("customer_id")]
public string CustomerId { get; set; }
// [JsonPropertyName("address_id")]
// public string AddressId { get; set; }
// [JsonPropertyName("business_id")]
// public object BusinessId { get; set; }
// [JsonPropertyName("currency_code")]
// public string CurrencyCode { get; set; }
// [JsonPropertyName("created_at")]
// public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("updated_at")]
// public DateTimeOffset UpdatedAt { get; set; }
// [JsonPropertyName("started_at")]
// public DateTimeOffset StartedAt { get; set; }
[JsonPropertyName("first_billed_at")]
public DateTimeOffset? FirstBilledAt { get; set; }
// [JsonPropertyName("next_billed_at")]
// public DateTimeOffset NextBilledAt { get; set; }
// [JsonPropertyName("paused_at")]
// public object PausedAt { get; set; }
// [JsonPropertyName("canceled_at")]
// public object CanceledAt { get; set; }
// [JsonPropertyName("collection_mode")]
// public string CollectionMode { get; set; }
// [JsonPropertyName("billing_details")]
// public object BillingDetails { get; set; }
// [JsonPropertyName("current_billing_period")]
// public CurrentBillingPeriod CurrentBillingPeriod { get; set; }
[JsonPropertyName("billing_cycle")]
public BillingCycle BillingCycle { get; set; }
// [JsonPropertyName("scheduled_change")]
// public object ScheduledChange { get; set; }
// [JsonPropertyName("items")]
// public Item[] Items { get; set; }
// [JsonPropertyName("custom_data")]
// public object CustomData { get; set; }
[JsonPropertyName("management_urls")]
public ManagementUrls ManagementUrls { get; set; }
// [JsonPropertyName("discount")]
// public object Discount { get; set; }
// [JsonPropertyName("import_meta")]
// public object ImportMeta { get; set; }
}
public partial class BillingCycle
{
[JsonPropertyName("frequency")]
public long Frequency { get; set; }
[JsonPropertyName("interval")]
public string Interval { get; set; }
}
// public partial class CurrentBillingPeriod
// {
// [JsonPropertyName("starts_at")]
// public DateTimeOffset StartsAt { get; set; }
// [JsonPropertyName("ends_at")]
// public DateTimeOffset EndsAt { get; set; }
// }
// public partial class Item
// {
// [JsonPropertyName("status")]
// public string Status { get; set; }
// [JsonPropertyName("quantity")]
// public long Quantity { get; set; }
// [JsonPropertyName("recurring")]
// public bool Recurring { get; set; }
// [JsonPropertyName("created_at")]
// public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("updated_at")]
// public DateTimeOffset UpdatedAt { get; set; }
// [JsonPropertyName("previously_billed_at")]
// public DateTimeOffset PreviouslyBilledAt { get; set; }
// [JsonPropertyName("next_billed_at")]
// public DateTimeOffset NextBilledAt { get; set; }
// [JsonPropertyName("trial_dates")]
// public object TrialDates { get; set; }
// [JsonPropertyName("price")]
// public Price Price { get; set; }
// }
// public partial class Price
// {
// [JsonPropertyName("id")]
// public string Id { get; set; }
// [JsonPropertyName("product_id")]
// public string ProductId { get; set; }
// [JsonPropertyName("type")]
// public string Type { get; set; }
// [JsonPropertyName("description")]
// public string Description { get; set; }
// [JsonPropertyName("name")]
// public string Name { get; set; }
// [JsonPropertyName("tax_mode")]
// public string TaxMode { get; set; }
// [JsonPropertyName("billing_cycle")]
// public BillingCycle BillingCycle { get; set; }
// [JsonPropertyName("trial_period")]
// public object TrialPeriod { get; set; }
// [JsonPropertyName("unit_price")]
// public UnitPrice UnitPrice { get; set; }
// [JsonPropertyName("unit_price_overrides")]
// public object[] UnitPriceOverrides { get; set; }
// [JsonPropertyName("custom_data")]
// public object CustomData { get; set; }
// [JsonPropertyName("status")]
// public string Status { get; set; }
// [JsonPropertyName("quantity")]
// public Quantity Quantity { get; set; }
// [JsonPropertyName("import_meta")]
// public object ImportMeta { get; set; }
// [JsonPropertyName("created_at")]
// public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("updated_at")]
// public DateTimeOffset UpdatedAt { get; set; }
// }
// public partial class Quantity
// {
// [JsonPropertyName("minimum")]
// public long Minimum { get; set; }
// [JsonPropertyName("maximum")]
// public long Maximum { get; set; }
// }
// public partial class UnitPrice
// {
// [JsonPropertyName("amount")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Amount { get; set; }
// [JsonPropertyName("currency_code")]
// public string CurrencyCode { get; set; }
// }
public partial class ManagementUrls
{
[JsonPropertyName("update_payment_method")]
public Uri UpdatePaymentMethod { get; set; }
[JsonPropertyName("cancel")]
public Uri Cancel { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public class GetTransactionInvoiceResponse : PaddleResponse
{
[JsonPropertyName("data")]
public Invoice Invoice { get; set; }
}
public partial class Invoice
{
[JsonPropertyName("url")]
public string Url { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public partial class GetTransactionResponse : PaddleResponse
{
[JsonPropertyName("data")]
public TransactionV2 Transaction { get; set; }
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Text.Json.Serialization;
namespace Streetwriters.Common.Models
{
public partial class ListPaymentsResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("response")]
public Payment[] Payments { get; set; }
}
public partial class Payment
{
[JsonPropertyName("id")]
public long Id { get; set; }
[JsonPropertyName("subscription_id")]
public long SubscriptionId { get; set; }
[JsonPropertyName("amount")]
public double Amount { get; set; }
[JsonPropertyName("currency")]
public string Currency { get; set; }
[JsonPropertyName("payout_date")]
public string PayoutDate { get; set; }
[JsonPropertyName("is_paid")]
public short IsPaid { get; set; }
[JsonPropertyName("is_one_off_charge")]
public bool IsOneOffCharge { get; set; }
[JsonPropertyName("receipt_url")]
public string ReceiptUrl { get; set; }
}
}

View File

@@ -0,0 +1,77 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Text.Json.Serialization;
public partial class ListTransactionsResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("response")]
public Transaction[] Transactions { get; set; }
}
public partial class Transaction
{
[JsonPropertyName("order_id")]
public string OrderId { get; set; }
[JsonPropertyName("checkout_id")]
public string CheckoutId { get; set; }
[JsonPropertyName("amount")]
public string Amount { get; set; }
[JsonPropertyName("currency")]
public string Currency { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("created_at")]
public string CreatedAt { get; set; }
[JsonPropertyName("passthrough")]
public object Passthrough { get; set; }
[JsonPropertyName("product_id")]
public long ProductId { get; set; }
[JsonPropertyName("is_subscription")]
public bool IsSubscription { get; set; }
[JsonPropertyName("is_one_off")]
public bool IsOneOff { get; set; }
[JsonPropertyName("subscription")]
public PaddleSubscription Subscription { get; set; }
[JsonPropertyName("user")]
public PaddleTransactionUser User { get; set; }
[JsonPropertyName("receipt_url")]
public string ReceiptUrl { get; set; }
}
public partial class PaddleSubscription
{
[JsonPropertyName("subscription_id")]
public long SubscriptionId { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
}
public partial class PaddleTransactionUser
{
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; }
[JsonPropertyName("marketing_consent")]
public bool MarketingConsent { get; set; }
}
}

View File

@@ -0,0 +1,511 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public partial class ListTransactionsResponseV2 : PaddleResponse
{
[JsonPropertyName("data")]
public TransactionV2[] Transactions { get; set; }
}
public partial class TransactionV2
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("customer_id")]
public string CustomerId { get; set; }
// [JsonPropertyName("address_id")]
// public string AddressId { get; set; }
// [JsonPropertyName("business_id")]
// public object BusinessId { get; set; }
[JsonPropertyName("custom_data")]
public Dictionary<string, string> CustomData { get; set; }
[JsonPropertyName("origin")]
public string Origin { get; set; }
// [JsonPropertyName("collection_mode")]
// public string CollectionMode { get; set; }
// [JsonPropertyName("subscription_id")]
// public string SubscriptionId { get; set; }
// [JsonPropertyName("invoice_id")]
// public string InvoiceId { get; set; }
// [JsonPropertyName("invoice_number")]
// public string InvoiceNumber { get; set; }
[JsonPropertyName("billing_details")]
public BillingDetails BillingDetails { get; set; }
[JsonPropertyName("billing_period")]
public BillingPeriod BillingPeriod { get; set; }
// [JsonPropertyName("currency_code")]
// public string CurrencyCode { get; set; }
// [JsonPropertyName("discount_id")]
// public string DiscountId { get; set; }
[JsonPropertyName("created_at")]
public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("updated_at")]
// public DateTimeOffset UpdatedAt { get; set; }
[JsonPropertyName("billed_at")]
public DateTimeOffset? BilledAt { get; set; }
[JsonPropertyName("items")]
public Item[] Items { get; set; }
[JsonPropertyName("details")]
public Details Details { get; set; }
// [JsonPropertyName("payments")]
// public Payment[] Payments { get; set; }
// [JsonPropertyName("checkout")]
// public Checkout Checkout { get; set; }
}
public partial class BillingDetails
{
// [JsonPropertyName("enable_checkout")]
// public bool EnableCheckout { get; set; }
[JsonPropertyName("payment_terms")]
public PaymentTerms PaymentTerms { get; set; }
// [JsonPropertyName("purchase_order_number")]
// public string PurchaseOrderNumber { get; set; }
// [JsonPropertyName("additional_information")]
// public object AdditionalInformation { get; set; }
}
public partial class PaymentTerms
{
[JsonPropertyName("interval")]
public string Interval { get; set; }
[JsonPropertyName("frequency")]
public long Frequency { get; set; }
}
public partial class BillingPeriod
{
[JsonPropertyName("starts_at")]
public DateTimeOffset StartsAt { get; set; }
[JsonPropertyName("ends_at")]
public DateTimeOffset EndsAt { get; set; }
}
// public partial class Checkout
// {
// [JsonPropertyName("url")]
// public Uri Url { get; set; }
// }
public partial class Details
{
// [JsonPropertyName("tax_rates_used")]
// public TaxRatesUsed[] TaxRatesUsed { get; set; }
[JsonPropertyName("totals")]
public Totals Totals { get; set; }
// [JsonPropertyName("adjusted_totals")]
// public AdjustedTotals AdjustedTotals { get; set; }
// [JsonPropertyName("payout_totals")]
// public Dictionary<string, string> PayoutTotals { get; set; }
// [JsonPropertyName("adjusted_payout_totals")]
// public AdjustedTotals AdjustedPayoutTotals { get; set; }
[JsonPropertyName("line_items")]
public LineItem[] LineItems { get; set; }
}
public partial class Totals
{
[JsonPropertyName("subtotal")]
public long Subtotal { get; set; }
[JsonPropertyName("tax")]
public long Tax { get; set; }
[JsonPropertyName("discount")]
public long Discount { get; set; }
[JsonPropertyName("total")]
public long Total { get; set; }
[JsonPropertyName("grand_total")]
public long GrandTotal { get; set; }
// [JsonPropertyName("fee")]
// public object Fee { get; set; }
// [JsonPropertyName("credit")]
// public long Credit { get; set; }
// [JsonPropertyName("credit_to_balance")]
// public long CreditToBalance { get; set; }
[JsonPropertyName("balance")]
public long Balance { get; set; }
// [JsonPropertyName("earnings")]
// public object Earnings { get; set; }
[JsonPropertyName("currency_code")]
public string CurrencyCode { get; set; }
}
// public partial class AdjustedTotals
// {
// [JsonPropertyName("subtotal")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Subtotal { get; set; }
// [JsonPropertyName("tax")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Tax { get; set; }
// [JsonPropertyName("total")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Total { get; set; }
// [JsonPropertyName("fee")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Fee { get; set; }
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
// [JsonPropertyName("chargeback_fee")]
// public ChargebackFee ChargebackFee { get; set; }
// [JsonPropertyName("earnings")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Earnings { get; set; }
// [JsonPropertyName("currency_code")]
// public string CurrencyCode { get; set; }
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
// [JsonPropertyName("grand_total")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long? GrandTotal { get; set; }
// }
// public partial class ChargebackFee
// {
// [JsonPropertyName("amount")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Amount { get; set; }
// [JsonPropertyName("original")]
// public object Original { get; set; }
// }
public partial class LineItem
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("price_id")]
public string PriceId { get; set; }
// [JsonPropertyName("quantity")]
// public long Quantity { get; set; }
// [JsonPropertyName("totals")]
// public Totals Totals { get; set; }
// [JsonPropertyName("product")]
// public Product Product { get; set; }
// [JsonPropertyName("tax_rate")]
// public string TaxRate { get; set; }
// [JsonPropertyName("unit_totals")]
// public Totals UnitTotals { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("proration")]
public Proration Proration { get; set; }
}
// public partial class Product
// {
// [JsonPropertyName("id")]
// public string Id { get; set; }
// [JsonPropertyName("name")]
// public string Name { get; set; }
// [JsonPropertyName("description")]
// public string Description { get; set; }
// [JsonPropertyName("type")]
// public TypeEnum Type { get; set; }
// [JsonPropertyName("tax_category")]
// public TypeEnum TaxCategory { get; set; }
// [JsonPropertyName("image_url")]
// public Uri ImageUrl { get; set; }
// [JsonPropertyName("custom_data")]
// public CustomData CustomData { get; set; }
// [JsonPropertyName("status")]
// public Status Status { get; set; }
// [JsonPropertyName("created_at")]
// public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("updated_at")]
// public DateTimeOffset UpdatedAt { get; set; }
// [JsonPropertyName("import_meta")]
// public object ImportMeta { get; set; }
// }
// public partial class CustomData
// {
// [JsonPropertyName("features")]
// public Features Features { get; set; }
// [JsonPropertyName("suggested_addons")]
// public string[] SuggestedAddons { get; set; }
// [JsonPropertyName("upgrade_description")]
// public string UpgradeDescription { get; set; }
// }
// public partial class Features
// {
// [JsonPropertyName("aircraft_performance")]
// public bool AircraftPerformance { get; set; }
// [JsonPropertyName("compliance_monitoring")]
// public bool ComplianceMonitoring { get; set; }
// [JsonPropertyName("flight_log_management")]
// public bool FlightLogManagement { get; set; }
// [JsonPropertyName("payment_by_invoice")]
// public bool PaymentByInvoice { get; set; }
// [JsonPropertyName("route_planning")]
// public bool RoutePlanning { get; set; }
// [JsonPropertyName("sso")]
// public bool Sso { get; set; }
// }
public partial class Proration
{
[JsonPropertyName("billing_period")]
public BillingPeriod BillingPeriod { get; set; }
}
// public partial class Totals
// {
// [JsonPropertyName("subtotal")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Subtotal { get; set; }
// [JsonPropertyName("discount")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Discount { get; set; }
// [JsonPropertyName("tax")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Tax { get; set; }
// [JsonPropertyName("total")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Total { get; set; }
// }
// public partial class TaxRatesUsed
// {
// [JsonPropertyName("tax_rate")]
// public string TaxRate { get; set; }
// [JsonPropertyName("totals")]
// public Totals Totals { get; set; }
// }
public partial class Item
{
[JsonPropertyName("price")]
public Price Price { get; set; }
[JsonPropertyName("quantity")]
public long Quantity { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("proration")]
public Proration Proration { get; set; }
}
public partial class Price
{
[JsonPropertyName("id")]
public string Id { get; set; }
// [JsonPropertyName("description")]
// public string Description { get; set; }
// [JsonPropertyName("type")]
// public TypeEnum Type { get; set; }
[JsonPropertyName("name")]
public string Name { get; set; }
// [JsonPropertyName("product_id")]
// public string ProductId { get; set; }
// [JsonPropertyName("billing_cycle")]
// public PaymentTerms BillingCycle { get; set; }
// [JsonPropertyName("trial_period")]
// public object TrialPeriod { get; set; }
// [JsonPropertyName("tax_mode")]
// public TaxMode TaxMode { get; set; }
// [JsonPropertyName("unit_price")]
// public UnitPrice UnitPrice { get; set; }
// [JsonPropertyName("unit_price_overrides")]
// public object[] UnitPriceOverrides { get; set; }
// [JsonPropertyName("custom_data")]
// public object CustomData { get; set; }
// [JsonPropertyName("quantity")]
// public Quantity Quantity { get; set; }
// [JsonPropertyName("status")]
// public Status Status { get; set; }
// [JsonPropertyName("created_at")]
// public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("updated_at")]
// public DateTimeOffset UpdatedAt { get; set; }
// [JsonPropertyName("import_meta")]
// public object ImportMeta { get; set; }
}
// public partial class Quantity
// {
// [JsonPropertyName("minimum")]
// public long Minimum { get; set; }
// [JsonPropertyName("maximum")]
// public long Maximum { get; set; }
// }
// public partial class UnitPrice
// {
// [JsonPropertyName("amount")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Amount { get; set; }
// [JsonPropertyName("currency_code")]
// public CurrencyCode CurrencyCode { get; set; }
// }
// public partial class Payment
// {
// [JsonPropertyName("payment_attempt_id")]
// public Guid PaymentAttemptId { get; set; }
// [JsonPropertyName("stored_payment_method_id")]
// public Guid StoredPaymentMethodId { get; set; }
// [JsonPropertyName("payment_method_id")]
// public string PaymentMethodId { get; set; }
// [JsonPropertyName("amount")]
// [JsonConverter(typeof(ParseStringConverter))]
// public long Amount { get; set; }
// [JsonPropertyName("status")]
// public string Status { get; set; }
// [JsonPropertyName("error_code")]
// public string ErrorCode { get; set; }
// [JsonPropertyName("method_details")]
// public MethodDetails MethodDetails { get; set; }
// [JsonPropertyName("created_at")]
// public DateTimeOffset CreatedAt { get; set; }
// [JsonPropertyName("captured_at")]
// public DateTimeOffset? CapturedAt { get; set; }
// }
// public partial class MethodDetails
// {
// [JsonPropertyName("type")]
// public string Type { get; set; }
// [JsonPropertyName("card")]
// public Card Card { get; set; }
// }
// public partial class Card
// {
// [JsonPropertyName("type")]
// public string Type { get; set; }
// [JsonPropertyName("last4")]
// public string Last4 { get; set; }
// [JsonPropertyName("expiry_month")]
// public long ExpiryMonth { get; set; }
// [JsonPropertyName("expiry_year")]
// public long ExpiryYear { get; set; }
// [JsonPropertyName("cardholder_name")]
// public string CardholderName { get; set; }
// }
public partial class Pagination
{
[JsonPropertyName("per_page")]
public long PerPage { get; set; }
[JsonPropertyName("next")]
public Uri Next { get; set; }
[JsonPropertyName("has_more")]
public bool HasMore { get; set; }
[JsonPropertyName("estimated_total")]
public long EstimatedTotal { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Text.Json.Serialization;
namespace Streetwriters.Common.Models
{
public partial class ListUsersResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("response")]
public PaddleUser[] Users { get; set; }
}
public class PaddleUser
{
[JsonPropertyName("subscription_id")]
public long SubscriptionId { get; set; }
[JsonPropertyName("plan_id")]
public long PlanId { get; set; }
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("user_email")]
public string UserEmail { get; set; }
[JsonPropertyName("marketing_consent")]
public bool MarketingConsent { get; set; }
[JsonPropertyName("update_url")]
public string UpdateUrl { get; set; }
[JsonPropertyName("cancel_url")]
public string CancelUrl { get; set; }
[JsonPropertyName("state")]
public string State { get; set; }
[JsonPropertyName("signup_date")]
public string SignupDate { get; set; }
[JsonPropertyName("quantity")]
public long Quantity { get; set; }
}
}

View File

@@ -0,0 +1,24 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public partial class PaddleResponse
{
[JsonPropertyName("error")]
public PaddleError Error { get; set; }
}
public class PaddleError
{
public string? Type { get; set; }
public string? Code { get; set; }
public string? Detail { get; set; }
[JsonPropertyName("documentation_url")]
public string? DocumentationUrl { get; set; }
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Text.Json.Serialization;
namespace Streetwriters.Common.Models
{
public partial class RefundPaymentResponse
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("response")]
public Refund Refund { get; set; }
}
public partial class Refund
{
[JsonPropertyName("refund_request_id")]
public long RefundRequestId { get; set; }
}
}

View File

@@ -17,6 +17,7 @@ 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/>.
*/
using System.Net.Http;
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Streetwriters.Common.Interfaces;
@@ -28,5 +29,7 @@ namespace Streetwriters.Common.Models
[JsonIgnore]
public bool Success { get; set; }
public int StatusCode { get; set; }
[JsonIgnore]
public HttpContent Content { get; set; }
}
}

View File

@@ -1,80 +1,103 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class Subscription : ISubscription
{
public Subscription()
{
Id = ObjectId.GenerateNewId().ToString();
}
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonIgnore]
public string OrderId { get; set; }
[JsonIgnore]
public string SubscriptionId { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("start")]
public long StartDate { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryDate { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("type")]
public SubscriptionType Type { get; set; }
[JsonPropertyName("cancelURL")]
public string CancelURL { get; set; }
[JsonPropertyName("updateURL")]
public string UpdateURL { get; set; }
[JsonPropertyName("productId")]
public string ProductId { get; set; }
[JsonIgnore]
public int TrialExtensionCount { get; set; }
}
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class Subscription
{
public Subscription()
{
Id = ObjectId.GenerateNewId().ToString();
}
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("userId")]
public required string UserId { get; set; }
[JsonIgnore]
public string? OrderId { get; set; }
[JsonIgnore]
public string? SubscriptionId { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("appId")]
public required ApplicationType AppId { get; set; }
[JsonPropertyName("start")]
public long StartDate { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryDate { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("type")]
[Obsolete("Use SubscriptionPlan and SubscriptionStatus instead.")]
public SubscriptionType Type { get; set; }
[JsonPropertyName("cancelURL")]
public string? CancelURL { get; set; }
[JsonPropertyName("updateURL")]
public string? UpdateURL { get; set; }
[JsonPropertyName("googlePurchaseToken")]
public string? GooglePurchaseToken { get; set; }
[JsonPropertyName("productId")]
public string? ProductId { get; set; }
[JsonIgnore]
public int TrialExtensionCount { get; set; }
[JsonPropertyName("trialExpiry")]
public long TrialExpiryDate { get; set; }
[JsonPropertyName("trialsAvailed")]
public SubscriptionPlan[]? TrialsAvailed { get; set; }
[JsonPropertyName("updatedAt")]
public long UpdatedAt { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("plan")]
public SubscriptionPlan Plan { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("status")]
public SubscriptionStatus Status { get; set; }
}
}

View File

@@ -0,0 +1,57 @@
namespace Streetwriters.Common.Models
{
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Globalization;
public partial class SubscriptionPreviewResponse : PaddleResponse
{
[JsonPropertyName("data")]
public SubscriptionPreviewData Data { get; set; }
}
public partial class SubscriptionPreviewData
{
[JsonPropertyName("currency_code")]
public string CurrencyCode { get; set; }
[JsonPropertyName("billing_cycle")]
public BillingCycle BillingCycle { get; set; }
[JsonPropertyName("update_summary")]
public UpdateSummary UpdateSummary { get; set; }
[JsonPropertyName("immediate_transaction")]
public TransactionV2 ImmediateTransaction { get; set; }
[JsonPropertyName("next_transaction")]
public TransactionV2 NextTransaction { get; set; }
[JsonPropertyName("recurring_transaction_details")]
public Details RecurringTransactionDetails { get; set; }
}
public partial class UpdateSummary
{
[JsonPropertyName("charge")]
public UpdateSummaryItem Charge { get; set; }
[JsonPropertyName("credit")]
public UpdateSummaryItem Credit { get; set; }
[JsonPropertyName("result")]
public UpdateSummaryItem Result { get; set; }
}
public partial class UpdateSummaryItem
{
[JsonPropertyName("amount")]
public long Amount { get; set; }
[JsonPropertyName("action")]
public string? Action { get; set; }
}
}

View File

@@ -0,0 +1,138 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Services
{
public class PaddleBillingService
{
#if DEBUG
private const string PADDLE_BASE_URI = "https://sandbox-api.paddle.com";
#else
private const string PADDLE_BASE_URI = "https://api.paddle.com";
#endif
private readonly HttpClient httpClient = new();
public PaddleBillingService(string paddleApiKey)
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", paddleApiKey);
}
public async Task<GetSubscriptionResponse?> GetSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}";
var response = await httpClient.GetAsync(url);
return await response.Content.ReadFromJsonAsync<GetSubscriptionResponse>();
}
public async Task<GetTransactionResponse?> GetTransactionAsync(string transactionId)
{
var url = $"{PADDLE_BASE_URI}/transactions/{transactionId}";
var response = await httpClient.GetAsync(url);
return await response.Content.ReadFromJsonAsync<GetTransactionResponse>();
}
public async Task<GetTransactionInvoiceResponse?> GetTransactionInvoiceAsync(string transactionId)
{
var url = $"{PADDLE_BASE_URI}/transactions/{transactionId}/invoice";
var response = await httpClient.GetAsync(url);
return await response.Content.ReadFromJsonAsync<GetTransactionInvoiceResponse>();
}
public async Task<ListTransactionsResponseV2?> ListTransactionsAsync(string? subscriptionId = null, string? customerId = null, string[]? status = null, string[]? origin = null)
{
var url = $"{PADDLE_BASE_URI}/transactions";
var parameters = new Dictionary<string, string?>()
{
{ "subscription_id", subscriptionId },
{ "customer_id", customerId },
{ "status", string.Join(',', status ?? ["billed","completed"]) },
{ "order_by", "billed_at[DESC]" }
};
if (origin is not null) parameters.Add("origin", string.Join(',', origin));
var response = await httpClient.GetAsync(QueryHelpers.AddQueryString(url, parameters));
return await response.Content.ReadFromJsonAsync<ListTransactionsResponseV2>();
}
public async Task<PaddleResponse?> RefundTransactionAsync(string transactionId, string transactionItemId, string reason = "")
{
var url = $"{PADDLE_BASE_URI}/adjustments";
var response = await httpClient.PostAsync(url, JsonContent.Create(new Dictionary<string, object>
{
{ "action", "refund" },
{
"items",
new object[]
{
new Dictionary<string, string> {
{"item_id", transactionItemId},
{"type", "full"}
}
}
},
{ "reason", reason },
{ "transaction_id", transactionId }
}));
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
}
public async Task<SubscriptionPreviewResponse?> PreviewSubscriptionChangeAsync(string subscriptionId, string newProductId, bool isTrialing)
{
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}/preview";
var response = await httpClient.PatchAsync(url, JsonContent.Create(new
{
proration_billing_mode = isTrialing ? "do_not_bill" : "prorated_immediately",
items = new[] { new { price_id = newProductId, quantity = 1 } }
}));
return await response.Content.ReadFromJsonAsync<SubscriptionPreviewResponse>();
}
public async Task<PaddleResponse?> ChangeSubscriptionAsync(string subscriptionId, string newProductId, bool isTrialing)
{
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}";
var response = await httpClient.PatchAsync(url, JsonContent.Create(new
{
proration_billing_mode = isTrialing ? "do_not_bill" : "prorated_immediately",
items = new[] { new { price_id = newProductId, quantity = 1 } }
}));
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
}
public async Task<PaddleResponse?> CancelSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}/cancel";
var response = await httpClient.PostAsync(url, JsonContent.Create(new { effective_from = "immediately" }));
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
}
public async Task<PaddleResponse?> PauseSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}/pause";
var response = await httpClient.PostAsync(url, JsonContent.Create(new { }));
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
}
public async Task<PaddleResponse?> ResumeSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/subscriptions/{subscriptionId}";
var response = await httpClient.PatchAsync(url, JsonContent.Create(new Dictionary<string, string?>
{
{"scheduled_change", null}
}));
return await response.Content.ReadFromJsonAsync<PaddleResponse>();
}
public async Task<GetCustomerResponse?> FindCustomerFromTransactionAsync(string transactionId)
{
var transaction = await GetTransactionAsync(transactionId);
if (transaction == null) return null;
var url = $"{PADDLE_BASE_URI}/customers/{transaction.Transaction.CustomerId}";
var response = await httpClient.GetFromJsonAsync<GetCustomerResponse>(url);
return response;
}
}
}

View File

@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Services
{
public class PaddleService(string vendorId, string vendorAuthCode)
{
#if (DEBUG || STAGING)
const string PADDLE_BASE_URI = "https://sandbox-vendors.paddle.com/api";
#else
const string PADDLE_BASE_URI = "https://vendors.paddle.com/api";
#endif
HttpClient httpClient = new HttpClient();
public async Task<ListUsersResponse> ListUsersAsync(
string subscriptionId,
int results
)
{
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
{ "subscription_id", subscriptionId },
{ "results_per_page", results.ToString() },
}
)
);
return await response.Content.ReadFromJsonAsync<ListUsersResponse>();
}
public async Task<ListPaymentsResponse> ListPaymentsAsync(
string subscriptionId,
long planId
)
{
var url = $"{PADDLE_BASE_URI}/2.0/subscription/payments";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
{ "subscription_id", subscriptionId },
{ "is_paid", "1" },
{ "plan", planId.ToString() },
{ "is_one_off_charge", "0" },
}
)
);
return await response.Content.ReadFromJsonAsync<ListPaymentsResponse>();
}
public async Task<ListTransactionsResponse> ListTransactionsAsync(
string subscriptionId
)
{
var url = $"{PADDLE_BASE_URI}/2.0/subscription/{subscriptionId}/transactions";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
}
)
);
return await response.Content.ReadFromJsonAsync<ListTransactionsResponse>();
}
public async Task<PaddleTransactionUser> FindUserFromOrderAsync(string orderId)
{
var url = $"{PADDLE_BASE_URI}/2.0/order/{orderId}/transactions";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
}
)
);
var transactions = await response.Content.ReadFromJsonAsync<ListTransactionsResponse>();
if (transactions.Transactions.Length == 0) return null;
return transactions.Transactions[0].User;
}
public async Task<bool> RefundPaymentAsync(string paymentId, string reason = "")
{
var url = $"{PADDLE_BASE_URI}/2.0/payment/refund";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
{ "order_id", paymentId },
{ "reason", reason },
}
)
);
var refundResponse = await response.Content.ReadFromJsonAsync<RefundPaymentResponse>();
return refundResponse.Success;
}
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users_cancel";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
{ "subscription_id", subscriptionId },
}
)
);
return response.IsSuccessStatusCode;
}
public async Task<bool> PauseSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users/update";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
{ "subscription_id", subscriptionId },
{ "pause", "true" },
}
)
);
return response.IsSuccessStatusCode;
}
public async Task<bool> ResumeSubscriptionAsync(string subscriptionId)
{
var url = $"{PADDLE_BASE_URI}/2.0/subscription/users/update";
var httpClient = new HttpClient();
var response = await httpClient.PostAsync(
url,
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "vendor_id", vendorId },
{ "vendor_auth_code", vendorAuthCode },
{ "subscription_id", subscriptionId },
{ "pause", "false" },
}
)
);
return response.IsSuccessStatusCode;
}
}
}

View File

@@ -0,0 +1,36 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Services
{
struct Threat
{
public string[]? ThreatTypes { get; set; }
}
struct WebRiskAPIResponse
{
public Threat Threat { get; set; }
}
public class URLAnalyzer : IURLAnalyzer, IDisposable
{
private readonly HttpClient httpClient = new();
public async Task<bool> IsURLSafeAsync(string uri)
{
if (string.IsNullOrEmpty(Constants.WEBRISK_API_URI)) return true;
var response = await httpClient.PostAsJsonAsync(Constants.WEBRISK_API_URI, new { uri });
if (!response.IsSuccessStatusCode) return true;
var json = await response.Content.ReadFromJsonAsync<WebRiskAPIResponse>();
return json.Threat.ThreatTypes == null || json.Threat.ThreatTypes.Length == 0;
}
void IDisposable.Dispose()
{
httpClient.Dispose();
}
}
}

View File

@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>

View File

@@ -1,125 +1,140 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reactive.Subjects;
using System.Threading.Tasks;
using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces;
using WampSharp.V2.Client;
namespace Streetwriters.Common
{
public class WampServer<T> where T : new()
{
private readonly ConcurrentDictionary<string, IWampRealmProxy> Channels = new();
public string Endpoint { get; set; }
public string Address { get; set; }
public T Topics { get; set; } = new T();
public string Realm { get; set; }
private async Task<IWampRealmProxy> GetChannelAsync(string topic)
{
if (!Channels.TryGetValue(topic, out IWampRealmProxy channel) || !channel.Monitor.IsConnected)
{
channel = await WampHelper.OpenWampChannelAsync(Address, Realm);
Channels.AddOrUpdate(topic, (key) => channel, (key, old) => channel);
}
return channel;
}
public async Task<V> GetServiceAsync<V>(string topic) where V : class
{
var channel = await GetChannelAsync(topic);
return channel.Services.GetCalleeProxy<V>();
}
public async Task PublishMessageAsync<V>(string topic, V message)
{
try
{
IWampRealmProxy channel = await GetChannelAsync(topic);
WampHelper.PublishMessage(channel, topic, message);
}
catch (Exception ex)
{
await Slogger<WampServer<T>>.Error(nameof(PublishMessageAsync), ex.ToString());
throw ex;
}
}
}
public class WampServers
{
public static WampServer<MessengerServerTopics> MessengerServer { get; } = new WampServer<MessengerServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.MessengerServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<SubscriptionServerTopics> SubscriptionServer { get; } = new WampServer<SubscriptionServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.SubscriptionServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<IdentityServerTopics> IdentityServer { get; } = new WampServer<IdentityServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.IdentityServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<NotesnookServerTopics> NotesnookServer { get; } = new WampServer<NotesnookServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.NotesnookAPI.WS()}/wamp",
Realm = "messages",
};
}
public class MessengerServerTopics
{
public const string SendSSETopic = "co.streetwriters.sse.send";
}
public class SubscriptionServerTopics
{
public const string UserSubscriptionServiceTopic = "co.streetwriters.subscriptions.subscriptions";
public const string CreateSubscriptionTopic = "co.streetwriters.subscriptions.create";
public const string DeleteSubscriptionTopic = "co.streetwriters.subscriptions.delete";
}
public class IdentityServerTopics
{
public const string UserAccountServiceTopic = "co.streetwriters.identity.users";
public const string ClearCacheTopic = "co.streetwriters.identity.clear_cache";
public const string DeleteUserTopic = "co.streetwriters.identity.delete_user";
}
public class NotesnookServerTopics
{
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reactive.Subjects;
using System.Threading.Tasks;
using Streetwriters.Common.Helpers;
using Streetwriters.Common.Interfaces;
using WampSharp.V2.Client;
namespace Streetwriters.Common
{
public class WampServer<T> where T : new()
{
private readonly ConcurrentDictionary<string, IWampRealmProxy> Channels = new();
public string Endpoint { get; set; }
public string Address { get; set; }
public T Topics { get; set; } = new T();
public string Realm { get; set; }
private async Task<IWampRealmProxy> GetChannelAsync(string topic)
{
if (!Channels.TryGetValue(topic, out IWampRealmProxy channel) || !channel.Monitor.IsConnected)
{
channel = await WampHelper.OpenWampChannelAsync(Address, Realm);
Channels.AddOrUpdate(topic, (key) => channel, (key, old) => channel);
}
return channel;
}
public async Task<V> GetServiceAsync<V>(string topic) where V : class
{
var channel = await GetChannelAsync(topic);
return channel.Services.GetCalleeProxy<V>();
}
public async Task PublishMessageAsync<V>(string topic, V message)
{
try
{
IWampRealmProxy channel = await GetChannelAsync(topic);
WampHelper.PublishMessage(channel, topic, message);
}
catch (Exception ex)
{
await Slogger<WampServer<T>>.Error(nameof(PublishMessageAsync), ex.ToString());
throw ex;
}
}
public async Task PublishMessagesAsync<V>(string topic, IEnumerable<V> messages)
{
try
{
IWampRealmProxy channel = await GetChannelAsync(topic);
WampHelper.PublishMessages(channel, topic, messages);
}
catch (Exception ex)
{
await Slogger<WampServer<T>>.Error(nameof(PublishMessagesAsync), ex.ToString());
throw ex;
}
}
}
public class WampServers
{
public static WampServer<MessengerServerTopics> MessengerServer { get; } = new WampServer<MessengerServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.MessengerServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<SubscriptionServerTopics> SubscriptionServer { get; } = new WampServer<SubscriptionServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.SubscriptionServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<IdentityServerTopics> IdentityServer { get; } = new WampServer<IdentityServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.IdentityServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<NotesnookServerTopics> NotesnookServer { get; } = new WampServer<NotesnookServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.NotesnookAPI.WS()}/wamp",
Realm = "messages",
};
}
public class MessengerServerTopics
{
public const string SendSSETopic = "co.streetwriters.sse.send";
}
public class SubscriptionServerTopics
{
public const string UserSubscriptionServiceTopic = "co.streetwriters.subscriptions.subscriptions";
public const string CreateSubscriptionTopic = "co.streetwriters.subscriptions.create";
public const string CreateSubscriptionV2Topic = "co.streetwriters.subscriptions.v2.create";
public const string DeleteSubscriptionTopic = "co.streetwriters.subscriptions.delete";
}
public class IdentityServerTopics
{
public const string UserAccountServiceTopic = "co.streetwriters.identity.users";
public const string ClearCacheTopic = "co.streetwriters.identity.clear_cache";
public const string DeleteUserTopic = "co.streetwriters.identity.delete_user";
}
public class NotesnookServerTopics
{
}
}

View File

@@ -84,6 +84,11 @@ namespace Streetwriters.Data.Repositories
return all.ToList();
}
public virtual async Task<long> CountAsync(Expression<Func<TEntity, bool>> filterExpression)
{
return await Collection.CountDocumentsAsync(filterExpression);
}
public virtual void Update(string id, TEntity obj)
{
dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, Builders<TEntity>.Filter.Eq("_id", ObjectId.Parse(id)), obj, cancellationToken: ct));

View File

@@ -91,7 +91,7 @@ namespace Streetwriters.Identity.Controllers
[HttpPost("send")]
[Authorize("mfa")]
[Authorize(LocalApi.PolicyName)]
[EnableRateLimiting("strict")]
[EnableRateLimiting("super_strict")]
public async Task<IActionResult> RequestCode([FromForm] string type)
{
var client = Clients.FindClientById(User.FindFirstValue("client_id"));

View File

@@ -109,7 +109,7 @@ namespace Streetwriters.Identity.Controllers
await UserManager.AddToRoleAsync(user, client.Id);
if (Constants.IS_SELF_HOSTED)
{
await UserManager.AddClaimAsync(user, UserService.SubscriptionTypeToClaim(client.Id, Common.Enums.SubscriptionType.PREMIUM));
await UserManager.AddClaimAsync(user, new Claim(UserService.GetClaimKey(client.Id), "believer"));
}
else
{

View File

@@ -1,49 +1,49 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Threading.Tasks;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Common;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using System.Linq;
using Streetwriters.Identity.Services;
namespace Streetwriters.Identity.MessageHandlers
{
public class CreateSubscription
{
public static async Task Process(CreateSubscriptionMessage message, UserManager<User> userManager)
{
var user = await userManager.FindByIdAsync(message.UserId);
var client = Clients.FindClientByAppId(message.AppId);
if (client == null || user == null) return;
IdentityUserClaim<string> statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == UserService.GetClaimKey(client.Id));
Claim subscriptionClaim = UserService.SubscriptionTypeToClaim(client.Id, message.Type);
if (statusClaim?.ClaimValue == subscriptionClaim.Value) return;
if (statusClaim != null)
await userManager.ReplaceClaimAsync(user, statusClaim.ToClaim(), subscriptionClaim);
else
await userManager.AddClaimAsync(user, subscriptionClaim);
}
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Threading.Tasks;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Common;
using Microsoft.AspNetCore.Identity;
using System.Security.Claims;
using System.Linq;
using Streetwriters.Identity.Services;
namespace Streetwriters.Identity.MessageHandlers
{
public class CreateSubscription
{
public static async Task Process(Subscription subscription, UserManager<User> userManager)
{
var user = await userManager.FindByIdAsync(subscription.UserId);
var client = Clients.FindClientByAppId(subscription.AppId);
if (client == null || user == null) return;
IdentityUserClaim<string> statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == UserService.GetClaimKey(client.Id));
Claim subscriptionClaim = UserService.SubscriptionPlanToClaim(client.Id, subscription);
if (statusClaim?.ClaimValue == subscriptionClaim.Value) return;
if (statusClaim != null)
await userManager.ReplaceClaimAsync(user, statusClaim.ToClaim(), subscriptionClaim);
else
await userManager.AddClaimAsync(user, subscriptionClaim);
}
}
}

View File

@@ -32,7 +32,7 @@ namespace Streetwriters.Identity.MessageHandlers
{
var user = await userManager.FindByIdAsync(message.UserId);
var client = Clients.FindClientByAppId(message.AppId);
if (client != null || user != null) return;
if (client == null || user == null) return;
IdentityUserClaim<string> statusClaim = user.Claims.FirstOrDefault((c) => c.ClaimType == $"{client.Id}:status");
if (statusClaim != null)

View File

@@ -24,6 +24,7 @@ using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
@@ -170,8 +171,8 @@ namespace Streetwriters.Identity.Services
if (isSetup &&
method == MFAMethods.SMS &&
!UserService.IsUserPremium(client.Id, user))
throw new Exception("Due to the high costs of SMS, currently 2FA via SMS is only available for Pro users.");
!UserService.IsSMSMFAAllowed(client.Id, user))
throw new Exception("Due to the high costs of SMS, 2FA via SMS is only available on Pro & Believer plans.");
// if (!user.EmailConfirmed) throw new Exception("Please confirm your email before activating 2FA by email.");
await GetAuthenticatorDetailsAsync(user, client);
@@ -185,6 +186,7 @@ namespace Streetwriters.Identity.Services
case "sms":
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client);
await Slogger<MFAService>.Info("SendOTPAsync", user.Id.ToString(), id);
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
break;

View File

@@ -12,11 +12,11 @@ namespace Streetwriters.Identity.Services
{
public class UserAccountService(UserManager<User> userManager, IMFAService mfaService) : IUserAccountService
{
public async Task<UserModel> GetUserAsync(string clientId, string userId)
public async Task<UserModel?> GetUserAsync(string clientId, string userId)
{
var user = await userManager.FindByIdAsync(userId);
if (!await UserService.IsUserValidAsync(userManager, user, clientId))
throw new Exception($"Unable to find user with ID '{userId}'.");
return null;
var claims = await userManager.GetClaimsAsync(user);
var marketingConsentClaim = claims.FirstOrDefault((claim) => claim.Type == $"{clientId}:marketing_consent");
@@ -46,7 +46,7 @@ namespace Streetwriters.Identity.Services
public async Task DeleteUserAsync(string clientId, string userId, string password)
{
var user = await userManager.FindByIdAsync(userId);
if (!await UserService.IsUserValidAsync(userManager, user, clientId)) throw new Exception($"User not found.");
if (!await UserService.IsUserValidAsync(userManager, user, clientId)) return;
if (!await userManager.CheckPasswordAsync(user, password)) throw new Exception("Wrong password.");

View File

@@ -1,89 +1,99 @@
/*
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 <http://www.gnu.org/licenses/>.
*/
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
namespace Streetwriters.Identity.Services
{
public class UserService
{
public static SubscriptionType GetUserSubscriptionStatus(string clientId, User user)
{
var claimKey = GetClaimKey(clientId);
var status = user.Claims.FirstOrDefault((c) => c.ClaimType == claimKey).ClaimValue;
switch (status)
{
case "basic":
return SubscriptionType.BASIC;
case "trial":
return SubscriptionType.TRIAL;
case "premium":
return SubscriptionType.PREMIUM;
case "premium_canceled":
return SubscriptionType.PREMIUM_CANCELED;
case "premium_expired":
return SubscriptionType.PREMIUM_EXPIRED;
default:
return SubscriptionType.BASIC;
}
}
public static bool IsUserPremium(string clientId, User user)
{
var status = GetUserSubscriptionStatus(clientId, user);
return status == SubscriptionType.PREMIUM || status == SubscriptionType.PREMIUM_CANCELED;
}
public static Claim SubscriptionTypeToClaim(string clientId, SubscriptionType type)
{
var claimKey = GetClaimKey(clientId);
switch (type)
{
case SubscriptionType.BASIC:
return new Claim(claimKey, "basic");
case SubscriptionType.TRIAL:
return new Claim(claimKey, "trial");
case SubscriptionType.PREMIUM:
return new Claim(claimKey, "premium");
case SubscriptionType.PREMIUM_CANCELED:
return new Claim(claimKey, "premium_canceled");
case SubscriptionType.PREMIUM_EXPIRED:
return new Claim(claimKey, "premium_expired");
}
return null;
}
public static string GetClaimKey(string clientId)
{
return $"{clientId}:status";
}
public static async Task<bool> IsUserValidAsync(UserManager<User> userManager, User user, string clientId)
{
return user != null && await userManager.IsInRoleAsync(user, clientId);
}
}
/*
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 <http://www.gnu.org/licenses/>.
*/
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
namespace Streetwriters.Identity.Services
{
public class UserService
{
private static SubscriptionPlan? GetUserSubscriptionPlan(string clientId, User user)
{
var claimKey = GetClaimKey(clientId);
var status = user.Claims.FirstOrDefault((c) => c.ClaimType == claimKey).ClaimValue;
switch (status)
{
case "free":
return SubscriptionPlan.FREE;
case "believer":
return SubscriptionPlan.BELIEVER;
case "education":
return SubscriptionPlan.EDUCATION;
case "essential":
return SubscriptionPlan.ESSENTIAL;
case "pro":
return SubscriptionPlan.PRO;
default:
return null;
}
}
public static bool IsSMSMFAAllowed(string clientId, User user)
{
var status = GetUserSubscriptionPlan(clientId, user);
if (status == null) return false;
return status == SubscriptionPlan.LEGACY_PRO ||
status == SubscriptionPlan.PRO ||
status == SubscriptionPlan.EDUCATION ||
status == SubscriptionPlan.BELIEVER;
}
public static Claim SubscriptionPlanToClaim(string clientId, Subscription subscription)
{
var claimKey = GetClaimKey(clientId);
// just in case
if (subscription.ExpiryDate <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())
return new Claim(claimKey, "free");
switch (subscription.Plan)
{
case SubscriptionPlan.FREE:
return new Claim(claimKey, "free");
case SubscriptionPlan.BELIEVER:
return new Claim(claimKey, "believer");
case SubscriptionPlan.EDUCATION:
return new Claim(claimKey, "education");
case SubscriptionPlan.ESSENTIAL:
return new Claim(claimKey, "essential");
case SubscriptionPlan.PRO:
return new Claim(claimKey, "pro");
case SubscriptionPlan.LEGACY_PRO:
return new Claim(claimKey, "legacy_pro");
}
return null;
}
public static string GetClaimKey(string clientId)
{
return $"{clientId}:status";
}
public static async Task<bool> IsUserValidAsync(UserManager<User> userManager, User user, string clientId)
{
return user != null && await userManager.IsInRoleAsync(user, clientId);
}
}
}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System;
using System.IO;
using System.Security.Claims;
using System.Threading.RateLimiting;
using AspNetCore.Identity.Mongo;
using IdentityServer4.MongoDB.Entities;
@@ -31,6 +32,7 @@ using IdentityServer4.Stores;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
@@ -137,6 +139,7 @@ namespace Streetwriters.Identity
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddSlidingWindowLimiter("strict", options =>
{
options.PermitLimit = 30;
@@ -145,17 +148,27 @@ namespace Streetwriters.Identity
options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
options.QueueLimit = 0;
});
});
services.AddAuthorization(options =>
{
options.AddPolicy("mfa", policy =>
options.AddPolicy("super_strict", (context) =>
{
policy.AddAuthenticationSchemes("Bearer+jwt");
policy.RequireClaim("scope", Config.MFA_GRANT_TYPE_SCOPE);
var key = context.User?.FindFirstValue("sub") ?? "default";
return RateLimitPartition.GetSlidingWindowLimiter(key, (key) => new SlidingWindowRateLimiterOptions
{
PermitLimit = 6,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 2,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = int.MaxValue,
AutoReplenishment = true
});
});
});
services.AddAuthorizationBuilder().AddPolicy("mfa", policy =>
{
policy.AddAuthenticationSchemes("Bearer+jwt");
policy.RequireClaim("scope", Config.MFA_GRANT_TYPE_SCOPE);
});
services.AddLocalApiAuthentication();
services.AddAuthentication()
@@ -166,7 +179,7 @@ namespace Streetwriters.Identity
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidTypes = new[] { "at+jwt" },
ValidTypes = ["at+jwt"],
ValidateAudience = false,
ValidateIssuerSigningKey = true,
ValidateIssuer = true,
@@ -222,15 +235,16 @@ namespace Streetwriters.Identity
{
realm.Services.RegisterCallee(() => app.ApplicationServices.CreateScope().ServiceProvider.GetRequiredService<IUserAccountService>());
realm.Subscribe(SubscriptionServerTopics.CreateSubscriptionTopic, async (CreateSubscriptionMessage message) =>
realm.Subscribe(SubscriptionServerTopics.CreateSubscriptionTopic, async (Subscription subscription) =>
{
using (var serviceScope = app.ApplicationServices.CreateScope())
{
var services = serviceScope.ServiceProvider;
var userManager = services.GetRequiredService<UserManager<User>>();
await MessageHandlers.CreateSubscription.Process(message, userManager);
await MessageHandlers.CreateSubscription.Process(subscription, userManager);
}
});
realm.Subscribe(SubscriptionServerTopics.DeleteSubscriptionTopic, async (DeleteSubscriptionMessage message) =>
{
using (var serviceScope = app.ApplicationServices.CreateScope())