open source Notesnook API

This commit is contained in:
Abdullah Atta
2022-12-28 16:20:25 +05:00
parent cf406454cd
commit d2217adce7
85 changed files with 4137 additions and 0 deletions

265
.gitignore vendored Normal file
View File

@@ -0,0 +1,265 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
#*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
keys/
dist/
appsettings.json

View File

@@ -0,0 +1,45 @@
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Repositories;
using Streetwriters.Common.Models;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Accessors
{
public class SyncItemsRepositoryAccessor : ISyncItemsRepositoryAccessor
{
public SyncItemsRepository<Note> Notes { get; }
public SyncItemsRepository<Notebook> Notebooks { get; }
public SyncItemsRepository<Shortcut> Shortcuts { get; }
public SyncItemsRepository<Relation> Relations { get; }
public SyncItemsRepository<Reminder> Reminders { get; }
public SyncItemsRepository<Content> Contents { get; }
public SyncItemsRepository<Setting> Settings { get; }
public SyncItemsRepository<Attachment> Attachments { get; }
public Repository<UserSettings> UsersSettings { get; }
public Repository<Monograph> Monographs { get; }
public SyncItemsRepositoryAccessor(SyncItemsRepository<Note> _notes,
SyncItemsRepository<Notebook> _notebooks,
SyncItemsRepository<Content> _content,
SyncItemsRepository<Setting> _settings,
SyncItemsRepository<Attachment> _attachments,
SyncItemsRepository<Shortcut> _shortcuts,
SyncItemsRepository<Relation> _relations,
SyncItemsRepository<Reminder> _reminders,
Repository<UserSettings> _usersSettings,
Repository<Monograph> _monographs)
{
Notebooks = _notebooks;
Notes = _notes;
Contents = _content;
Settings = _settings;
Attachments = _attachments;
UsersSettings = _usersSettings;
Monographs = _monographs;
Shortcuts = _shortcuts;
Reminders = _reminders;
Relations = _relations;
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
namespace Notesnook.API.Authorization
{
public class EmailVerifiedRequirement : AuthorizationHandler<EmailVerifiedRequirement>, IAuthorizationRequirement
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, EmailVerifiedRequirement requirement)
{
var isEmailVerified = context.User.HasClaim("verified", "true");
var isUserBasic = context.User.HasClaim("notesnook:status", "basic") || context.User.HasClaim("notesnook:status", "premium_expired");
if (!isUserBasic || isEmailVerified)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
namespace Notesnook.API.Authorization
{
public class NotesnookUserRequirement : AuthorizationHandler<NotesnookUserRequirement>, IAuthorizationRequirement
{
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, NotesnookUserRequirement requirement)
{
var isInAudience = context.User.HasClaim("aud", "notesnook");
var hasRole = context.User.HasClaim("role", "notesnook");
if (isInAudience && hasRole)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
namespace Notesnook.API.Authorization
{
public class ProUserRequirement : AuthorizationHandler<ProUserRequirement>, IAuthorizationRequirement
{
private string[] allowedClaims = { "trial", "premium", "premium_canceled" };
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ProUserRequirement requirement)
{
var isProOrTrial = context.User.HasClaim((c) => c.Type == "notesnook:status" && allowedClaims.Contains(c.Value));
if (isProOrTrial)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
namespace Notesnook.API.Authorization
{
public class SyncRequirement : AuthorizationHandler<SyncRequirement>, IAuthorizationRequirement
{
private Dictionary<string, string> pathErrorPhraseMap = new Dictionary<string, string>
{
["/sync/attachments"] = "use attachments",
["/sync"] = "sync your notes",
["/hubs/sync"] = "sync your notes",
["/monographs"] = "publish monographs"
};
private string[] allowedClaims = { "trial", "premium", "premium_canceled" };
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SyncRequirement requirement)
{
PathString path = context.Resource is DefaultHttpContext httpContext ? httpContext.Request.Path : null;
var result = this.IsAuthorized(context.User, path);
if (result.Succeeded) context.Succeed(requirement);
else
{
var hasReason = result.AuthorizationFailure.FailureReasons.Count() > 0;
if (hasReason)
context.Fail(result.AuthorizationFailure.FailureReasons.First());
else context.Fail();
}
return Task.CompletedTask;
}
public PolicyAuthorizationResult IsAuthorized(ClaimsPrincipal User, PathString requestPath)
{
var id = User.FindFirstValue("sub");
if (string.IsNullOrEmpty(id))
{
var reason = new AuthorizationFailureReason[]
{
new AuthorizationFailureReason(this, "Invalid token.")
};
return PolicyAuthorizationResult.Forbid(AuthorizationFailure.Failed(reason));
}
var hasSyncScope = User.HasClaim("scope", "notesnook.sync");
var isInAudience = User.HasClaim("aud", "notesnook");
var hasRole = User.HasClaim("role", "notesnook");
var isEmailVerified = User.HasClaim("verified", "true");
if (!isEmailVerified)
{
var phrase = "continue";
foreach (var item in pathErrorPhraseMap)
{
if (requestPath != null && requestPath.StartsWithSegments(item.Key))
phrase = item.Value;
}
var error = $"Please confirm your email to {phrase}.";
var reason = new AuthorizationFailureReason[]
{
new AuthorizationFailureReason(this, error)
};
return PolicyAuthorizationResult.Forbid(AuthorizationFailure.Failed(reason));
// context.Fail(new AuthorizationFailureReason(this, error));
}
var isProOrTrial = User.HasClaim((c) => c.Type == "notesnook:status" && allowedClaims.Contains(c.Value));
if (hasSyncScope && isInAudience && hasRole && isEmailVerified)
return PolicyAuthorizationResult.Success(); //(requirement);
return PolicyAuthorizationResult.Forbid();
}
public override Task HandleAsync(AuthorizationHandlerContext context)
{
return this.HandleRequirementAsync(context, this);
}
}
}

View File

@@ -0,0 +1,32 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Notesnook.API.Models;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Controllers
{
[ApiController]
[Route("announcements")]
public class AnnouncementController : ControllerBase
{
private Repository<Announcement> Announcements { get; set; }
public AnnouncementController(Repository<Announcement> announcements)
{
Announcements = announcements;
}
[HttpGet("active")]
[AllowAnonymous]
public async Task<IActionResult> GetActiveAnnouncements([FromQuery] string userId)
{
var announcements = await Announcements.FindAsync((a) => a.IsActive);
return Ok(announcements.Where((a) => a.UserIds != null && a.UserIds.Length > 0
? a.UserIds.Contains(userId)
: true));
}
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Notesnook.API.Models;
using Streetwriters.Common.Models;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Controllers
{
[ApiController]
[Route("monographs")]
[Authorize("Sync")]
public class MonographsController : ControllerBase
{
private Repository<Monograph> Monographs { get; set; }
private readonly IUnitOfWork unit;
private const int MAX_DOC_SIZE = 15 * 1024 * 1024;
public MonographsController(Repository<Monograph> monographs, IUnitOfWork unitOfWork)
{
Monographs = monographs;
unit = unitOfWork;
}
[HttpPost]
public async Task<IActionResult> PublishAsync([FromBody] Monograph monograph)
{
var userId = this.User.FindFirstValue("sub");
if (userId == null) return Unauthorized();
if (await Monographs.GetAsync(monograph.Id) != null) return base.Conflict("This monograph is already published.");
if (monograph.EncryptedContent == null)
monograph.CompressedContent = monograph.Content.CompressBrotli();
monograph.UserId = userId;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (monograph.EncryptedContent?.Cipher.Length > MAX_DOC_SIZE || monograph.CompressedContent?.Length > MAX_DOC_SIZE)
return base.BadRequest("Monograph is too big. Max allowed size is 15mb.");
Monographs.Insert(monograph);
if (!await unit.Commit()) return BadRequest();
return Ok(new
{
id = monograph.Id
});
}
[HttpPatch]
public async Task<IActionResult> UpdateAsync([FromBody] Monograph monograph)
{
if (await Monographs.GetAsync(monograph.Id) == null) return NotFound();
if (monograph.EncryptedContent == null)
monograph.CompressedContent = monograph.Content.CompressBrotli();
else
monograph.Content = null;
monograph.DatePublished = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Monographs.Update(monograph.Id, monograph);
if (!await unit.Commit()) return BadRequest();
return Ok(new
{
id = monograph.Id
});
}
[HttpGet]
public async Task<IActionResult> GetUserMonographsAsync()
{
var userId = this.User.FindFirstValue("sub");
if (userId == null) return Unauthorized();
var userMonographs = await Monographs.FindAsync((m) => m.UserId == userId);
return Ok(userMonographs.Select((m) => m.Id));
}
[HttpGet("{id}")]
[AllowAnonymous]
public async Task<IActionResult> GetMonographAsync([FromRoute] string id)
{
var monograph = await Monographs.FindOneAsync((m) => m.Id == id);
if (monograph == null)
{
return NotFound(new
{
error = "invalid_id",
error_description = $"No such monograph found."
});
}
if (monograph.SelfDestruct)
await Monographs.DeleteByIdAsync(monograph.Id);
if (monograph.EncryptedContent == null)
monograph.Content = monograph.CompressedContent.DecompressBrotli();
return Ok(monograph);
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteAsync([FromRoute] string id)
{
Monographs.DeleteById(id);
if (!await unit.Commit()) return BadRequest();
return Ok();
}
}
}

View File

@@ -0,0 +1,111 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Amazon.S3;
using Amazon.Runtime;
using Amazon.S3.Model;
using System.Threading.Tasks;
using System.Text.RegularExpressions;
using System.Security.Claims;
using System.Net.Http;
using System.Linq;
using Notesnook.API.Interfaces;
using System;
namespace Notesnook.API.Controllers
{
[ApiController]
[Route("s3")]
[Authorize("Sync")]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public class S3Controller : ControllerBase
{
private IS3Service S3Service { get; set; }
public S3Controller(IS3Service s3Service)
{
S3Service = s3Service;
}
[HttpPut]
public IActionResult Upload([FromQuery] string name)
{
var userId = this.User.FindFirstValue("sub");
var url = S3Service.GetUploadObjectUrl(userId, name);
if (url == null) return BadRequest("Could not create signed url.");
return Ok(url);
}
[HttpGet("multipart")]
public async Task<IActionResult> MultipartUpload([FromQuery] string name, [FromQuery] int parts, [FromQuery] string uploadId)
{
var userId = this.User.FindFirstValue("sub");
try
{
var meta = await S3Service.StartMultipartUploadAsync(userId, name, parts, uploadId);
return Ok(meta);
}
catch (Exception ex) { return BadRequest(ex.Message); }
}
[HttpDelete("multipart")]
public async Task<IActionResult> AbortMultipartUpload([FromQuery] string name, [FromQuery] string uploadId)
{
var userId = this.User.FindFirstValue("sub");
try
{
await S3Service.AbortMultipartUploadAsync(userId, name, uploadId);
return Ok();
}
catch (Exception ex) { return BadRequest(ex.Message); }
}
[HttpPost("multipart")]
public async Task<IActionResult> CompleteMultipartUpload([FromBody] CompleteMultipartUploadRequest uploadRequest)
{
var userId = this.User.FindFirstValue("sub");
try
{
await S3Service.CompleteMultipartUploadAsync(userId, uploadRequest);
return Ok();
}
catch (Exception ex) { return BadRequest(ex.Message); }
}
[HttpGet]
[Authorize]
public IActionResult Download([FromQuery] string name)
{
var userId = this.User.FindFirstValue("sub");
var url = S3Service.GetDownloadObjectUrl(userId, name);
if (url == null) return BadRequest("Could not create signed url.");
return Ok(url);
}
[HttpHead]
[Authorize]
public async Task<IActionResult> Info([FromQuery] string name)
{
var userId = this.User.FindFirstValue("sub");
var size = await S3Service.GetObjectSizeAsync(userId, name);
if (size == null) return BadRequest();
HttpContext.Response.Headers.ContentLength = size;
return Ok();
}
[HttpDelete]
public async Task<IActionResult> DeleteAsync([FromQuery] string name)
{
try
{
var userId = this.User.FindFirstValue("sub");
await S3Service.DeleteObjectAsync(userId, name);
return Ok();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
}
}

View File

@@ -0,0 +1,98 @@
using System;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Notesnook.API.Interfaces;
using Notesnook.API.Models.Responses;
using Streetwriters.Common;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Models;
namespace Notesnook.API.Controllers
{
[ApiController]
[Authorize]
[Route("users")]
public class UsersController : ControllerBase
{
private readonly HttpClient httpClient;
private readonly IHttpContextAccessor HttpContextAccessor;
private IUserService UserService { get; set; }
public UsersController(IUserService userService, IHttpContextAccessor accessor)
{
httpClient = new HttpClient();
HttpContextAccessor = accessor;
UserService = userService;
}
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Signup()
{
try
{
await UserService.CreateUserAsync();
return Ok();
}
catch (Exception ex)
{
await Slogger<UsersController>.Error(nameof(Signup), "Couldn't sign up.", ex.ToString());
return BadRequest(new { error = ex.Message });
}
}
[HttpGet]
public async Task<IActionResult> GetUser()
{
UserResponse response = await UserService.GetUserAsync();
if (!response.Success) return BadRequest(response);
return Ok(response);
}
[HttpPatch]
public async Task<IActionResult> UpdateUser([FromBody] UserResponse user)
{
UserResponse response = await UserService.GetUserAsync(false);
if (user.AttachmentsKey != null)
await UserService.SetUserAttachmentsKeyAsync(response.UserId, user.AttachmentsKey);
else return BadRequest();
return Ok();
}
[HttpPost("reset")]
public async Task<IActionResult> Reset([FromForm] bool removeAttachments)
{
var userId = this.User.FindFirstValue("sub");
if (await UserService.ResetUserAsync(userId, removeAttachments))
return Ok();
return BadRequest();
}
[HttpPost("delete")]
public async Task<IActionResult> Delete()
{
try
{
var userId = this.User.FindFirstValue("sub");
Response response = await this.httpClient.ForwardAsync<Response>(this.HttpContextAccessor, $"{Servers.IdentityServer.ToString()}/account/unregister", HttpMethod.Post);
if (!response.Success) return BadRequest();
if (await UserService.DeleteUserAsync(userId, User.FindFirstValue("jti")))
return Ok();
return BadRequest();
}
catch
{
return BadRequest();
}
}
}
}

View File

@@ -0,0 +1,54 @@
using System.Linq;
using System.Net;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Notesnook.API.Authorization;
namespace Notesnook.API.Extensions
{
public class AuthorizationResultTransformer : IAuthorizationMiddlewareResultHandler
{
private readonly IAuthorizationMiddlewareResultHandler _handler;
public AuthorizationResultTransformer()
{
_handler = new AuthorizationMiddlewareResultHandler();
}
public async Task HandleAsync(
RequestDelegate requestDelegate,
HttpContext httpContext,
AuthorizationPolicy authorizationPolicy,
PolicyAuthorizationResult policyAuthorizationResult)
{
var isWebsocket = httpContext.Request.Headers.Upgrade == "websocket";
if (!isWebsocket && policyAuthorizationResult.Forbidden && policyAuthorizationResult.AuthorizationFailure != null)
{
var error = string.Join("\n", policyAuthorizationResult.AuthorizationFailure.FailureReasons.Select((r) => r.Message));
if (!string.IsNullOrEmpty(error) && !isWebsocket)
{
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsync(JsonSerializer.Serialize(new { error }));
return;
}
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, policyAuthorizationResult);
}
else if (isWebsocket)
{
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, PolicyAuthorizationResult.Success());
}
else
{
await _handler.HandleAsync(requestDelegate, httpContext, authorizationPolicy, policyAuthorizationResult);
}
}
}
}

View File

@@ -0,0 +1,25 @@
using System.Threading;
using System;
using System.Threading.Tasks;
namespace MongoDB.Driver
{
public static class TransactionHelper
{
public static async Task StartTransaction(this IMongoClient client, Action<CancellationToken> operate, CancellationToken ct)
{
using (var session = await client.StartSessionAsync())
{
var transactionOptions = new TransactionOptions(readPreference: ReadPreference.Nearest, readConcern: ReadConcern.Local, writeConcern: WriteConcern.WMajority);
await session.WithTransactionAsync((handle, token) =>
{
return Task.Run(() =>
{
operate(token);
return true;
});
}, transactionOptions, ct);
}
}
}
}

View File

@@ -0,0 +1,272 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Notesnook.API.Authorization;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common.Models;
using Streetwriters.Data.Interfaces;
namespace Notesnook.API.Hubs
{
public interface ISyncHubClient
{
Task SyncItem(SyncTransferItem transferItem);
Task RemoteSyncCompleted(long lastSynced);
Task SyncCompleted();
}
[Authorize("Sync")]
public class SyncHub : Hub<ISyncHubClient>
{
private ISyncItemsRepositoryAccessor Repositories { get; }
private readonly IUnitOfWork unit;
public SyncHub(ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor, IUnitOfWork unitOfWork)
{
Repositories = syncItemsRepositoryAccessor;
unit = unitOfWork;
}
public override Task OnConnectedAsync()
{
var result = new SyncRequirement().IsAuthorized(Context.User, new PathString("/hubs/sync"));
if (!result.Succeeded)
{
var reason = result.AuthorizationFailure.FailureReasons.FirstOrDefault();
throw new HubException(reason?.Message ?? "Unauthorized");
}
var id = Context.User.FindFirstValue("sub");
Groups.AddToGroupAsync(Context.ConnectionId, id);
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception exception)
{
var id = Context.User.FindFirstValue("sub");
Groups.RemoveFromGroupAsync(Context.ConnectionId, id);
return base.OnDisconnectedAsync(exception);
}
public async Task<int> SyncItem(BatchedSyncTransferItem transferItem)
{
var userId = Context.User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId)) return 0;
var others = Clients.OthersInGroup(userId);
UserSettings userSettings = await this.Repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
long dateSynced = transferItem.LastSynced > userSettings.LastSynced ? transferItem.LastSynced : userSettings.LastSynced;
for (int i = 0; i < transferItem.Items.Length; ++i)
{
var data = transferItem.Items[i];
var type = transferItem.Types[i];
others.SyncItem(
new SyncTransferItem
{
Item = data,
ItemType = type,
LastSynced = dateSynced,
Total = transferItem.Total,
Current = transferItem.Current + i
});
switch (type)
{
case "content":
Repositories.Contents.Upsert(JsonSerializer.Deserialize<Content>(data), userId, dateSynced);
break;
case "attachment":
Repositories.Attachments.Upsert(JsonSerializer.Deserialize<Attachment>(data), userId, dateSynced);
break;
case "note":
Repositories.Notes.Upsert(JsonSerializer.Deserialize<Note>(data), userId, dateSynced);
break;
case "notebook":
Repositories.Notebooks.Upsert(JsonSerializer.Deserialize<Notebook>(data), userId, dateSynced);
break;
case "shortcut":
Repositories.Shortcuts.Upsert(JsonSerializer.Deserialize<Shortcut>(data), userId, dateSynced);
break;
case "reminder":
Repositories.Reminders.Upsert(JsonSerializer.Deserialize<Reminder>(data), userId, dateSynced);
break;
case "relation":
Repositories.Relations.Upsert(JsonSerializer.Deserialize<Relation>(data), userId, dateSynced);
break;
case "settings":
var settings = JsonSerializer.Deserialize<Setting>(data);
settings.Id = MongoDB.Bson.ObjectId.Parse(userId);
settings.ItemId = userId;
Repositories.Settings.Upsert(settings, userId, dateSynced);
break;
case "vaultKey":
userSettings.VaultKey = JsonSerializer.Deserialize<EncryptedData>(data);
Repositories.UsersSettings.Upsert(userSettings, (u) => u.UserId == userId);
break;
default:
throw new HubException("Invalid item type.");
}
}
return await unit.Commit() ? 1 : 0;
}
public async Task<bool> SyncCompleted(long dateSynced)
{
var userId = Context.User.FindFirstValue("sub");
UserSettings userSettings = await this.Repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
long lastSynced = dateSynced > userSettings.LastSynced ? dateSynced : userSettings.LastSynced;
userSettings.LastSynced = lastSynced;
await this.Repositories.UsersSettings.UpsertAsync(userSettings, (u) => u.UserId == userId);
await Clients.OthersInGroup(userId).RemoteSyncCompleted(lastSynced);
return true;
}
public async IAsyncEnumerable<SyncTransferItem> FetchItems(long lastSyncedTimestamp, [EnumeratorCancellation]
CancellationToken cancellationToken)
{
var userId = Context.User.FindFirstValue("sub");
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
if (userSettings.LastSynced > 0 && lastSyncedTimestamp > userSettings.LastSynced)
throw new HubException($"Provided timestamp value is too large. Server timestamp: {userSettings.LastSynced} Sent timestamp: {lastSyncedTimestamp}");
// var client = Clients.Caller;
if (lastSyncedTimestamp > 0 && userSettings.LastSynced == lastSyncedTimestamp)
{
yield return new SyncTransferItem
{
LastSynced = userSettings.LastSynced,
Synced = true
};
yield break;
}
var attachments = await Repositories.Attachments.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var notes = await Repositories.Notes.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var notebooks = await Repositories.Notebooks.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var contents = await Repositories.Contents.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var settings = await Repositories.Settings.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var shortcuts = await Repositories.Shortcuts.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var reminders = await Repositories.Reminders.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var relations = await Repositories.Relations.GetItemsSyncedAfterAsync(userId, lastSyncedTimestamp);
var collections = new Dictionary<string, IEnumerable<object>>
{
["attachment"] = attachments,
["note"] = notes,
["notebook"] = notebooks,
["content"] = contents,
["shortcut"] = shortcuts,
["reminder"] = reminders,
["relation"] = relations,
["settings"] = settings,
};
if (userSettings.VaultKey != null)
{
collections.Add("vaultKey", new object[] { userSettings.VaultKey });
}
var total = collections.Values.Sum((a) => a.Count());
if (total == 0)
{
yield return new SyncTransferItem
{
Synced = true,
LastSynced = userSettings.LastSynced
};
yield break;
}
foreach (var collection in collections)
{
foreach (var item in collection.Value)
{
if (item == null) continue;
// Check the cancellation token regularly so that the server will stop producing items if the client disconnects.
cancellationToken.ThrowIfCancellationRequested();
yield return new SyncTransferItem
{
LastSynced = userSettings.LastSynced,
Synced = false,
Item = JsonSerializer.Serialize(item),
ItemType = collection.Key,
Total = total,
};
}
}
}
}
[MessagePack.MessagePackObject]
public struct BatchedSyncTransferItem
{
[MessagePack.Key("lastSynced")]
public long LastSynced { get; set; }
[MessagePack.Key("items")]
public string[] Items { get; set; }
[MessagePack.Key("types")]
public string[] Types { get; set; }
[MessagePack.Key("total")]
public int Total { get; set; }
[MessagePack.Key("current")]
public int Current { get; set; }
}
[MessagePack.MessagePackObject]
public struct SyncTransferItem
{
[MessagePack.Key("synced")]
public bool Synced { get; set; }
[MessagePack.Key("lastSynced")]
public long LastSynced { get; set; }
[MessagePack.Key("item")]
public string Item { get; set; }
[MessagePack.Key("itemType")]
public string ItemType { get; set; }
[MessagePack.Key("total")]
public int Total { get; set; }
[MessagePack.Key("current")]
public int Current { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
namespace Notesnook.API.Interfaces
{
public interface IEncrypted
{
string Cipher { get; set; }
string IV { get; set; }
long Length { get; set; }
string Salt { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using Notesnook.API.Models;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
public interface IMonograph : IDocument
{
string Title { get; set; }
string UserId { get; set; }
byte[] CompressedContent { get; set; }
EncryptedData EncryptedContent { get; set; }
long DatePublished { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using Amazon.S3.Model;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
public interface IS3Service
{
Task DeleteObjectAsync(string userId, string name);
Task DeleteDirectoryAsync(string userId);
Task<long?> GetObjectSizeAsync(string userId, string name);
string GetUploadObjectUrl(string userId, string name);
string GetDownloadObjectUrl(string userId, string name);
Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string uploadId = null);
Task AbortMultipartUploadAsync(string userId, string name, string uploadId);
Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest);
}
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Bson.Serialization.Serializers;
using Notesnook.API.Models;
using Streetwriters.Common.Attributes;
using Streetwriters.Common.Converters;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
[BsonSerializer(typeof(ImpliedImplementationInterfaceSerializer<ISyncItem, SyncItem>))]
[JsonInterfaceConverter(typeof(InterfaceConverter<ISyncItem, SyncItem>))]
public interface ISyncItem
{
long DateSynced
{
get; set;
}
string UserId { get; set; }
string Algorithm { get; set; }
string IV { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
using Notesnook.API.Models;
using Notesnook.API.Repositories;
using Streetwriters.Common.Models;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Interfaces
{
public interface ISyncItemsRepositoryAccessor
{
SyncItemsRepository<Note> Notes { get; }
SyncItemsRepository<Notebook> Notebooks { get; }
SyncItemsRepository<Shortcut> Shortcuts { get; }
SyncItemsRepository<Reminder> Reminders { get; }
SyncItemsRepository<Relation> Relations { get; }
SyncItemsRepository<Content> Contents { get; }
SyncItemsRepository<Setting> Settings { get; }
SyncItemsRepository<Attachment> Attachments { get; }
Repository<UserSettings> UsersSettings { get; }
Repository<Monograph> Monographs { get; }
}
}

View File

@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using Notesnook.API.Models.Responses;
using Streetwriters.Common.Interfaces;
namespace Notesnook.API.Interfaces
{
public interface IUserService
{
Task CreateUserAsync();
Task<bool> DeleteUserAsync(string userId, string jti);
Task<bool> ResetUserAsync(string userId, bool removeAttachments);
Task<UserResponse> GetUserAsync(bool repair = true);
Task SetUserAttachmentsKeyAsync(string userId, IEncrypted key);
}
}

View File

@@ -0,0 +1,23 @@
using Notesnook.API.Models;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Interfaces
{
public interface IUserSettings : IDocument
{
string UserId { get; set; }
long LastSynced
{
get; set;
}
EncryptedData VaultKey
{
get; set;
}
string Salt { get; set; }
}
}

View File

@@ -0,0 +1,7 @@
namespace Notesnook.API.Models
{
public class Algorithms
{
public static string Default => "xcha-argon2i13-7";
}
}

View File

@@ -0,0 +1,142 @@
using System;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Data.Attributes;
namespace Notesnook.API.Models
{
[BsonCollection("notesnook", "announcements")]
public class Announcement
{
public Announcement()
{
this.Id = ObjectId.GenerateNewId().ToString();
}
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[BsonElement("id")]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("type")]
[BsonElement("type")]
public string Type { get; set; }
[JsonPropertyName("timestamp")]
[BsonElement("timestamp")]
public long Timestamp { get; set; }
[JsonPropertyName("platforms")]
[BsonElement("platforms")]
public string[] Platforms { get; set; }
[JsonPropertyName("isActive")]
[BsonElement("isActive")]
public bool IsActive { get; set; }
[JsonPropertyName("userTypes")]
[BsonElement("userTypes")]
public string[] UserTypes { get; set; }
[JsonPropertyName("appVersion")]
[BsonElement("appVersion")]
public int AppVersion { get; set; }
[JsonPropertyName("body")]
[BsonElement("body")]
public BodyComponent[] Body { get; set; }
[JsonIgnore]
[BsonElement("userIds")]
public string[] UserIds { get; set; }
[Obsolete]
[JsonPropertyName("title")]
[DataMember(Name = "title")]
[BsonElement("title")]
public string Title { get; set; }
[Obsolete]
[JsonPropertyName("description")]
[BsonElement("description")]
public string Description { get; set; }
[Obsolete]
[JsonPropertyName("callToActions")]
[BsonElement("callToActions")]
public CallToAction[] CallToActions { get; set; }
}
public class BodyComponent
{
[JsonPropertyName("type")]
[BsonElement("type")]
public string Type { get; set; }
[JsonPropertyName("platforms")]
[BsonElement("platforms")]
public string[] Platforms { get; set; }
[JsonPropertyName("style")]
[BsonElement("style")]
public Style Style { get; set; }
[JsonPropertyName("src")]
[BsonElement("src")]
public string Src { get; set; }
[JsonPropertyName("text")]
[BsonElement("text")]
public string Text { get; set; }
[JsonPropertyName("value")]
[BsonElement("value")]
public string Value { get; set; }
[JsonPropertyName("items")]
[BsonElement("items")]
public BodyComponent[] Items { get; set; }
[JsonPropertyName("actions")]
[BsonElement("actions")]
public CallToAction[] Actions { get; set; }
}
public class Style
{
[JsonPropertyName("marginTop")]
[BsonElement("marginTop")]
public int MarginTop { get; set; }
[JsonPropertyName("marginBottom")]
[BsonElement("marginBottom")]
public int MarginBottom { get; set; }
[JsonPropertyName("textAlign")]
[BsonElement("textAlign")]
public string TextAlign { get; set; }
}
public class CallToAction
{
[JsonPropertyName("type")]
[BsonElement("type")]
public string Type { get; set; }
[JsonPropertyName("platforms")]
[BsonElement("platforms")]
public string[] Platforms { get; set; }
[JsonPropertyName("data")]
[BsonElement("data")]
public string Data { get; set; }
[JsonPropertyName("title")]
[BsonElement("title")]
public string Title { get; set; }
}
}

View File

@@ -0,0 +1,37 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Notesnook.API.Interfaces;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
namespace Notesnook.API.Models
{
public class EncryptedData : IEncrypted
{
[JsonPropertyName("iv")]
[BsonElement("iv")]
[DataMember(Name = "iv")]
public string IV
{
get; set;
}
[JsonPropertyName("cipher")]
[BsonElement("cipher")]
[DataMember(Name = "cipher")]
public string Cipher
{
get; set;
}
[JsonPropertyName("length")]
[BsonElement("length")]
[DataMember(Name = "length")]
public long Length { get; set; }
[JsonPropertyName("salt")]
[BsonElement("salt")]
[DataMember(Name = "salt")]
public string Salt { get; set; }
}
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Notesnook.API.Interfaces;
using Streetwriters.Data.Attributes;
namespace Notesnook.API.Models
{
[BsonCollection("notesnook", "monographs")]
public class Monograph : IMonograph
{
public Monograph()
{
Id = ObjectId.GenerateNewId().ToString();
}
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("selfDestruct")]
public bool SelfDestruct { get; set; }
[JsonPropertyName("encryptedContent")]
public EncryptedData EncryptedContent { get; set; }
[JsonPropertyName("datePublished")]
public long DatePublished { get; set; }
[JsonPropertyName("content")]
[BsonIgnore]
public string Content { get; set; }
[JsonIgnore]
public byte[] CompressedContent { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
namespace Notesnook.API.Models
{
public class MultipartUploadMeta
{
public string UploadId { get; set; }
public string[] Parts { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Models.Responses
{
public class SignupResponse : Response
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("errors")]
public string[] Errors { get; set; }
}
}

View File

@@ -0,0 +1,24 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Notesnook.API.Interfaces;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Notesnook.API.Models.Responses
{
public class UserResponse : UserModel, IResponse
{
[JsonPropertyName("salt")]
public string Salt { get; set; }
[JsonPropertyName("attachmentsKey")]
public EncryptedData AttachmentsKey { get; set; }
[JsonPropertyName("subscription")]
public ISubscription Subscription { get; set; }
[JsonIgnore]
public bool Success { get; set; }
public int StatusCode { get; set; }
}
}

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Microsoft.AspNetCore.Mvc;
namespace Notesnook.API.Models
{
public class S3Options
{
public string ServiceUrl { get; set; }
public string Region { get; set; }
public string AccessKeyId { get; set; }
public string SecretAccessKey { get; set; }
}
}

View File

@@ -0,0 +1,108 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Notesnook.API.Interfaces;
using Streetwriters.Data.Attributes;
namespace Notesnook.API.Models
{
public class SyncItem : ISyncItem
{
[IgnoreDataMember]
[JsonPropertyName("dateSynced")]
public long DateSynced
{
get; set;
}
[DataMember(Name = "userId")]
[JsonPropertyName("userId")]
public string UserId
{
get; set;
}
[JsonPropertyName("iv")]
[DataMember(Name = "iv")]
[Required]
public string IV
{
get; set;
}
[JsonPropertyName("cipher")]
[DataMember(Name = "cipher")]
[Required]
public string Cipher
{
get; set;
}
[DataMember(Name = "id")]
[JsonPropertyName("id")]
public string ItemId
{
get; set;
}
[BsonId]
[BsonIgnoreIfDefault]
[BsonRepresentation(BsonType.ObjectId)]
[JsonIgnore]
public ObjectId Id
{
get; set;
}
[JsonPropertyName("length")]
[DataMember(Name = "length")]
[Required]
public long Length
{
get; set;
}
[JsonPropertyName("v")]
[DataMember(Name = "v")]
[Required]
public double Version
{
get; set;
}
[JsonPropertyName("alg")]
[DataMember(Name = "alg")]
[Required]
public string Algorithm
{
get; set;
} = Algorithms.Default;
}
[BsonCollection("notesnook", "attachments")]
public class Attachment : SyncItem { }
[BsonCollection("notesnook", "content")]
public class Content : SyncItem { }
[BsonCollection("notesnook", "notes")]
public class Note : SyncItem { }
[BsonCollection("notesnook", "notebooks")]
public class Notebook : SyncItem { }
[BsonCollection("notesnook", "relations")]
public class Relation : SyncItem { }
[BsonCollection("notesnook", "reminders")]
public class Reminder : SyncItem { }
[BsonCollection("notesnook", "settings")]
public class Setting : SyncItem { }
[BsonCollection("notesnook", "shortcuts")]
public class Shortcut : SyncItem { }
}

View File

@@ -0,0 +1,25 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Notesnook.API.Interfaces;
using Streetwriters.Data.Attributes;
namespace Notesnook.API.Models
{
[BsonCollection("notesnook", "user_settings")]
public class UserSettings : IUserSettings
{
public UserSettings()
{
this.Id = ObjectId.GenerateNewId().ToString();
}
public string UserId { get; set; }
public long LastSynced { get; set; }
public string Salt { get; set; }
public EncryptedData VaultKey { get; set; }
public EncryptedData AttachmentsKey { get; set; }
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string Id { get; set; }
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<StartupObject>Notesnook.API.Program</StartupObject>
<LangVersion>10.0</LangVersion>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<TargetLatestRuntimePatch>true</TargetLatestRuntimePatch>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.Core" Version="3.7.12.5" />
<PackageReference Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="6.0.1-rc2.2" />
<PackageReference Include="AWSSDK.S3" Version="3.7.9.21" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="6.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Streetwriters.Common\Streetwriters.Common.csproj" />
<ProjectReference Include="..\Streetwriters.Data\Streetwriters.Data.csproj" />
</ItemGroup>
</Project>

49
Notesnook.API/Program.cs Normal file
View File

@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Streetwriters.Common;
namespace Notesnook.API
{
public class Program
{
public static async Task Main(string[] args)
{
IHost host = CreateHostBuilder(args).Build();
await host.RunAsync();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder
.UseStartup<Startup>()
.UseKestrel((options) =>
{
options.Limits.MaxRequestBodySize = long.MaxValue;
#if DEBUG
options.ListenAnyIP(int.Parse(Servers.NotesnookAPI.Port));
#else
options.ListenAnyIP(443, listenerOptions =>
{
listenerOptions.UseHttps(Servers.OriginSSLCertificate);
});
options.ListenAnyIP(80);
options.Listen(IPAddress.Parse(Servers.NotesnookAPI.Hostname), int.Parse(Servers.NotesnookAPI.Port));
#endif
});
});
}
}

View File

@@ -0,0 +1,30 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:1740",
"sslPort": 44313
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Notesnook.API": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:6000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualBasic;
using MongoDB.Bson;
using MongoDB.Driver;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
namespace Notesnook.API.Repositories
{
public class SyncItemsRepository<T> : Repository<T> where T : SyncItem
{
public SyncItemsRepository(IDbContext dbContext) : base(dbContext)
{
Collection.Indexes.CreateOne(new CreateIndexModel<T>(Builders<T>.IndexKeys.Descending(i => i.DateSynced).Ascending(i => i.UserId)));
Collection.Indexes.CreateOne(new CreateIndexModel<T>(Builders<T>.IndexKeys.Ascending((i) => i.ItemId).Ascending(i => i.UserId)));
}
private readonly List<string> ALGORITHMS = new List<string> { Algorithms.Default };
private bool IsValidAlgorithm(string algorithm)
{
return ALGORITHMS.Contains(algorithm);
}
public async Task<IEnumerable<T>> GetItemsSyncedAfterAsync(string userId, long timestamp)
{
var cursor = await Collection.FindAsync(n => (n.DateSynced > timestamp) && n.UserId.Equals(userId));
return cursor.ToList();
}
// public async Task DeleteIdsAsync(string[] ids, string userId, CancellationToken token = default(CancellationToken))
// {
// await Collection.DeleteManyAsync<T>((i) => ids.Contains(i.Id) && i.UserId == userId, token);
// }
public void DeleteByUserId(string userId)
{
dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync<T>(handle, (i) => i.UserId == userId, cancellationToken: ct));
}
public async Task UpsertAsync(T item, string userId, long dateSynced)
{
if (item.Length > 15 * 1024 * 1024)
{
throw new Exception($"Size of item \"{item.ItemId}\" is too large. Maximum allowed size is 15 MB.");
}
if (!IsValidAlgorithm(item.Algorithm))
{
throw new Exception($"Invalid alg identifier {item.Algorithm}");
}
item.DateSynced = dateSynced;
item.UserId = userId;
await base.UpsertAsync(item, (x) => (x.ItemId == item.ItemId) && x.UserId == userId);
}
public void Upsert(T item, string userId, long dateSynced)
{
if (item.Length > 15 * 1024 * 1024)
{
throw new Exception($"Size of item \"{item.ItemId}\" is too large. Maximum allowed size is 15 MB.");
}
if (!IsValidAlgorithm(item.Algorithm))
{
throw new Exception($"Invalid alg identifier {item.Algorithm}");
}
item.DateSynced = dateSynced;
item.UserId = userId;
// await base.UpsertAsync(item, (x) => (x.ItemId == item.ItemId) && x.UserId == userId);
base.Upsert(item, (x) => (x.ItemId == item.ItemId) && x.UserId == userId);
}
}
}

View File

@@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Options;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Streetwriters.Common;
namespace Notesnook.API.Services
{
public class S3Service : IS3Service
{
private readonly string BUCKET_NAME = "nn-attachments";
private AmazonS3Client S3Client { get; }
private HttpClient httpClient = new HttpClient();
public S3Service(IOptions<S3Options> s3Options)
{
var config = new AmazonS3Config
{
#if DEBUG
ServiceURL = Servers.S3Server.ToString(),
#else
ServiceURL = s3Options.Value.ServiceUrl,
AuthenticationRegion = s3Options.Value.Region,
#endif
ForcePathStyle = true,
SignatureMethod = SigningAlgorithm.HmacSHA256,
SignatureVersion = "4"
};
#if DEBUG
S3Client = new AmazonS3Client("S3RVER", "S3RVER", config);
#else
S3Client = new AmazonS3Client(s3Options.Value.AccessKeyId, s3Options.Value.SecretAccessKey, config);
#endif
AWSConfigsS3.UseSignatureVersion4 = true;
}
public async Task DeleteObjectAsync(string userId, string name)
{
var objectName = GetFullObjectName(userId, name);
if (objectName == null) throw new Exception("Invalid object name."); ;
var response = await S3Client.DeleteObjectAsync(BUCKET_NAME, objectName);
if (!IsSuccessStatusCode(((int)response.HttpStatusCode)))
throw new Exception("Could not delete object.");
}
public async Task DeleteDirectoryAsync(string userId)
{
var request = new ListObjectsV2Request
{
BucketName = BUCKET_NAME,
Prefix = userId,
};
var response = new ListObjectsV2Response();
var keys = new List<KeyVersion>();
do
{
response = await S3Client.ListObjectsV2Async(request);
response.S3Objects.ForEach(obj => keys.Add(new KeyVersion
{
Key = obj.Key,
}));
request.ContinuationToken = response.NextContinuationToken;
}
while (response.IsTruncated);
if (keys.Count <= 0) return;
var deleteObjectsResponse = await S3Client
.DeleteObjectsAsync(new DeleteObjectsRequest
{
BucketName = BUCKET_NAME,
Objects = keys,
});
if (!IsSuccessStatusCode((int)deleteObjectsResponse.HttpStatusCode))
throw new Exception("Could not delete directory.");
}
public async Task<long?> GetObjectSizeAsync(string userId, string name)
{
var url = this.GetPresignedURL(userId, name, HttpVerb.HEAD);
if (url == null) return null;
var request = new HttpRequestMessage(HttpMethod.Head, url);
var response = await httpClient.SendAsync(request);
return response.Content.Headers.ContentLength;
}
public string GetUploadObjectUrl(string userId, string name)
{
var url = this.GetPresignedURL(userId, name, HttpVerb.PUT);
if (url == null) return null;
return url;
}
public string GetDownloadObjectUrl(string userId, string name)
{
var url = this.GetPresignedURL(userId, name, HttpVerb.GET);
if (url == null) return null;
return url;
}
public async Task<MultipartUploadMeta> StartMultipartUploadAsync(string userId, string name, int parts, string uploadId = null)
{
var objectName = GetFullObjectName(userId, name);
if (userId == null || objectName == null) throw new Exception("Could not initiate multipart upload.");
if (string.IsNullOrEmpty(uploadId))
{
var response = await S3Client.InitiateMultipartUploadAsync(BUCKET_NAME, objectName);
if (!IsSuccessStatusCode(((int)response.HttpStatusCode))) throw new Exception("Failed to initiate multipart upload.");
uploadId = response.UploadId;
}
var signedUrls = new string[parts];
for (var i = 0; i < parts; ++i)
{
signedUrls[i] = GetPresignedURLForUploadPart(objectName, uploadId, i + 1);
}
return new MultipartUploadMeta
{
UploadId = uploadId,
Parts = signedUrls
};
}
public async Task AbortMultipartUploadAsync(string userId, string name, string uploadId)
{
var objectName = GetFullObjectName(userId, name);
if (userId == null || objectName == null) throw new Exception("Could not abort multipart upload.");
var response = await S3Client.AbortMultipartUploadAsync(BUCKET_NAME, objectName, uploadId);
if (!IsSuccessStatusCode(((int)response.HttpStatusCode))) throw new Exception("Failed to abort multipart upload.");
}
public async Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest)
{
var objectName = GetFullObjectName(userId, uploadRequest.Key);
if (userId == null || objectName == null) throw new Exception("Could not abort multipart upload.");
uploadRequest.Key = objectName;
uploadRequest.BucketName = BUCKET_NAME;
var response = await S3Client.CompleteMultipartUploadAsync(uploadRequest);
if (!IsSuccessStatusCode(((int)response.HttpStatusCode))) throw new Exception("Failed to complete multipart upload.");
}
private string GetPresignedURL(string userId, string name, HttpVerb httpVerb)
{
var objectName = GetFullObjectName(userId, name);
if (userId == null || objectName == null) return null;
var request = new GetPreSignedUrlRequest
{
BucketName = BUCKET_NAME,
Expires = System.DateTime.Now.AddHours(1),
Verb = httpVerb,
Key = objectName,
#if DEBUG
Protocol = Protocol.HTTP,
#else
Protocol = Protocol.HTTPS,
#endif
};
return S3Client.GetPreSignedURL(request);
}
private string GetPresignedURLForUploadPart(string objectName, string uploadId, int partNumber)
{
return S3Client.GetPreSignedURL(new GetPreSignedUrlRequest
{
BucketName = BUCKET_NAME,
Expires = System.DateTime.Now.AddHours(1),
Verb = HttpVerb.PUT,
Key = objectName,
PartNumber = partNumber,
UploadId = uploadId,
#if DEBUG
Protocol = Protocol.HTTP,
#else
Protocol = Protocol.HTTPS,
#endif
});
}
private string GetFullObjectName(string userId, string name)
{
if (userId == null || !Regex.IsMatch(name, "[0-9a-zA-Z!" + Regex.Escape("-") + "_.*'()]")) return null;
return $"{userId}/{name}";
}
bool IsSuccessStatusCode(int statusCode)
{
return ((int)statusCode >= 200) && ((int)statusCode <= 299);
}
}
}

View File

@@ -0,0 +1,194 @@
using System;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Models.Responses;
using Streetwriters.Common;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Data.Interfaces;
namespace Notesnook.API.Services
{
public class UserService : IUserService
{
private static readonly System.Security.Cryptography.RandomNumberGenerator Rng = System.Security.Cryptography.RandomNumberGenerator.Create();
private readonly HttpClient httpClient;
private IHttpContextAccessor HttpContextAccessor { get; }
private ISyncItemsRepositoryAccessor Repositories { get; }
private IS3Service S3Service { get; set; }
private readonly IUnitOfWork unit;
public UserService(IHttpContextAccessor accessor,
ISyncItemsRepositoryAccessor syncItemsRepositoryAccessor,
IUnitOfWork unitOfWork, IS3Service s3Service)
{
httpClient = new HttpClient();
Repositories = syncItemsRepositoryAccessor;
HttpContextAccessor = accessor;
unit = unitOfWork;
S3Service = s3Service;
}
public async Task CreateUserAsync()
{
SignupResponse response = await httpClient.ForwardAsync<SignupResponse>(this.HttpContextAccessor, $"{Servers.IdentityServer}/signup", HttpMethod.Post);
if (!response.Success || (response.Errors != null && response.Errors.Length > 0))
{
await Slogger<UserService>.Error(nameof(CreateUserAsync), "Couldn't sign up.", JsonSerializer.Serialize(response));
if (response.Errors != null && response.Errors.Length > 0) throw new Exception(string.Join(" ", response.Errors));
else throw new Exception("Could not create a new account. Error code: " + response.StatusCode);
}
await Repositories.UsersSettings.InsertAsync(new UserSettings
{
UserId = response.UserId,
LastSynced = 0,
Salt = GetSalt()
});
await WampServers.SubscriptionServer.PublishMessageAsync(WampServers.SubscriptionServer.Topics.CreateSubscriptionTopic, new CreateSubscriptionMessage
{
AppId = ApplicationType.NOTESNOOK,
Provider = SubscriptionProvider.STREETWRITERS,
Type = SubscriptionType.BASIC,
UserId = response.UserId,
StartTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
await Slogger<UserService>.Info(nameof(CreateUserAsync), "New user created.", JsonSerializer.Serialize(response));
}
public async Task<UserResponse> GetUserAsync(bool repair = true)
{
UserResponse response = await httpClient.ForwardAsync<UserResponse>(this.HttpContextAccessor, $"{Servers.IdentityServer.ToString()}/account", HttpMethod.Get);
if (!response.Success) return response;
SubscriptionResponse subscriptionResponse = await httpClient.ForwardAsync<SubscriptionResponse>(this.HttpContextAccessor, $"{Servers.SubscriptionServer}/subscriptions", HttpMethod.Get);
if (repair && subscriptionResponse.StatusCode == 404)
{
await Slogger<UserService>.Error(nameof(GetUserAsync), "Repairing user subscription.", JsonSerializer.Serialize(response));
// user was partially created. We should continue the process here.
await WampServers.SubscriptionServer.PublishMessageAsync(WampServers.SubscriptionServer.Topics.CreateSubscriptionTopic, new CreateSubscriptionMessage
{
AppId = ApplicationType.NOTESNOOK,
Provider = SubscriptionProvider.STREETWRITERS,
Type = SubscriptionType.TRIAL,
UserId = response.UserId,
StartTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ExpiryTime = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeMilliseconds()
});
// just a dummy object
subscriptionResponse.Subscription = new Subscription
{
AppId = ApplicationType.NOTESNOOK,
Provider = SubscriptionProvider.STREETWRITERS,
Type = SubscriptionType.TRIAL,
UserId = response.UserId,
StartDate = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
ExpiryDate = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeMilliseconds()
};
}
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == response.UserId);
if (repair && userSettings == null)
{
await Slogger<UserService>.Error(nameof(GetUserAsync), "Repairing user settings.", JsonSerializer.Serialize(response));
userSettings = new UserSettings
{
UserId = response.UserId,
LastSynced = 0,
Salt = GetSalt()
};
await Repositories.UsersSettings.InsertAsync(userSettings);
}
response.AttachmentsKey = userSettings.AttachmentsKey;
response.Salt = userSettings.Salt;
response.Subscription = subscriptionResponse.Subscription;
return response;
}
public async Task SetUserAttachmentsKeyAsync(string userId, IEncrypted key)
{
var userSettings = await Repositories.UsersSettings.FindOneAsync((u) => u.UserId == userId);
userSettings.AttachmentsKey = (EncryptedData)key;
await Repositories.UsersSettings.UpdateAsync(userSettings.Id, userSettings);
}
public async Task<bool> DeleteUserAsync(string userId, string jti)
{
var cc = new CancellationTokenSource();
Repositories.Notes.DeleteByUserId(userId);
Repositories.Notebooks.DeleteByUserId(userId);
Repositories.Shortcuts.DeleteByUserId(userId);
Repositories.Contents.DeleteByUserId(userId);
Repositories.Settings.DeleteByUserId(userId);
Repositories.Attachments.DeleteByUserId(userId);
Repositories.UsersSettings.Delete((u) => u.UserId == userId);
await WampServers.SubscriptionServer.PublishMessageAsync(WampServers.SubscriptionServer.Topics.DeleteSubscriptionTopic, new DeleteSubscriptionMessage
{
AppId = ApplicationType.NOTESNOOK,
UserId = userId
});
await WampServers.MessengerServer.PublishMessageAsync(WampServers.MessengerServer.Topics.SendSSETopic, new SendSSEMessage
{
SendToAll = false,
OriginTokenId = jti,
UserId = userId,
Message = new Message
{
Type = "userDeleted",
Data = JsonSerializer.Serialize(new { reason = "accountDeleted" })
}
});
await S3Service.DeleteDirectoryAsync(userId);
return await unit.Commit();
}
public async Task<bool> ResetUserAsync(string userId, bool removeAttachments)
{
var cc = new CancellationTokenSource();
Repositories.Notes.DeleteByUserId(userId);
Repositories.Notebooks.DeleteByUserId(userId);
Repositories.Shortcuts.DeleteByUserId(userId);
Repositories.Contents.DeleteByUserId(userId);
Repositories.Settings.DeleteByUserId(userId);
Repositories.Attachments.DeleteByUserId(userId);
Repositories.Monographs.DeleteMany((m) => m.UserId == userId);
if (!await unit.Commit()) return false;
var userSettings = await Repositories.UsersSettings.FindOneAsync((s) => s.UserId == userId);
userSettings.AttachmentsKey = null;
userSettings.VaultKey = null;
userSettings.LastSynced = 0;
await Repositories.UsersSettings.UpsertAsync(userSettings, (s) => s.UserId == userId);
if (removeAttachments)
await S3Service.DeleteDirectoryAsync(userId);
return true;
}
private string GetSalt()
{
byte[] salt = new byte[16];
Rng.GetNonZeroBytes(salt);
return Convert.ToBase64String(salt).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
}
}

233
Notesnook.API/Startup.cs Normal file
View File

@@ -0,0 +1,233 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.IO.Compression;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using IdentityModel.AspNetCore.OAuth2Introspection;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson.Serialization;
using Notesnook.API.Accessors;
using Notesnook.API.Authorization;
using Notesnook.API.Extensions;
using Notesnook.API.Hubs;
using Notesnook.API.Interfaces;
using Notesnook.API.Models;
using Notesnook.API.Repositories;
using Notesnook.API.Services;
using Streetwriters.Common;
using Streetwriters.Common.Extensions;
using Streetwriters.Common.Messages;
using Streetwriters.Common.Models;
using Streetwriters.Data;
using Streetwriters.Data.DbContexts;
using Streetwriters.Data.Interfaces;
using Streetwriters.Data.Repositories;
namespace Notesnook.API
{
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
var dbSettings = Configuration.GetSection("MongoDbSettings").Get<DbSettings>();
services.AddSingleton<IDbSettings>(dbSettings);
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
services.AddCors();
services.AddDistributedMemoryCache(delegate (MemoryDistributedCacheOptions cacheOptions)
{
cacheOptions.SizeLimit = 262144000L;
});
services.AddAuthorization(options =>
{
options.AddPolicy("Notesnook", policy =>
{
policy.AuthenticationSchemes.Add("introspection");
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new NotesnookUserRequirement());
});
options.AddPolicy("Sync", policy =>
{
policy.AuthenticationSchemes.Add("introspection");
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new SyncRequirement());
});
options.AddPolicy("Verified", policy =>
{
policy.AuthenticationSchemes.Add("introspection");
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new EmailVerifiedRequirement());
});
options.AddPolicy("Pro", policy =>
{
policy.AuthenticationSchemes.Add("introspection");
policy.RequireAuthenticatedUser();
policy.Requirements.Add(new ProUserRequirement());
});
options.AddPolicy("BasicAdmin", policy =>
{
policy.AuthenticationSchemes.Add("BasicAuthentication");
policy.RequireClaim(ClaimTypes.Role, "Admin");
});
options.DefaultPolicy = options.GetPolicy("Notesnook");
}).AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationResultTransformer>(); ;
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = Servers.IdentityServer.ToString();
options.ClientSecret = Environment.GetEnvironmentVariable("NOTESNOOK_API_SECRET");
options.ClientId = "notesnook";
options.DiscoveryPolicy.RequireHttps = false;
options.TokenRetriever = new Func<HttpRequest, string>(req =>
{
var fromHeader = TokenRetrieval.FromAuthorizationHeader();
var fromQuery = TokenRetrieval.FromQueryString(); //needed for signalr and ws/wss conections to be authed via jwt
return fromHeader(req) ?? fromQuery(req);
});
options.Events.OnTokenValidated = (context) =>
{
if (long.TryParse(context.Principal.FindFirst("exp")?.Value, out long expiryTime))
{
context.Properties.ExpiresUtc = DateTimeOffset.FromUnixTimeSeconds(expiryTime);
}
context.Properties.AllowRefresh = true;
context.Properties.IsPersistent = true;
context.HttpContext.User = context.Principal;
return Task.CompletedTask;
};
options.SaveToken = true;
options.EnableCaching = true;
options.CacheDuration = TimeSpan.FromMinutes(30);
});
if (!BsonClassMap.IsClassMapRegistered(typeof(UserSettings)))
{
BsonClassMap.RegisterClassMap<UserSettings>();
}
if (!BsonClassMap.IsClassMapRegistered(typeof(EncryptedData)))
{
BsonClassMap.RegisterClassMap<EncryptedData>();
}
if (!BsonClassMap.IsClassMapRegistered(typeof(CallToAction)))
{
BsonClassMap.RegisterClassMap<CallToAction>();
}
if (!BsonClassMap.IsClassMapRegistered(typeof(Announcement)))
{
BsonClassMap.RegisterClassMap<Announcement>();
}
services.AddScoped<IDbContext, MongoDbContext>();
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped(typeof(Repository<>));
services.AddScoped(typeof(SyncItemsRepository<>));
services.TryAddTransient<ISyncItemsRepositoryAccessor, SyncItemsRepositoryAccessor>();
services.TryAddTransient<IUserService, UserService>();
services.TryAddTransient<IS3Service, S3Service>();
services.AddControllers();
services.AddHealthChecks().AddMongoDb(dbSettings.ConnectionString, dbSettings.DatabaseName, "database-check");
services.AddSignalR((hub) =>
{
hub.MaximumReceiveMessageSize = 100 * 1024 * 1024;
hub.EnableDetailedErrors = true;
}).AddMessagePackProtocol();
services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
});
services.Configure<BrotliCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Fastest;
});
services.Configure<GzipCompressionProviderOptions>(options =>
{
options.Level = CompressionLevel.Fastest;
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
if (!env.IsDevelopment())
{
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
}
app.UseResponseCompression();
app.UseCors("notesnook");
app.UseWamp(WampServers.NotesnookServer, (realm, server) =>
{
IUserService service = app.GetScopedService<IUserService>();
realm.Subscribe<DeleteUserMessage>(server.Topics.DeleteUserTopic, async (ev) =>
{
await service.DeleteUserAsync(ev.UserId, null);
});
});
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapHealthChecks("/health");
endpoints.MapHub<SyncHub>("/hubs/sync", options =>
{
options.CloseOnAuthenticationExpiration = false;
options.Transports = HttpTransportType.WebSockets;
});
});
}
}
}

View File

@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"MongoDbSettings": {
"ConnectionString": "mongodb://localhost:27017/notesnook",
"DatabaseName": "notesnook"
}
}

34
Notesnook.sln Normal file
View File

@@ -0,0 +1,34 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notesnook.API", "Notesnook.API\Notesnook.API.csproj", "{05F79941-F8EF-4C56-A763-CDDFCA6645A3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Common", "Streetwriters.Common\Streetwriters.Common.csproj", "{0606F6B6-118F-42C0-A1E2-EBEF406F7DD9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Streetwriters.Data", "Streetwriters.Data\Streetwriters.Data.csproj", "{CBBA4BD8-B348-4CF0-A72A-0D3DE0E6001D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{05F79941-F8EF-4C56-A763-CDDFCA6645A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{05F79941-F8EF-4C56-A763-CDDFCA6645A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{05F79941-F8EF-4C56-A763-CDDFCA6645A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{05F79941-F8EF-4C56-A763-CDDFCA6645A3}.Release|Any CPU.Build.0 = Release|Any CPU
{0606F6B6-118F-42C0-A1E2-EBEF406F7DD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0606F6B6-118F-42C0-A1E2-EBEF406F7DD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0606F6B6-118F-42C0-A1E2-EBEF406F7DD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0606F6B6-118F-42C0-A1E2-EBEF406F7DD9}.Release|Any CPU.Build.0 = Release|Any CPU
{CBBA4BD8-B348-4CF0-A72A-0D3DE0E6001D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBBA4BD8-B348-4CF0-A72A-0D3DE0E6001D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBBA4BD8-B348-4CF0-A72A-0D3DE0E6001D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBBA4BD8-B348-4CF0-A72A-0D3DE0E6001D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,14 @@
using System;
using System.Text.Json.Serialization;
namespace Streetwriters.Common.Attributes
{
[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)]
public class JsonInterfaceConverterAttribute : JsonConverterAttribute
{
public JsonInterfaceConverterAttribute(Type converterType)
: base(converterType)
{
}
}
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Streetwriters.Common
{
public class Clients
{
private static IClient Notesnook = new Client
{
Id = "notesnook",
Name = "Notesnook",
ProductIds = new string[]
{
"com.streetwriters.notesnook",
"org.streetwriters.notesnook",
"com.streetwriters.notesnook.sub.mo",
"com.streetwriters.notesnook.sub.yr",
"com.streetwriters.notesnook.sub.mo.15",
"com.streetwriters.notesnook.sub.yr.15",
"com.streetwriters.notesnook.sub.yr.trialoffer",
"com.streetwriters.notesnook.sub.mo.trialoffer",
"com.streetwriters.notesnook.sub.mo.tier1",
"com.streetwriters.notesnook.sub.yr.tier1",
"com.streetwriters.notesnook.sub.mo.tier2",
"com.streetwriters.notesnook.sub.yr.tier2",
"com.streetwriters.notesnook.sub.mo.tier3",
"com.streetwriters.notesnook.sub.yr.tier3",
"9822", // dev
"648884", // monthly tier 1
"658759", // yearly tier 1
"763942", // monthly tier 2
"763945", // yearly tier 2
"763943", // monthly tier 3
"763944", // yearly tier 3
},
SenderEmail = "support@notesnook.com",
SenderName = "Notesnook",
Type = ApplicationType.NOTESNOOK,
AppId = ApplicationType.NOTESNOOK,
WelcomeEmailTemplateId = "d-87768b3ee17d41fdbe4bcf0eb2583682"
};
public static Dictionary<string, IClient> ClientsMap = new Dictionary<string, IClient>
{
{ "notesnook", Notesnook }
};
public static IClient FindClientById(string id)
{
if (!IsValidClient(id)) return null;
return ClientsMap[id];
}
public static IClient FindClientByAppId(ApplicationType appId)
{
switch (appId)
{
case ApplicationType.NOTESNOOK:
return ClientsMap["notesnook"];
}
return null;
}
public static IClient FindClientByProductId(string productId)
{
foreach (var client in ClientsMap)
{
if (client.Value.ProductIds.Contains(productId)) return client.Value;
}
return null;
}
public static bool IsValidClient(string id)
{
return ClientsMap.ContainsKey(id);
}
public static SubscriptionProvider? PlatformToSubscriptionProvider(string platform)
{
return platform switch
{
"ios" => SubscriptionProvider.APPLE,
"android" => SubscriptionProvider.GOOGLE,
"web" => SubscriptionProvider.PADDLE,
_ => null,
};
}
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Streetwriters.Common.Converters
{
/// <summary>
/// Converts simple interface into an object (assumes that there is only one class of TInterface)
/// </summary>
/// <typeparam name="TInterface">Interface type</typeparam>
/// <typeparam name="TClass">Class type</typeparam>
public class InterfaceConverter<TInterface, TClass> : JsonConverter<TInterface> where TClass : TInterface
{
public override TInterface Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return JsonSerializer.Deserialize<TClass>(ref reader, options);
}
public override void Write(Utf8JsonWriter writer, TInterface value, JsonSerializerOptions options)
{
switch (value)
{
case null:
JsonSerializer.Serialize(writer, null, options);
break;
default:
{
var type = value.GetType();
JsonSerializer.Serialize(writer, value, type, options);
break;
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Streetwriters.Common.Enums
{
public enum ApplicationType
{
NOTESNOOK = 0
}
}

View File

@@ -0,0 +1,10 @@
namespace Streetwriters.Common.Enums
{
public class MFAMethods
{
public static string Email => "email";
public static string SMS => "sms";
public static string App => "app";
public static string RecoveryCode => "recoveryCode";
}
}

View File

@@ -0,0 +1,10 @@
namespace Streetwriters.Common.Enums
{
public enum SubscriptionProvider
{
STREETWRITERS = 0,
APPLE = 1,
GOOGLE = 2,
PADDLE = 3
}
}

View File

@@ -0,0 +1,12 @@
namespace Streetwriters.Common.Enums
{
public enum SubscriptionType
{
BASIC = 0,
TRIAL = 1,
BETA = 2,
PREMIUM = 5,
PREMIUM_EXPIRED = 6,
PREMIUM_CANCELED = 7
}
}

View File

@@ -0,0 +1,45 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using WampSharp.AspNetCore.WebSockets.Server;
using WampSharp.Binding;
using WampSharp.V2;
using WampSharp.V2.Realm;
namespace Streetwriters.Common.Extensions
{
public static class AppBuilderExtensions
{
public static IApplicationBuilder UseWamp<T>(this IApplicationBuilder app, WampServer<T> server, Action<IWampHostedRealm, WampServer<T>> action) where T : new()
{
WampHost host = new WampHost();
app.Map(server.Endpoint, builder =>
{
builder.UseWebSockets();
host.RegisterTransport(new AspNetCoreWebSocketTransport(builder),
new JTokenJsonBinding(),
new JTokenMsgpackBinding());
});
host.Open();
action.Invoke(host.RealmContainer.GetRealmByName(server.Realm), server);
return app;
}
public static T GetService<T>(this IApplicationBuilder app)
{
return app.ApplicationServices.GetRequiredService<T>();
}
public static T GetScopedService<T>(this IApplicationBuilder app)
{
using (var scope = app.ApplicationServices.CreateScope())
{
return scope.ServiceProvider.GetRequiredService<T>();
}
}
}
}

View File

@@ -0,0 +1,53 @@
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Extensions
{
public static class HttpClientExtensions
{
public static async Task<T> SendRequestAsync<T>(this HttpClient httpClient, string url, IHeaderDictionary headers, HttpMethod method, HttpContent content = null) where T : IResponse, new()
{
var request = new HttpRequestMessage(method, url);
if (method != HttpMethod.Get && method != HttpMethod.Delete)
{
request.Content = content;
}
foreach (var header in headers)
{
if (header.Key == "Content-Type" || header.Key == "Content-Length")
{
if (request.Content != null)
request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value.AsEnumerable());
continue;
}
request.Headers.TryAddWithoutValidation(header.Key, header.Value.AsEnumerable());
}
var response = await httpClient.SendAsync(request);
if (response.Content.Headers.ContentLength > 0)
{
var res = await response.Content.ReadFromJsonAsync<T>();
res.Success = response.IsSuccessStatusCode;
res.StatusCode = (int)response.StatusCode;
return res;
}
else
{
return new T { Success = response.IsSuccessStatusCode, StatusCode = (int)response.StatusCode };
}
}
public static Task<T> ForwardAsync<T>(this HttpClient httpClient, IHttpContextAccessor accessor, string url, HttpMethod method) where T : IResponse, new()
{
var httpContext = accessor.HttpContext;
var content = new StreamContent(httpContext.Request.BodyReader.AsStream());
return httpClient.SendRequestAsync<T>(url, httpContext.Request.Headers, method, content);
}
}
}

View File

@@ -0,0 +1,24 @@
namespace Microsoft.Extensions.DependencyInjection.CorsServiceCollectionExtensions
{
public static class ServiceCollectionServiceExtensions
{
public static IServiceCollection AddCors(this IServiceCollection services)
{
services.AddCors(options =>
{
options.AddPolicy("notesnook", (b) =>
{
#if DEBUG
b.AllowAnyOrigin();
#else
b.WithOrigins("http://localhost:3000", "http://192.168.10.29:3000", "https://app.notesnook.com", "https://beta.notesnook.com", "https://budi.streetwriters.co", "http://localhost:9876");
#endif
b.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
return services;
}
}
}

View File

@@ -0,0 +1,72 @@
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
namespace System
{
public static class StringExtensions
{
public static string ToSha256(this string rawData, int maxLength = 12)
{
// Create a SHA256
using (SHA256 sha256Hash = SHA256.Create())
{
// ComputeHash - returns byte array
byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawData));
return ToHex(bytes, 0, maxLength);
}
}
public static byte[] CompressBrotli(this string input)
{
var raw = Encoding.Default.GetBytes(input);
using (MemoryStream memory = new MemoryStream())
{
using (BrotliStream brotli = new BrotliStream(memory, CompressionLevel.Optimal))
{
brotli.Write(raw, 0, raw.Length);
}
return memory.ToArray();
}
}
public static string DecompressBrotli(this byte[] compressed)
{
using (BrotliStream stream = new BrotliStream(new MemoryStream(compressed), CompressionMode.Decompress))
{
const int size = 4096;
byte[] buffer = new byte[size];
using (MemoryStream memory = new MemoryStream())
{
int count = 0;
do
{
count = stream.Read(buffer, 0, size);
if (count > 0)
{
memory.Write(buffer, 0, count);
}
}
while (count > 0);
return Encoding.Default.GetString(memory.ToArray());
}
}
}
private static string ToHex(byte[] bytes, int startIndex, int length)
{
char[] c = new char[length * 2];
byte b;
for (int bx = startIndex, cx = startIndex; bx < length; ++bx, ++cx)
{
b = ((byte)(bytes[bx] >> 4));
c[cx] = (char)(b > 9 ? b + 0x37 + 0x20 : b + 0x30);
b = ((byte)(bytes[bx] & 0x0F));
c[++cx] = (char)(b > 9 ? b + 0x37 + 0x20 : b + 0x30);
}
return new string(c);
}
}
}

View File

@@ -0,0 +1,23 @@
using System;
using Microsoft.AspNetCore.Builder;
using Streetwriters.Common.Interfaces;
using WampSharp.AspNetCore.WebSockets.Server;
using WampSharp.Binding;
using WampSharp.V2;
using WampSharp.V2.Realm;
namespace Streetwriters.Common.Extensions
{
public static class WampRealmExtensions
{
public static IDisposable Subscribe<T>(this IWampHostedRealm realm, string topicName, Action<T> onNext)
{
return realm.Services.GetSubject<T>(topicName).Subscribe<T>(onNext);
}
public static IDisposable Subscribe<T>(this IWampHostedRealm realm, string topicName, IMessageHandler<T> handler)
{
return realm.Services.GetSubject<T>(topicName).Subscribe<T>(async (message) => await handler.Process(message));
}
}
}

View File

@@ -0,0 +1,28 @@
using System.Reactive.Subjects;
using System.Threading.Tasks;
using Streetwriters.Common.Messages;
using WampSharp.V2;
using WampSharp.V2.Client;
namespace Streetwriters.Common.Helpers
{
public class WampHelper
{
public static async Task<IWampRealmProxy> OpenWampChannelAsync<T>(string server, string realmName)
{
DefaultWampChannelFactory channelFactory = new DefaultWampChannelFactory();
IWampChannel channel = channelFactory.CreateJsonChannel(server, realmName);
await channel.Open();
return channel.RealmProxy;
}
public static void PublishMessage<T>(IWampRealmProxy realm, string topicName, T message)
{
var subject = realm.Services.GetSubject<T>(topicName);
subject.OnNext(message);
}
}
}

View File

@@ -0,0 +1,16 @@
using Streetwriters.Common.Enums;
namespace Streetwriters.Common.Interfaces
{
public interface IClient
{
string Id { get; set; }
string Name { get; set; }
string[] ProductIds { get; set; }
ApplicationType Type { get; set; }
ApplicationType AppId { get; set; }
string SenderEmail { get; set; }
string SenderName { get; set; }
string WelcomeEmailTemplateId { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
namespace Streetwriters.Common.Interfaces
{
public interface IDocument
{
string Id
{
get; set;
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Interfaces
{
public interface IMessageHandler<T>
{
Task Process(T message);
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Interfaces
{
public interface IOffer : IDocument
{
ApplicationType AppId { get; set; }
string PromoCode { get; set; }
PromoCode[] Codes { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
namespace Streetwriters.Common.Interfaces
{
public interface IResponse
{
bool Success { get; set; }
int StatusCode { get; set; }
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using Streetwriters.Common.Attributes;
using Streetwriters.Common.Converters;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Models;
namespace Streetwriters.Common.Interfaces
{
[JsonInterfaceConverter(typeof(InterfaceConverter<ISubscription, Subscription>))]
public interface ISubscription : IDocument
{
string UserId { get; set; }
ApplicationType AppId { get; set; }
SubscriptionProvider Provider { get; set; }
long StartDate { get; set; }
long ExpiryDate { get; set; }
SubscriptionType Type { get; set; }
string OrderId { get; set; }
string SubscriptionId { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Common.Models;
namespace Streetwriters.Common
{
public class Slogger<T>
{
public static Task Info(string scope, params string[] messages)
{
return Write(Format("info", scope, messages));
}
public static Task Error(string scope, params string[] messages)
{
return Write(Format("error", scope, messages));
}
private static string Format(string level, string scope, params string[] messages)
{
var date = DateTime.UtcNow.ToString("MM-dd-yyyy HH:mm:ss");
var messageText = string.Join(" ", messages);
return $"[{date}] | {level} | <{scope}> {messageText}";
}
private static Task Write(string line)
{
var logDirectory = Path.GetFullPath("./logs");
if (!Directory.Exists(logDirectory))
Directory.CreateDirectory(logDirectory);
var path = Path.Join(logDirectory, typeof(T).FullName + "-" + DateTime.UtcNow.ToString("MM-dd-yyyy") + ".log");
return File.AppendAllLinesAsync(path, new string[1] { line });
}
}
}

View File

@@ -0,0 +1,44 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class CreateSubscriptionMessage
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("type")]
public SubscriptionType Type { get; set; }
[JsonPropertyName("start")]
public long StartTime { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryTime { get; set; }
[JsonPropertyName("orderId")]
public string OrderId { get; set; }
[JsonPropertyName("updateURL")]
public string UpdateURL { get; set; }
[JsonPropertyName("cancelURL")]
public string CancelURL { get; set; }
[JsonPropertyName("subscriptionId")]
public string SubscriptionId { get; set; }
[JsonPropertyName("productId")]
public string ProductId { get; set; }
public bool Extend { get; set; }
}
}

View File

@@ -0,0 +1,16 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class DeleteSubscriptionMessage
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class DeleteUserMessage
{
[JsonPropertyName("userId")]
public string UserId { get; set; }
}
}

View File

@@ -0,0 +1,29 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Messages
{
public class Message
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("data")]
public string Data { get; set; }
}
public class SendSSEMessage
{
[JsonPropertyName("sendToAll")]
public bool SendToAll { get; set; }
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonPropertyName("message")]
public Message Message { get; set; }
[JsonPropertyName("originTokenId")]
public string OriginTokenId { get; set; }
}
}

View File

@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class Client : IClient
{
public string Id { get; set; }
public string Name { get; set; }
public string[] ProductIds { get; set; }
public ApplicationType Type { get; set; }
public ApplicationType AppId { get; set; }
public string SenderEmail { get; set; }
public string SenderName { get; set; }
public string WelcomeEmailTemplateId { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class SubscriptionResponse : Response
{
[JsonPropertyName("subscription")]
public ISubscription Subscription { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
namespace Streetwriters.Common.Models
{
public class MFAConfig
{
public bool IsEnabled { get; set; }
public string PrimaryMethod { get; set; }
public string SecondaryMethod { get; set; }
public int RemainingValidCodes { get; set; }
}
}

View File

@@ -0,0 +1,36 @@
// Streetwriters.Common.Models.Offer
using System.Collections.Generic;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Data.Attributes;
namespace Streetwriters.Common.Models
{
[BsonCollection("subscriptions", "offers")]
public class Offer : IOffer
{
public Offer()
{
Id = ObjectId.GenerateNewId().ToString();
}
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("promoCode")]
public string PromoCode { get; set; }
[JsonPropertyName("codes")]
public PromoCode[] Codes { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
// Streetwriters.Common.Models.Offer
using System.Collections.Generic;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class PromoCode
{
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[JsonPropertyName("code")]
public string Code { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System.Runtime.Serialization;
using Newtonsoft.Json;
using Streetwriters.Common.Interfaces;
namespace Streetwriters.Common.Models
{
public class Response : IResponse
{
[JsonIgnore]
public bool Success { get; set; }
public int StatusCode { get; set; }
}
}

View File

@@ -0,0 +1,124 @@
using AspNetCore.Identity.Mongo.Model;
using Streetwriters.Data.Attributes;
namespace Streetwriters.Common.Models
{
[BsonCollection("identity", "roles")]
public class Role : MongoRole
{
// [DataMember(Name = "email")]
// [BsonElement("email")]
// public string Email
// {
// get; set;
// }
// [DataMember(Name = "isEmailConfirmed")]
// [BsonElement("isEmailConfirmed")]
// public bool IsEmailConfirmed { get; set; }
// [DataMember(Name = "username")]
// [BsonElement("username")]
// public string Username
// {
// get; set;
// }
// [BsonId]
// [BsonRepresentation(BsonType.ObjectId)]
// public string Id
// {
// get; set;
// }
// [IgnoreDataMember]
// [BsonElement("passwordHash")]
// public string PasswordHash
// {
// get; set;
// }
// [DataMember(Name = "salt")]
// public string Salt
// {
// get; set;
// }
}
/*
public class Picture
{
[DataMember(Name = "thumbnail")]
public string Thumbnail
{
get; set;
}
[DataMember(Name = "full")]
public string Full
{
get; set;
}
}
public class Streetwriters
{
[DataMember(Name = "fullName")]
public string FullName
{
get; set;
}
[DataMember(Name = "biography")]
[StringLength(240)]
public string Biography
{
get; set;
}
[DataMember(Name = "favoriteWords")]
public string FavoriteWords
{
get; set;
}
[DataMember(Name = "profilePicture")]
public Picture ProfilePicture
{
get; set;
}
[DataMember(Name = "followers")]
public string[] Followers
{
get; set;
}
[DataMember(Name = "following")]
public string[] Following
{
get; set;
}
[DataMember(Name = "website")]
[Url]
public string Website
{
get; set;
}
[DataMember(Name = "instagram")]
public string Instagram
{
get; set;
}
[DataMember(Name = "twitter")]
public string Twitter
{
get; set;
}
} */
}

View File

@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using Streetwriters.Common.Enums;
using Streetwriters.Common.Interfaces;
using Streetwriters.Data.Attributes;
namespace Streetwriters.Common.Models
{
[BsonCollection("subscriptions", "subscriptions")]
public class Subscription : ISubscription
{
public Subscription()
{
Id = ObjectId.GenerateNewId().ToString();
}
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("userId")]
public string UserId { get; set; }
[JsonIgnore]
public string OrderId { get; set; }
[JsonIgnore]
public string SubscriptionId { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("appId")]
public ApplicationType AppId { get; set; }
[JsonPropertyName("start")]
public long StartDate { get; set; }
[JsonPropertyName("expiry")]
public long ExpiryDate { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("provider")]
public SubscriptionProvider Provider { get; set; }
[BsonRepresentation(BsonType.Int32)]
[JsonPropertyName("type")]
public SubscriptionType Type { get; set; }
[JsonPropertyName("cancelURL")]
public string CancelURL { get; set; }
[JsonPropertyName("updateURL")]
public string UpdateURL { get; set; }
[JsonPropertyName("productId")]
public string ProductId { get; set; }
[JsonIgnore]
public int TrialExtensionCount { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
using AspNetCore.Identity.Mongo.Model;
using Streetwriters.Data.Attributes;
namespace Streetwriters.Common.Models
{
[BsonCollection("identity", "users")]
public class User : MongoUser
{
}
}

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Streetwriters.Common.Models
{
public class UserModel
{
[JsonPropertyName("id")]
public string UserId { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; }
[JsonPropertyName("phoneNumber")]
public string PhoneNumber { get; set; }
[JsonPropertyName("isEmailConfirmed")]
public bool IsEmailConfirmed { get; set; }
[JsonPropertyName("mfa")]
public MFAConfig MFA { get; set; }
}
}

View File

@@ -0,0 +1,130 @@
using System;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
namespace Streetwriters.Common
{
public class Server
{
public string Port { get; set; }
public bool IsSecure { get; set; }
public string Hostname { get; set; }
public string Domain { get; set; }
public override string ToString()
{
var url = "";
url += IsSecure ? "https" : "http";
url += $"://{Hostname}";
url += IsSecure ? "" : $":{Port}";
return url;
}
public string WS()
{
var url = "";
url += IsSecure ? "ws" : "ws";
url += $"://{Hostname}";
url += $":{Port}";
return url;
}
}
public class Servers
{
#if DEBUG
public static string GetLocalIPv4(NetworkInterfaceType _type)
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
string output = "";
foreach (NetworkInterface item in interfaces)
{
if (item.NetworkInterfaceType == _type && item.OperationalStatus == OperationalStatus.Up)
{
foreach (UnicastIPAddressInformation ip in item.GetIPProperties().UnicastAddresses)
{
if (ip.Address.AddressFamily == AddressFamily.InterNetwork)
{
output = ip.Address.ToString();
}
}
}
}
return output;
}
public readonly static string HOST = GetLocalIPv4(NetworkInterfaceType.Ethernet);
public static Server S3Server { get; } = new()
{
Port = "4568",
Hostname = HOST,
IsSecure = false,
Domain = HOST
};
#else
private readonly static string HOST = "localhost";
public readonly static X509Certificate2 OriginSSLCertificate = X509Certificate2.CreateFromPemFile("/home/notesnook/.ssl/CF_Origin_Streetwriters.pem", "/home/notesnook/.ssl/CF_Origin_Streetwriters.key");
#endif
public static Server NotesnookAPI { get; } = new()
{
Domain = "api.notesnook.com",
Port = "5264",
#if DEBUG
IsSecure = false,
Hostname = HOST,
#else
IsSecure = true,
Hostname = "10.0.0.5",
#endif
};
public static Server MessengerServer { get; } = new()
{
Domain = "events.streetwriters.co",
Port = "7264",
#if DEBUG
IsSecure = false,
Hostname = HOST,
#else
IsSecure = true,
Hostname = "10.0.0.6",
#endif
};
public static Server IdentityServer { get; } = new()
{
Domain = "auth.streetwriters.co",
IsSecure = false,
Port = "8264",
#if DEBUG
Hostname = HOST,
#else
Hostname = "10.0.0.4",
#endif
};
public static Server SubscriptionServer { get; } = new()
{
Domain = "subscriptions.streetwriters.co",
IsSecure = false,
Port = "9264",
#if DEBUG
Hostname = HOST,
#else
Hostname = "10.0.0.4",
#endif
};
public static Server PaymentsServer { get; } = new()
{
Domain = "payments.streetwriters.co",
IsSecure = false,
Port = "6264",
#if DEBUG
Hostname = HOST,
#else
Hostname = "10.0.0.4",
#endif
};
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Cors" Version="2.2.0" />
<PackageReference Include="WampSharp.Default" Version="20.1.1" />
<PackageReference Include="WampSharp.AspNetCore.WebSockets.Server" Version="20.1.1" />
<PackageReference Include="WampSharp.NewtonsoftMsgpack" Version="20.1.1" />
<PackageReference Include="Microsoft.AspNetCore.WebSockets" Version="2.2.1" />
<PackageReference Include="AspNetCore.Identity.Mongo" Version="8.3.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Streetwriters.Data\Streetwriters.Data.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Reactive.Subjects;
using System.Threading.Tasks;
using Streetwriters.Common.Helpers;
using WampSharp.V2.Client;
namespace Streetwriters.Common
{
public class WampServer<T> where T : new()
{
private readonly ConcurrentDictionary<string, IWampRealmProxy> Channels = new();
public string Endpoint { get; set; }
public string Address { get; set; }
public T Topics { get; set; } = new T();
public string Realm { get; set; }
public async Task PublishMessageAsync<V>(string topic, V message)
{
try
{
IWampRealmProxy channel;
if (Channels.ContainsKey(topic))
channel = Channels[topic];
else
{
channel = await WampHelper.OpenWampChannelAsync<V>(this.Address, this.Realm);
Channels.TryAdd(topic, channel);
}
if (!channel.Monitor.IsConnected)
{
Channels.TryRemove(topic, out IWampRealmProxy value);
await PublishMessageAsync<V>(topic, message);
return;
}
WampHelper.PublishMessage<V>(channel, topic, message);
}
catch (Exception ex)
{
await Slogger<WampServer<T>>.Error(nameof(PublishMessageAsync), ex.ToString());
throw ex;
}
}
}
public class WampServers
{
public static WampServer<MessengerServerTopics> MessengerServer { get; } = new WampServer<MessengerServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.MessengerServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<SubscriptionServerTopics> SubscriptionServer { get; } = new WampServer<SubscriptionServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.SubscriptionServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<IdentityServerTopics> IdentityServer { get; } = new WampServer<IdentityServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.IdentityServer.WS()}/wamp",
Realm = "messages",
};
public static WampServer<NotesnookServerTopics> NotesnookServer { get; } = new WampServer<NotesnookServerTopics>
{
Endpoint = "/wamp",
Address = $"{Servers.NotesnookAPI.WS()}/wamp",
Realm = "messages",
};
}
public class MessengerServerTopics
{
public string SendSSETopic => "com.streetwriters.sse.send";
}
public class SubscriptionServerTopics
{
public string CreateSubscriptionTopic => "com.streetwriters.subscriptions.create";
public string DeleteSubscriptionTopic => "com.streetwriters.subscriptions.delete";
}
public class IdentityServerTopics
{
public string CreateSubscriptionTopic => "com.streetwriters.subscriptions.create";
public string DeleteSubscriptionTopic => "com.streetwriters.subscriptions.delete";
}
public class NotesnookServerTopics
{
public string DeleteUserTopic => "com.streetwriters.notesnook.user.delete";
}
}

View File

@@ -0,0 +1,17 @@
using System;
namespace Streetwriters.Data.Attributes
{
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class BsonCollectionAttribute : Attribute
{
public string CollectionName { get; }
public string DatabaseName { get; }
public BsonCollectionAttribute(string databaseName, string collectionName)
{
CollectionName = collectionName;
DatabaseName = databaseName;
}
}
}

View File

@@ -0,0 +1,90 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using Streetwriters.Data.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Streetwriters.Data.DbContexts
{
public class MongoDbContext : IDbContext
{
private IMongoDatabase Database { get; set; }
private MongoClient MongoClient { get; set; }
private readonly List<Func<IClientSessionHandle, CancellationToken, Task>> _commands;
private IDbSettings DbSettings { get; set; }
public MongoDbContext(IDbSettings dbSettings)
{
DbSettings = dbSettings;
Configure();
// Every command will be stored and it'll be processed at SaveChanges
_commands = new List<Func<IClientSessionHandle, CancellationToken, Task>>();
}
public async Task<int> SaveChanges()
{
try
{
var count = _commands.Count;
using (IClientSessionHandle session = await MongoClient.StartSessionAsync())
{
#if DEBUG
await Task.WhenAll(_commands.Select(c => c(session, default(CancellationToken))));
#else
await session.WithTransactionAsync(async (handle, token) =>
{
await Task.WhenAll(_commands.Select(c => c(handle, token)));
return true;
});
#endif
}
return count;
}
catch (Exception ex)
{
// TODO use Slogger here.
await Console.Error.WriteLineAsync(ex.ToString());
return 0;
}
}
private void Configure()
{
if (MongoClient != null)
{
return;
}
var settings = MongoClientSettings.FromConnectionString(DbSettings.ConnectionString);
settings.MaxConnectionPoolSize = 5000;
settings.MinConnectionPoolSize = 300;
MongoClient = new MongoClient(settings);
}
public IMongoCollection<T> GetCollection<T>(string databaseName, string collectionName)
{
return MongoClient.GetDatabase(databaseName).GetCollection<T>(collectionName, new MongoCollectionSettings()
{
AssignIdOnInsert = true,
});
}
public void AddCommand(Func<IClientSessionHandle, CancellationToken, Task> func)
{
_commands.Add(func);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
public Task DropDatabaseAsync()
{
return MongoClient.DropDatabaseAsync(DbSettings.DatabaseName);
}
}
}

View File

@@ -0,0 +1,10 @@
using Streetwriters.Data.Interfaces;
namespace Streetwriters.Data
{
public class DbSettings : IDbSettings
{
public string DatabaseName { get; set; }
public string ConnectionString { get; set; }
}
}

View File

@@ -0,0 +1,15 @@
using MongoDB.Driver;
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Streetwriters.Data.Interfaces
{
public interface IDbContext : IDisposable
{
void AddCommand(Func<IClientSessionHandle, CancellationToken, Task> func);
Task<int> SaveChanges();
IMongoCollection<T> GetCollection<T>(string databaseName, string collectionName);
}
}

View File

@@ -0,0 +1,8 @@
namespace Streetwriters.Data.Interfaces
{
public interface IDbSettings
{
string DatabaseName { get; set; }
string ConnectionString { get; set; }
}
}

View File

@@ -0,0 +1,10 @@
using System;
using System.Threading.Tasks;
namespace Streetwriters.Data.Interfaces
{
public interface IUnitOfWork : IDisposable
{
Task<bool> Commit();
}
}

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using MongoDB.Bson;
using MongoDB.Driver;
using Streetwriters.Data.Attributes;
using Streetwriters.Data.Interfaces;
namespace Streetwriters.Data.Repositories
{
public class Repository<TEntity> where TEntity : class
{
protected readonly IDbContext dbContext;
protected IMongoCollection<TEntity> Collection { get; set; }
public Repository(IDbContext _dbContext)
{
dbContext = _dbContext;
Collection = GetCollection();
}
private protected IMongoCollection<TEntity> GetCollection()
{
var attribute = (BsonCollectionAttribute)typeof(TEntity).GetCustomAttributes(
typeof(BsonCollectionAttribute),
true).FirstOrDefault();
if (string.IsNullOrEmpty(attribute.CollectionName) || string.IsNullOrEmpty(attribute.DatabaseName)) throw new Exception("Could not get a valid collection or database name.");
return dbContext.GetCollection<TEntity>(attribute.DatabaseName, attribute.CollectionName);
}
public virtual void Insert(TEntity obj)
{
dbContext.AddCommand((handle, ct) => Collection.InsertOneAsync(handle, obj, null, ct));
}
public virtual Task InsertAsync(TEntity obj)
{
return Collection.InsertOneAsync(obj);
}
public virtual void Upsert(TEntity obj, Expression<Func<TEntity, bool>> filterExpression)
{
dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, filterExpression, obj, new ReplaceOptions { IsUpsert = true }, ct));
}
public virtual Task UpsertAsync(TEntity obj, Expression<Func<TEntity, bool>> filterExpression)
{
return Collection.ReplaceOneAsync(filterExpression, obj, new ReplaceOptions { IsUpsert = true });
}
public virtual async Task<TEntity> FindOneAsync(Expression<Func<TEntity, bool>> filterExpression)
{
var data = await Collection.FindAsync(filterExpression);
return data.FirstOrDefault();
}
public virtual async Task<TEntity> GetAsync(string id)
{
var data = await Collection.FindAsync(Builders<TEntity>.Filter.Eq("_id", ObjectId.Parse(id)));
return data.FirstOrDefault();
}
public virtual async Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> filterExpression)
{
var data = await Collection.FindAsync(filterExpression);
return data.ToList();
}
public virtual async Task<IEnumerable<TEntity>> GetAllAsync()
{
var all = await Collection.FindAsync(Builders<TEntity>.Filter.Empty);
return all.ToList();
}
public virtual void Update(string id, TEntity obj)
{
dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, Builders<TEntity>.Filter.Eq("_id", ObjectId.Parse(id)), obj, cancellationToken: ct));
}
public virtual Task UpdateAsync(string id, TEntity obj)
{
return Collection.ReplaceOneAsync(Builders<TEntity>.Filter.Eq("_id", ObjectId.Parse(id)), obj);
}
public virtual void DeleteById(string id)
{
dbContext.AddCommand((handle, ct) => Collection.DeleteOneAsync(handle, Builders<TEntity>.Filter.Eq("_id", ObjectId.Parse(id)), cancellationToken: ct));
}
public virtual Task DeleteByIdAsync(string id)
{
return Collection.DeleteOneAsync(Builders<TEntity>.Filter.Eq("_id", ObjectId.Parse(id)));
}
public virtual void Delete(Expression<Func<TEntity, bool>> filterExpression)
{
dbContext.AddCommand((handle, ct) => Collection.DeleteOneAsync(handle, filterExpression, cancellationToken: ct));
}
public virtual void DeleteMany(Expression<Func<TEntity, bool>> filterExpression)
{
dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filterExpression, cancellationToken: ct));
}
public virtual Task DeleteAsync(Expression<Func<TEntity, bool>> filterExpression)
{
return Collection.DeleteOneAsync(filterExpression);
}
public virtual Task DeleteManyAsync(Expression<Func<TEntity, bool>> filterExpression)
{
return Collection.DeleteManyAsync(filterExpression);
}
}
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="6.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.13.2" />
<PackageReference Include="MongoDB.Driver.Core" Version="2.13.2" />
<PackageReference Include="MongoDB.Bson" Version="2.13.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
using System;
using System.Threading.Tasks;
using Streetwriters.Data.Interfaces;
namespace Streetwriters.Data
{
public class UnitOfWork : IUnitOfWork
{
private readonly IDbContext dbContext;
public UnitOfWork(IDbContext _dbContext)
{
dbContext = _dbContext;
}
public async Task<bool> Commit()
{
var changeAmount = await dbContext.SaveChanges();
return changeAmount > 0;
}
public void Dispose()
{
this.dbContext.Dispose();
}
}
}