mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-08-13 20:10:18 +02:00
Compare commits
7
Commits
v1.0-beta.32
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768384011d | ||
|
|
a8e73069c2 | ||
|
|
594f81a3b9 | ||
|
|
518e396079 | ||
|
|
d694ed2fee | ||
|
|
13ab0ba039 | ||
|
|
3c7327d3c7 |
@@ -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
|
||||||
|
|||||||
@@ -93,39 +93,38 @@ namespace Notesnook.API.Controllers
|
|||||||
[HttpPatch("password/{type}")]
|
[HttpPatch("password/{type}")]
|
||||||
public async Task<IActionResult> ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form)
|
public async Task<IActionResult> ChangePassword([FromRoute] string type, [FromBody] ChangePasswordForm form)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Password change is currently disabled." });
|
var userId = User.GetUserId();
|
||||||
// var userId = User.GetUserId();
|
var clientId = User.FindFirstValue("client_id");
|
||||||
// var clientId = User.FindFirstValue("client_id");
|
var jti = User.FindFirstValue("jti");
|
||||||
// var jti = User.FindFirstValue("jti");
|
var isPasswordReset = type == "reset";
|
||||||
// var isPasswordReset = type == "reset";
|
try
|
||||||
// try
|
{
|
||||||
// {
|
var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword);
|
||||||
// var result = isPasswordReset ? await serviceAccessor.UserAccountService.ResetPasswordAsync(userId, form.NewPassword) : await serviceAccessor.UserAccountService.ChangePasswordAsync(userId, form.OldPassword, form.NewPassword);
|
if (!result)
|
||||||
// if (!result)
|
return BadRequest("Failed to change password.");
|
||||||
// return BadRequest("Failed to change password.");
|
|
||||||
|
|
||||||
// await UserService.SetUserKeysAsync(userId, form.UserKeys);
|
await UserService.SetUserKeysAsync(userId, form.UserKeys);
|
||||||
|
|
||||||
// await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null);
|
await serviceAccessor.UserAccountService.ClearSessionsAsync(userId, clientId, all: false, jti, null);
|
||||||
|
|
||||||
// await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||||
// {
|
{
|
||||||
// UserId = userId,
|
UserId = userId,
|
||||||
// OriginTokenId = jti,
|
OriginTokenId = jti,
|
||||||
// Message = new Message
|
Message = new Message
|
||||||
// {
|
{
|
||||||
// Type = "logout",
|
Type = "logout",
|
||||||
// Data = JsonSerializer.Serialize(new { reason = "Password changed." })
|
Data = JsonSerializer.Serialize(new { reason = "Password changed." })
|
||||||
// }
|
}
|
||||||
// });
|
});
|
||||||
|
|
||||||
// return Ok();
|
return Ok();
|
||||||
// }
|
}
|
||||||
// catch (Exception ex)
|
catch (Exception ex)
|
||||||
// {
|
{
|
||||||
// logger.LogError(ex, "Failed to change password");
|
logger.LogError(ex, "Failed to change password");
|
||||||
// return BadRequest(new { error = ex.Message });
|
return BadRequest(new { error = ex.Message });
|
||||||
// }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("reset")]
|
[HttpPost("reset")]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ 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 readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string, long>> UpsertActionsMap;
|
private readonly FrozenDictionary<string, Action<IEnumerable<SyncItem>, string>> UpsertActionsMap;
|
||||||
private readonly CollectionDef[] BaseCollectionDefs;
|
private readonly CollectionDef[] BaseCollectionDefs;
|
||||||
private readonly CollectionDef[] V4CollectionDefs;
|
private readonly CollectionDef[] V4CollectionDefs;
|
||||||
ILogger<SyncV2Hub> Logger { get; }
|
ILogger<SyncV2Hub> Logger { get; }
|
||||||
@@ -96,7 +96,7 @@ namespace Notesnook.API.Hubs
|
|||||||
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
|
new("inboxitemhistory", Repositories.InboxItemsHistory.FindItemsById),
|
||||||
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
|
new("relation", Repositories.Relations.FindItemsById), // relations must sync at the end to prevent invalid state
|
||||||
];
|
];
|
||||||
UpsertActionsMap = new Dictionary<string, Action<IEnumerable<SyncItem>, string, long>> {
|
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 },
|
||||||
@@ -149,7 +149,7 @@ namespace Notesnook.API.Hubs
|
|||||||
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;
|
||||||
|
|
||||||
|
|||||||
@@ -55,24 +55,6 @@ namespace Notesnook.API.Repositories
|
|||||||
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ namespace Streetwriters.Common
|
|||||||
PackageName = "com.streetwriters.notesnook",
|
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,7 +78,7 @@ 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 MONOGRAPH_PUBLIC_URL => ReadSecret("MONOGRAPH_PUBLIC_URL") ?? "https://monogr.ph";
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -32,27 +31,25 @@ namespace Streetwriters.Common.Services
|
|||||||
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();
|
||||||
message.From.Add(new MailboxAddress(from.DisplayName, from.Address));
|
message.From.Add(new MailboxAddress(from.DisplayName, from.Address));
|
||||||
@@ -70,6 +67,11 @@ namespace Streetwriters.Common.Services
|
|||||||
);
|
);
|
||||||
|
|
||||||
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(
|
||||||
@@ -129,10 +131,5 @@ namespace Streetwriters.Common.Services
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async ValueTask IAsyncDisposable.DisposeAsync()
|
|
||||||
{
|
|
||||||
await mailClient.DisconnectAsync(true);
|
|
||||||
mailClient.Dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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