mirror of
https://github.com/streetwriters/notesnook-sync-server.git
synced 2026-08-13 12:00:20 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5715f4c9ca | ||
|
|
8116ce70e4 | ||
|
|
2f8b0ad607 | ||
|
|
1c5bcd6eff | ||
|
|
3a2a04317f | ||
|
|
8d92aff8cd | ||
|
|
4bc1469dfe | ||
|
|
da58262afb | ||
|
|
864baa702b | ||
|
|
55c5cd0a7c | ||
|
|
077d411fc7 |
@@ -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
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -151,34 +151,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
|
||||||
@@ -70,13 +68,15 @@ namespace Notesnook.API.Controllers
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FilterDefinition<Monograph> CreateMonographFilter(string itemId)
|
private static FilterDefinition<Monograph> CreateMonographFilter(string itemIdOrSlug)
|
||||||
{
|
{
|
||||||
return ObjectId.TryParse(itemId, out ObjectId id)
|
return ObjectId.TryParse(itemIdOrSlug, out ObjectId id)
|
||||||
? Builders<Monograph>.Filter.Or(
|
? Builders<Monograph>.Filter.Or(
|
||||||
Builders<Monograph>.Filter.Eq("_id", id),
|
Builders<Monograph>.Filter.Eq("_id", id),
|
||||||
Builders<Monograph>.Filter.Eq("ItemId", itemId))
|
Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug))
|
||||||
: Builders<Monograph>.Filter.Eq("ItemId", itemId);
|
: Builders<Monograph>.Filter.Or(
|
||||||
|
Builders<Monograph>.Filter.Eq("Slug", itemIdOrSlug),
|
||||||
|
Builders<Monograph>.Filter.Eq("ItemId", itemIdOrSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Monograph> FindMonographAsync(string userId, Monograph monograph)
|
private async Task<Monograph> FindMonographAsync(string userId, Monograph monograph)
|
||||||
@@ -88,15 +88,20 @@ namespace Notesnook.API.Controllers
|
|||||||
return await result.FirstOrDefaultAsync();
|
return await result.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Monograph> FindMonographAsync(string itemId)
|
private async Task<Monograph> FindMonographAsync(string itemIdOrSlug)
|
||||||
{
|
{
|
||||||
var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemId), new FindOptions<Monograph>
|
var result = await monographs.Collection.FindAsync(CreateMonographFilter(itemIdOrSlug), new FindOptions<Monograph>
|
||||||
{
|
{
|
||||||
Limit = 1
|
Limit = 1
|
||||||
});
|
});
|
||||||
return await result.FirstOrDefaultAsync();
|
return await result.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// private static string GenerateSlug()
|
||||||
|
// {
|
||||||
|
// return Nanoid.Generate(size: 24);
|
||||||
|
// }
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
public async Task<IActionResult> PublishAsync([FromQuery] string? deviceId, [FromBody] Monograph monograph)
|
||||||
{
|
{
|
||||||
@@ -126,6 +131,7 @@ namespace Notesnook.API.Controllers
|
|||||||
}
|
}
|
||||||
monograph.Deleted = false;
|
monograph.Deleted = false;
|
||||||
monograph.ViewCount = 0;
|
monograph.ViewCount = 0;
|
||||||
|
// monograph.Slug = GenerateSlug();
|
||||||
await monographs.Collection.ReplaceOneAsync(
|
await monographs.Collection.ReplaceOneAsync(
|
||||||
CreateMonographFilter(userId, monograph),
|
CreateMonographFilter(userId, monograph),
|
||||||
monograph,
|
monograph,
|
||||||
@@ -137,7 +143,8 @@ 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(monograph)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
@@ -192,7 +199,8 @@ 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)
|
||||||
@@ -259,7 +267,8 @@ 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);
|
var hasVisitedBefore = Request.Cookies.ContainsKey(cookieName);
|
||||||
@@ -300,6 +309,7 @@ namespace Notesnook.API.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
[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 +353,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;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
return ConstructPublishUrl(monograph.Slug ?? monograph.ItemId ?? monograph.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ConstructPublishUrl(MonographMetadata metadata)
|
||||||
|
{
|
||||||
|
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;
|
||||||
@@ -130,13 +132,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, 1);
|
||||||
|
|
||||||
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
|
||||||
@@ -275,15 +283,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.");
|
||||||
|
|||||||
@@ -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";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ 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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ namespace Notesnook.API.Services
|
|||||||
};
|
};
|
||||||
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
|
await Repositories.InboxApiKey.InsertAsync(defaultInboxKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Repositories.InboxItems.DeleteManyAsync(t => t.UserId == userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
|
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -31,62 +29,31 @@ 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) {
|
||||||
@@ -96,11 +63,11 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
|
|||||||
headers: {
|
headers: {
|
||||||
Authorization: apiKey,
|
Authorization: apiKey,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
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()}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +77,7 @@ async function getInboxPublicEncryptionKey(apiKey: string) {
|
|||||||
|
|
||||||
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,9 +98,12 @@ 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) {
|
||||||
@@ -154,9 +124,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 +150,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;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ 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) =>
|
||||||
{
|
{
|
||||||
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
await WampServers.MessengerServer.PublishMessageAsync(MessengerServerTopics.SendSSETopic, new SendSSEMessage
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ namespace Streetwriters.Common
|
|||||||
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")?.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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,14 +55,26 @@ namespace Streetwriters.Identity.Controllers
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +143,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user