8 Commits

Author SHA1 Message Date
Abdullah Atta
014c4e3b32 data: configure mongodb using connection string 2026-02-02 22:33:32 +05:00
Abdullah Atta
bf70a32b95 identity: temporarily disable password recovery & changing 2026-01-19 09:12:39 +05:00
Abdullah Atta
d047bd052e cors: add headers to allow the YouTube embed wrapper to be displayed in an iframe 2026-01-19 08:57:50 +05:00
Abdullah Atta
03f230dbca cors: add special handling for youtube embeds to bypass referer policy restrictions 2026-01-15 15:07:34 +05:00
Abdullah Atta
0a8c707387 docker: do not publish trimmed images 2026-01-01 10:36:02 +05:00
Abdullah Atta
8908b8c6ed sync: minor refactors 2026-01-01 10:35:35 +05:00
Abdullah Atta
da8df8973c sync: reduce batch size for fetching chunks 2025-12-30 12:52:56 +05:00
Abdullah Atta
7e50311c92 sync: reduce device ids chunk size 2025-12-30 12:52:36 +05:00
11 changed files with 146 additions and 63 deletions

View File

@@ -27,8 +27,6 @@ FROM build AS publish
RUN dotnet publish -c Release -o /app/publish \ RUN dotnet publish -c Release -o /app/publish \
#--runtime alpine-x64 \ #--runtime alpine-x64 \
--self-contained true \ --self-contained true \
/p:TrimMode=partial \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true \ /p:PublishSingleFile=true \
/p:JsonSerializerIsReflectionEnabledByDefault=true \ /p:JsonSerializerIsReflectionEnabledByDefault=true \
-a $TARGETARCH -a $TARGETARCH

View File

@@ -247,7 +247,7 @@ namespace Notesnook.API.Hubs
var chunks = PrepareChunks( var chunks = PrepareChunks(
userId, userId,
ids, ids,
size: 1000, size: 100,
resetSync: device.IsSyncReset, resetSync: device.IsSyncReset,
maxBytes: 7 * 1024 * 1024 maxBytes: 7 * 1024 * 1024
); );

View File

@@ -91,11 +91,7 @@ namespace Notesnook.API.Repositories
public void DeleteByUserId(string userId) public void DeleteByUserId(string userId)
{ {
var filter = Builders<SyncItem>.Filter.Eq("UserId", userId); var filter = Builders<SyncItem>.Filter.Eq("UserId", userId);
var writes = new List<WriteModel<SyncItem>> dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filter, null, ct));
{
new DeleteManyModel<SyncItem>(filter)
};
dbContext.AddCommand((handle, ct) => Collection.BulkWriteAsync(handle, writes, options: null, ct));
} }
public void Upsert(SyncItem item, string userId, long dateSynced) public void Upsert(SyncItem item, string userId, long dateSynced)

View File

