Compare commits

...
Author SHA1 Message Date
Abdullah Atta 0f287328d9 s3: re-enable uploads with rate limiting 2026-09-21 23:22:30 +05:00
Abdullah Atta cdde244c69 s3: temporarily disable attachment uploads 2026-09-21 12:57:23 +05:00
Abdullah Atta 8b610e0952 cors: fix possible XSS vuln when embeding youtube-nocookie.com videos 2026-08-18 09:15:32 +05:00
Abdullah Atta 0a3ee07b95 api: add encryption verifier
encryption verifier is used for verifying the encryption key during password resets. It can be any encrypted item.
2026-08-18 08:36:02 +05:00
Abdullah Atta 768384011d api: re-enable password change 2026-08-10 12:08:35 +05:00
Abdullah Atta a8e73069c2 common: avoid long lived mail client 2026-08-05 22:16:56 +05:00
Abdullah Atta 594f81a3b9 identity: log on email confirmation 2026-08-05 17:58:31 +05:00
Abdullah Atta 518e396079 common: fix NOTESNOOK_CORS_ORIGINS env var 2026-08-05 17:50:43 +05:00
14 changed files with 357 additions and 109 deletions

No files matched your search

+60 -17
View File
@@ -20,10 +20,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System; using System;
using System.Net.Http; using System.Net.Http;
using System.Security.Claims; using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MongoDB.Driver; using MongoDB.Driver;
using Notesnook.API.Helpers; using Notesnook.API.Helpers;
@@ -32,7 +34,6 @@ using Notesnook.API.Models;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors; using Streetwriters.Common.Accessors;
using Streetwriters.Common.Extensions; using Streetwriters.Common.Extensions;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers namespace Notesnook.API.Controllers
{ {
@@ -40,9 +41,10 @@ namespace Notesnook.API.Controllers
[Route("s3")] [Route("s3")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
[Authorize("Sync")] [Authorize("Sync")]
public class S3Controller(IS3Service s3Service, ISyncItemsRepositoryAccessor repositories, WampServiceAccessor serviceAccessor, ILogger<S3Controller> logger) : ControllerBase public class S3Controller(IS3Service s3Service, ISyncItemsRepositoryAccessor repositories, WampServiceAccessor serviceAccessor, IHttpClientFactory httpClientFactory, ILogger<S3Controller> logger) : ControllerBase
{ {
[HttpPut] [HttpPut]
[EnableRateLimiting("s3-direct")]
public async Task<IActionResult> Upload([FromQuery] string name) public async Task<IActionResult> Upload([FromQuery] string name)
{ {
try try
@@ -62,6 +64,10 @@ namespace Notesnook.API.Controllers
return Ok(); return Ok();
} }
catch (Exception ex) when (S3TransientErrorClassifier.IsTransient(ex))
{
return StorageUnavailable(ex);
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error uploading attachment for user."); logger.LogError(ex, "Error uploading attachment for user.");
@@ -71,7 +77,9 @@ namespace Notesnook.API.Controllers
private async Task UploadFileWithChecksAsync(string userId, string name, long fileSize) private async Task UploadFileWithChecksAsync(string userId, string name, long fileSize)
{ {
var userSettings = await repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId); var userSettings = await repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId)
?? throw new Exception("User settings not found.");
var observedStorageLimit = userSettings.StorageLimit;
var subscription = await serviceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found."); var subscription = await serviceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
@@ -84,35 +92,51 @@ namespace Notesnook.API.Controllers
var uploadedFileSize = await UploadFileAsync(userId, name, fileSize); var uploadedFileSize = await UploadFileAsync(userId, name, fileSize);
userSettings.StorageLimit.Value += uploadedFileSize; try
await repositories.UsersSettings.Collection.UpdateOneAsync(
Builders<UserSettings>.Filter.Eq(u => u.UserId, userId),
Builders<UserSettings>.Update.Set(u => u.StorageLimit, userSettings.StorageLimit)
);
// extra check in case user sets wrong ContentLength in the HTTP header
if (uploadedFileSize != fileSize && StorageHelper.IsStorageLimitReached(subscription, userSettings.StorageLimit.Value))
{ {
await s3Service.DeleteObjectAsync(userId, name); await s3Service.IncrementStorageUsageAsync(userId, uploadedFileSize, observedStorageLimit);
throw new Exception("Storage limit exceeded.");
} }
catch (Exception ex)
{
logger.LogError(ex, "Failed to account for attachment usage for user {UserId}.", userId);
}
} }
private async Task<long> UploadFileAsync(string userId, string name, long fileSize) private async Task<long> UploadFileAsync(string userId, string name, long fileSize)
{ {
var url = await s3Service.GetInternalUploadObjectUrlAsync(userId, name) ?? throw new Exception("Could not create signed url."); var url = await s3Service.GetInternalUploadObjectUrlAsync(userId, name) ?? throw new Exception("Could not create signed url.");
var httpClient = new HttpClient(); var httpClient = httpClientFactory.CreateClient("S3Upload");
var content = new StreamContent(HttpContext.Request.BodyReader.AsStream()); var content = new StreamContent(HttpContext.Request.BodyReader.AsStream());
content.Headers.ContentLength = fileSize; content.Headers.ContentLength = fileSize;
var response = await httpClient.SendRequestAsync<Response>(url, null, HttpMethod.Put, content); using var request = new HttpRequestMessage(HttpMethod.Put, url)
if (!response.Success) throw new Exception(response.Content != null ? await response.Content.ReadAsStringAsync() : "Could not upload file."); {
Content = content
};
using var uploadTimeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted);
uploadTimeout.CancelAfter(TimeSpan.FromMinutes(15 + (15 * fileSize / (1024d * 1024d * 1024d))));
using var response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
uploadTimeout.Token);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
if (S3TransientErrorClassifier.IsTransient(statusCode))
throw new S3StorageUnavailableException("Attachment storage is temporarily unavailable.");
return await s3Service.GetObjectSizeAsync(userId, name); throw new Exception(await response.Content.ReadAsStringAsync(uploadTimeout.Token));
}
// The PUT response confirms this request. A follow-up HEAD can lag or
// be throttled, so direct accounting uses the accepted request bytes.
return fileSize;
} }
[HttpGet("multipart")] [HttpGet("multipart")]
[EnableRateLimiting("s3-multipart-control")]
public async Task<IActionResult> MultipartUpload([FromQuery] string name, [FromQuery] int parts, [FromQuery] string? uploadId) public async Task<IActionResult> MultipartUpload([FromQuery] string name, [FromQuery] int parts, [FromQuery] string? uploadId)
{ {
var userId = this.User.GetUserId(); var userId = this.User.GetUserId();
@@ -121,6 +145,10 @@ namespace Notesnook.API.Controllers
var meta = await s3Service.StartMultipartUploadAsync(userId, name, parts, uploadId); var meta = await s3Service.StartMultipartUploadAsync(userId, name, parts, uploadId);
return Ok(meta); return Ok(meta);
} }
catch (Exception ex) when (S3TransientErrorClassifier.IsTransient(ex))
{
return StorageUnavailable(ex);
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error starting multipart upload for user."); logger.LogError(ex, "Error starting multipart upload for user.");
@@ -137,6 +165,10 @@ namespace Notesnook.API.Controllers
await s3Service.AbortMultipartUploadAsync(userId, name, uploadId); await s3Service.AbortMultipartUploadAsync(userId, name, uploadId);
return Ok(); return Ok();
} }
catch (Exception ex) when (S3TransientErrorClassifier.IsTransient(ex))
{
return StorageUnavailable(ex);
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error aborting multipart upload for user."); logger.LogError(ex, "Error aborting multipart upload for user.");
@@ -145,6 +177,7 @@ namespace Notesnook.API.Controllers
} }
[HttpPost("multipart")] [HttpPost("multipart")]
[EnableRateLimiting("s3-multipart-control")]
public async Task<IActionResult> CompleteMultipartUpload([FromBody] CompleteMultipartUploadRequestWrapper uploadRequestWrapper) public async Task<IActionResult> CompleteMultipartUpload([FromBody] CompleteMultipartUploadRequestWrapper uploadRequestWrapper)
{ {
var userId = this.User.GetUserId(); var userId = this.User.GetUserId();
@@ -153,6 +186,10 @@ namespace Notesnook.API.Controllers
await s3Service.CompleteMultipartUploadAsync(userId, uploadRequestWrapper.ToRequest()); await s3Service.CompleteMultipartUploadAsync(userId, uploadRequestWrapper.ToRequest());
return Ok(); return Ok();
} }
catch (Exception ex) when (S3TransientErrorClassifier.IsTransient(ex))
{
return StorageUnavailable(ex);
}
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "Error completing multipart upload for user."); logger.LogError(ex, "Error completing multipart upload for user.");
@@ -160,6 +197,12 @@ namespace Notesnook.API.Controllers
} }
} }
private IActionResult StorageUnavailable(Exception exception)
{
logger.LogWarning(exception, "Attachment storage is temporarily unavailable.");
return StatusCode(503, new { error = "Attachment storage is temporarily unavailable. Please try again later." });
}
[HttpGet] [HttpGet]
public async Task<IActionResult> Download([FromQuery] string name) public async Task<IActionResult> Download([FromQuery] string name)
{ {
+46 -29
View File
@@ -93,39 +93,56 @@ 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 });
// } }
}
[HttpGet("verifier")]
public async Task<IActionResult> GetEncryptionVerifier()
{
var userId = User.GetUserId();
try
{
var response = await UserService.GetEncryptionVerifier(userId);
if (response == null) return NotFound();
return Ok(response);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to get encryption verifier for user id: {UserId}", userId);
return BadRequest(new { error = ex.Message });
}
} }
[HttpPost("reset")] [HttpPost("reset")]
+58 -19
View File
@@ -20,6 +20,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3; using Amazon.S3;
using Amazon.S3.Model; using Amazon.S3.Model;
@@ -47,12 +49,6 @@ namespace Notesnook.API.Helpers
/// </summary> /// </summary>
public bool UseExponentialBackoff { get; set; } = true; public bool UseExponentialBackoff { get; set; } = true;
/// <summary>
/// Whether to allow failover for write operations (PUT, POST, DELETE).
/// Default is false to prevent data consistency issues.
/// </summary>
public bool AllowWriteFailover { get; set; } = false;
/// <summary> /// <summary>
/// List of exception types that should trigger failover /// List of exception types that should trigger failover
/// </summary> /// </summary>
@@ -77,6 +73,39 @@ namespace Notesnook.API.Helpers
}; };
} }
public sealed class S3StorageUnavailableException : Exception
{
public S3StorageUnavailableException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}
public static class S3TransientErrorClassifier
{
public static bool IsTransient(int statusCode)
{
return statusCode == 408 || statusCode == 429 || statusCode >= 500;
}
public static bool IsTransient(Exception exception)
{
if (exception is S3StorageUnavailableException)
return true;
if (exception is AmazonS3Exception s3Exception)
{
return IsTransient((int)s3Exception.StatusCode)
|| s3Exception.ErrorCode is "SlowDown" or "ServiceUnavailable" or "InternalError" or "RequestTimeout";
}
if (exception is HttpRequestException or SocketException or TimeoutException or TaskCanceledException)
return true;
return exception.InnerException != null && IsTransient(exception.InnerException);
}
}
/// <summary> /// <summary>
/// Result of a failover operation /// Result of a failover operation
/// </summary> /// </summary>
@@ -161,10 +190,11 @@ namespace Notesnook.API.Helpers
var result = new S3FailoverResult<T>(); var result = new S3FailoverResult<T>();
Exception? lastException = null; Exception? lastException = null;
// Determine max clients to try based on write operation flag // Writes stay on the provider that owns the object or multipart upload.
var maxClientsToTry = (isWriteOperation && !config.AllowWriteFailover) ? 1 : clients.Count; // They get one application attempt and never fail over.
var maxClientsToTry = isWriteOperation ? 1 : clients.Count;
if (isWriteOperation && !config.AllowWriteFailover && clients.Count > 1) if (isWriteOperation && clients.Count > 1)
{ {
logger?.LogDebug( logger?.LogDebug(
"Write operation {Operation} will only use primary endpoint. Failover is disabled for write operations.", "Write operation {Operation} will only use primary endpoint. Failover is disabled for write operations.",
@@ -185,7 +215,12 @@ namespace Notesnook.API.Helpers
operationName, clientName, i + 1, maxClientsToTry); operationName, clientName, i + 1, maxClientsToTry);
} }
var (success, value, exception, attempts) = await TryExecuteAsync(client, operation, operationName, clientName); var (success, value, exception, attempts) = await TryExecuteAsync(
client,
operation,
operationName,
clientName,
isWriteOperation ? 0 : config.MaxRetries);
result.AttemptsUsed += attempts; result.AttemptsUsed += attempts;
if (success && value != null) if (success && value != null)
@@ -222,19 +257,22 @@ namespace Notesnook.API.Helpers
operationName, maxClientsToTry, result.AttemptsUsed); operationName, maxClientsToTry, result.AttemptsUsed);
return result; return result;
} /// <summary> }
/// Try to execute an operation with retries
/// </summary> /// <summary>
/// Try to execute an operation with retries
/// </summary>
private async Task<(bool success, T? value, Exception? exception, int attempts)> TryExecuteAsync<T>( private async Task<(bool success, T? value, Exception? exception, int attempts)> TryExecuteAsync<T>(
AmazonS3Client client, AmazonS3Client client,
Func<AmazonS3Client, Task<T>> operation, Func<AmazonS3Client, Task<T>> operation,
string operationName, string operationName,
string endpointName) string endpointName,
int maxRetries)
{ {
Exception? lastException = null; Exception? lastException = null;
int attempts = 0; int attempts = 0;
for (int retry = 0; retry <= config.MaxRetries; retry++) for (int retry = 0; retry <= maxRetries; retry++)
{ {
attempts++; attempts++;
try try
@@ -246,12 +284,12 @@ namespace Notesnook.API.Helpers
{ {
lastException = ex; lastException = ex;
if (retry < config.MaxRetries && ShouldRetry(ex)) if (retry < maxRetries && ShouldRetry(ex))
{ {
var delay = CalculateRetryDelay(retry); var delay = CalculateRetryDelay(retry);
logger?.LogWarning(ex, logger?.LogWarning(ex,
"Attempt {Attempt}/{MaxAttempts} failed for {Operation} on {Endpoint}. Retrying in {Delay}ms", "Attempt {Attempt}/{MaxAttempts} failed for {Operation} on {Endpoint}. Retrying in {Delay}ms",
retry + 1, config.MaxRetries + 1, operationName, endpointName, delay); retry + 1, maxRetries + 1, operationName, endpointName, delay);
await Task.Delay(delay); await Task.Delay(delay);
} }
@@ -322,10 +360,10 @@ namespace Notesnook.API.Helpers
string operationName = "S3Operation", string operationName = "S3Operation",
bool isWriteOperation = false) bool isWriteOperation = false)
{ {
await ExecuteWithFailoverAsync<object?>(async (client) => await ExecuteWithFailoverAsync(async (client) =>
{ {
await operation(client); await operation(client);
return null; return true;
}, operationName, isWriteOperation); }, operationName, isWriteOperation);
} }
} }
@@ -384,6 +422,7 @@ namespace Notesnook.API.Helpers
ServiceURL = url, ServiceURL = url,
AuthenticationRegion = region, AuthenticationRegion = region,
ForcePathStyle = forcePathStyle, ForcePathStyle = forcePathStyle,
MaxErrorRetry = 0,
SignatureMethod = Amazon.Runtime.SigningAlgorithm.HmacSHA256, SignatureMethod = Amazon.Runtime.SigningAlgorithm.HmacSHA256,
SignatureVersion = "4" SignatureVersion = "4"
}; };
+1
View File
@@ -35,5 +35,6 @@ namespace Notesnook.API.Interfaces
Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string? uploadId = null); Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string? uploadId = null);
Task AbortMultipartUploadAsync(string userId, string name, string uploadId); Task AbortMultipartUploadAsync(string userId, string name, string uploadId);
Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest); Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest);
Task IncrementStorageUsageAsync(string userId, long fileSize, Limit? observedStorageLimit);
} }
} }
+1
View File
@@ -31,6 +31,7 @@ namespace Notesnook.API.Interfaces
Task DeleteUserAsync(string userId, string? jti, string password); Task DeleteUserAsync(string userId, string? jti, string password);
Task<bool> ResetUserAsync(string userId, bool removeAttachments); Task<bool> ResetUserAsync(string userId, bool removeAttachments);
Task<UserResponse> GetUserAsync(string userId); Task<UserResponse> GetUserAsync(string userId);
Task<EncryptedData?> GetEncryptionVerifier(string userId);
Task SetUserKeysAsync(string userId, UserKeys keys); Task SetUserKeysAsync(string userId, UserKeys keys);
} }
} }
@@ -36,6 +36,7 @@ using Streetwriters.Common;
using Streetwriters.Data.DbContexts; using Streetwriters.Data.DbContexts;
using Streetwriters.Data.Interfaces; using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories; using Streetwriters.Data.Repositories;
using AspNetCore.Identity.Mongo.Mongo;
namespace Notesnook.API.Repositories namespace Notesnook.API.Repositories
{ {
+98 -14
View File
@@ -60,9 +60,11 @@ namespace Notesnook.API.Services
// That is why we create 2 separate S3 clients. One for internal traffic and one for external. // That is why we create 2 separate S3 clients. One for internal traffic and one for external.
private readonly S3FailoverHelper S3InternalClient; private readonly S3FailoverHelper S3InternalClient;
private readonly HttpClient httpClient = new(); private readonly HttpClient httpClient = new();
private readonly ILogger<S3Service> logger;
public S3Service(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, WampServiceAccessor wampServiceAccessor, ILogger<S3Service> logger) public S3Service(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, WampServiceAccessor wampServiceAccessor, ILogger<S3Service> logger)
{ {
this.logger = logger;
Repositories = syncItemsRepositoryAccessor; Repositories = syncItemsRepositoryAccessor;
ServiceAccessor = wampServiceAccessor; ServiceAccessor = wampServiceAccessor;
S3Client = new S3FailoverHelper( S3Client = new S3FailoverHelper(
@@ -277,12 +279,32 @@ namespace Notesnook.API.Services
private async Task<long> GetMultipartUploadSizeAsync(string userId, string key, string uploadId) private async Task<long> GetMultipartUploadSizeAsync(string userId, string key, string uploadId)
{ {
var objectName = GetFullObjectName(userId, key); var objectName = GetFullObjectName(userId, key) ?? throw new Exception("Invalid object name.");
var parts = await S3InternalClient.ExecuteWithFailoverAsync((client) => client.ListPartsAsync(INTERNAL_BUCKET_NAME, objectName, uploadId), operationName: "ListParts");
long totalSize = 0; long totalSize = 0;
foreach (var part in parts.Parts) var marker = 0;
while (true)
{ {
totalSize += part.Size; var request = new ListPartsRequest
{
BucketName = INTERNAL_BUCKET_NAME,
Key = objectName,
UploadId = uploadId,
PartNumberMarker = marker.ToString()
};
var parts = await S3InternalClient.ExecuteWithFailoverAsync(
(client) => client.ListPartsAsync(request),
operationName: "ListParts",
isWriteOperation: true);
foreach (var part in parts.Parts)
{
totalSize = checked(totalSize + part.Size);
}
if (!parts.IsTruncated)
break;
if (parts.NextPartNumberMarker <= marker)
throw new Exception("S3 returned an invalid parts page.");
marker = parts.NextPartNumberMarker;
} }
return totalSize; return totalSize;
} }
@@ -298,21 +320,22 @@ namespace Notesnook.API.Services
await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId); await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
throw new Exception("User settings not found."); throw new Exception("User settings not found.");
} }
userSettings.StorageLimit ??= StorageHelper.RolloverStorageLimit(userSettings.StorageLimit); var observedStorageLimit = userSettings.StorageLimit;
userSettings.StorageLimit = StorageHelper.RolloverStorageLimit(userSettings.StorageLimit);
long fileSize = 0;
if (!Constants.IS_SELF_HOSTED) if (!Constants.IS_SELF_HOSTED)
{ {
var subscription = await ServiceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found."); var subscription = await ServiceAccessor.UserSubscriptionService.GetUserSubscriptionAsync(Clients.Notesnook.Id, userId) ?? throw new Exception("User subscription not found.");
long fileSize = await GetMultipartUploadSizeAsync(userId, uploadRequest.Key, uploadRequest.UploadId); fileSize = await GetMultipartUploadSizeAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
if (StorageHelper.IsFileSizeExceeded(subscription, fileSize)) if (StorageHelper.IsFileSizeExceeded(subscription, fileSize))
{ {
await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId); await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
throw new Exception("Max file size exceeded."); throw new Exception("Max file size exceeded.");
} }
userSettings.StorageLimit.Value += fileSize; if (StorageHelper.IsStorageLimitReached(subscription, userSettings.StorageLimit.Value + fileSize))
if (StorageHelper.IsStorageLimitReached(subscription, userSettings.StorageLimit.Value))
{ {
await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId); await this.AbortMultipartUploadAsync(userId, uploadRequest.Key, uploadRequest.UploadId);
throw new Exception("Storage limit reached."); throw new Exception("Storage limit reached.");
@@ -326,14 +349,75 @@ namespace Notesnook.API.Services
if (!Constants.IS_SELF_HOSTED) if (!Constants.IS_SELF_HOSTED)
{ {
await Repositories.UsersSettings.UpsertAsync(userSettings, (u) => u.UserId == userId); try
await Repositories.UsersSettings.Collection.UpdateOneAsync( {
Builders<UserSettings>.Filter.Eq(u => u.UserId, userId), await IncrementStorageUsageAsync(userId, fileSize, observedStorageLimit);
Builders<UserSettings>.Update.Set(u => u.StorageLimit, userSettings.StorageLimit) }
); catch (Exception ex)
{
// The object is already committed. Accounting is best effort so
// a Mongo failure cannot turn a successful upload into a retry.
logger.LogError(ex, "Failed to account for multipart attachment usage for user {UserId}.", userId);
}
} }
} }
public async Task IncrementStorageUsageAsync(string userId, long fileSize, Limit? observedStorageLimit)
{
var collection = Repositories.UsersSettings.Collection;
var userFilter = Builders<UserSettings>.Filter.Eq(u => u.UserId, userId);
for (var attempt = 0; attempt < 3; attempt++)
{
var now = DateTimeOffset.UtcNow;
var monthStart = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero);
var nextMonthStart = monthStart.AddMonths(1);
var nowMilliseconds = now.ToUnixTimeMilliseconds();
var monthStartMilliseconds = monthStart.ToUnixTimeMilliseconds();
var nextMonthStartMilliseconds = nextMonthStart.ToUnixTimeMilliseconds();
var currentStorageLimit = observedStorageLimit;
if (attempt > 0)
{
currentStorageLimit = (await collection.Find(userFilter).FirstOrDefaultAsync())?.StorageLimit;
}
var isCurrentMonth = currentStorageLimit != null
&& currentStorageLimit.UpdatedAt >= monthStartMilliseconds
&& currentStorageLimit.UpdatedAt < nextMonthStartMilliseconds;
if (!isCurrentMonth)
{
var observedFilter = currentStorageLimit == null
? Builders<UserSettings>.Filter.Eq(u => u.StorageLimit, null)
: Builders<UserSettings>.Filter.Eq("StorageLimit.UpdatedAt", currentStorageLimit.UpdatedAt);
var resetResult = await collection.UpdateOneAsync(
Builders<UserSettings>.Filter.And(userFilter, observedFilter),
Builders<UserSettings>.Update.Set(
"StorageLimit",
new Limit { Value = 0, UpdatedAt = nowMilliseconds }));
if (resetResult.MatchedCount == 0)
continue;
}
var result = await collection.UpdateOneAsync(
Builders<UserSettings>.Filter.And(
userFilter,
Builders<UserSettings>.Filter.Gte("StorageLimit.UpdatedAt", monthStartMilliseconds),
Builders<UserSettings>.Filter.Lt("StorageLimit.UpdatedAt", nextMonthStartMilliseconds)),
Builders<UserSettings>.Update.Combine(
Builders<UserSettings>.Update.Inc("StorageLimit.Value", fileSize),
Builders<UserSettings>.Update.Set("StorageLimit.UpdatedAt", nowMilliseconds)));
if (result.MatchedCount > 0)
return;
}
throw new Exception("Storage usage counter was not available after rollover retries.");
}
private async Task<string?> GetPresignedURLAsync(string userId, string name, HttpVerb httpVerb, S3ClientMode mode = S3ClientMode.EXTERNAL) private async Task<string?> GetPresignedURLAsync(string userId, string name, HttpVerb httpVerb, S3ClientMode mode = S3ClientMode.EXTERNAL)
{ {
var objectName = GetFullObjectName(userId, name); var objectName = GetFullObjectName(userId, name);
@@ -393,4 +477,4 @@ namespace Notesnook.API.Services
return ((int)statusCode >= 200) && ((int)statusCode <= 299); return ((int)statusCode >= 200) && ((int)statusCode <= 299);
} }
} }
} }
+21
View File
@@ -30,6 +30,7 @@ using Notesnook.API.Helpers;
using Notesnook.API.Interfaces; using Notesnook.API.Interfaces;
using Notesnook.API.Models; using Notesnook.API.Models;
using Notesnook.API.Models.Responses; using Notesnook.API.Models.Responses;
using Notesnook.API.Repositories;
using Streetwriters.Common; using Streetwriters.Common;
using Streetwriters.Common.Accessors; using Streetwriters.Common.Accessors;
using Streetwriters.Common.Enums; using Streetwriters.Common.Enums;
@@ -192,6 +193,26 @@ namespace Notesnook.API.Services
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings); await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
} }
public async Task<EncryptedData?> GetEncryptionVerifier(string userId)
{
SyncItemsRepository[] repositories = [Repositories.Notes, Repositories.Notebooks, Repositories.Shortcuts, Repositories.Contents, Repositories.Settings, Repositories.LegacySettings, Repositories.Attachments, Repositories.Reminders, Repositories.Relations, Repositories.Colors, Repositories.Tags, Repositories.Vaults, Repositories.InboxItemsHistory];
foreach (var repo in repositories)
{
var item = await repo.FindOneAsync((s) => s.UserId == userId && (s.KeyVersion == null || s.KeyVersion == 0));
if (item != null)
{
return new EncryptedData
{
Cipher = item.Cipher,
IV = item.IV,
Salt = "",
Length = item.Length
};
}
}
return null;
}
public async Task DeleteUserAsync(string userId) public async Task DeleteUserAsync(string userId)
{ {
logger.LogInformation("Deleting user {UserId}", userId); logger.LogInformation("Deleting user {UserId}", userId);
+36
View File
@@ -23,6 +23,7 @@ using System.IO.Compression;
using System.Security.Claims; using System.Security.Claims;
using System.Text; using System.Text;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.Runtime; using Amazon.Runtime;
using StackExchange.Redis; using StackExchange.Redis;
@@ -35,6 +36,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections; using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@@ -68,6 +70,7 @@ using Streetwriters.Data;
using Streetwriters.Data.DbContexts; using Streetwriters.Data.DbContexts;
using Streetwriters.Data.Interfaces; using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories; using Streetwriters.Data.Repositories;
using System.Threading.RateLimiting;
namespace Notesnook.API namespace Notesnook.API
{ {
@@ -125,6 +128,27 @@ namespace Notesnook.API
options.DefaultPolicy = options.GetPolicy("Notesnook") ?? throw new Exception("Notesnook policy not found"); options.DefaultPolicy = options.GetPolicy("Notesnook") ?? throw new Exception("Notesnook policy not found");
}).AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationResultTransformer>(); }).AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationResultTransformer>();
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status503ServiceUnavailable;
options.AddPolicy("s3-direct", _ => RateLimitPartition.GetConcurrencyLimiter(
"s3-direct",
_ => new ConcurrencyLimiterOptions
{
PermitLimit = ReadRateLimit("S3_DIRECT_UPLOAD_CONCURRENCY", 128),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
options.AddPolicy("s3-multipart-control", _ => RateLimitPartition.GetConcurrencyLimiter(
"s3-multipart-control",
_ => new ConcurrencyLimiterOptions
{
PermitLimit = ReadRateLimit("S3_MULTIPART_CONTROL_CONCURRENCY", 32),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
}));
});
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddOAuth2Introspection("introspection", options => .AddOAuth2Introspection("introspection", options =>
{ {
@@ -204,6 +228,10 @@ namespace Notesnook.API
services.AddScoped<SyncDeviceService>(); services.AddScoped<SyncDeviceService>();
services.AddScoped<IUserService, UserService>(); services.AddScoped<IUserService, UserService>();
services.AddScoped<IS3Service, S3Service>(); services.AddScoped<IS3Service, S3Service>();
services.AddHttpClient("S3Upload", client =>
{
client.Timeout = Timeout.InfiniteTimeSpan;
});
services.AddScoped<IURLAnalyzer, URLAnalyzer>(); services.AddScoped<IURLAnalyzer, URLAnalyzer>();
services.AddWampServiceAccessor(Servers.NotesnookAPI); services.AddWampServiceAccessor(Servers.NotesnookAPI);
@@ -314,6 +342,7 @@ namespace Notesnook.API
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseRateLimiter();
app.UseEndpoints(endpoints => app.UseEndpoints(endpoints =>
{ {
@@ -326,6 +355,13 @@ namespace Notesnook.API
}); });
}); });
} }
private int ReadRateLimit(string name, int defaultValue)
{
return int.TryParse(Configuration[name], out var value) && value > 0
? value
: defaultValue;
}
} }
public static class ServiceCollectionMongoCollectionExtensions public static class ServiceCollectionMongoCollectionExtensions
+1 -1
View File
@@ -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";
+22 -25
View File
@@ -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();
}
} }
} }
@@ -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);
} }
+11 -3
View File
@@ -279,8 +279,8 @@ function serveYouTubeEmbed(url: string) {
</style> </style>
</head> </head>
<body> <body>
<iframe src="${transformYouTubeUrl( <iframe src="${escapeHtmlAttr(
url, transformYouTubeUrl(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>`;
@@ -299,6 +299,14 @@ function isYouTubeEmbed(urlString: string) {
); );
} }
function escapeHtmlAttr(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// Transform YouTube URLs to use youtube-nocookie.com for enhanced privacy // Transform YouTube URLs to use youtube-nocookie.com for enhanced privacy
function transformYouTubeUrl(urlString: string): string { function transformYouTubeUrl(urlString: string): string {
try { try {
@@ -315,7 +323,7 @@ function transformYouTubeUrl(urlString: string): string {
return url.toString(); return url.toString();
} }
return urlString; return url.toString();
} catch { } catch {
return urlString; return urlString;
} }
-1
View File
@@ -167,7 +167,6 @@ services:
S3_SERVICE_URL: "${ATTACHMENTS_SERVER_PUBLIC_URL}" S3_SERVICE_URL: "${ATTACHMENTS_SERVER_PUBLIC_URL}"
S3_REGION: "us-east-1" S3_REGION: "us-east-1"
S3_BUCKET_NAME: "attachments" S3_BUCKET_NAME: "attachments"
NOTESNOOK_CORS: ${NOTESNOOK_CORS_ORIGINS:-}
sse-server: sse-server:
image: streetwriters/sse:latest image: streetwriters/sse:latest