mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-08-13 20:10:18 +02:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768384011d | ||
|
|
a8e73069c2 | ||
|
|
594f81a3b9 | ||
|
|
518e396079 | ||
|
|
d694ed2fee | ||
|
|
13ab0ba039 | ||
|
|
3c7327d3c7 | ||
|
|
7ce7f02646 | ||
|
|
a1dbd3f8b8 | ||
|
|
99489b9b4c | ||
|
|
1b953f756e | ||
|
|
d27ab68735 | ||
|
|
294d885dbf | ||
|
|
bdd5017394 | ||
|
|
7ad70c63ee | ||
|
|
0367ab6f80 | ||
|
|
f3bfe0957b | ||
|
|
7f614f6954 | ||
|
|
6663778e3e | ||
|
|
14f0a3b37e | ||
|
|
82a1152f9f | ||
|
|
580524b855 | ||
|
|
159fe0e376 | ||
|
|
30fdaae36c | ||
|
|
815c8fb84c | ||
|
|
04b0c305ed | ||
|
|
31c57f95b2 | ||
|
|
33413b0a5c | ||
|
|
4278b0624e | ||
|
|
a56ef1fe11 | ||
|
|
d9c282fcf8 | ||
|
|
73750613c4 | ||
|
|
bb008c032d | ||
|
|
5715f4c9ca | ||
|
|
8116ce70e4 | ||
|
|
2f8b0ad607 | ||
|
|
1c5bcd6eff | ||
|
|
3a2a04317f | ||
|
|
8d92aff8cd | ||
|
|
4bc1469dfe | ||
|
|
da58262afb | ||
|
|
864baa702b | ||
|
|
55c5cd0a7c | ||
|
|
077d411fc7 | ||
|
|
1cecfe4b3c |
@@ -12,21 +12,26 @@ NOTESNOOK_API_SECRET=
|
|||||||
# Possible values: true/false
|
# Possible values: true/false
|
||||||
DISABLE_SIGNUPS=false
|
DISABLE_SIGNUPS=false
|
||||||
|
|
||||||
### SMTP Configuration ###
|
### SMTP Configuration (optional) ###
|
||||||
# SMTP Configuration is required for sending emails for password reset, 2FA emails etc. You can get SMTP settings from your email provider.
|
# ================================================================================
|
||||||
|
# WARNING: If SMTP is not configured, email 2FA and password-reset emails will
|
||||||
|
# NOT work. Newly created users MUST change 2FA to an Authenticator app:
|
||||||
|
# Settings > Auth > Change 2FA method
|
||||||
|
# ================================================================================
|
||||||
|
# SMTP is used for sending emails for password reset, 2FA emails etc. You can get SMTP settings from your email provider.
|
||||||
|
|
||||||
# Description: Username for the SMTP connection (most time it is the email address of your account). Check your email provider's documentation to get the appropriate value.
|
# Description: Username for the SMTP connection (most time it is the email address of your account). Check your email provider's documentation to get the appropriate value.
|
||||||
# Required: yes
|
# Required: no
|
||||||
SMTP_USERNAME=
|
SMTP_USERNAME=
|
||||||
# Description: Password for the SMTP connection. Check your email provider's documentation to get the appropriate value.
|
# Description: Password for the SMTP connection. Check your email provider's documentation to get the appropriate value.
|
||||||
# Required: yes
|
# Required: no
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
# Description: Host on which the the SMTP connection is running. Check your email provider's documentation to get the appropriate value.
|
# Description: Host on which the the SMTP connection is running. Check your email provider's documentation to get the appropriate value.
|
||||||
# Required: yes
|
# Required: no
|
||||||
# Example: smtp.gmail.com
|
# Example: smtp.gmail.com
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
# Description: Port on which the the SMTP connection is running. Check your email provider's documentation to get the appropriate value.
|
# Description: Port on which the the SMTP connection is running. Check your email provider's documentation to get the appropriate value.
|
||||||
# Required: yes
|
# Required: no
|
||||||
# Example: 465
|
# Example: 465
|
||||||
SMTP_PORT=
|
SMTP_PORT=
|
||||||
|
|
||||||
@@ -42,6 +47,7 @@ TWILIO_AUTH_TOKEN=
|
|||||||
TWILIO_SERVICE_SID=
|
TWILIO_SERVICE_SID=
|
||||||
|
|
||||||
# Description: Add the origins for which you want to allow CORS. Leave it empty to allow all origins to access your server. If you want to allow multiple origins, seperate each origin with a comma.
|
# Description: Add the origins for which you want to allow CORS. Leave it empty to allow all origins to access your server. If you want to allow multiple origins, seperate each origin with a comma.
|
||||||
|
# Note: this value is passed to the server as NOTESNOOK_CORS internally.
|
||||||
# Required: no
|
# Required: no
|
||||||
# Example: https://app.notesnook.com,http://localhost:3000
|
# Example: https://app.notesnook.com,http://localhost:3000
|
||||||
NOTESNOOK_CORS_ORIGINS=
|
NOTESNOOK_CORS_ORIGINS=
|
||||||
@@ -58,16 +64,20 @@ KNOWN_PROXIES=
|
|||||||
NOTESNOOK_APP_PUBLIC_URL=https://app.notesnook.com
|
NOTESNOOK_APP_PUBLIC_URL=https://app.notesnook.com
|
||||||
# Description: This is the public URL for the monograph frontend.
|
# Description: This is the public URL for the monograph frontend.
|
||||||
# Required: yes
|
# Required: yes
|
||||||
# Example: https://monogr.ph
|
# Example: https://monogr.yourdomain.com
|
||||||
MONOGRAPH_PUBLIC_URL=http://localhost:6264
|
MONOGRAPH_PUBLIC_URL=http://localhost:6264
|
||||||
# Description: This is the public URL for the Authentication server. Used for generating email confirmation & password reset URLs.
|
# Description: This is the public URL for the Authentication server. Used for generating email confirmation & password reset URLs.
|
||||||
# Required: yes
|
# Required: yes
|
||||||
# Example: https://auth.streetwriters.co
|
# Example: https://auth.yourdomain.com
|
||||||
AUTH_SERVER_PUBLIC_URL=http://localhost:8264
|
AUTH_SERVER_PUBLIC_URL=http://localhost:8264
|
||||||
# Description: This is the public URL for the S3 attachments server (minio). It'll be used by the Notesnook clients for uploading/downloading attachments.
|
# Description: This is the public URL for the S3 attachments server (minio). It'll be used by the Notesnook clients for uploading/downloading attachments.
|
||||||
# Required: yes
|
# Required: yes
|
||||||
# Example: https://attachments.notesnook.com
|
# Example: https://attachments.yourdomain.com
|
||||||
ATTACHMENTS_SERVER_PUBLIC_URL=http://localhost:9000
|
ATTACHMENTS_SERVER_PUBLIC_URL=http://localhost:9000
|
||||||
|
# Description: This is the public URL for the Notesnook sync API (browser-reachable; used by Monograph).
|
||||||
|
# Required: yes
|
||||||
|
# Example: https://api.yourdomain.com
|
||||||
|
NOTESNOOK_API_PUBLIC_URL=http://localhost:5264
|
||||||
|
|
||||||
# Description: Custom username for the root Minio account. Minio is used for storing your attachments. This must be greater than 3 characters in length.
|
# Description: Custom username for the root Minio account. Minio is used for storing your attachments. This must be greater than 3 characters in length.
|
||||||
# Required: no
|
# Required: no
|
||||||
@@ -75,3 +85,15 @@ MINIO_ROOT_USER=
|
|||||||
# Description: Custom password for the root Minio account. Minio is used for storing your attachments. This must be greater than 8 characters in length.
|
# Description: Custom password for the root Minio account. Minio is used for storing your attachments. This must be greater than 8 characters in length.
|
||||||
# Required: no
|
# Required: no
|
||||||
MINIO_ROOT_PASSWORD=
|
MINIO_ROOT_PASSWORD=
|
||||||
|
|
||||||
|
# Optional: only needed when using `docker compose --profile extras up`
|
||||||
|
# Description: Public URL for the CORS proxy service
|
||||||
|
CORS_PROXY_PUBLIC_URL=http://localhost:3001
|
||||||
|
# Description: Allowed origins for the CORS proxy (* for all origins)
|
||||||
|
CORS_PROXY_ALLOWED_ORIGINS=*
|
||||||
|
# Description: Public URL for the Inbox API service
|
||||||
|
INBOX_API_PUBLIC_URL=http://localhost:5181
|
||||||
|
# Description: Public URL for the Themes server
|
||||||
|
THEMES_SERVER_PUBLIC_URL=http://localhost:9200
|
||||||
|
# Description: Git repository URL for the Themes server
|
||||||
|
THEMES_REPO_URL=https://github.com/streetwriters/notesnook-themes.git
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ jobs:
|
|||||||
- image: streetwriters/sse
|
- image: streetwriters/sse
|
||||||
file: ./Streetwriters.Messenger/Dockerfile
|
file: ./Streetwriters.Messenger/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
|
|
||||||
|
- image: streetwriters/notesnook-inbox
|
||||||
|
file: ./Notesnook.Inbox.API/Dockerfile
|
||||||
|
context: ./Notesnook.Inbox.API/
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
packages: write
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ namespace Notesnook.API.Accessors
|
|||||||
public SyncItemsRepository Colors { get; }
|
public SyncItemsRepository Colors { get; }
|
||||||
public SyncItemsRepository Vaults { get; }
|
public SyncItemsRepository Vaults { get; }
|
||||||
public SyncItemsRepository Tags { get; }
|
public SyncItemsRepository Tags { get; }
|
||||||
|
public SyncItemsRepository InboxItemsHistory { get; }
|
||||||
public Repository<UserSettings> UsersSettings { get; }
|
public Repository<UserSettings> UsersSettings { get; }
|
||||||
public Repository<Monograph> Monographs { get; }
|
public Repository<Monograph> Monographs { get; }
|
||||||
public Repository<InboxApiKey> InboxApiKey { get; }
|
public Repository<InboxApiKey> InboxApiKey { get; }
|
||||||
@@ -75,6 +76,8 @@ namespace Notesnook.API.Accessors
|
|||||||
IMongoCollection<SyncItem> vaults,
|
IMongoCollection<SyncItem> vaults,
|
||||||
[FromKeyedServices(Collections.TagsKey)]
|
[FromKeyedServices(Collections.TagsKey)]
|
||||||
IMongoCollection<SyncItem> tags,
|
IMongoCollection<SyncItem> tags,
|
||||||
|
[FromKeyedServices(Collections.InboxItemsHistoryKey)]
|
||||||
|
IMongoCollection<SyncItem> inboxItemsHistory,
|
||||||
|
|
||||||
Repository<UserSettings> usersSettings,
|
Repository<UserSettings> usersSettings,
|
||||||
Repository<Monograph> monographs,
|
Repository<Monograph> monographs,
|
||||||
@@ -102,6 +105,7 @@ namespace Notesnook.API.Accessors
|
|||||||
Colors = new SyncItemsRepository(dbContext, colors, logger);
|
Colors = new SyncItemsRepository(dbContext, colors, logger);
|
||||||
Vaults = new SyncItemsRepository(dbContext, vaults, logger);
|
Vaults = new SyncItemsRepository(dbContext, vaults, logger);
|
||||||
Tags = new SyncItemsRepository(dbContext, tags, logger);
|
Tags = new SyncItemsRepository(dbContext, tags, logger);
|
||||||
|
InboxItemsHistory = new SyncItemsRepository(dbContext, inboxItemsHistory, logger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ namespace Notesnook.API.Authorization
|
|||||||
return AuthenticateResult.Fail("API key has expired");
|
return AuthenticateResult.Fail("API key has expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
inboxApiKey.LastUsedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey);
|
await _inboxApiKeyRepository.UpsertAsync(inboxApiKey, k => k.Key == apiKey);
|
||||||
|
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ namespace Notesnook.API
|
|||||||
public const string InboxApiKeysKey = "inbox_api_keys";
|
public const string InboxApiKeysKey = "inbox_api_keys";
|
||||||
public const string SyncDevicesKey = "sync_devices";
|
public const string SyncDevicesKey = "sync_devices";
|
||||||
public const string DeviceIdsChunksKey = "device_ids_chunks";
|
public const string DeviceIdsChunksKey = "device_ids_chunks";
|
||||||
|
public const string InboxItemsHistoryKey = "inbox_items_history";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -63,7 +64,7 @@ namespace Notesnook.API.Controllers
|
|||||||
|
|
||||||
[HttpPost("api-keys")]
|
[HttpPost("api-keys")]
|
||||||
[Authorize(Policy = "Notesnook")]
|
[Authorize(Policy = "Notesnook")]
|
||||||
public async Task<IActionResult> CreateApiKeyAsync([FromBody] InboxApiKey request)
|
public async Task<IActionResult> CreateApiKeyAsync([FromBody] CreateInboxApiKeyRequest request)
|
||||||
{
|
{
|
||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
try
|
try
|
||||||
@@ -72,9 +73,9 @@ namespace Notesnook.API.Controllers
|
|||||||
{
|
{
|
||||||
return BadRequest(new { error = "Api key name is required." });
|
return BadRequest(new { error = "Api key name is required." });
|
||||||
}
|
}
|
||||||
if (request.ExpiryDate <= -1)
|
if (request.ExpiryDate == null)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Valid expiry date is required." });
|
return BadRequest(new { error = "Expiry date is required." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId);
|
var count = await inboxApiKeysRepository.CountAsync(t => t.UserId == userId);
|
||||||
@@ -133,7 +134,7 @@ namespace Notesnook.API.Controllers
|
|||||||
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
|
var userSetting = await userSettingsRepository.FindOneAsync(u => u.UserId == userId);
|
||||||
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
|
if (string.IsNullOrWhiteSpace(userSetting?.InboxKeys?.Public))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Inbox public key is not configured." });
|
return NotFound(new { error = "Inbox public key is not configured." });
|
||||||
}
|
}
|
||||||
return Ok(new { key = userSetting.InboxKeys.Public });
|
return Ok(new { key = userSetting.InboxKeys.Public });
|
||||||
}
|
}
|
||||||
@@ -151,34 +152,18 @@ namespace Notesnook.API.Controllers
|
|||||||
var userId = User.GetUserId();
|
var userId = User.GetUserId();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (request.Key.Algorithm != Algorithms.XSAL_X25519_7)
|
if (string.IsNullOrWhiteSpace(request.Cipher))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = $"Only {Algorithms.XSAL_X25519_7} is supported for inbox item password." });
|
return BadRequest(new { error = "Inbox item is required." });
|
||||||
}
|
}
|
||||||
if (string.IsNullOrWhiteSpace(request.Key.Cipher))
|
if (string.IsNullOrWhiteSpace(request.Algorithm))
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Inbox item password cipher is required." });
|
return BadRequest(new { error = "Inbox item algorithm 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)
|
if (request.Version <= 0)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Valid inbox item version is required." });
|
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.UserId = userId;
|
||||||
request.ItemId = ObjectId.GenerateNewId().ToString();
|
request.ItemId = ObjectId.GenerateNewId().ToString();
|
||||||
|
|||||||
@@ -18,20 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AngleSharp;
|
using AngleSharp;
|
||||||
using AngleSharp.Dom;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MongoDB.Bson;
|
using MongoDB.Bson;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using Notesnook.API.Authorization;
|
using NanoidDotNet;
|
||||||
|
using Notesnook.API.Extensions;
|
||||||
using Notesnook.API.Models;
|
using Notesnook.API.Models;
|
||||||
using Notesnook.API.Services;
|
using Notesnook.API.Services;
|
||||||
using Streetwriters.Common;
|
using Streetwriters.Common;
|
||||||
@@ -40,7 +39,6 @@ using Streetwriters.Common.Enums;
|
|||||||
using Streetwriters.Common.Helpers;
|
using Streetwriters.Common.Helpers;
|
||||||
using Streetwriters.Common.Interfaces;
|
using Streetwriters.Common.Interfaces;
|
||||||
using Streetwriters.Common.Messages;
|
using Streetwriters.Common.Messages;
|
||||||
using Streetwriters.Data.Interfaces;
|
|
||||||
using Streetwriters.Data.Repositories;
|
using Streetwriters.Data.Repositories;
|
||||||
|
|
||||||
namespace Notesnook.API.Controllers
|
namespace Notesnook.API.Controllers
|
||||||
@@ -97,6 +95,29 @@ namespace Notesnook.API.Controllers
|
|||||||
return await result.FirstOrDefaultAsync();
|
return await result.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<Monograph> FindMonographBySlugAsync(string slug)
|
||||||
|
{
|
||||||
|
var result = await monographs.Collection.FindAsync(
|
||||||
|
Builders<Monograph>.Filter.Eq("Slug", slug), new FindOptions<Monograph>
|
||||||
|
{
|
||||||
|
Limit = 1
|
||||||
|
});
|
||||||
|
return await result.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GenerateUniqueSlugAsync(int length = 10, int maxAttempts = 5)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < maxAttempts; i++)
|
||||||
|
{
|
||||||
|
var slug = Nanoid.Generate(size: length);
|
||||||
|
var exists = await monographs.Collection.Find(Builders<Monograph>.Filter.Eq("Slug", slug))
|
||||||
|
.Limit(1)
|
||||||
|
.AnyAsync();
|
||||||
|
if (!exists) return slug;
|
||||||
|
}
|
||||||
|
throw new Exception("Failed to generate unique slug");
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
||||||
{
|
{
|
||||||
@@ -108,24 +129,12 @@ namespace Notesnook.API.Controllers
|
|||||||
var existingMonograph = await FindMonographAsync(userId, monograph);
|
var existingMonograph = await FindMonographAsync(userId, monograph);
|
||||||
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
|
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
|
||||||
|
|
||||||
if (monograph.EncryptedContent == null)
|
monograph = await CreateMonographAsync(monograph, userId);
|
||||||
{
|
|
||||||
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
|
|
||||||
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
|
|
||||||
monograph.ContentSanitizationLevel = sanitizationLevel;
|
|
||||||
}
|
|
||||||
monograph.UserId = userId;
|
|
||||||
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
||||||
|
|
||||||
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
|
|
||||||
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
|
|
||||||
|
|
||||||
if (existingMonograph != null)
|
if (existingMonograph != null)
|
||||||
{
|
{
|
||||||
monograph.Id = existingMonograph.Id;
|
monograph.Id = existingMonograph.Id;
|
||||||
}
|
}
|
||||||
monograph.Deleted = false;
|
|
||||||
monograph.ViewCount = 0;
|
|
||||||
await monographs.Collection.ReplaceOneAsync(
|
await monographs.Collection.ReplaceOneAsync(
|
||||||
CreateMonographFilter(userId, monograph),
|
CreateMonographFilter(userId, monograph),
|
||||||
monograph,
|
monograph,
|
||||||
@@ -137,13 +146,54 @@ namespace Notesnook.API.Controllers
|
|||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
id = monograph.ItemId,
|
id = monograph.ItemId,
|
||||||
datePublished = monograph.DatePublished
|
datePublished = monograph.DatePublished,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
logger.LogError(e, "Failed to publish monograph");
|
logger.LogError(e, "Failed to publish monograph");
|
||||||
return BadRequest();
|
return BadRequest(new { error = e.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("v2")]
|
||||||
|
public async Task<IActionResult> PublishV2Async([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = this.User.GetUserId();
|
||||||
|
var jti = this.User.FindFirstValue("jti");
|
||||||
|
|
||||||
|
var existingMonograph = await FindMonographAsync(userId, monograph);
|
||||||
|
if (existingMonograph != null && !existingMonograph.Deleted) return await UpdateAsync(deviceId, monograph);
|
||||||
|
|
||||||
|
monograph = await CreateMonographAsync(monograph, userId);
|
||||||
|
monograph.Slug = await GenerateUniqueSlugAsync();
|
||||||
|
|
||||||
|
if (existingMonograph != null)
|
||||||
|
{
|
||||||
|
monograph.Id = existingMonograph.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await monographs.Collection.ReplaceOneAsync(
|
||||||
|
CreateMonographFilter(userId, monograph),
|
||||||
|
monograph,
|
||||||
|
new ReplaceOptions { IsUpsert = true }
|
||||||
|
);
|
||||||
|
|
||||||
|
await MarkMonographForSyncAsync(userId, monograph.ItemId ?? monograph.Id, deviceId, jti);
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
id = monograph.ItemId,
|
||||||
|
datePublished = monograph.DatePublished,
|
||||||
|
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
logger.LogError(e, "Failed to publish monograph");
|
||||||
|
return BadRequest(new { error = e.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +242,14 @@ namespace Notesnook.API.Controllers
|
|||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
id = monograph.ItemId,
|
id = monograph.ItemId,
|
||||||
datePublished = monograph.DatePublished
|
datePublished = monograph.DatePublished,
|
||||||
|
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(existingMonograph)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
logger.LogError(e, "Failed to update monograph");
|
logger.LogError(e, "Failed to update monograph");
|
||||||
return BadRequest();
|
return BadRequest(new { error = e.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,25 +284,7 @@ namespace Notesnook.API.Controllers
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (monograph.EncryptedContent == null)
|
return Ok(await ProcessMonographAsync(monograph));
|
||||||
{
|
|
||||||
var isContentUnsanitized = monograph.ContentSanitizationLevel == ContentSanitizationLevel.Partial || monograph.ContentSanitizationLevel == ContentSanitizationLevel.Unknown;
|
|
||||||
if (!Constants.IS_SELF_HOSTED && isContentUnsanitized && serviceAccessor.UserSubscriptionService != null && !await serviceAccessor.UserSubscriptionService.IsUserSubscribedAsync(Clients.Notesnook.Id, monograph.UserId!))
|
|
||||||
{
|
|
||||||
var cleaned = await SanitizeContentAsync(monograph.CompressedContent?.DecompressBrotli(), ContentSanitizationLevel.Full);
|
|
||||||
monograph.CompressedContent = cleaned.CompressBrotli();
|
|
||||||
await monographs.Collection.UpdateOneAsync(
|
|
||||||
CreateMonographFilter(monograph.UserId!, monograph),
|
|
||||||
Builders<Monograph>.Update
|
|
||||||
.Set(m => m.CompressedContent, monograph.CompressedContent)
|
|
||||||
.Set(m => m.ContentSanitizationLevel, ContentSanitizationLevel.Full)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
|
|
||||||
}
|
|
||||||
|
|
||||||
monograph.ItemId ??= monograph.Id;
|
|
||||||
return Ok(monograph);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/view")]
|
[HttpGet("{id}/view")]
|
||||||
@@ -259,47 +292,47 @@ namespace Notesnook.API.Controllers
|
|||||||
public async Task<IActionResult> TrackView([FromRoute] string id)
|
public async Task<IActionResult> TrackView([FromRoute] string id)
|
||||||
{
|
{
|
||||||
var monograph = await FindMonographAsync(id);
|
var monograph = await FindMonographAsync(id);
|
||||||
if (monograph == null || monograph.Deleted) return Content(SVG_PIXEL, "image/svg+xml");
|
if (monograph == null || monograph.Deleted)
|
||||||
|
return Content(SVG_PIXEL, "image/svg+xml");
|
||||||
|
|
||||||
var cookieName = $"viewed_{id}";
|
var cookieName = $"viewed_{id}";
|
||||||
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
|
await TrackViewAsync(monograph, cookieName, $"/monographs/{id}");
|
||||||
|
|
||||||
if (monograph.SelfDestruct)
|
|
||||||
{
|
|
||||||
await monographs.Collection.ReplaceOneAsync(
|
|
||||||
CreateMonographFilter(monograph.UserId!, monograph),
|
|
||||||
new Monograph
|
|
||||||
{
|
|
||||||
ItemId = id,
|
|
||||||
Id = monograph.Id,
|
|
||||||
Deleted = true,
|
|
||||||
UserId = monograph.UserId,
|
|
||||||
ViewCount = 0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
await MarkMonographForSyncAsync(monograph.UserId!, id);
|
|
||||||
}
|
|
||||||
else if (!hasVisitedBefore)
|
|
||||||
{
|
|
||||||
await monographs.Collection.UpdateOneAsync(
|
|
||||||
CreateMonographFilter(monograph.UserId!, monograph),
|
|
||||||
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
|
|
||||||
);
|
|
||||||
|
|
||||||
var cookieOptions = new CookieOptions
|
|
||||||
{
|
|
||||||
Path = $"/monographs/{id}",
|
|
||||||
HttpOnly = true,
|
|
||||||
Secure = Request.IsHttps,
|
|
||||||
Expires = DateTimeOffset.UtcNow.AddMonths(1)
|
|
||||||
};
|
|
||||||
Response.Cookies.Append(cookieName, "1", cookieOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Content(SVG_PIXEL, "image/svg+xml");
|
return Content(SVG_PIXEL, "image/svg+xml");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("v2/{slug}/view")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> TrackViewV2([FromRoute] string slug)
|
||||||
|
{
|
||||||
|
var monograph = await FindMonographBySlugAsync(slug);
|
||||||
|
if (monograph == null || monograph.Deleted)
|
||||||
|
return Content(SVG_PIXEL, "image/svg+xml");
|
||||||
|
|
||||||
|
var cookieName = $"viewed_{slug}";
|
||||||
|
await TrackViewAsync(monograph, cookieName, $"/monographs/v2/{slug}");
|
||||||
|
return Content(SVG_PIXEL, "image/svg+xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("v2/{slug}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<IActionResult> GetMonographBySlugAsync([FromRoute] string slug)
|
||||||
|
{
|
||||||
|
var monograph = await FindMonographBySlugAsync(slug);
|
||||||
|
if (monograph == null || monograph.Deleted)
|
||||||
|
{
|
||||||
|
return NotFound(new
|
||||||
|
{
|
||||||
|
error = "invalid_id",
|
||||||
|
error_description = $"No such monograph found."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(await ProcessMonographAsync(monograph));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/analytics")]
|
[HttpGet("{id}/analytics")]
|
||||||
|
[Obsolete("This endpoint is deprecated and will be removed in future versions. Use GET /monographs/{id}/metadata instead.")]
|
||||||
public async Task<IActionResult> GetMonographAnalyticsAsync([FromRoute] string id)
|
public async Task<IActionResult> GetMonographAnalyticsAsync([FromRoute] string id)
|
||||||
{
|
{
|
||||||
if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User))
|
if (!FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User))
|
||||||
@@ -343,6 +376,29 @@ namespace Notesnook.API.Controllers
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/metadata")]
|
||||||
|
public async Task<IActionResult> GetMetadataAsync([FromRoute] string id)
|
||||||
|
{
|
||||||
|
var userId = this.User.GetUserId();
|
||||||
|
var monograph = await FindMonographAsync(id);
|
||||||
|
if (monograph == null || monograph.Deleted || monograph.UserId != userId)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPro = FeatureAuthorizationHelper.IsFeatureAllowed(Features.MONOGRAPH_ANALYTICS, Clients.Notesnook.Id, User);
|
||||||
|
var totalViews = isPro ? monograph.ViewCount : 0;
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
publishUrl = Helpers.UrlHelper.ConstructPublishUrl(monograph),
|
||||||
|
analytics = new
|
||||||
|
{
|
||||||
|
totalViews
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
|
private async Task MarkMonographForSyncAsync(string userId, string monographId, string? deviceId, string? jti)
|
||||||
{
|
{
|
||||||
if (deviceId == null) return;
|
if (deviceId == null) return;
|
||||||
@@ -368,6 +424,88 @@ namespace Notesnook.API.Controllers
|
|||||||
("audio", "src"),
|
("audio", "src"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private async Task<Monograph> CreateMonographAsync(Monograph monograph, string userId)
|
||||||
|
{
|
||||||
|
if (monograph.EncryptedContent == null)
|
||||||
|
{
|
||||||
|
var sanitizationLevel = User.IsUserSubscribed() ? ContentSanitizationLevel.Partial : ContentSanitizationLevel.Full;
|
||||||
|
monograph.CompressedContent = (await SanitizeContentAsync(monograph.Content, sanitizationLevel)).CompressBrotli();
|
||||||
|
monograph.ContentSanitizationLevel = sanitizationLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
monograph.UserId = userId;
|
||||||
|
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
|
||||||
|
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
|
||||||
|
throw new Exception("Monograph is too big. Max allowed size is 15mb.");
|
||||||
|
|
||||||
|
monograph.Deleted = false;
|
||||||
|
monograph.ViewCount = 0;
|
||||||
|
|
||||||
|
return monograph;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TrackViewAsync(Monograph monograph, string cookieName, string cookiePath)
|
||||||
|
{
|
||||||
|
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
|
||||||
|
|
||||||
|
if (monograph.SelfDestruct)
|
||||||
|
{
|
||||||
|
await monographs.Collection.ReplaceOneAsync(
|
||||||
|
CreateMonographFilter(monograph.UserId!, monograph),
|
||||||
|
new Monograph
|
||||||
|
{
|
||||||
|
ItemId = monograph.ItemId,
|
||||||
|
Id = monograph.Id,
|
||||||
|
Deleted = true,
|
||||||
|
UserId = monograph.UserId,
|
||||||
|
ViewCount = 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await MarkMonographForSyncAsync(monograph.UserId!, monograph.ItemId ?? monograph.Id);
|
||||||
|
}
|
||||||
|
else if (!hasVisitedBefore)
|
||||||
|
{
|
||||||
|
await monographs.Collection.UpdateOneAsync(
|
||||||
|
CreateMonographFilter(monograph.UserId!, monograph),
|
||||||
|
Builders<Monograph>.Update.Inc(m => m.ViewCount, 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
var cookieOptions = new CookieOptions
|
||||||
|
{
|
||||||
|
Path = cookiePath,
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = Request.IsHttps,
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddMonths(1)
|
||||||
|
};
|
||||||
|
Response.Cookies.Append(cookieName, "1", cookieOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Monograph> ProcessMonographAsync(Monograph monograph)
|
||||||
|
{
|
||||||
|
|
||||||
|
if (monograph.EncryptedContent == null)
|
||||||
|
{
|
||||||
|
var isContentUnsanitized = monograph.ContentSanitizationLevel == ContentSanitizationLevel.Partial || monograph.ContentSanitizationLevel == ContentSanitizationLevel.Unknown;
|
||||||
|
if (!Constants.IS_SELF_HOSTED && isContentUnsanitized && serviceAccessor.UserSubscriptionService != null && !await serviceAccessor.UserSubscriptionService.IsUserSubscribedAsync(Clients.Notesnook.Id, monograph.UserId!))
|
||||||
|
{
|
||||||
|
var cleaned = await SanitizeContentAsync(monograph.CompressedContent?.DecompressBrotli(), ContentSanitizationLevel.Full);
|
||||||
|
monograph.CompressedContent = cleaned.CompressBrotli();
|
||||||
|
await monographs.Collection.UpdateOneAsync(
|
||||||
|
CreateMonographFilter(monograph.UserId!, monograph),
|
||||||
|
Builders<Monograph>.Update
|
||||||
|
.Set(m => m.CompressedContent, monograph.CompressedContent)
|
||||||
|
.Set(m => m.ContentSanitizationLevel, ContentSanitizationLevel.Full)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
monograph.Content = monograph.CompressedContent?.DecompressBrotli();
|
||||||
|
}
|
||||||
|
|
||||||
|
monograph.ItemId ??= monograph.Id;
|
||||||
|
return monograph;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
|
private async Task<string> SanitizeContentAsync(string? content, ContentSanitizationLevel level)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(content)) return string.Empty;
|
if (string.IsNullOrEmpty(content)) return string.Empty;
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ namespace Notesnook.API.Controllers
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = this.User.GetUserId();
|
var userId = this.User.GetUserId();
|
||||||
var size = await s3Service.GetObjectSizeAsync(userId, name);
|
var size = await s3Service.GetObjectSizeAsync(userId, name); Response.Headers.ContentLength = size;
|
||||||
HttpContext.Response.Headers.ContentLength = size;
|
Response.Headers["X-Object-Size"] = size.ToString();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -210,25 +210,25 @@ namespace Notesnook.API.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("bulk-delete")]
|
// [HttpPost("bulk-delete")]
|
||||||
public async Task<IActionResult> DeleteBulkAsync([FromBody] DeleteBulkObjectsRequest request)
|
// public async Task<IActionResult> DeleteBulkAsync([FromBody] DeleteBulkObjectsRequest request)
|
||||||
{
|
// {
|
||||||
try
|
// try
|
||||||
{
|
// {
|
||||||
if (request.Names == null || request.Names.Length == 0)
|
// if (request.Names == null || request.Names.Length == 0)
|
||||||
{
|
// {
|
||||||
return BadRequest(new { error = "No files specified for deletion." });
|
// return BadRequest(new { error = "No files specified for deletion." });
|
||||||
}
|
// }
|
||||||
|
|
||||||
var userId = this.User.GetUserId();
|
// var userId = this.User.GetUserId();
|
||||||
await s3Service.DeleteObjectsAsync(userId, request.Names);
|
// await s3Service.DeleteObjectsAsync(userId, request.Names);
|
||||||
return Ok();
|
// return Ok();
|
||||||
}
|
// }
|
||||||
catch (Exception ex)
|
// catch (Exception ex)
|
||||||
{
|
// {
|
||||||
logger.LogError(ex, "Error deleting objects for user.");
|
// logger.LogError(ex, "Error deleting objects for user.");
|
||||||
return BadRequest(new { error = "Failed to delete attachments." });
|
// return BadRequest(new { error = "Failed to delete attachments." });
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
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 Notesnook.API.Models;
|
||||||
|
using Streetwriters.Common;
|
||||||
|
|
||||||
|
namespace Notesnook.API.Helpers
|
||||||
|
{
|
||||||
|
public class UrlHelper
|
||||||
|
{
|
||||||
|
public static string ConstructPublishUrl(string slug)
|
||||||
|
{
|
||||||
|
var baseUrl = Constants.MONOGRAPH_PUBLIC_URL;
|
||||||
|
return $"{baseUrl}/{slug}";
|
||||||
|
}
|
||||||
|
public static string ConstructPublishUrl(Monograph monograph)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(monograph.Slug))
|
||||||
|
{
|
||||||
|
return ConstructPublishUrl("s/" + monograph.Slug);
|
||||||
|
}
|
||||||
|
return ConstructPublishUrl(monograph.ItemId ?? monograph.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ConstructPublishUrl(MonographMetadata metadata)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(metadata.PublishUrl))
|
||||||
|
{
|
||||||
|
return ConstructPublishUrl("s/" + metadata.PublishUrl);
|
||||||
|
}
|
||||||
|
return ConstructPublishUrl(metadata.PublishUrl ?? metadata.ItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,8 @@ using Microsoft.AspNetCore.SignalR;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using Notesnook.API.Authorization;
|
using Notesnook.API.Authorization;
|
||||||
|
using Notesnook.API.Extensions;
|
||||||
|
using Notesnook.API.Helpers;
|
||||||
using Notesnook.API.Interfaces;
|
using Notesnook.API.Interfaces;
|
||||||
using Notesnook.API.Models;
|
using Notesnook.API.Models;
|
||||||
using Notesnook.API.Services;
|
using Notesnook.API.Services;
|
||||||
@@ -55,21 +57,9 @@ namespace Notesnook.API.Hubs
|
|||||||
private ISyncItemsRepositoryAccessor Repositories { get; }
|
private ISyncItemsRepositoryAccessor Repositories { get; }
|
||||||
private SyncDeviceService SyncDeviceService { get; }
|
private SyncDeviceService SyncDeviceService { get; }
|
||||||
private readonly IUnitOfWork unit;
|
private readonly IUnitOfWork unit;
|
||||||
private static readonly string[] CollectionKeys = [
|
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string>> UpsertActionsMap;
|
||||||
"settingitem",
|
private readonly CollectionDef[] BaseCollectionDefs;
|
||||||
"attachment",
|
private readonly CollectionDef[] V4CollectionDefs;
|
||||||
"note",
|
|
||||||
"notebook",
|
|
||||||
"content",
|
|
||||||
"shortcut",
|
|
||||||
"reminder",
|
|
||||||
"color",
|
|
||||||
"tag",
|
|
||||||
"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, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>>[] Collections;
|
|
||||||
ILogger<SyncV2Hub> Logger { get; }
|
ILogger<SyncV2Hub> Logger { get; }
|
||||||
|
|
||||||
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger)
|
public SyncV2Hub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork, SyncDeviceService syncDeviceService, ILogger<SyncV2Hub> logger)
|
||||||
@@ -79,20 +69,34 @@ namespace Notesnook.API.Hubs
|
|||||||
unit = unitOfWork;
|
unit = unitOfWork;
|
||||||
SyncDeviceService = syncDeviceService;
|
SyncDeviceService = syncDeviceService;
|
||||||
|
|
||||||
Collections = [
|
BaseCollectionDefs = [
|
||||||
Repositories.Settings.FindItemsById,
|
new("settingitem", Repositories.Settings.FindItemsById),
|
||||||
Repositories.Attachments.FindItemsById,
|
new("attachment", Repositories.Attachments.FindItemsById),
|
||||||
Repositories.Notes.FindItemsById,
|
new("note", Repositories.Notes.FindItemsById),
|
||||||
Repositories.Notebooks.FindItemsById,
|
new("notebook", Repositories.Notebooks.FindItemsById),
|
||||||
Repositories.Contents.FindItemsById,
|
new("content", Repositories.Contents.FindItemsById),
|
||||||
Repositories.Shortcuts.FindItemsById,
|
new("shortcut", Repositories.Shortcuts.FindItemsById),
|
||||||
Repositories.Reminders.FindItemsById,
|
new("reminder", Repositories.Reminders.FindItemsById),
|
||||||
Repositories.Colors.FindItemsById,
|
new("color", Repositories.Colors.FindItemsById),
|
||||||
Repositories.Tags.FindItemsById,
|
new("tag", Repositories.Tags.FindItemsById),
|
||||||
Repositories.Vaults.FindItemsById,
|
new("vault", Repositories.Vaults.FindItemsById),
|
||||||
Repositories.Relations.FindItemsById,
|
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
|
||||||
];
|
];
|
||||||
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
|
V4CollectionDefs = [
|
||||||
|
new("settingitem", Repositories.Settings.FindItemsById),
|
||||||
|
new("attachment", Repositories.Attachments.FindItemsById),
|
||||||
|
new("note", Repositories.Notes.FindItemsById),
|
||||||
|
new("notebook", Repositories.Notebooks.FindItemsById),
|
||||||
|
new("content", Repositories.Contents.FindItemsById),
|
||||||
|
new("shortcut", Repositories.Shortcuts.FindItemsById),
|
||||||
|
new("reminder", Repositories.Reminders.FindItemsById),
|
||||||
|
new("color", Repositories.Colors.FindItemsById),
|
||||||
|
new("tag", Repositories.Tags.FindItemsById),
|
||||||
|
new("vault", Repositories.Vaults.FindItemsById),
|
||||||
|
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
|
||||||
|
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
|
||||||
|
];
|
||||||
|
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string>> {
|
||||||
{ "settingitem", Repositories.Settings.UpsertMany },
|
{ "settingitem", Repositories.Settings.UpsertMany },
|
||||||
{ "attachment", Repositories.Attachments.UpsertMany },
|
{ "attachment", Repositories.Attachments.UpsertMany },
|
||||||
{ "note", Repositories.Notes.UpsertMany },
|
{ "note", Repositories.Notes.UpsertMany },
|
||||||
@@ -104,6 +108,7 @@ namespace Notesnook.API.Hubs
|
|||||||
{ "color", Repositories.Colors.UpsertMany },
|
{ "color", Repositories.Colors.UpsertMany },
|
||||||
{ "vault", Repositories.Vaults.UpsertMany },
|
{ "vault", Repositories.Vaults.UpsertMany },
|
||||||
{ "tag", Repositories.Tags.UpsertMany },
|
{ "tag", Repositories.Tags.UpsertMany },
|
||||||
|
{ "inboxitemhistory", Repositories.InboxItemsHistory.UpsertMany },
|
||||||
}.ToFrozenDictionary();
|
}.ToFrozenDictionary();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +125,19 @@ namespace Notesnook.API.Hubs
|
|||||||
await base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
if (exception != null)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(exception, "Connection {ConnectionId} disconnected with error (server-side drop)", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Connection {ConnectionId} disconnected cleanly (client-initiated)", Context.ConnectionId);
|
||||||
|
}
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<int> PushItems(string deviceId, SyncTransferItemV2 pushItem)
|
public async Task<int> PushItems(string deviceId, SyncTransferItemV2 pushItem)
|
||||||
{
|
{
|
||||||
@@ -130,13 +148,19 @@ namespace Notesnook.API.Hubs
|
|||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
||||||
var UpsertItems = UpsertActionsMap[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);
|
UpsertItems(pushItem.Items, userId);
|
||||||
|
|
||||||
if (!await unit.Commit()) return 0;
|
if (!await unit.Commit()) return 0;
|
||||||
|
|
||||||
await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type)));
|
await SyncDeviceService.AddIdsToOtherDevicesAsync(userId, deviceId, pushItem.Items.Select((i) => new ItemKey(i.ItemId, pushItem.Type)));
|
||||||
|
|
||||||
|
// we need to delete the inbox items from the inbox collection
|
||||||
|
// after syncing to prevent them from being sent again in the
|
||||||
|
// next fetch.
|
||||||
|
var itemIds = pushItem.Items.Select(i => i.ItemId).ToList();
|
||||||
|
await Repositories.InboxItems.DeleteManyAsync(i => i.UserId == userId && itemIds.Contains(i.ItemId));
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -160,17 +184,17 @@ namespace Notesnook.API.Hubs
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes)
|
private async IAsyncEnumerable<SyncTransferItemV2> PrepareChunks(string userId, HashSet<ItemKey> ids, int size, bool resetSync, long maxBytes, CollectionDef[] collectionDefs)
|
||||||
{
|
{
|
||||||
var itemsProcessed = 0;
|
var itemsProcessed = 0;
|
||||||
for (int i = 0; i < Collections.Length; i++)
|
foreach (var def in collectionDefs)
|
||||||
{
|
{
|
||||||
var type = CollectionKeys[i];
|
var type = def.Key;
|
||||||
|
|
||||||
var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray();
|
var filteredIds = ids.Where((id) => id.Type == type).Select((id) => id.ItemId).ToArray();
|
||||||
if (!resetSync && filteredIds.Length == 0) continue;
|
if (!resetSync && filteredIds.Length == 0) continue;
|
||||||
|
|
||||||
using var cursor = await Collections[i](userId, filteredIds, resetSync, size);
|
using var cursor = await def.FindItems(userId, filteredIds, resetSync, size);
|
||||||
|
|
||||||
var chunk = new List<SyncItem>();
|
var chunk = new List<SyncItem>();
|
||||||
long totalBytes = 0;
|
long totalBytes = 0;
|
||||||
@@ -212,20 +236,25 @@ namespace Notesnook.API.Hubs
|
|||||||
|
|
||||||
public async Task<SyncV2Metadata> RequestFetch(string deviceId)
|
public async Task<SyncV2Metadata> RequestFetch(string deviceId)
|
||||||
{
|
{
|
||||||
return await HandleRequestFetch(deviceId, false, false);
|
return await HandleRequestFetch(deviceId, false, false, BaseCollectionDefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SyncV2Metadata> RequestFetchV2(string deviceId)
|
public async Task<SyncV2Metadata> RequestFetchV2(string deviceId)
|
||||||
{
|
{
|
||||||
return await HandleRequestFetch(deviceId, true, false);
|
return await HandleRequestFetch(deviceId, true, false, BaseCollectionDefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SyncV2Metadata> RequestFetchV3(string deviceId)
|
public async Task<SyncV2Metadata> RequestFetchV3(string deviceId)
|
||||||
{
|
{
|
||||||
return await HandleRequestFetch(deviceId, true, true);
|
return await HandleRequestFetch(deviceId, true, true, BaseCollectionDefs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems)
|
public async Task<SyncV2Metadata> RequestFetchV4(string deviceId)
|
||||||
|
{
|
||||||
|
return await HandleRequestFetch(deviceId, true, true, V4CollectionDefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SyncV2Metadata> HandleRequestFetch(string deviceId, bool includeMonographs, bool includeInboxItems, CollectionDef[] collectionDefs)
|
||||||
{
|
{
|
||||||
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
|
var userId = Context.User?.FindFirstValue("sub") ?? throw new HubException("Please login to sync.");
|
||||||
|
|
||||||
@@ -249,7 +278,8 @@ namespace Notesnook.API.Hubs
|
|||||||
ids,
|
ids,
|
||||||
size: 100,
|
size: 100,
|
||||||
resetSync: device.IsSyncReset,
|
resetSync: device.IsSyncReset,
|
||||||
maxBytes: 7 * 1024 * 1024
|
maxBytes: 3 * 1024 * 1024,
|
||||||
|
collectionDefs
|
||||||
);
|
);
|
||||||
|
|
||||||
await foreach (var chunk in chunks)
|
await foreach (var chunk in chunks)
|
||||||
@@ -275,15 +305,25 @@ namespace Notesnook.API.Hubs
|
|||||||
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds)
|
Builders<Monograph>.Filter.In("_id", unsyncedMonographIds)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
var userMonographs = await Repositories.Monographs.Collection.Find(filter).Project((m) => new MonographMetadata
|
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(),
|
||||||
|
PublishUrl = m.Slug // this will be converted to full url in the end, but we only need slug for now
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
userMonographs = userMonographs.Select((p) =>
|
||||||
{
|
{
|
||||||
DatePublished = m.DatePublished,
|
p.PublishUrl = UrlHelper.ConstructPublishUrl(p);
|
||||||
Deleted = m.Deleted,
|
return p;
|
||||||
Password = m.Password,
|
}).ToList();
|
||||||
SelfDestruct = m.SelfDestruct,
|
|
||||||
Title = m.Title,
|
|
||||||
ItemId = m.ItemId ?? m.Id.ToString()
|
|
||||||
}).ToListAsync();
|
|
||||||
|
|
||||||
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
|
if (userMonographs.Count > 0 && !await Clients.Caller.SendMonographs(userMonographs).WaitAsync(TimeSpan.FromMinutes(10)))
|
||||||
throw new HubException("Client rejected monographs.");
|
throw new HubException("Client rejected monographs.");
|
||||||
@@ -294,7 +334,7 @@ namespace Notesnook.API.Hubs
|
|||||||
var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId);
|
var unsyncedInboxItemIds = ids.Where(k => k.Type == "inbox_item").Select(k => k.ItemId);
|
||||||
var userInboxItems = device.IsSyncReset
|
var userInboxItems = device.IsSyncReset
|
||||||
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
|
? await Repositories.InboxItems.FindAsync(m => m.UserId == userId)
|
||||||
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId ?? m.Id.ToString()));
|
: await Repositories.InboxItems.FindAsync(m => m.UserId == userId && unsyncedInboxItemIds.Contains(m.ItemId));
|
||||||
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
|
if (userInboxItems.Any() && !await Clients.Caller.SendInboxItems(userInboxItems).WaitAsync(TimeSpan.FromMinutes(10)))
|
||||||
{
|
{
|
||||||
throw new HubException("Client rejected inbox items.");
|
throw new HubException("Client rejected inbox items.");
|
||||||
@@ -313,6 +353,11 @@ namespace Notesnook.API.Hubs
|
|||||||
SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds);
|
SyncEventCounterSource.Log.RecordFetchDuration(stopwatch.ElapsedMilliseconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record CollectionDef(
|
||||||
|
string Key,
|
||||||
|
Func<string, IEnumerable<string>, bool, int, Task<IAsyncCursor<SyncItem>>> FindItems
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[MessagePack.MessagePackObject]
|
[MessagePack.MessagePackObject]
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace Notesnook.API.Interfaces
|
|||||||
SyncItemsRepository Colors { get; }
|
SyncItemsRepository Colors { get; }
|
||||||
SyncItemsRepository Vaults { get; }
|
SyncItemsRepository Vaults { get; }
|
||||||
SyncItemsRepository Tags { get; }
|
SyncItemsRepository Tags { get; }
|
||||||
|
SyncItemsRepository InboxItemsHistory { get; }
|
||||||
Repository<UserSettings> UsersSettings { get; }
|
Repository<UserSettings> UsersSettings { get; }
|
||||||
Repository<Monograph> Monographs { get; }
|
Repository<Monograph> Monographs { get; }
|
||||||
Repository<InboxApiKey> InboxApiKey { get; }
|
Repository<InboxApiKey> InboxApiKey { get; }
|
||||||
|
|||||||
@@ -22,6 +22,5 @@ namespace Notesnook.API.Models
|
|||||||
public class Algorithms
|
public class Algorithms
|
||||||
{
|
{
|
||||||
public static string Default => "xcha-argon2i13-7";
|
public static string Default => "xcha-argon2i13-7";
|
||||||
public static string XSAL_X25519_7 => "xsal-x25519-7";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,15 @@ using NanoidDotNet;
|
|||||||
|
|
||||||
namespace Notesnook.API.Models
|
namespace Notesnook.API.Models
|
||||||
{
|
{
|
||||||
|
public class CreateInboxApiKeyRequest
|
||||||
|
{
|
||||||
|
[JsonPropertyName("name")]
|
||||||
|
public required string Name { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expiryDate")]
|
||||||
|
public long ExpiryDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class InboxApiKey
|
public class InboxApiKey
|
||||||
{
|
{
|
||||||
public InboxApiKey()
|
public InboxApiKey()
|
||||||
|
|||||||
@@ -20,46 +20,64 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Runtime.Serialization;
|
using System.Runtime.Serialization;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using MongoDB.Bson;
|
||||||
|
using MongoDB.Bson.Serialization.Attributes;
|
||||||
|
|
||||||
namespace Notesnook.API.Models
|
namespace Notesnook.API.Models
|
||||||
{
|
{
|
||||||
|
|
||||||
[MessagePack.MessagePackObject]
|
[MessagePack.MessagePackObject]
|
||||||
public class InboxSyncItem : SyncItem
|
public class InboxSyncItem
|
||||||
{
|
{
|
||||||
[DataMember(Name = "key")]
|
|
||||||
[JsonPropertyName("key")]
|
|
||||||
[MessagePack.Key("key")]
|
|
||||||
[Required]
|
|
||||||
public required EncryptedKey Key { get; set; }
|
|
||||||
|
|
||||||
[DataMember(Name = "salt")]
|
|
||||||
[JsonPropertyName("salt")]
|
|
||||||
[MessagePack.Key("salt")]
|
|
||||||
[Required]
|
|
||||||
public required string Salt { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[MessagePack.MessagePackObject]
|
|
||||||
public class EncryptedKey
|
|
||||||
{
|
|
||||||
[DataMember(Name = "alg")]
|
|
||||||
[JsonPropertyName("alg")]
|
|
||||||
[MessagePack.Key("alg")]
|
|
||||||
[Required]
|
|
||||||
public required string Algorithm { get; set; }
|
|
||||||
|
|
||||||
[DataMember(Name = "cipher")]
|
[DataMember(Name = "cipher")]
|
||||||
[JsonPropertyName("cipher")]
|
[JsonPropertyName("cipher")]
|
||||||
[MessagePack.Key("cipher")]
|
[MessagePack.Key("cipher")]
|
||||||
[Required]
|
[Required]
|
||||||
public required string Cipher { get; set; }
|
public string Cipher
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
[JsonPropertyName("length")]
|
[DataMember(Name = "userId")]
|
||||||
[DataMember(Name = "length")]
|
[JsonPropertyName("userId")]
|
||||||
[MessagePack.Key("length")]
|
[MessagePack.Key("userId")]
|
||||||
|
public string? UserId
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DataMember(Name = "id")]
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
[MessagePack.Key("id")]
|
||||||
|
public string? ItemId
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[BsonId]
|
||||||
|
[BsonIgnoreIfDefault]
|
||||||
|
[BsonRepresentation(BsonType.ObjectId)]
|
||||||
|
[JsonIgnore]
|
||||||
|
[MessagePack.IgnoreMember]
|
||||||
|
public ObjectId Id
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("v")]
|
||||||
|
[DataMember(Name = "v")]
|
||||||
|
[MessagePack.Key("v")]
|
||||||
[Required]
|
[Required]
|
||||||
public long Length
|
public double Version
|
||||||
|
{
|
||||||
|
get; set;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonPropertyName("alg")]
|
||||||
|
[DataMember(Name = "alg")]
|
||||||
|
[MessagePack.Key("alg")]
|
||||||
|
[Required]
|
||||||
|
public string Algorithm
|
||||||
{
|
{
|
||||||
get; set;
|
get; set;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ namespace Notesnook.API.Models
|
|||||||
[JsonPropertyName("title")]
|
[JsonPropertyName("title")]
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("slug")]
|
||||||
|
public string? Slug { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("userId")]
|
[JsonPropertyName("userId")]
|
||||||
public string? UserId { get; set; }
|
public string? UserId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
|
|
||||||
using System.Runtime.Serialization;
|
using System.Runtime.Serialization;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using MongoDB.Bson;
|
|
||||||
using MongoDB.Bson.Serialization.Attributes;
|
|
||||||
|
|
||||||
namespace Notesnook.API.Models
|
namespace Notesnook.API.Models
|
||||||
{
|
{
|
||||||
@@ -37,6 +35,9 @@ namespace Notesnook.API.Models
|
|||||||
[JsonPropertyName("title")]
|
[JsonPropertyName("title")]
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("publishUrl")]
|
||||||
|
public string? PublishUrl { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("selfDestruct")]
|
[JsonPropertyName("selfDestruct")]
|
||||||
public bool SelfDestruct { get; set; }
|
public bool SelfDestruct { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Aws.S3" Version="9.0.0" />
|
<PackageReference Include="AspNetCore.HealthChecks.Aws.S3" Version="9.0.0" />
|
||||||
|
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="9.0.0" />
|
||||||
<PackageReference Include="AWSSDK.Core" Version="3.7.304.31" />
|
<PackageReference Include="AWSSDK.Core" Version="3.7.304.31" />
|
||||||
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
||||||
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
|
<PackageReference Include="IdentityModel.AspNetCore.OAuth2Introspection" Version="6.2.0" />
|
||||||
|
|||||||
@@ -49,30 +49,12 @@ namespace Notesnook.API.Repositories
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly List<string> ALGORITHMS = [Algorithms.Default, Algorithms.XSAL_X25519_7];
|
private readonly List<string> ALGORITHMS = [Algorithms.Default];
|
||||||
private bool IsValidAlgorithm(string algorithm)
|
private bool IsValidAlgorithm(string algorithm)
|
||||||
{
|
{
|
||||||
return ALGORITHMS.Contains(algorithm);
|
return ALGORITHMS.Contains(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<long> CountItemsSyncedAfterAsync(string userId, long timestamp)
|
|
||||||
{
|
|
||||||
var filter = Builders<SyncItem>.Filter.And(Builders<SyncItem>.Filter.Gt("DateSynced", timestamp), Builders<SyncItem>.Filter.Eq("UserId", userId));
|
|
||||||
return Collection.CountDocumentsAsync(filter);
|
|
||||||
}
|
|
||||||
public Task<IAsyncCursor<SyncItem>> FindItemsSyncedAfter(string userId, long timestamp, int batchSize)
|
|
||||||
{
|
|
||||||
var filter = Builders<SyncItem>.Filter.And(Builders<SyncItem>.Filter.Gt("DateSynced", timestamp), Builders<SyncItem>.Filter.Eq("UserId", userId));
|
|
||||||
return Collection.FindAsync(filter, new FindOptions<SyncItem>
|
|
||||||
{
|
|
||||||
BatchSize = batchSize,
|
|
||||||
AllowDiskUse = true,
|
|
||||||
AllowPartialResults = false,
|
|
||||||
NoCursorTimeout = true,
|
|
||||||
Sort = new SortDefinitionBuilder<SyncItem>().Ascending("_id")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<IAsyncCursor<SyncItem>> FindItemsById(string userId, IEnumerable<string> ids, bool all, int batchSize)
|
public Task<IAsyncCursor<SyncItem>> FindItemsById(string userId, IEnumerable<string> ids, bool all, int batchSize)
|
||||||
{
|
{
|
||||||
var filters = new List<FilterDefinition<SyncItem>>(new[] { Builders<SyncItem>.Filter.Eq("UserId", userId) });
|
var filters = new List<FilterDefinition<SyncItem>>(new[] { Builders<SyncItem>.Filter.Eq("UserId", userId) });
|
||||||
@@ -94,41 +76,7 @@ namespace Notesnook.API.Repositories
|
|||||||
dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filter, null, ct));
|
dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filter, null, ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Upsert(SyncItem item, string userId, long dateSynced)
|
public void UpsertMany(IEnumerable<SyncItem> items, string userId)
|
||||||
{
|
|
||||||
if (item.Length > 15 * 1024 * 1024)
|
|
||||||
{
|
|
||||||
throw new Exception($"Size of item \"{item.ItemId}\" is too large. Maximum allowed size is 15 MB.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!IsValidAlgorithm(item.Algorithm))
|
|
||||||
{
|
|
||||||
throw new Exception($"Invalid alg identifier {item.Algorithm}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle case where the cipher is corrupted.
|
|
||||||
if (!IsBase64String(item.Cipher))
|
|
||||||
{
|
|
||||||
logger.LogError("Corrupted item {ItemId} in collection {CollectionName}. Length: {Length}, Cipher: {Cipher}",
|
|
||||||
item.ItemId, this.collectionName, item.Length, item.Cipher);
|
|
||||||
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;
|
|
||||||
|
|
||||||
var filter = Builders<SyncItem>.Filter.And(
|
|
||||||
Builders<SyncItem>.Filter.Eq("UserId", userId),
|
|
||||||
Builders<SyncItem>.Filter.Eq("ItemId", item.ItemId)
|
|
||||||
);
|
|
||||||
|
|
||||||
dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, filter, item, new ReplaceOptions { IsUpsert = true }, ct));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpsertMany(IEnumerable<SyncItem> items, string userId, long dateSynced)
|
|
||||||
{
|
{
|
||||||
var userIdFilter = Builders<SyncItem>.Filter.Eq("UserId", userId);
|
var userIdFilter = Builders<SyncItem>.Filter.Eq("UserId", userId);
|
||||||
var writes = new List<WriteModel<SyncItem>>();
|
var writes = new List<WriteModel<SyncItem>>();
|
||||||
@@ -160,7 +108,7 @@ namespace Notesnook.API.Repositories
|
|||||||
Builders<SyncItem>.Filter.Eq("ItemId", item.ItemId)
|
Builders<SyncItem>.Filter.Eq("ItemId", item.ItemId)
|
||||||
);
|
);
|
||||||
|
|
||||||
item.DateSynced = dateSynced;
|
item.DateSynced = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
item.UserId = userId;
|
item.UserId = userId;
|
||||||
|
|
||||||
writes.Add(new ReplaceOneModel<SyncItem>(filter, item)
|
writes.Add(new ReplaceOneModel<SyncItem>(filter, item)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ namespace Notesnook.API.Services
|
|||||||
if (chunk != null)
|
if (chunk != null)
|
||||||
{
|
{
|
||||||
var update = Builders<DeviceIdsChunk>.Update.AddToSetEach(x => x.Ids, ids.Select(i => i.ToString()));
|
var update = Builders<DeviceIdsChunk>.Update.AddToSetEach(x => x.Ids, ids.Select(i => i.ToString()));
|
||||||
await repositories.DeviceIdsChunks.Collection.UpdateOneAsync(
|
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).UpdateOneAsync(
|
||||||
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
|
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
|
||||||
update
|
update
|
||||||
);
|
);
|
||||||
@@ -96,11 +96,11 @@ namespace Notesnook.API.Services
|
|||||||
Key = key,
|
Key = key,
|
||||||
Ids = [.. ids.Select(i => i.ToString())]
|
Ids = [.. ids.Select(i => i.ToString())]
|
||||||
};
|
};
|
||||||
await repositories.DeviceIdsChunks.Collection.InsertOneAsync(newChunk);
|
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).InsertOneAsync(newChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
var emptyChunksFilter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Size(x => x.Ids, 0);
|
var emptyChunksFilter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Size(x => x.Ids, 0);
|
||||||
await repositories.DeviceIdsChunks.Collection.DeleteManyAsync(emptyChunksFilter);
|
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).DeleteManyAsync(emptyChunksFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task WriteIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
|
public async Task WriteIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
|
||||||
@@ -121,7 +121,7 @@ namespace Notesnook.API.Services
|
|||||||
};
|
};
|
||||||
writes.Add(new InsertOneModel<DeviceIdsChunk>(newChunk));
|
writes.Add(new InsertOneModel<DeviceIdsChunk>(newChunk));
|
||||||
}
|
}
|
||||||
await repositories.DeviceIdsChunks.Collection.BulkWriteAsync(writes);
|
await repositories.DeviceIdsChunks.Collection.WithWriteConcern(WriteConcern.W1).BulkWriteAsync(writes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<HashSet<ItemKey>> FetchUnsyncedIdsAsync(string userId, string deviceId)
|
public async Task<HashSet<ItemKey>> FetchUnsyncedIdsAsync(string userId, string deviceId)
|
||||||
|
|||||||
@@ -174,16 +174,19 @@ namespace Notesnook.API.Services
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
userSettings.InboxKeys = keys.InboxKeys;
|
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.InboxItems.DeleteManyAsync(t => t.UserId == userId);
|
||||||
|
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||||
|
{
|
||||||
|
OriginTokenId = null,
|
||||||
|
UserId = userId,
|
||||||
|
Message = new Message
|
||||||
|
{
|
||||||
|
Type = "inboxUpdated",
|
||||||
|
Data = JsonSerializer.Serialize(new { reason = "Inbox PGP keys added, updated, or removed." })
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
|
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
|
||||||
@@ -206,6 +209,7 @@ namespace Notesnook.API.Services
|
|||||||
Repositories.Colors.DeleteByUserId(userId);
|
Repositories.Colors.DeleteByUserId(userId);
|
||||||
Repositories.Tags.DeleteByUserId(userId);
|
Repositories.Tags.DeleteByUserId(userId);
|
||||||
Repositories.Vaults.DeleteByUserId(userId);
|
Repositories.Vaults.DeleteByUserId(userId);
|
||||||
|
Repositories.InboxItemsHistory.DeleteByUserId(userId);
|
||||||
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
|
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
|
||||||
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
||||||
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
||||||
@@ -267,6 +271,7 @@ namespace Notesnook.API.Services
|
|||||||
Repositories.Colors.DeleteByUserId(userId);
|
Repositories.Colors.DeleteByUserId(userId);
|
||||||
Repositories.Tags.DeleteByUserId(userId);
|
Repositories.Tags.DeleteByUserId(userId);
|
||||||
Repositories.Vaults.DeleteByUserId(userId);
|
Repositories.Vaults.DeleteByUserId(userId);
|
||||||
|
Repositories.InboxItemsHistory.DeleteByUserId(userId);
|
||||||
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
|
||||||
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
Repositories.InboxApiKey.DeleteMany((t) => t.UserId == userId);
|
||||||
if (!await unit.Commit()) return false;
|
if (!await unit.Commit()) return false;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ using System.Text;
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Amazon.Runtime;
|
using Amazon.Runtime;
|
||||||
|
using StackExchange.Redis;
|
||||||
using IdentityModel.AspNetCore.OAuth2Introspection;
|
using IdentityModel.AspNetCore.OAuth2Introspection;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
@@ -196,7 +197,8 @@ namespace Notesnook.API
|
|||||||
.AddMongoCollection(Collections.ColorsKey)
|
.AddMongoCollection(Collections.ColorsKey)
|
||||||
.AddMongoCollection(Collections.VaultsKey)
|
.AddMongoCollection(Collections.VaultsKey)
|
||||||
.AddMongoCollection(Collections.InboxItemsKey)
|
.AddMongoCollection(Collections.InboxItemsKey)
|
||||||
.AddMongoCollection(Collections.InboxApiKeysKey);
|
.AddMongoCollection(Collections.InboxApiKeysKey)
|
||||||
|
.AddMongoCollection(Collections.InboxItemsHistoryKey);
|
||||||
|
|
||||||
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
|
services.AddScoped<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
|
||||||
services.AddScoped<SyncDeviceService>();
|
services.AddScoped<SyncDeviceService>();
|
||||||
@@ -213,12 +215,26 @@ namespace Notesnook.API
|
|||||||
var signalR = services.AddSignalR((hub) =>
|
var signalR = services.AddSignalR((hub) =>
|
||||||
{
|
{
|
||||||
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
|
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
|
||||||
|
hub.KeepAliveInterval = TimeSpan.FromSeconds(15);
|
||||||
hub.ClientTimeoutInterval = TimeSpan.FromMinutes(10);
|
hub.ClientTimeoutInterval = TimeSpan.FromMinutes(10);
|
||||||
hub.EnableDetailedErrors = true;
|
hub.EnableDetailedErrors = true;
|
||||||
}).AddMessagePackProtocol().AddJsonProtocol();
|
}).AddMessagePackProtocol().AddJsonProtocol();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(Constants.SIGNALR_REDIS_CONNECTION_STRING))
|
if (!string.IsNullOrEmpty(Constants.SIGNALR_REDIS_CONNECTION_STRING))
|
||||||
signalR.AddStackExchangeRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING);
|
{
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddRedis(Constants.SIGNALR_REDIS_CONNECTION_STRING, tags: ["ready"]);
|
||||||
|
signalR.AddStackExchangeRedis(options =>
|
||||||
|
{
|
||||||
|
options.Configuration = ConfigurationOptions.Parse(Constants.SIGNALR_REDIS_CONNECTION_STRING);
|
||||||
|
options.Configuration.AbortOnConnectFail = false;
|
||||||
|
options.Configuration.ConnectRetry = 5;
|
||||||
|
options.Configuration.ReconnectRetryPolicy = new ExponentialRetry(5000, 30000);
|
||||||
|
options.Configuration.KeepAlive = 60;
|
||||||
|
options.Configuration.ConnectTimeout = 5000;
|
||||||
|
options.Configuration.SyncTimeout = 5000;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
services.AddResponseCompression(options =>
|
services.AddResponseCompression(options =>
|
||||||
{
|
{
|
||||||
@@ -270,6 +286,12 @@ namespace Notesnook.API
|
|||||||
app.UseOpenTelemetryPrometheusScrapingEndpoint((context) => context.Request.Path == "/metrics" && context.Connection.LocalPort == 5067);
|
app.UseOpenTelemetryPrometheusScrapingEndpoint((context) => context.Request.Path == "/metrics" && context.Connection.LocalPort == 5067);
|
||||||
app.UseResponseCompression();
|
app.UseResponseCompression();
|
||||||
|
|
||||||
|
app.UseWebSockets(new Microsoft.AspNetCore.Builder.WebSocketOptions
|
||||||
|
{
|
||||||
|
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||||
|
KeepAliveTimeout = TimeSpan.FromSeconds(60),
|
||||||
|
});
|
||||||
|
|
||||||
app.UseCors("notesnook");
|
app.UseCors("notesnook");
|
||||||
app.UseVersion(Servers.NotesnookAPI);
|
app.UseVersion(Servers.NotesnookAPI);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM oven/bun:1.2.21-slim
|
FROM oven/bun:1.3.5-slim
|
||||||
|
|
||||||
RUN mkdir -p /home/bun/app && chown -R bun:bun /home/bun/app
|
RUN mkdir -p /home/bun/app && chown -R bun:bun /home/bun/app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Notesnook Inbox API
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
- Bun (v1.3.0 or higher)
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
- `bun install` - Install dependencies
|
||||||
|
- `bun run dev` - Start the development server
|
||||||
|
- `bun run build` - Build the project for production
|
||||||
|
- `bun run start` - Start the production server
|
||||||
|
|
||||||
|
## Self-hosting
|
||||||
|
|
||||||
|
The easiest way to self-host is with Docker or Docker Compose.
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
|
||||||
|
- `docker` (Engine) installed
|
||||||
|
- `docker-compose` (optional, for multi-service setups)
|
||||||
|
|
||||||
|
Build and run with Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# build the image from the current folder
|
||||||
|
docker build -t notesnook-inbox-api .
|
||||||
|
|
||||||
|
# run the container (example)
|
||||||
|
docker run --rm -p 3000:3000 \
|
||||||
|
-e PORT=3000 \
|
||||||
|
-e NOTESNOOK_API_SERVER_URL="https://api.notesnook.com" \
|
||||||
|
notesnook-inbox-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Docker Compose (example):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
inbox-api:
|
||||||
|
image: notesnook-inbox-api
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
NOTESNOOK_API_SERVER_URL: "https://api.notesnook.com"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `PORT` — port the service listens on (default: `5181`)
|
||||||
|
- `NOTESNOOK_API_SERVER_URL` — base URL of the Notesnook API used to fetch public inbox keys
|
||||||
|
|
||||||
|
_If you prefer running without Docker, use `bun install` and `bun run start` with the environment variables set._
|
||||||
|
|
||||||
|
## Writing from scratch
|
||||||
|
|
||||||
|
The inbox API server is pretty simple to write from scratch in any programming language and/or framework. There's only one endpoint that needs to be implemented, which does these three steps:
|
||||||
|
|
||||||
|
1. Fetch the user's public inbox API key from the Notesnook API.
|
||||||
|
2. Encrypt the payload using `openpgp` or any other `openpgp` compatible library.
|
||||||
|
3. Post the encrypted payload to the Notesnook API.
|
||||||
|
|
||||||
|
You can refer to the [source code](./src/index.ts) for implementation details.
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^8.1.0",
|
"express-rate-limit": "^8.1.0",
|
||||||
"libsodium-wrappers-sumo": "^0.7.15",
|
"openpgp": "^6.2.2",
|
||||||
"zod": "^4.1.9",
|
"zod": "^4.1.9",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -116,10 +116,6 @@
|
|||||||
|
|
||||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
"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=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||||
@@ -140,6 +136,8 @@
|
|||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"openpgp": ["openpgp@6.2.2", "", {}, "sha512-P/dyEqQ3gfwOCo+xsqffzXjmUhGn4AZTOJ1LCcN21S23vAk+EAvMJOQTsb/C8krL6GjOSBxqGYckhik7+hneNw=="],
|
||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^8.1.0",
|
"express-rate-limit": "^8.1.0",
|
||||||
"libsodium-wrappers-sumo": "^0.7.15",
|
"openpgp": "^6.2.2",
|
||||||
"zod": "^4.1.9"
|
"zod": "^4.1.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
GNUPGHOME=$(mktemp -d)
|
||||||
|
|
||||||
|
curl -s http://localhost:5264/inbox/public-encryption-key -H "Authorization: $API_KEY" | jq -r .key > "$GNUPGHOME"/pubkey.asc && gpg --batch --homedir "$GNUPGHOME" --import "$GNUPGHOME"/pubkey.asc >/dev/null 2>&1 && KEYID=$(gpg --homedir "$GNUPGHOME" --list-keys --with-colons | awk -F: '/^pub:/ {print $5; exit}') && printf '%s' '{"title":"Test title CLIE S","type":"note","source":"cli","version":1}' | gpg --batch --homedir "$GNUPGHOME" --trust-model always --armor --encrypt -r "$KEYID" | jq -Rs --arg alg "pgp-aes256" '{v:1, cipher:., alg:$alg}' | curl -s -X POST http://localhost:5264/inbox/items -H "Content-Type: application/json" -H "Authorization: $API_KEY" -d @- && rm -rf "$GNUPGHOME"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const response = await fetch("http://localhost:5181/inbox", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: process.env.API_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: "This is test note 4",
|
||||||
|
type: "note",
|
||||||
|
source: "script",
|
||||||
|
version: 1,
|
||||||
|
content: {
|
||||||
|
type: "html",
|
||||||
|
data: "<p>This is test note content 3</p>",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
console.log(await response.text());
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import _sodium, { base64_variants } from "libsodium-wrappers-sumo";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { rateLimit } from "express-rate-limit";
|
import { rateLimit } from "express-rate-limit";
|
||||||
|
import * as openpgp from "openpgp";
|
||||||
|
|
||||||
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
|
const NOTESNOOK_API_SERVER_URL = process.env.NOTESNOOK_API_SERVER_URL;
|
||||||
if (!NOTESNOOK_API_SERVER_URL) {
|
if (!NOTESNOOK_API_SERVER_URL) {
|
||||||
throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
|
throw new Error("NOTESNOOK_API_SERVER_URL is not defined");
|
||||||
}
|
}
|
||||||
|
|
||||||
let sodium: typeof _sodium;
|
|
||||||
|
|
||||||
const RawInboxItemSchema = z.object({
|
const RawInboxItemSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required"),
|
title: z.string().min(1, "Title is required"),
|
||||||
pinned: z.boolean().optional(),
|
pinned: z.boolean().optional(),
|
||||||
@@ -19,7 +17,7 @@ const RawInboxItemSchema = z.object({
|
|||||||
notebookIds: z.array(z.string()).optional(),
|
notebookIds: z.array(z.string()).optional(),
|
||||||
tagIds: z.array(z.string()).optional(),
|
tagIds: z.array(z.string()).optional(),
|
||||||
type: z.enum(["note"]),
|
type: z.enum(["note"]),
|
||||||
source: z.string(),
|
source: z.string().min(1, "Source is required"),
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
content: z
|
content: z
|
||||||
.object({
|
.object({
|
||||||
@@ -31,86 +29,60 @@ const RawInboxItemSchema = z.object({
|
|||||||
|
|
||||||
interface EncryptedInboxItem {
|
interface EncryptedInboxItem {
|
||||||
v: 1;
|
v: 1;
|
||||||
key: Omit<EncryptedInboxItem, "key" | "iv" | "v" | "salt">;
|
|
||||||
iv: string;
|
|
||||||
alg: string;
|
|
||||||
cipher: string;
|
cipher: string;
|
||||||
length: number;
|
alg: string;
|
||||||
salt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encrypt(rawData: string, publicKey: string): EncryptedInboxItem {
|
/**
|
||||||
try {
|
* Encrypts raw data using OpenPGP with the recipient's public key
|
||||||
const password = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
|
*
|
||||||
const saltBytes = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
|
* @param {string} rawData - The plaintext data to encrypt
|
||||||
const key = sodium.crypto_pwhash(
|
* @param {string} rawPublicKey - The recipient's OpenPGP public key
|
||||||
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
|
*/
|
||||||
password,
|
async function encrypt(
|
||||||
saltBytes,
|
rawData: string,
|
||||||
3, // operations limit
|
rawPublicKey: string,
|
||||||
1024 * 1024 * 8, // memory limit (8MB)
|
): Promise<EncryptedInboxItem> {
|
||||||
sodium.crypto_pwhash_ALG_ARGON2I13
|
const publicKey = await openpgp.readKey({ armoredKey: rawPublicKey });
|
||||||
);
|
const message = await openpgp.createMessage({ text: rawData });
|
||||||
const nonce = sodium.randombytes_buf(
|
const encrypted = await openpgp.encrypt({
|
||||||
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
message,
|
||||||
);
|
encryptionKeys: publicKey,
|
||||||
const data = sodium.from_string(rawData);
|
});
|
||||||
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
return {
|
||||||
data,
|
v: 1,
|
||||||
null,
|
cipher: encrypted,
|
||||||
null,
|
alg: "pgp-aes256",
|
||||||
nonce,
|
};
|
||||||
key
|
|
||||||
);
|
|
||||||
const inboxPublicKey = sodium.from_base64(
|
|
||||||
publicKey,
|
|
||||||
base64_variants.URLSAFE_NO_PADDING
|
|
||||||
);
|
|
||||||
const encryptedKey = sodium.crypto_box_seal(key, inboxPublicKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
v: 1,
|
|
||||||
key: {
|
|
||||||
cipher: sodium.to_base64(
|
|
||||||
encryptedKey,
|
|
||||||
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,
|
|
||||||
salt: sodium.to_base64(saltBytes, base64_variants.URLSAFE_NO_PADDING),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`encryption failed: ${error}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getInboxPublicEncryptionKey(apiKey: string) {
|
async function getInboxPublicEncryptionKey(
|
||||||
|
apiKey: string,
|
||||||
|
): Promise<{ status: "unauthorized" } | { status: "ok"; key: string | null }> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
|
`${NOTESNOOK_API_SERVER_URL}/inbox/public-encryption-key`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: apiKey,
|
Authorization: apiKey,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
if (response.status === 401) {
|
||||||
|
return { status: "unauthorized" };
|
||||||
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`failed to fetch inbox public encryption key: ${await response.text()}`
|
`failed to fetch inbox public encryption key: ${await response.text()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as unknown as any;
|
const data = (await response.json()) as unknown as any;
|
||||||
return (data?.key as string) || null;
|
return { status: "ok", key: (data?.key as string) || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postEncryptedInboxItem(
|
async function postEncryptedInboxItem(
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
item: EncryptedInboxItem
|
item: EncryptedInboxItem,
|
||||||
) {
|
) {
|
||||||
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, {
|
const response = await fetch(`${NOTESNOOK_API_SERVER_URL}/inbox/items`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -131,19 +103,26 @@ app.use(
|
|||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: 1 * 60 * 1000, // 1 minute
|
windowMs: 1 * 60 * 1000, // 1 minute
|
||||||
limit: 60,
|
limit: 60,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
app.post("/inbox", async (req, res) => {
|
app.get("/health", (_, res) => {
|
||||||
|
return res.status(200).json({ status: "ok" });
|
||||||
|
});
|
||||||
|
app.post("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.headers["authorization"];
|
const apiKey = req.headers["authorization"];
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.status(401).json({ error: "unauthorized" });
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const inboxPublicKey = await getInboxPublicEncryptionKey(apiKey);
|
const encryptionKeyResult = await getInboxPublicEncryptionKey(apiKey);
|
||||||
if (!inboxPublicKey) {
|
if (encryptionKeyResult.status === "unauthorized") {
|
||||||
return res.status(403).json({ error: "inbox public key not found" });
|
return res.status(401).json({ error: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
if (!encryptionKeyResult.key) {
|
||||||
|
return res.status(404).json({ error: "inbox public key not found" });
|
||||||
|
}
|
||||||
|
const inboxPublicKey = encryptionKeyResult.key;
|
||||||
console.log("[info] fetched inbox public key");
|
console.log("[info] fetched inbox public key");
|
||||||
|
|
||||||
const validationResult = RawInboxItemSchema.safeParse(req.body);
|
const validationResult = RawInboxItemSchema.safeParse(req.body);
|
||||||
@@ -154,9 +133,9 @@ app.post("/inbox", async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const encryptedItem = encrypt(
|
const encryptedItem = await encrypt(
|
||||||
JSON.stringify(validationResult.data),
|
JSON.stringify(validationResult.data),
|
||||||
inboxPublicKey
|
inboxPublicKey,
|
||||||
);
|
);
|
||||||
console.log("[info] encrypted item");
|
console.log("[info] encrypted item");
|
||||||
|
|
||||||
@@ -180,14 +159,9 @@ app.post("/inbox", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(async () => {
|
const PORT = Number(process.env.PORT || "5181");
|
||||||
await _sodium.ready;
|
app.listen(PORT, () => {
|
||||||
sodium = _sodium;
|
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
|
||||||
|
});
|
||||||
const PORT = Number(process.env.PORT || "5181");
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`📫 notesnook inbox api server running on port ${PORT}`);
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -55,17 +55,26 @@ dotnet run --project Streetwriters.Identity/Streetwriters.Identity.csproj
|
|||||||
|
|
||||||
The sync server can easily be started using Docker.
|
The sync server can easily be started using Docker.
|
||||||
|
|
||||||
|
Download `docker-compose.yml` and [`.env`](.env):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://raw.githubusercontent.com/streetwriters/notesnook-sync-server/master/docker-compose.yml
|
wget https://raw.githubusercontent.com/streetwriters/notesnook-sync-server/master/docker-compose.yml
|
||||||
|
wget https://raw.githubusercontent.com/streetwriters/notesnook-sync-server/master/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
And then use Docker Compose to start the servers:
|
Edit `.env` and set at least `NOTESNOOK_API_SECRET`. See `.env` for all configuration options.
|
||||||
|
|
||||||
|
Then start the stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up
|
docker compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
This takes care of setting up everything including MongoDB, Minio etc.
|
This sets up MongoDB, MinIO, and the Notesnook services.
|
||||||
|
|
||||||
|
Optional services (`themes-server`, `cors-proxy`, `inbox-api`): `docker compose --profile extras up`
|
||||||
|
|
||||||
|
For external MongoDB, MinIO, or Garage deployments, see [`examples/`](examples/).
|
||||||
|
|
||||||
## TODO Self-hosting
|
## TODO Self-hosting
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,23 @@ namespace Streetwriters.Common
|
|||||||
AppId = ApplicationType.NOTESNOOK,
|
AppId = ApplicationType.NOTESNOOK,
|
||||||
AccountRecoveryRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/recovery",
|
AccountRecoveryRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/recovery",
|
||||||
EmailConfirmedRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/verified",
|
EmailConfirmedRedirectURL = $"{Constants.NOTESNOOK_APP_HOST}/account/verified",
|
||||||
|
PackageName = "com.streetwriters.notesnook",
|
||||||
OnEmailConfirmed = async (userId) =>
|
OnEmailConfirmed = async (userId) =>
|
||||||
{
|
{
|
||||||
|
if (!Constants.IS_SELF_HOSTED)
|
||||||
|
{
|
||||||
|
await WampServers.SubscriptionServer.PublishMessageAsync(
|
||||||
|
SubscriptionServerTopics.EmailConfirmedTopic,
|
||||||
|
new EmailConfirmedMessage
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
ClientId = "notesnook",
|
||||||
|
AppId = ApplicationType.NOTESNOOK,
|
||||||
|
ConfirmedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
|
|||||||
@@ -78,8 +78,9 @@ namespace Streetwriters.Common
|
|||||||
public static string? SUBSCRIPTIONS_SERVER_HOST => ReadSecret("SUBSCRIPTIONS_SERVER_HOST");
|
public static string? SUBSCRIPTIONS_SERVER_HOST => ReadSecret("SUBSCRIPTIONS_SERVER_HOST");
|
||||||
public static string? SUBSCRIPTIONS_CERT_PATH => ReadSecret("SUBSCRIPTIONS_CERT_PATH");
|
public static string? SUBSCRIPTIONS_CERT_PATH => ReadSecret("SUBSCRIPTIONS_CERT_PATH");
|
||||||
public static string? SUBSCRIPTIONS_CERT_KEY_PATH => ReadSecret("SUBSCRIPTIONS_CERT_KEY_PATH");
|
public static string? SUBSCRIPTIONS_CERT_KEY_PATH => ReadSecret("SUBSCRIPTIONS_CERT_KEY_PATH");
|
||||||
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS")?.Split(",") ?? [];
|
public static string[] NOTESNOOK_CORS_ORIGINS => ReadSecret("NOTESNOOK_CORS_ORIGINS")?.Split(",") ?? [];
|
||||||
public static string? SIGNALR_REDIS_CONNECTION_STRING => ReadSecret("SIGNALR_REDIS_CONNECTION_STRING");
|
public static string? SIGNALR_REDIS_CONNECTION_STRING => ReadSecret("SIGNALR_REDIS_CONNECTION_STRING");
|
||||||
|
public static string MONOGRAPH_PUBLIC_URL => ReadSecret("MONOGRAPH_PUBLIC_URL") ?? "https://monogr.ph";
|
||||||
|
|
||||||
public static string? ReadSecret(string name)
|
public static string? ReadSecret(string name)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ namespace Streetwriters.Common.Extensions
|
|||||||
b.WithOrigins(Constants.NOTESNOOK_CORS_ORIGINS);
|
b.WithOrigins(Constants.NOTESNOOK_CORS_ORIGINS);
|
||||||
|
|
||||||
b.AllowAnyMethod()
|
b.AllowAnyMethod()
|
||||||
.AllowAnyHeader();
|
.AllowAnyHeader()
|
||||||
|
.WithExposedHeaders(["X-Object-Size", "Content-Length"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Mail;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
using MimeKit.Cryptography;
|
using MimeKit.Cryptography;
|
||||||
@@ -11,7 +12,7 @@ namespace Streetwriters.Common.Interfaces
|
|||||||
Task SendEmailAsync(
|
Task SendEmailAsync(
|
||||||
string email,
|
string email,
|
||||||
EmailTemplate template,
|
EmailTemplate template,
|
||||||
IClient client,
|
MailAddress from,
|
||||||
GnuPGContext? gpgContext = null,
|
GnuPGContext? gpgContext = null,
|
||||||
Dictionary<string, byte[]>? attachments = null
|
Dictionary<string, byte[]>? attachments = null
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Streetwriters.Common.Enums;
|
||||||
|
|
||||||
|
namespace Streetwriters.Common.Messages
|
||||||
|
{
|
||||||
|
public class EmailConfirmedMessage
|
||||||
|
{
|
||||||
|
[JsonPropertyName("userId")]
|
||||||
|
public required string UserId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("clientId")]
|
||||||
|
public required string ClientId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("appId")]
|
||||||
|
public ApplicationType AppId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("confirmedAt")]
|
||||||
|
public long ConfirmedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ namespace Streetwriters.Common.Models
|
|||||||
public required string SenderName { get; set; }
|
public required string SenderName { get; set; }
|
||||||
public required string EmailConfirmedRedirectURL { get; set; }
|
public required string EmailConfirmedRedirectURL { get; set; }
|
||||||
public required string AccountRecoveryRedirectURL { get; set; }
|
public required string AccountRecoveryRedirectURL { get; set; }
|
||||||
|
public required string PackageName { get; set; }
|
||||||
|
|
||||||
public Func<string, Task>? OnEmailConfirmed { get; set; }
|
public Func<string, Task>? OnEmailConfirmed { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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 required Refund Refund { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Refund
|
|
||||||
{
|
|
||||||
[JsonPropertyName("refund_request_id")]
|
|
||||||
public long RefundRequestId { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,6 +93,9 @@ namespace Streetwriters.Common.Models
|
|||||||
[JsonPropertyName("trialsAvailed")]
|
[JsonPropertyName("trialsAvailed")]
|
||||||
public SubscriptionPlan[]? TrialsAvailed { get; set; }
|
public SubscriptionPlan[]? TrialsAvailed { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("extensionsAvailed")]
|
||||||
|
public SubscriptionExtension[]? ExtensionsAvailed { get; set; }
|
||||||
|
|
||||||
[JsonPropertyName("updatedAt")]
|
[JsonPropertyName("updatedAt")]
|
||||||
public long UpdatedAt { get; set; }
|
public long UpdatedAt { get; set; }
|
||||||
|
|
||||||
@@ -104,4 +107,16 @@ namespace Streetwriters.Common.Models
|
|||||||
[JsonPropertyName("status")]
|
[JsonPropertyName("status")]
|
||||||
public SubscriptionStatus Status { get; set; }
|
public SubscriptionStatus Status { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SubscriptionExtension
|
||||||
|
{
|
||||||
|
[JsonPropertyName("timestamp")]
|
||||||
|
public required long Timestamp { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expiry")]
|
||||||
|
public required long ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public required string Type { get; set; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,9 +14,8 @@ using Streetwriters.Common.Models;
|
|||||||
|
|
||||||
namespace Streetwriters.Common.Services
|
namespace Streetwriters.Common.Services
|
||||||
{
|
{
|
||||||
public class EmailSender : IEmailSender, IAsyncDisposable
|
public class EmailSender : IEmailSender
|
||||||
{
|
{
|
||||||
private readonly SmtpClient mailClient = new();
|
|
||||||
private readonly ILogger<EmailSender> logger;
|
private readonly ILogger<EmailSender> logger;
|
||||||
|
|
||||||
public EmailSender(ILogger<EmailSender> logger)
|
public EmailSender(ILogger<EmailSender> logger)
|
||||||
@@ -27,36 +26,33 @@ namespace Streetwriters.Common.Services
|
|||||||
public async Task SendEmailAsync(
|
public async Task SendEmailAsync(
|
||||||
string email,
|
string email,
|
||||||
EmailTemplate template,
|
EmailTemplate template,
|
||||||
IClient client,
|
System.Net.Mail.MailAddress from,
|
||||||
GnuPGContext? gpgContext = null,
|
GnuPGContext? gpgContext = null,
|
||||||
Dictionary<string, byte[]>? attachments = null
|
Dictionary<string, byte[]>? attachments = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (!mailClient.IsConnected)
|
using var mailClient = new SmtpClient();
|
||||||
|
|
||||||
|
if (int.TryParse(Common.Constants.SMTP_PORT, out int port))
|
||||||
{
|
{
|
||||||
if (int.TryParse(Common.Constants.SMTP_PORT, out int port))
|
await mailClient.ConnectAsync(
|
||||||
{
|
Common.Constants.SMTP_HOST,
|
||||||
await mailClient.ConnectAsync(
|
port,
|
||||||
Common.Constants.SMTP_HOST,
|
MailKit.Security.SecureSocketOptions.Auto
|
||||||
port,
|
);
|
||||||
MailKit.Security.SecureSocketOptions.Auto
|
}
|
||||||
);
|
else
|
||||||
}
|
{
|
||||||
else
|
throw new InvalidDataException("SMTP_PORT is not a valid integer value.");
|
||||||
{
|
|
||||||
throw new InvalidDataException("SMTP_PORT is not a valid integer value.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mailClient.IsAuthenticated)
|
await mailClient.AuthenticateAsync(
|
||||||
await mailClient.AuthenticateAsync(
|
Common.Constants.SMTP_USERNAME,
|
||||||
Common.Constants.SMTP_USERNAME,
|
Common.Constants.SMTP_PASSWORD
|
||||||
Common.Constants.SMTP_PASSWORD
|
);
|
||||||
);
|
|
||||||
|
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
var sender = new MailboxAddress(client.SenderName, client.SenderEmail);
|
message.From.Add(new MailboxAddress(from.DisplayName, from.Address));
|
||||||
message.From.Add(sender);
|
|
||||||
message.To.Add(new MailboxAddress("", email));
|
message.To.Add(new MailboxAddress("", email));
|
||||||
message.Subject = await Template.Parse(template.Subject).RenderAsync(template.Data);
|
message.Subject = await Template.Parse(template.Subject).RenderAsync(template.Data);
|
||||||
|
|
||||||
@@ -65,18 +61,21 @@ namespace Streetwriters.Common.Services
|
|||||||
|
|
||||||
message.Body = await GetEmailBodyAsync(
|
message.Body = await GetEmailBodyAsync(
|
||||||
template,
|
template,
|
||||||
client,
|
new MailboxAddress(from.DisplayName, from.Address),
|
||||||
sender,
|
|
||||||
gpgContext,
|
gpgContext,
|
||||||
attachments
|
attachments
|
||||||
);
|
);
|
||||||
|
|
||||||
await mailClient.SendAsync(message);
|
await mailClient.SendAsync(message);
|
||||||
|
|
||||||
|
if (mailClient.IsConnected)
|
||||||
|
{
|
||||||
|
await mailClient.DisconnectAsync(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<MimeEntity> GetEmailBodyAsync(
|
private async Task<MimeEntity> GetEmailBodyAsync(
|
||||||
EmailTemplate template,
|
EmailTemplate template,
|
||||||
IClient client,
|
|
||||||
MailboxAddress sender,
|
MailboxAddress sender,
|
||||||
GnuPGContext? gpgContext = null,
|
GnuPGContext? gpgContext = null,
|
||||||
Dictionary<string, byte[]>? attachments = null
|
Dictionary<string, byte[]>? attachments = null
|
||||||
@@ -107,7 +106,7 @@ namespace Streetwriters.Common.Services
|
|||||||
}
|
}
|
||||||
outputStream.Seek(0, SeekOrigin.Begin);
|
outputStream.Seek(0, SeekOrigin.Begin);
|
||||||
builder.Attachments.Add(
|
builder.Attachments.Add(
|
||||||
$"{client.Id}_pub.asc",
|
$"pub.asc",
|
||||||
Encoding.ASCII.GetBytes(
|
Encoding.ASCII.GetBytes(
|
||||||
Encoding.ASCII.GetString(outputStream.ToArray())
|
Encoding.ASCII.GetString(outputStream.ToArray())
|
||||||
)
|
)
|
||||||
@@ -132,10 +131,5 @@ namespace Streetwriters.Common.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
|
||||||
{
|
|
||||||
await mailClient.DisconnectAsync(true);
|
|
||||||
mailClient.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
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?.Transaction?.CustomerId == null) return null;
|
|
||||||
var url = $"{PADDLE_BASE_URI}/customers/{transaction.Transaction.CustomerId}";
|
|
||||||
var response = await httpClient.GetFromJsonAsync<GetCustomerResponse>(url);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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 == null || 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 ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,6 +110,7 @@ namespace Streetwriters.Common
|
|||||||
public const string CreateSubscriptionTopic = "co.streetwriters.subscriptions.create";
|
public const string CreateSubscriptionTopic = "co.streetwriters.subscriptions.create";
|
||||||
public const string CreateSubscriptionV2Topic = "co.streetwriters.subscriptions.v2.create";
|
public const string CreateSubscriptionV2Topic = "co.streetwriters.subscriptions.v2.create";
|
||||||
public const string DeleteSubscriptionTopic = "co.streetwriters.subscriptions.delete";
|
public const string DeleteSubscriptionTopic = "co.streetwriters.subscriptions.delete";
|
||||||
|
public const string EmailConfirmedTopic = "co.streetwriters.subscriptions.email_confirmed";
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct IdentityServerTopics
|
public struct IdentityServerTopics
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Streetwriters.Common;
|
using Streetwriters.Common;
|
||||||
using Streetwriters.Common.Enums;
|
using Streetwriters.Common.Enums;
|
||||||
|
using Streetwriters.Common.Helpers;
|
||||||
using Streetwriters.Common.Interfaces;
|
using Streetwriters.Common.Interfaces;
|
||||||
using Streetwriters.Common.Messages;
|
using Streetwriters.Common.Messages;
|
||||||
using Streetwriters.Common.Models;
|
using Streetwriters.Common.Models;
|
||||||
@@ -52,17 +53,32 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
[Authorize(LocalApi.PolicyName)]
|
[Authorize(LocalApi.PolicyName)]
|
||||||
public class AccountController : IdentityControllerBase
|
public class AccountController : IdentityControllerBase
|
||||||
{
|
{
|
||||||
|
private static readonly string emailConfirmedPageHtml = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailConfirmedPage.html");
|
||||||
|
private static readonly string emailConfirmErrorPageHtml = HtmlHelper.ReadMinifiedHtmlFile("Templates/EmailConfirmErrorPage.html");
|
||||||
|
|
||||||
private IPersistedGrantStore PersistedGrantStore { get; set; }
|
private IPersistedGrantStore PersistedGrantStore { get; set; }
|
||||||
private ITokenGenerationService TokenGenerationService { get; set; }
|
private ITokenGenerationService TokenGenerationService { get; set; }
|
||||||
private IUserAccountService UserAccountService { get; set; }
|
private IUserAccountService UserAccountService { get; set; }
|
||||||
|
private EmailAddressValidator EmailValidator { get; set; }
|
||||||
private readonly ILogger<AccountController> logger;
|
private readonly ILogger<AccountController> logger;
|
||||||
public AccountController(UserManager<User> _userManager, ITemplatedEmailSender _emailSender,
|
|
||||||
SignInManager<User> _signInManager, RoleManager<MongoRole> _roleManager, IPersistedGrantStore store,
|
public AccountController(
|
||||||
ITokenGenerationService tokenGenerationService, IMFAService _mfaService, IUserAccountService userAccountService, ILogger<AccountController> logger) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
|
UserManager<User> _userManager,
|
||||||
|
ITemplatedEmailSender _emailSender,
|
||||||
|
SignInManager<User> _signInManager,
|
||||||
|
RoleManager<MongoRole> _roleManager,
|
||||||
|
IPersistedGrantStore store,
|
||||||
|
ITokenGenerationService tokenGenerationService,
|
||||||
|
IMFAService _mfaService,
|
||||||
|
IUserAccountService userAccountService,
|
||||||
|
ILogger<AccountController> logger,
|
||||||
|
EmailAddressValidator emailValidator
|
||||||
|
) : base(_userManager, _emailSender, _signInManager, _roleManager, _mfaService)
|
||||||
{
|
{
|
||||||
PersistedGrantStore = store;
|
PersistedGrantStore = store;
|
||||||
TokenGenerationService = tokenGenerationService;
|
TokenGenerationService = tokenGenerationService;
|
||||||
UserAccountService = userAccountService;
|
UserAccountService = userAccountService;
|
||||||
|
EmailValidator = emailValidator;
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,21 +97,36 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
{
|
{
|
||||||
case TokenType.CONFRIM_EMAIL:
|
case TokenType.CONFRIM_EMAIL:
|
||||||
{
|
{
|
||||||
if (await UserManager.IsEmailConfirmedAsync(user)) return Ok("Email already verified.");
|
if (await UserManager.IsEmailConfirmedAsync(user))
|
||||||
|
{
|
||||||
|
return Content(
|
||||||
|
emailConfirmedPageHtml.Replace("{{subheading}}", "Your email is already verified."),
|
||||||
|
"text/html"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
var result = await UserManager.ConfirmEmailAsync(user, code);
|
var result = await UserManager.ConfirmEmailAsync(user, code);
|
||||||
if (!result.Succeeded) return BadRequest(result.Errors.ToErrors());
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
return Content(
|
||||||
|
emailConfirmErrorPageHtml.Replace("{{errors}}", string.Join(" ", result.Errors.ToErrors())),
|
||||||
|
"text/html"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (await UserManager.IsInRoleAsync(user, client.Id) && client.OnEmailConfirmed != null)
|
if (await UserManager.IsInRoleAsync(user, client.Id) && client.OnEmailConfirmed != null)
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("Email confirmed for user {UserId} on client {ClientId}. Triggering OnEmailConfirmed callback.", userId, client.Id);
|
||||||
await client.OnEmailConfirmed(userId);
|
await client.OnEmailConfirmed(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await UserManager.GetTwoFactorEnabledAsync(user))
|
if (!await UserManager.GetTwoFactorEnabledAsync(user))
|
||||||
await MFAService.EnableMFAAsync(user, MFAMethods.Email);
|
await MFAService.EnableMFAAsync(user, MFAMethods.Email);
|
||||||
|
|
||||||
var redirectUrl = $"{client.EmailConfirmedRedirectURL}?userId={userId}";
|
return Content(
|
||||||
return RedirectPermanent(redirectUrl);
|
emailConfirmedPageHtml.Replace("{{subheading}}", "Your email has been confirmed."),
|
||||||
|
"text/html"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case TokenType.RESET_PASSWORD:
|
case TokenType.RESET_PASSWORD:
|
||||||
{
|
{
|
||||||
@@ -131,6 +162,11 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
if (!await EmailValidator.IsEmailAddressValidAsync(newEmail.ToLowerInvariant()))
|
||||||
|
{
|
||||||
|
return BadRequest("Invalid email address.");
|
||||||
|
}
|
||||||
|
|
||||||
var code = await UserManager.GenerateChangeEmailTokenAsync(user, newEmail);
|
var code = await UserManager.GenerateChangeEmailTokenAsync(user, newEmail);
|
||||||
await EmailSender.SendChangeEmailConfirmationAsync(newEmail, code, client);
|
await EmailSender.SendChangeEmailConfirmationAsync(newEmail, code, client);
|
||||||
}
|
}
|
||||||
@@ -155,8 +191,8 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
var client = Clients.FindClientById(form.ClientId);
|
var client = Clients.FindClientById(form.ClientId);
|
||||||
if (client == null) return BadRequest("Invalid client_id.");
|
if (client == null) return BadRequest("Invalid client_id.");
|
||||||
|
|
||||||
var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
|
var user = await UserManager.FindByEmailAsync(form.Email);
|
||||||
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
|
if (user == null || !await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
|
||||||
|
|
||||||
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
|
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
|
||||||
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
|
var callbackUrl = UrlExtensions.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace Streetwriters.Identity.Interfaces
|
|||||||
{
|
{
|
||||||
public interface ISMSSender
|
public interface ISMSSender
|
||||||
{
|
{
|
||||||
Task<string> SendOTPAsync(string number, IClient client);
|
Task<string?> SendOTPAsync(string number, IClient client);
|
||||||
Task<bool> VerifyOTPAsync(string id, string code);
|
Task<bool> VerifyOTPAsync(string id, string code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +186,8 @@ namespace Streetwriters.Identity.Services
|
|||||||
ArgumentNullException.ThrowIfNull(form.PhoneNumber);
|
ArgumentNullException.ThrowIfNull(form.PhoneNumber);
|
||||||
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
|
await UserManager.SetPhoneNumberAsync(user, form.PhoneNumber);
|
||||||
var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client);
|
var id = await SMSSender.SendOTPAsync(form.PhoneNumber, client);
|
||||||
|
if (string.IsNullOrEmpty(id)) throw new Exception("Failed to send SMS. Please try again.");
|
||||||
|
|
||||||
logger.LogInformation("SMS OTP sent for user: {UserId}, SMS ID: {SmsId}", user.Id, id);
|
logger.LogInformation("SMS OTP sent for user: {UserId}, SMS ID: {SmsId}", user.Id, id);
|
||||||
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
|
await this.ReplaceClaimAsync(user, MFAService.SMS_ID_CLAIM, id);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -23,36 +23,56 @@ using Streetwriters.Common;
|
|||||||
using Twilio.Rest.Verify.V2.Service;
|
using Twilio.Rest.Verify.V2.Service;
|
||||||
using Twilio;
|
using Twilio;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Streetwriters.Identity.Services
|
namespace Streetwriters.Identity.Services
|
||||||
{
|
{
|
||||||
public class SMSSender : ISMSSender
|
public class SMSSender : ISMSSender
|
||||||
{
|
{
|
||||||
public SMSSender()
|
private readonly ILogger<SMSSender> Logger;
|
||||||
|
public SMSSender(ILogger<SMSSender> logger)
|
||||||
{
|
{
|
||||||
|
Logger = logger;
|
||||||
if (!string.IsNullOrEmpty(Constants.TWILIO_ACCOUNT_SID) && !string.IsNullOrEmpty(Constants.TWILIO_AUTH_TOKEN))
|
if (!string.IsNullOrEmpty(Constants.TWILIO_ACCOUNT_SID) && !string.IsNullOrEmpty(Constants.TWILIO_AUTH_TOKEN))
|
||||||
{
|
{
|
||||||
TwilioClient.Init(Constants.TWILIO_ACCOUNT_SID, Constants.TWILIO_AUTH_TOKEN);
|
TwilioClient.Init(Constants.TWILIO_ACCOUNT_SID, Constants.TWILIO_AUTH_TOKEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> SendOTPAsync(string number, IClient app)
|
public async Task<string?> SendOTPAsync(string number, IClient app)
|
||||||
{
|
{
|
||||||
var verification = await VerificationResource.CreateAsync(
|
try
|
||||||
to: number,
|
{
|
||||||
channel: "sms",
|
var verification = await VerificationResource.CreateAsync(
|
||||||
pathServiceSid: Constants.TWILIO_SERVICE_SID
|
to: number,
|
||||||
);
|
channel: "sms",
|
||||||
return verification.Sid;
|
pathServiceSid: Constants.TWILIO_SERVICE_SID
|
||||||
|
);
|
||||||
|
return verification.Sid;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error sending OTP with Twilio");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> VerifyOTPAsync(string id, string code)
|
public async Task<bool> VerifyOTPAsync(string id, string code)
|
||||||
{
|
{
|
||||||
return (await VerificationCheckResource.CreateAsync(
|
try
|
||||||
verificationSid: id,
|
{
|
||||||
pathServiceSid: Constants.TWILIO_SERVICE_SID,
|
return (await VerificationCheckResource.CreateAsync(
|
||||||
code: code
|
verificationSid: id,
|
||||||
)).Status == "approved";
|
pathServiceSid: Constants.TWILIO_SERVICE_SID,
|
||||||
|
code: code
|
||||||
|
)).Status == "approved";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Error verifying OTP with Twilio");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ namespace Streetwriters.Identity.Services
|
|||||||
Subject = Email2FATemplate.Subject,
|
Subject = Email2FATemplate.Subject,
|
||||||
Data = new { app_name = client.Name, code },
|
Data = new { app_name = client.Name, code },
|
||||||
};
|
};
|
||||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendConfirmationEmailAsync(
|
public async Task SendConfirmationEmailAsync(
|
||||||
@@ -120,7 +120,7 @@ namespace Streetwriters.Identity.Services
|
|||||||
Subject = ConfirmEmailTemplate.Subject,
|
Subject = ConfirmEmailTemplate.Subject,
|
||||||
Data = new { app_name = client.Name, confirm_link = callbackUrl },
|
Data = new { app_name = client.Name, confirm_link = callbackUrl },
|
||||||
};
|
};
|
||||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendChangeEmailConfirmationAsync(
|
public async Task SendChangeEmailConfirmationAsync(
|
||||||
@@ -136,7 +136,7 @@ namespace Streetwriters.Identity.Services
|
|||||||
Subject = ConfirmChangeEmailTemplate.Subject,
|
Subject = ConfirmChangeEmailTemplate.Subject,
|
||||||
Data = new { app_name = client.Name, code },
|
Data = new { app_name = client.Name, code },
|
||||||
};
|
};
|
||||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendPasswordResetEmailAsync(
|
public async Task SendPasswordResetEmailAsync(
|
||||||
@@ -152,7 +152,7 @@ namespace Streetwriters.Identity.Services
|
|||||||
Subject = PasswordResetEmailTemplate.Subject,
|
Subject = PasswordResetEmailTemplate.Subject,
|
||||||
Data = new { app_name = client.Name, reset_link = callbackUrl },
|
Data = new { app_name = client.Name, reset_link = callbackUrl },
|
||||||
};
|
};
|
||||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client)
|
public async Task SendFailedLoginAlertAsync(string email, string deviceInfo, IClient client)
|
||||||
@@ -168,7 +168,7 @@ namespace Streetwriters.Identity.Services
|
|||||||
device_info = deviceInfo.Replace("\n", "<br>"),
|
device_info = deviceInfo.Replace("\n", "<br>"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await EmailSender.SendEmailAsync(email, template, client, NNGnuPGContext);
|
await EmailSender.SendEmailAsync(email, template, new System.Net.Mail.MailAddress(client.SenderEmail, client.SenderName), NNGnuPGContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,13 @@ namespace Streetwriters.Identity.Services
|
|||||||
|
|
||||||
await mfaService.ResetMFAAsync(user);
|
await mfaService.ResetMFAAsync(user);
|
||||||
result = await userManager.AddPasswordAsync(user, newPassword);
|
result = await userManager.AddPasswordAsync(user, newPassword);
|
||||||
|
|
||||||
|
// force change email to lowercase if it is not already
|
||||||
|
if (user.Email != null && user.Email != user.Email.ToLower())
|
||||||
|
{
|
||||||
|
var token = await userManager.GenerateChangeEmailTokenAsync(user, user.Email.ToLower());
|
||||||
|
result = await userManager.ChangeEmailAsync(user, user.Email.ToLower(), token);
|
||||||
|
}
|
||||||
return result.Succeeded;
|
return result.Succeeded;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +192,14 @@ namespace Streetwriters.Identity.Services
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return SignupResponse.Error(result.Errors.ToErrors());
|
var otherErrors = result.Errors
|
||||||
|
.Where(e => e.Code != "DuplicateUserName" && e.Code != "DuplicateEmail")
|
||||||
|
.ToErrors();
|
||||||
|
var hasDuplicate = result.Errors.Any(e => e.Code == "DuplicateUserName" || e.Code == "DuplicateEmail");
|
||||||
|
var errors = hasDuplicate
|
||||||
|
? ["Unable to create an account on this email.", .. otherErrors]
|
||||||
|
: otherErrors;
|
||||||
|
return SignupResponse.Error(errors);
|
||||||
}
|
}
|
||||||
catch (System.Exception ex)
|
catch (System.Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -504,7 +504,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
@@ -536,7 +535,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
@@ -567,7 +565,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
|
|||||||
@@ -309,7 +309,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
|
|||||||
@@ -401,7 +401,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
background-color: rgb(
|
background-color: rgb(
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Email Confirmation Failed - Notesnook</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
background: #fdecea;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper svg {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
fill: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subheading {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #5b5b5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-text {
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #808080;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-heading {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-subtext {
|
||||||
|
font-size: 1em;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #5b5b5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
body {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="main">
|
||||||
|
<div class="icon-wrapper">
|
||||||
|
<!-- Mail X Icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M13 19C13 18.66 13.04 18.33 13.09 18H4V8L12 13L20 8V13.09C20.72 13.21 21.39 13.46 22 13.81V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H13.09C13.04 19.67 13 19.34 13 19M20 6L12 11L4 6H20M21.12 15.46L19 17.59L16.88 15.46L15.47 16.88L17.59 19L15.47 21.12L16.88 22.54L19 20.41L21.12 22.54L22.54 21.12L20.41 19L22.54 16.88L21.12 15.46Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="heading">Uh oh!</h1>
|
||||||
|
<p class="subheading">Email confirmation failed. Please try again!</p>
|
||||||
|
<p class="body-text">{{errors}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<h2 class="footer-heading">Notesnook</h2>
|
||||||
|
<p class="footer-subtext">Privacy for everyone</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Email Confirmed - Notesnook</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
|
background: #ffffff;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
background: #e8f5e9;
|
||||||
|
border-radius: 100px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper svg {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
fill: #008837;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subheading {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #5b5b5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-text {
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #808080;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-heading {
|
||||||
|
font-size: 1.2em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-subtext {
|
||||||
|
font-size: 1em;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #5b5b5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icons a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5b5b5b;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icons a:hover svg {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icons svg {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promo-text {
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #5b5b5b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.promo-text .hashtag {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #008837;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
body {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="main">
|
||||||
|
<div class="icon-wrapper">
|
||||||
|
<!-- Mail Check Icon -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M13 19C13 18.66 13.04 18.33 13.09 18H4V8L12 13L20 8V13.09C20.72 13.21 21.39 13.46 22 13.81V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V18C2 19.1 2.9 20 4 20H13.09C13.04 19.67 13 19.34 13 19M20 6L12 11L4 6H20M17.75 22.16L15 19.16L16.16 18L17.75 19.59L21.34 16L22.5 17.41L17.75 22.16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="heading">Huzzah!</h1>
|
||||||
|
<p class="subheading">{{subheading}}</p>
|
||||||
|
<p class="body-text">
|
||||||
|
Thank you for choosing end-to-end encrypted note taking. Now you can
|
||||||
|
sync your notes to unlimited devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<h2 class="footer-heading">Share Notesnook with friends!</h2>
|
||||||
|
<p class="footer-subtext">Because where's the fun in nookin' alone?</p>
|
||||||
|
<div class="social-icons">
|
||||||
|
<!-- Discord -->
|
||||||
|
<a
|
||||||
|
href="https://discord.com/invite/zQBK97EE22"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Discord"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<!-- Twitter -->
|
||||||
|
<a
|
||||||
|
href="https://twitter.com/notesnook"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Twitter"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M23.953 4.57a10 10 0 0 1-2.825.775 4.958 4.958 0 0 0 2.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 0 0-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 0 0-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 0 1-2.228-.616v.06a4.923 4.923 0 0 0 3.946 4.827 4.996 4.996 0 0 1-2.212.085 4.936 4.936 0 0 0 4.604 3.417 9.867 9.867 0 0 1-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 0 0 7.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0 0 24 4.59z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<!-- Reddit -->
|
||||||
|
<a
|
||||||
|
href="https://reddit.com/r/Notesnook"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Reddit"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="promo-text">
|
||||||
|
Use <span class="hashtag">#notesnook</span> and get a chance to win free
|
||||||
|
promo codes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -467,7 +467,6 @@
|
|||||||
text-align: start;
|
text-align: start;
|
||||||
text-indent: 0px;
|
text-indent: 0px;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
white-space: pre-wrap;
|
|
||||||
widows: 2;
|
widows: 2;
|
||||||
word-spacing: 0px;
|
word-spacing: 0px;
|
||||||
-webkit-text-stroke-width: 0px;
|
-webkit-text-stroke-width: 0px;
|
||||||
@@ -483,9 +482,7 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
"
|
"
|
||||||
><em
|
><em
|
||||||
>If you did not request to reset
|
>If you did not request to reset your account password, you can safely ignore this email.</em
|
||||||
your account password, you can
|
|
||||||
safely ignore this email.</em
|
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +551,6 @@
|
|||||||
>
|
>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
.
|
|
||||||
<td
|
<td
|
||||||
style="
|
style="
|
||||||
padding: 18px 0px 18px 0px;
|
padding: 18px 0px 18px 0px;
|
||||||
|
|||||||
@@ -18,28 +18,49 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Lib.AspNetCore.ServerSentEvents;
|
using Lib.AspNetCore.ServerSentEvents;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace Streetwriters.Messenger.Helpers
|
namespace Streetwriters.Messenger.Helpers
|
||||||
{
|
{
|
||||||
public class SSEHelper
|
public class SSEHelper
|
||||||
{
|
{
|
||||||
public static async Task SendEventToUserAsync(string data, IServerSentEventsService sseService, string userId, string? originTokenId = null)
|
public static async Task SendEventToUserAsync(string data, IServerSentEventsService sseService, string userId, string? originTokenId = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var clients = sseService.GetClients().Where(c => c.User.FindFirstValue("sub") == userId);
|
var clients = sseService.GetClients()
|
||||||
foreach (var client in clients)
|
.Where(c => c.User?.FindFirstValue("sub") == userId)
|
||||||
{
|
.Where(c => originTokenId == null || c.User?.FindFirstValue("jti") != originTokenId);
|
||||||
if (originTokenId != null && client.User.FindFirstValue("jti") == originTokenId) continue;
|
|
||||||
if (!client.IsConnected) continue;
|
await SendEventToClientsAsync(clients, data, cancellationToken);
|
||||||
await client.SendEventAsync(data);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task SendEventToAllUsersAsync(string data, IServerSentEventsService sseService)
|
public static async Task SendEventToAllUsersAsync(string data, IServerSentEventsService sseService, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
await sseService.SendEventAsync(data);
|
await SendEventToClientsAsync(sseService.GetClients(), data, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendEventToClientsAsync(IEnumerable<IServerSentEventsClient> clients, string data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var client in clients)
|
||||||
|
{
|
||||||
|
if (!client.IsConnected) continue;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await client.SendEventAsync(data, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Lib.AspNetCore.ServerSentEvents;
|
using Lib.AspNetCore.ServerSentEvents;
|
||||||
using Streetwriters.Messenger.Helpers;
|
using Streetwriters.Messenger.Helpers;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -33,12 +34,14 @@ namespace Streetwriters.Messenger.Services
|
|||||||
private const string HEARTBEAT_MESSAGE_FORMAT = "Streetwriters Heartbeat ({0} UTC)";
|
private const string HEARTBEAT_MESSAGE_FORMAT = "Streetwriters Heartbeat ({0} UTC)";
|
||||||
|
|
||||||
private readonly IServerSentEventsService _serverSentEventsService;
|
private readonly IServerSentEventsService _serverSentEventsService;
|
||||||
|
private readonly ILogger<HeartbeatService> _logger;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constructor
|
#region Constructor
|
||||||
public HeartbeatService(IServerSentEventsService serverSentEventsService)
|
public HeartbeatService(IServerSentEventsService serverSentEventsService, ILogger<HeartbeatService> logger)
|
||||||
{
|
{
|
||||||
_serverSentEventsService = serverSentEventsService;
|
_serverSentEventsService = serverSentEventsService;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -47,15 +50,28 @@ namespace Streetwriters.Messenger.Services
|
|||||||
{
|
{
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var message = JsonSerializer.Serialize(new
|
try
|
||||||
{
|
{
|
||||||
type = "heartbeat",
|
var message = JsonSerializer.Serialize(new
|
||||||
data = JsonSerializer.Serialize(new
|
|
||||||
{
|
{
|
||||||
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
type = "heartbeat",
|
||||||
})
|
data = JsonSerializer.Serialize(new
|
||||||
});
|
{
|
||||||
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService);
|
t = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await SSEHelper.SendEventToAllUsersAsync(message, _serverSentEventsService, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to send SSE heartbeat to one or more clients.");
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
<PackageReference Include="DotNetEnv" Version="2.3.0" />
|
||||||
<PackageReference Include="Lib.AspNetCore.ServerSentEvents" Version="6.0.0" />
|
<PackageReference Include="Lib.AspNetCore.ServerSentEvents" Version="9.1.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.0"
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.0"
|
||||||
NoWarn="NU1605" />
|
NoWarn="NU1605" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.0"
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.0"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function isValidUrl(urlString: string): boolean {
|
|||||||
// Handle proxied request with redirect support
|
// Handle proxied request with redirect support
|
||||||
async function proxyRequest(
|
async function proxyRequest(
|
||||||
targetUrl: string,
|
targetUrl: string,
|
||||||
redirectCount = 0
|
redirectCount = 0,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (redirectCount >= MAX_REDIRECTS) {
|
if (redirectCount >= MAX_REDIRECTS) {
|
||||||
return new Response("Too many redirects", {
|
return new Response("Too many redirects", {
|
||||||
@@ -147,7 +147,7 @@ const server = Bun.serve({
|
|||||||
method2: "GET /?url=<encoded-url>",
|
method2: "GET /?url=<encoded-url>",
|
||||||
example1: `${url.origin}/https://example.com/image.jpg`,
|
example1: `${url.origin}/https://example.com/image.jpg`,
|
||||||
example2: `${url.origin}/?url=${encodeURIComponent(
|
example2: `${url.origin}/?url=${encodeURIComponent(
|
||||||
"https://example.com/image.jpg"
|
"https://example.com/image.jpg",
|
||||||
)}`,
|
)}`,
|
||||||
},
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
@@ -190,7 +190,7 @@ const server = Bun.serve({
|
|||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: corsHeaders,
|
headers: corsHeaders,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +218,8 @@ const server = Bun.serve({
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/html; charset=utf-8",
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
"Content-Security-Policy": "frame-ancestors *",
|
// "Content-Security-Policy": "frame-ancestors *",
|
||||||
"X-Frame-Options": "ALLOWALL",
|
// "X-Frame-Options": "ALLOWALL",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ const server = Bun.serve({
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`🚀 CORS Proxy Server running on http://${server.hostname}:${server.port}`
|
`🚀 CORS Proxy Server running on http://${server.hostname}:${server.port}`,
|
||||||
);
|
);
|
||||||
console.log(`📋 Health check: http://${server.hostname}:${server.port}/health`);
|
console.log(`📋 Health check: http://${server.hostname}:${server.port}/health`);
|
||||||
console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`);
|
console.log(`🌍 Environment: ${Bun.env.NODE_ENV || "development"}`);
|
||||||
@@ -280,7 +280,7 @@ function serveYouTubeEmbed(url: string) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<iframe src="${transformYouTubeUrl(
|
<iframe src="${transformYouTubeUrl(
|
||||||
url
|
url,
|
||||||
)}" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin" title="Video player"></iframe>
|
)}" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin" title="Video player"></iframe>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
+109
-7
@@ -25,14 +25,11 @@ services:
|
|||||||
"INSTANCE_NAME"
|
"INSTANCE_NAME"
|
||||||
"NOTESNOOK_API_SECRET"
|
"NOTESNOOK_API_SECRET"
|
||||||
"DISABLE_SIGNUPS"
|
"DISABLE_SIGNUPS"
|
||||||
"SMTP_USERNAME"
|
|
||||||
"SMTP_PASSWORD"
|
|
||||||
"SMTP_HOST"
|
|
||||||
"SMTP_PORT"
|
|
||||||
"AUTH_SERVER_PUBLIC_URL"
|
"AUTH_SERVER_PUBLIC_URL"
|
||||||
"NOTESNOOK_APP_PUBLIC_URL"
|
"NOTESNOOK_APP_PUBLIC_URL"
|
||||||
"MONOGRAPH_PUBLIC_URL"
|
"MONOGRAPH_PUBLIC_URL"
|
||||||
"ATTACHMENTS_SERVER_PUBLIC_URL"
|
"ATTACHMENTS_SERVER_PUBLIC_URL"
|
||||||
|
"NOTESNOOK_API_PUBLIC_URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check each required environment variable
|
# Check each required environment variable
|
||||||
@@ -43,6 +40,23 @@ services:
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -z "$$SMTP_HOST" ] || [ -z "$$SMTP_PORT" ] || [ -z "$$SMTP_USERNAME" ] || [ -z "$$SMTP_PASSWORD" ]; then
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
WARNING: SMTP IS NOT CONFIGURED
|
||||||
|
================================================================================
|
||||||
|
Email-based 2FA codes and password-reset emails will NOT be sent.
|
||||||
|
|
||||||
|
Newly created users MUST switch 2FA to an Authenticator app:
|
||||||
|
Settings > Auth > Change 2FA method
|
||||||
|
|
||||||
|
Configure SMTP_* in .env if you need email-based 2FA or password reset.
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
echo "All required environment variables are set."
|
echo "All required environment variables are set."
|
||||||
# Ensure the validate service runs first
|
# Ensure the validate service runs first
|
||||||
restart: "no"
|
restart: "no"
|
||||||
@@ -183,16 +197,104 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- notesnook
|
- notesnook
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: wget --tries=1 -nv -q http://localhost:3000/api/health -O- || exit 1
|
test: >
|
||||||
|
bun -e "fetch('http://127.0.0.1:3000/api/health')
|
||||||
|
.then(r => { if (!r.ok) process.exit(1); })
|
||||||
|
.catch(() => process.exit(1))"
|
||||||
interval: 40s
|
interval: 40s
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 60s
|
start_period: 60s
|
||||||
environment:
|
environment:
|
||||||
<<: *server-discovery
|
<<: *server-discovery
|
||||||
API_HOST: http://notesnook-server:5264
|
NODE_ENV: production
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
API_HOST: ${NOTESNOOK_API_PUBLIC_URL:-http://localhost:5264}
|
||||||
PUBLIC_URL: ${MONOGRAPH_PUBLIC_URL}
|
PUBLIC_URL: ${MONOGRAPH_PUBLIC_URL}
|
||||||
|
|
||||||
|
setup-themes:
|
||||||
|
profiles: [extras]
|
||||||
|
image: busybox:latest
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
volumes:
|
||||||
|
- themesdata:/data
|
||||||
|
command: ['sh', '-c', 'if [ ! -f /data/installs.json ]; then echo "{}" > /data/installs.json; fi']
|
||||||
|
|
||||||
|
themes-server:
|
||||||
|
profiles: [extras]
|
||||||
|
image: streetwriters/themes-server:latest
|
||||||
|
ports:
|
||||||
|
- 9200:9000
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
depends_on:
|
||||||
|
setup-themes:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: >
|
||||||
|
bun -e "fetch('http://127.0.0.1:9000/health')
|
||||||
|
.then(r => { if (!r.ok) process.exit(1); })
|
||||||
|
.catch(() => process.exit(1))"
|
||||||
|
interval: 40s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 9000
|
||||||
|
THEMES_REPO_URL: ${THEMES_REPO_URL:-https://github.com/streetwriters/notesnook-themes.git}
|
||||||
|
volumes:
|
||||||
|
- type: volume
|
||||||
|
source: themesdata
|
||||||
|
target: /installs.json
|
||||||
|
volume:
|
||||||
|
subpath: installs.json
|
||||||
|
|
||||||
|
cors-proxy:
|
||||||
|
profiles: [extras]
|
||||||
|
image: streetwriters/cors-proxy:latest
|
||||||
|
ports:
|
||||||
|
- 3001:3000
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
healthcheck:
|
||||||
|
test: >
|
||||||
|
bun -e "fetch('http://127.0.0.1:3000/health')
|
||||||
|
.then(r => { if (!r.ok) process.exit(1); })
|
||||||
|
.catch(() => process.exit(1))"
|
||||||
|
interval: 40s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 3000
|
||||||
|
ALLOWED_ORIGINS: ${CORS_PROXY_ALLOWED_ORIGINS:-*}
|
||||||
|
|
||||||
|
inbox-api:
|
||||||
|
profiles: [extras]
|
||||||
|
image: streetwriters/notesnook-inbox:latest
|
||||||
|
ports:
|
||||||
|
- 5181:5181
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
depends_on:
|
||||||
|
- notesnook-server
|
||||||
|
healthcheck:
|
||||||
|
test: >
|
||||||
|
bun -e "fetch('http://127.0.0.1:5181/health')
|
||||||
|
.then(r => { if (!r.ok) process.exit(1); })
|
||||||
|
.catch(() => process.exit(1))"
|
||||||
|
interval: 40s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 5181
|
||||||
|
NOTESNOOK_API_SERVER_URL: http://notesnook-server:5264
|
||||||
|
|
||||||
autoheal:
|
autoheal:
|
||||||
image: willfarrell/autoheal:latest
|
image: willfarrell/autoheal:latest
|
||||||
tty: true
|
tty: true
|
||||||
@@ -212,4 +314,4 @@ networks:
|
|||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
s3data:
|
s3data:
|
||||||
|
themesdata:
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Docker Compose Examples
|
||||||
|
|
||||||
|
Reference deployments that split infrastructure from the Notesnook stack. Each example overrides the root [`docker-compose.yml`](../docker-compose.yml) instead of duplicating it.
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
Run all commands from the **repository root**.
|
||||||
|
|
||||||
|
| Step | Compose files | Role |
|
||||||
|
|------|---------------|------|
|
||||||
|
| 1. Infra | `examples/<example>/infra.compose.yml` | MongoDB, MinIO, or Garage on `notesnook-shared` |
|
||||||
|
| 2. Notesnook | `docker-compose.yml` + `examples/<example>/notesnook.override.yml` | Disables embedded services; patches connection strings |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Merge example vars into root .env (see examples/<example>/.env.example)
|
||||||
|
# 2. Start infrastructure
|
||||||
|
docker compose -f examples/<example>/infra.compose.yml up -d
|
||||||
|
|
||||||
|
# 3. Start Notesnook (one-time setup for garage — see example README)
|
||||||
|
docker compose -f docker-compose.yml -f examples/<example>/notesnook.override.yml up -d
|
||||||
|
|
||||||
|
# Optional extras
|
||||||
|
docker compose -f docker-compose.yml -f examples/<example>/notesnook.override.yml --profile extras up -d
|
||||||
|
|
||||||
|
# Tear down (reverse order)
|
||||||
|
docker compose -f docker-compose.yml -f examples/<example>/notesnook.override.yml down
|
||||||
|
docker compose -f examples/<example>/infra.compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
The root [`.env`](../.env) is loaded automatically when running from the repository root. Each example ships a **delta-only** [`.env.example`](external-mongodb/.env.example) listing only variables that differ — merge those lines into your root `.env`.
|
||||||
|
|
||||||
|
Infra-only variables (e.g. `GARAGE_RPC_SECRET`) are in `infra.env.example` where applicable; pass with `--env-file` or merge into root `.env`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
| Example | External dependency | Notes |
|
||||||
|
|---------|---------------------|-------|
|
||||||
|
| [external-mongodb](external-mongodb/) | MongoDB | Embedded MinIO stays enabled |
|
||||||
|
| [external-minio](external-minio/) | MinIO | Embedded MongoDB stays enabled |
|
||||||
|
| [garage](garage/) | [GarageHQ](https://garagehq.deuxfleurs.fr/) S3 | Manual bucket setup via `setup-garage.sh` |
|
||||||
|
|
||||||
|
## Shared network
|
||||||
|
|
||||||
|
Infra compose files create **`notesnook-shared`**. Override files attach Notesnook services to it as an external network. Run only one infra example at a time on the same host.
|
||||||
|
|
||||||
|
## Root compose
|
||||||
|
|
||||||
|
The all-in-one stack: [`docker-compose.yml`](../docker-compose.yml) (no override file).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Merge these into the repository root .env (see /.env for all other variables).
|
||||||
|
#
|
||||||
|
# MINIO_ROOT_USER and MINIO_ROOT_PASSWORD must match examples/external-minio/infra.env.example
|
||||||
|
# when starting the external MinIO infra stack.
|
||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# External MinIO
|
||||||
|
|
||||||
|
MinIO runs in a separate compose project. The Notesnook stack uses the root [`docker-compose.yml`](../../docker-compose.yml) with [`notesnook.override.yml`](notesnook.override.yml), which disables embedded `notesnook-s3` / `setup-s3` and points at external MinIO. Embedded MongoDB is unchanged.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
From the **repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure MINIO_* in root .env match infra.env.example
|
||||||
|
# Merge notesnook delta if needed (see .env.example)
|
||||||
|
|
||||||
|
# 1. MinIO + bucket setup
|
||||||
|
docker compose -f examples/external-minio/infra.compose.yml \
|
||||||
|
--env-file examples/external-minio/infra.env.example up -d
|
||||||
|
|
||||||
|
# Wait for notesnook-minio-setup to complete
|
||||||
|
docker compose -f examples/external-minio/infra.compose.yml ps -a
|
||||||
|
|
||||||
|
# 2. Notesnook
|
||||||
|
docker compose -f docker-compose.yml -f examples/external-minio/notesnook.override.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| [`infra.env.example`](infra.env.example) | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` for infra stack |
|
||||||
|
| [`.env.example`](.env.example) | Notesnook delta (credentials must match infra) |
|
||||||
|
|
||||||
|
Merge `MINIO_*` into root [`.env`](../../.env) so the sync server uses the same credentials.
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f examples/external-minio/notesnook.override.yml down
|
||||||
|
docker compose -f examples/external-minio/infra.compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Bucket `attachments` is created by `notesnook-minio-setup` on first infra start.
|
||||||
|
- `S3_INTERNAL_SERVICE_URL` is overridden to `http://notesnook-minio:9000`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: notesnook-external-minio-infra
|
||||||
|
|
||||||
|
services:
|
||||||
|
notesnook-minio:
|
||||||
|
image: minio/minio:RELEASE.2024-07-29T22-14-52Z
|
||||||
|
hostname: notesnook-minio
|
||||||
|
ports:
|
||||||
|
- 9000:9000
|
||||||
|
networks:
|
||||||
|
- notesnook-shared
|
||||||
|
volumes:
|
||||||
|
- s3data:/data/s3
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
MINIO_BROWSER: "on"
|
||||||
|
command: server /data/s3 --console-address :9090
|
||||||
|
healthcheck:
|
||||||
|
test: timeout 5s bash -c ':> /dev/tcp/127.0.0.1/9000' || exit 1
|
||||||
|
interval: 40s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
notesnook-minio-setup:
|
||||||
|
image: minio/mc:RELEASE.2024-07-26T13-08-44Z
|
||||||
|
depends_on:
|
||||||
|
notesnook-minio:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- notesnook-shared
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
command:
|
||||||
|
- /bin/bash
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
mc alias set minio http://notesnook-minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin}
|
||||||
|
mc mb minio/attachments -p
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notesnook-shared:
|
||||||
|
name: notesnook-shared
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
s3data:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
services:
|
||||||
|
notesnook-s3:
|
||||||
|
profiles: [embedded-disabled]
|
||||||
|
|
||||||
|
setup-s3:
|
||||||
|
profiles: [embedded-disabled]
|
||||||
|
|
||||||
|
notesnook-server:
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
- notesnook-shared
|
||||||
|
depends_on: !override
|
||||||
|
identity-server:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
S3_INTERNAL_SERVICE_URL: "http://notesnook-minio:9000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notesnook-shared:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Merge these into the repository root .env (see /.env for all other variables).
|
||||||
|
|
||||||
|
MONGODB_CONNECTION_STRING_IDENTITY=mongodb://notesnook-mongodb:27017/identity?replSet=rs0
|
||||||
|
MONGODB_CONNECTION_STRING=mongodb://notesnook-mongodb:27017/?replSet=rs0
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# External MongoDB
|
||||||
|
|
||||||
|
MongoDB runs in a separate compose project. The Notesnook stack uses the root [`docker-compose.yml`](../../docker-compose.yml) with [`notesnook.override.yml`](notesnook.override.yml), which disables the embedded `notesnook-db` service and points at external Mongo. Embedded MinIO is unchanged.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
From the **repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Merge into root .env
|
||||||
|
cat examples/external-mongodb/.env.example >> .env
|
||||||
|
# set NOTESNOOK_API_SECRET and other required root .env values
|
||||||
|
|
||||||
|
# 1. MongoDB
|
||||||
|
docker compose -f examples/external-mongodb/infra.compose.yml up -d
|
||||||
|
|
||||||
|
# 2. Notesnook
|
||||||
|
docker compose -f docker-compose.yml -f examples/external-mongodb/notesnook.override.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment (delta)
|
||||||
|
|
||||||
|
See [`.env.example`](.env.example) — only `MONGODB_CONNECTION_STRING*` overrides are needed beyond the root `.env`.
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f examples/external-mongodb/notesnook.override.yml down
|
||||||
|
docker compose -f examples/external-mongodb/infra.compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Replica set `rs0` is required; the infra healthcheck initializes it.
|
||||||
|
- Host port `27017` is exposed for debugging.
|
||||||
|
- If infra stops, `identity-server` and `notesnook-server` fail until MongoDB is back.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: notesnook-external-mongodb-infra
|
||||||
|
|
||||||
|
services:
|
||||||
|
notesnook-mongodb:
|
||||||
|
image: mongo:7.0.12
|
||||||
|
hostname: notesnook-mongodb
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
|
volumes:
|
||||||
|
- dbdata:/data/db
|
||||||
|
networks:
|
||||||
|
- notesnook-shared
|
||||||
|
command: --replSet rs0 --bind_ip_all
|
||||||
|
healthcheck:
|
||||||
|
test: echo 'try { rs.status() } catch (err) { rs.initiate() }; db.runCommand("ping").ok' | mongosh mongodb://localhost:27017 --quiet
|
||||||
|
interval: 40s
|
||||||
|
timeout: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notesnook-shared:
|
||||||
|
name: notesnook-shared
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dbdata:
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
services:
|
||||||
|
notesnook-db:
|
||||||
|
profiles: [embedded-disabled]
|
||||||
|
|
||||||
|
identity-server:
|
||||||
|
depends_on: !override
|
||||||
|
validate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
- notesnook-shared
|
||||||
|
environment:
|
||||||
|
MONGODB_CONNECTION_STRING: ${MONGODB_CONNECTION_STRING_IDENTITY}
|
||||||
|
|
||||||
|
notesnook-server:
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
- notesnook-shared
|
||||||
|
depends_on: !override
|
||||||
|
notesnook-s3:
|
||||||
|
condition: service_started
|
||||||
|
setup-s3:
|
||||||
|
condition: service_started
|
||||||
|
identity-server:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
MONGODB_CONNECTION_STRING: ${MONGODB_CONNECTION_STRING}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notesnook-shared:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Merge these into the repository root .env (see /.env for all other variables).
|
||||||
|
# Fill after running examples/garage/setup-garage.sh
|
||||||
|
|
||||||
|
GARAGE_ACCESS_KEY_ID=
|
||||||
|
GARAGE_ACCESS_KEY_SECRET=
|
||||||
|
ATTACHMENTS_SERVER_PUBLIC_URL=http://localhost:3900
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# GarageHQ S3
|
||||||
|
|
||||||
|
[Garage](https://garagehq.deuxfleurs.fr/) replaces embedded MinIO. The Notesnook stack uses the root [`docker-compose.yml`](../../docker-compose.yml) with [`notesnook.override.yml`](notesnook.override.yml). MongoDB stays embedded.
|
||||||
|
|
||||||
|
Based on [PR #79](https://github.com/streetwriters/notesnook-sync-server/pull/79), with community review adjustments:
|
||||||
|
|
||||||
|
- Notesnook uses **presigned URLs** — no public bucket policy required.
|
||||||
|
- Do **not** use `aws s3api put-bucket-policy` for public read ([Garage S3 compatibility](https://garagehq.deuxfleurs.fr/documentation/reference-manual/s3-compatibility/)).
|
||||||
|
- Use port **3900** (S3 API) for `ATTACHMENTS_SERVER_PUBLIC_URL` when not behind a reverse proxy.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
From the **repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Generate RPC secret and add to root .env or use --env-file
|
||||||
|
openssl rand -hex 32
|
||||||
|
# add GARAGE_RPC_SECRET=... to .env (see infra.env.example)
|
||||||
|
|
||||||
|
# 2. Garage infra
|
||||||
|
docker compose -f examples/garage/infra.compose.yml up -d
|
||||||
|
|
||||||
|
# 3. One-time bucket/key setup (from repo root)
|
||||||
|
./examples/garage/setup-garage.sh
|
||||||
|
|
||||||
|
# 4. Merge keys into root .env (see .env.example)
|
||||||
|
# 5. Notesnook
|
||||||
|
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`notesnook-server` may stay unhealthy until valid `GARAGE_ACCESS_KEY_*` values are in root `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml up -d --force-recreate notesnook-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| [`infra.env.example`](infra.env.example) | `GARAGE_RPC_SECRET` for infra |
|
||||||
|
| [`.env.example`](.env.example) | `GARAGE_ACCESS_KEY_*` and `ATTACHMENTS_SERVER_PUBLIC_URL` (merge into root `.env`) |
|
||||||
|
|
||||||
|
## Stop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml down
|
||||||
|
docker compose -f examples/garage/infra.compose.yml down
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual setup
|
||||||
|
|
||||||
|
If `setup-garage.sh` fails, follow PR #79 layout/bucket/key steps manually. Skip public bucket policy. Optional bucket alias only for Garage web port 3902.
|
||||||
|
|
||||||
|
## MongoDB
|
||||||
|
|
||||||
|
This example only externalizes S3. For external MongoDB, combine with [external-mongodb](../external-mongodb/) or use the root all-in-one compose.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
metadata_dir = "/var/lib/garage/meta"
|
||||||
|
data_dir = "/var/lib/garage/data"
|
||||||
|
db_engine = "lmdb"
|
||||||
|
|
||||||
|
replication_factor = 1
|
||||||
|
consistency_mode = "consistent"
|
||||||
|
|
||||||
|
rpc_bind_addr = "[::]:3901"
|
||||||
|
rpc_public_addr = "127.0.0.1:3901"
|
||||||
|
|
||||||
|
[s3_api]
|
||||||
|
s3_region = "us-east-1"
|
||||||
|
api_bind_addr = "[::]:3900"
|
||||||
|
root_domain = ".s3.garage"
|
||||||
|
|
||||||
|
[s3_web]
|
||||||
|
bind_addr = "[::]:3902"
|
||||||
|
root_domain = ".web.garage"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
api_bind_addr = "[::]:3903"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
name: notesnook-garage-infra
|
||||||
|
|
||||||
|
services:
|
||||||
|
notesnook-garage:
|
||||||
|
image: dxflrs/garage:v2.3.0
|
||||||
|
hostname: notesnook-garage
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 3900:3900
|
||||||
|
- 3901:3901
|
||||||
|
- 3902:3902
|
||||||
|
networks:
|
||||||
|
- notesnook-shared
|
||||||
|
volumes:
|
||||||
|
- s3data:/var/lib/garage
|
||||||
|
- ./garage.toml:/etc/garage.toml:ro
|
||||||
|
environment:
|
||||||
|
GARAGE_RPC_SECRET: ${GARAGE_RPC_SECRET}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/garage", "status"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notesnook-shared:
|
||||||
|
name: notesnook-shared
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
s3data:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
GARAGE_RPC_SECRET=
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
notesnook-s3:
|
||||||
|
profiles: [embedded-disabled]
|
||||||
|
|
||||||
|
setup-s3:
|
||||||
|
profiles: [embedded-disabled]
|
||||||
|
|
||||||
|
notesnook-server:
|
||||||
|
networks:
|
||||||
|
- notesnook
|
||||||
|
- notesnook-shared
|
||||||
|
depends_on: !override
|
||||||
|
identity-server:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
S3_INTERNAL_SERVICE_URL: "http://notesnook-garage:3900"
|
||||||
|
S3_ACCESS_KEY_ID: "${GARAGE_ACCESS_KEY_ID}"
|
||||||
|
S3_ACCESS_KEY: "${GARAGE_ACCESS_KEY_SECRET}"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
notesnook-shared:
|
||||||
|
external: true
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
BUCKET="${GARAGE_BUCKET:-attachments}"
|
||||||
|
ZONE="${GARAGE_ZONE:-us-east-1}"
|
||||||
|
CAPACITY="${GARAGE_CAPACITY:-10G}"
|
||||||
|
KEY_NAME="${GARAGE_KEY_NAME:-notesnook-key}"
|
||||||
|
ENDPOINT="${GARAGE_S3_ENDPOINT:-http://127.0.0.1:3900}"
|
||||||
|
|
||||||
|
echo -e "${GREEN}--- Notesnook Garage setup ---${NC}"
|
||||||
|
|
||||||
|
CONTAINER_NAME="${GARAGE_CONTAINER_NAME:-}"
|
||||||
|
if [ -z "$CONTAINER_NAME" ]; then
|
||||||
|
CONTAINER_NAME=$(docker ps --format '{{.Names}}' | grep -E 'notesnook-garage|garage' | head -n 1 || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$CONTAINER_NAME" ]; then
|
||||||
|
echo -e "${RED}Error: no running Garage container found.${NC}"
|
||||||
|
echo "Start infra first (from repo root): docker compose -f examples/garage/infra.compose.yml up -d"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "Using container: ${GREEN}${CONTAINER_NAME}${NC}"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[1/5] Layout${NC}"
|
||||||
|
NODE_ID=""
|
||||||
|
for _ in $(seq 1 10); do
|
||||||
|
NODE_ID=$(docker exec "$CONTAINER_NAME" /garage node id -q | cut -d '@' -f 1 || true)
|
||||||
|
[ -n "$NODE_ID" ] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$NODE_ID" ]; then
|
||||||
|
echo -e "${RED}Error: could not read Garage node id.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Node ID: $NODE_ID"
|
||||||
|
docker exec "$CONTAINER_NAME" /garage layout assign -z "$ZONE" -c "$CAPACITY" "$NODE_ID"
|
||||||
|
echo "yes" | docker exec -i "$CONTAINER_NAME" /garage layout apply --version 1
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[2/5] Bucket${NC}"
|
||||||
|
docker exec "$CONTAINER_NAME" /garage bucket create "$BUCKET" || true
|
||||||
|
|
||||||
|
if [ -n "${GARAGE_BUCKET_ALIAS:-}" ]; then
|
||||||
|
echo "Aliasing bucket to ${GARAGE_BUCKET_ALIAS} (optional website access)"
|
||||||
|
docker exec "$CONTAINER_NAME" /garage bucket alias "$BUCKET" "$GARAGE_BUCKET_ALIAS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[3/5] API key${NC}"
|
||||||
|
KEY_INFO=$(docker exec "$CONTAINER_NAME" /garage key create "$KEY_NAME")
|
||||||
|
echo "$KEY_INFO"
|
||||||
|
|
||||||
|
KEY_ID=$(echo "$KEY_INFO" | awk '/Key ID:/ {print $3}')
|
||||||
|
SECRET_KEY=$(echo "$KEY_INFO" | awk '/Secret key:/ {print $3}')
|
||||||
|
|
||||||
|
if [ -z "$KEY_ID" ] || [ -z "$SECRET_KEY" ]; then
|
||||||
|
echo -e "${RED}Error: failed to parse key id/secret from garage output.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[4/5] Bucket permissions${NC}"
|
||||||
|
docker exec "$CONTAINER_NAME" /garage bucket allow "$BUCKET" --read --write --owner --key "$KEY_ID"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[5/5] CORS (aws-cli)${NC}"
|
||||||
|
docker run --rm \
|
||||||
|
--env AWS_ACCESS_KEY_ID="$KEY_ID" \
|
||||||
|
--env AWS_SECRET_ACCESS_KEY="$SECRET_KEY" \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
amazon/aws-cli \
|
||||||
|
--endpoint-url "$ENDPOINT" \
|
||||||
|
s3api put-bucket-cors --bucket "$BUCKET" --cors-configuration '{
|
||||||
|
"CORSRules": [
|
||||||
|
{
|
||||||
|
"AllowedHeaders": ["*"],
|
||||||
|
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
||||||
|
"AllowedOrigins": ["*"],
|
||||||
|
"ExposeHeaders": ["ETag"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
${GREEN}--- Setup complete ---${NC}
|
||||||
|
|
||||||
|
Merge these into the repository root .env (see examples/garage/.env.example):
|
||||||
|
|
||||||
|
GARAGE_ACCESS_KEY_ID=$KEY_ID
|
||||||
|
GARAGE_ACCESS_KEY_SECRET=$SECRET_KEY
|
||||||
|
ATTACHMENTS_SERVER_PUBLIC_URL=$ENDPOINT
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Notesnook uses presigned URLs for attachments; public bucket policies are not required.
|
||||||
|
- Use the S3 API URL (port 3900) for ATTACHMENTS_SERVER_PUBLIC_URL unless you proxy it.
|
||||||
|
- Do not use aws s3api put-bucket-policy for public read; Garage does not support this like MinIO.
|
||||||
|
|
||||||
|
Then restart the sync server (from repo root):
|
||||||
|
|
||||||
|
docker compose -f docker-compose.yml -f examples/garage/notesnook.override.yml up -d --force-recreate notesnook-server
|
||||||
|
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user