@@ -73,7 +73,7 @@ namespace Notesnook.API.Services
return result; return result;
} }
const int MaxIdsPerChunk = 400_000; const int MaxIdsPerChunk = 25_000;
public async Task AppendIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids) public async Task AppendIdsAsync(string userId, string deviceId, string key, IEnumerable<ItemKey> ids)
{ {
var filter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Where(x => x.Ids.Length < MaxIdsPerChunk); var filter = DeviceIdsChunkFilter(userId, deviceId, key) & Builders<DeviceIdsChunk>.Filter.Where(x => x.Ids.Length < MaxIdsPerChunk);
@@ -81,7 +81,7 @@ namespace Notesnook.API.Services
if (chunk != null) if (chunk != null)
{ {
var update = Builders<DeviceIdsChunk>.Update.PushEach(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.UpdateOneAsync(
Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id), Builders<DeviceIdsChunk>.Filter.Eq(x => x.Id, chunk.Id),
update update

View File

@@ -182,6 +182,7 @@ namespace Notesnook.API.Services
public async Task DeleteUserAsync(string userId) public async Task DeleteUserAsync(string userId)
{ {
logger.LogInformation("Deleting user {UserId}", userId);
var cc = new CancellationTokenSource(); var cc = new CancellationTokenSource();
Repositories.Notes.DeleteByUserId(userId); Repositories.Notes.DeleteByUserId(userId);

View File

@@ -33,9 +33,6 @@ namespace Streetwriters.Data.DbContexts
public static IMongoClient CreateMongoDbClient(IDbSettings dbSettings) public static IMongoClient CreateMongoDbClient(IDbSettings dbSettings)
{ {
var settings = MongoClientSettings.FromConnectionString(dbSettings.ConnectionString); var settings = MongoClientSettings.FromConnectionString(dbSettings.ConnectionString);
settings.MaxConnectionPoolSize = 500;
settings.MinConnectionPoolSize = 0;
settings.HeartbeatInterval = TimeSpan.FromSeconds(60);
return new MongoClient(settings); return new MongoClient(settings);
} }

View File

@@ -97,12 +97,12 @@ namespace Streetwriters.Identity.Controllers
} }
case TokenType.RESET_PASSWORD: case TokenType.RESET_PASSWORD:
{ {
if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code)) // if (!await UserManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", code))
return BadRequest("Invalid token."); return BadRequest("Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience.");
var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode"); // var authorizationCode = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "PasswordResetAuthorizationCode");
var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}"; // var redirectUrl = $"{client.AccountRecoveryRedirectURL}?userId={userId}&code={authorizationCode}";
return RedirectPermanent(redirectUrl); // return RedirectPermanent(redirectUrl);
} }
default: default:
return BadRequest("Invalid type."); return BadRequest("Invalid type.");
@@ -149,21 +149,22 @@ namespace Streetwriters.Identity.Controllers
[EnableRateLimiting("strict")] [EnableRateLimiting("strict")]
public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form) public async Task<IActionResult> ResetUserPassword([FromForm] ResetPasswordForm form)
{ {
var client = Clients.FindClientById(form.ClientId); return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
if (client == null) return BadRequest("Invalid client_id."); // var client = Clients.FindClientById(form.ClientId);
// if (client == null) return BadRequest("Invalid client_id.");
var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found."); // var user = await UserManager.FindByEmailAsync(form.Email) ?? throw new Exception("User not found.");
if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok(); // if (!await UserService.IsUserValidAsync(UserManager, user, form.ClientId)) return Ok();
var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword"); // var code = await UserManager.GenerateUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword");
var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD); // var callbackUrl = Url.TokenLink(user.Id.ToString(), code, client.Id, TokenType.RESET_PASSWORD);
#if (DEBUG || STAGING) // #if (DEBUG || STAGING)
return Ok(callbackUrl); // return Ok(callbackUrl);
#else // #else
logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl); // logger.LogInformation("Password reset email sent to: {Email}, callback URL: {CallbackUrl}", user.Email, callbackUrl);
await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client); // await EmailSender.SendPasswordResetEmailAsync(user.Email, callbackUrl, client);
return Ok(); // return Ok();
#endif // #endif
} }
[HttpPost("logout")] [HttpPost("logout")]
@@ -250,31 +251,33 @@ namespace Streetwriters.Identity.Controllers
} }
case "change_password": case "change_password":
{ {
ArgumentNullException.ThrowIfNull(form.OldPassword); return BadRequest(new { error = "Password change is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
ArgumentNullException.ThrowIfNull(form.NewPassword); // ArgumentNullException.ThrowIfNull(form.OldPassword);
var result = await UserManager.ChangePasswordAsync(user, form.OldPassword, form.NewPassword); // ArgumentNullException.ThrowIfNull(form.NewPassword);
if (result.Succeeded) // var result = await UserManager.ChangePasswordAsync(user, form.OldPassword, form.NewPassword);
{ // if (result.Succeeded)
await SendLogoutMessageAsync(user.Id.ToString(), "Password changed."); // {
return Ok(); // await SendLogoutMessageAsync(user.Id.ToString(), "Password changed.");
} // return Ok();
return BadRequest(result.Errors.ToErrors()); // }
// return BadRequest(result.Errors.ToErrors());
} }
case "reset_password": case "reset_password":
{ {
ArgumentNullException.ThrowIfNull(form.NewPassword); return BadRequest(new { error = "Password reset is temporarily disabled due to some issues. It should be back soon. We apologize for the inconvenience." });
var result = await UserManager.RemovePasswordAsync(user); // ArgumentNullException.ThrowIfNull(form.NewPassword);
if (result.Succeeded) // var result = await UserManager.RemovePasswordAsync(user);
{ // if (result.Succeeded)
await MFAService.ResetMFAAsync(user); // {
result = await UserManager.AddPasswordAsync(user, form.NewPassword); // await MFAService.ResetMFAAsync(user);
if (result.Succeeded) // result = await UserManager.AddPasswordAsync(user, form.NewPassword);
{ // if (result.Succeeded)
await SendLogoutMessageAsync(user.Id.ToString(), "Password reset."); // {
return Ok(); // await SendLogoutMessageAsync(user.Id.ToString(), "Password reset.");
} // return Ok();
} // }
return BadRequest(result.Errors.ToErrors()); // }
// return BadRequest(result.Errors.ToErrors());
} }
case "change_marketing_consent": case "change_marketing_consent":
{ {

View File

@@ -27,8 +27,6 @@ FROM build AS publish
RUN dotnet publish -c Release -o /app/publish \ RUN dotnet publish -c Release -o /app/publish \
#--runtime alpine-x64 \ #--runtime alpine-x64 \
--self-contained true \ --self-contained true \
/p:TrimMode=partial \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true \ /p:PublishSingleFile=true \
/p:JsonSerializerIsReflectionEnabledByDefault=true \ /p:JsonSerializerIsReflectionEnabledByDefault=true \
-a $TARGETARCH -a $TARGETARCH

View File

@@ -53,6 +53,7 @@ using Streetwriters.Identity.Interfaces;
using Streetwriters.Identity.Jobs; using Streetwriters.Identity.Jobs;
using Streetwriters.Identity.Services; using Streetwriters.Identity.Services;
using Streetwriters.Identity.Validation; using Streetwriters.Identity.Validation;
using IdentityServer4.MongoDB.Configuration;
namespace Streetwriters.Identity namespace Streetwriters.Identity
{ {
@@ -107,11 +108,6 @@ namespace Streetwriters.Identity
options.UsersCollection = "users"; options.UsersCollection = "users";
// options.MigrationCollection = "migration"; // options.MigrationCollection = "migration";
options.ConnectionString = connectionString; options.ConnectionString = connectionString;
options.ClusterConfigurator = builder =>
{
builder.ConfigureConnectionPool((c) => c.With(maxConnections: 500, minConnections: 0));
builder.ConfigureServer(s => s.With(heartbeatInterval: TimeSpan.FromSeconds(60)));
};
}).AddDefaultTokenProviders(); }).AddDefaultTokenProviders();
services.AddIdentityServer( services.AddIdentityServer(
@@ -137,6 +133,11 @@ namespace Streetwriters.Identity
.AddKeyManagement() .AddKeyManagement()
.AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore")); .AddFileSystemPersistence(Path.Combine(WebHostEnvironment.ContentRootPath, @"keystore"));
services.Configure<MongoDBConfiguration>(options =>
{
options.ConnectionString = connectionString;
});
services.Configure<DataProtectionTokenProviderOptions>(options => services.Configure<DataProtectionTokenProviderOptions>(options =>
{ {
options.TokenLifespan = TimeSpan.FromHours(2); options.TokenLifespan = TimeSpan.FromHours(2);

View File

@@ -27,8 +27,6 @@ FROM build AS publish
RUN dotnet publish -c Release -o /app/publish \ RUN dotnet publish -c Release -o /app/publish \
#--runtime alpine-x64 \ #--runtime alpine-x64 \
--self-contained true \ --self-contained true \
/p:TrimMode=partial \
/p:PublishTrimmed=true \
/p:PublishSingleFile=true \ /p:PublishSingleFile=true \
/p:JsonSerializerIsReflectionEnabledByDefault=true \ /p:JsonSerializerIsReflectionEnabledByDefault=true \
-a $TARGETARCH -a $TARGETARCH

View File

@@ -210,7 +210,21 @@ const server = Bun.serve({
}); });
} }
// Proxy the request // Check if it's a YouTube URL and redirect instead of proxying
if (isYouTubeEmbed(targetUrl)) {
// YouTube URL detected, redirect to youtube-nocookie.com
logRequest(req.method, targetUrl, 200);
return new Response(serveYouTubeEmbed(targetUrl), {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": "frame-ancestors *",
"X-Frame-Options": "ALLOWALL",
},
});
}
// Proxy the request for non-YouTube URLs
const response = await proxyRequest(targetUrl); const response = await proxyRequest(targetUrl);
logRequest(req.method, targetUrl, response.status); logRequest(req.method, targetUrl, response.status);
return response; return response;
@@ -229,3 +243,80 @@ console.log(
); );
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"}`);
/**
* This is required to bypass YouTube's Referrer Policy restrictions when
* embedding videos on the mobile app. It basically "proxies" the Referrer and
* allows any YouTube video to be embedded anywhere without restrictions.
*/
function serveYouTubeEmbed(url: string) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="robots" content="noindex,nofollow">
<title>YouTube Video Embed</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing:border-box
}
body, html {
overflow: hidden;
background:#000
}
iframe {
border: 0;
width: 100vw;
height: 100vh;
display: block
}
</style>
</head>
<body>
<iframe src="${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>
</body>
</html>`;
}
// Check if URL is a YouTube embed (including youtube-nocookie.com)
function isYouTubeEmbed(urlString: string) {
const url = new URL(urlString);
return (
(url.hostname === "www.youtube.com" ||
url.hostname === "youtube.com" ||
url.hostname === "m.youtube.com" ||
url.hostname === "www.youtube-nocookie.com" ||
url.hostname === "youtube-nocookie.com") &&
url.pathname.startsWith("/embed/")
);
}
// Transform YouTube URLs to use youtube-nocookie.com for enhanced privacy
function transformYouTubeUrl(urlString: string): string {
try {
const url = new URL(urlString);
// Check if it's a YouTube domain
if (
url.hostname === "www.youtube.com" ||
url.hostname === "youtube.com" ||
url.hostname === "m.youtube.com"
) {
// Replace with youtube-nocookie.com
url.hostname = "www.youtube-nocookie.com";
return url.toString();
}
return urlString;
} catch {
return urlString;
}
}