diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c50423 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs b/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs new file mode 100644 index 0000000..7d33ffb --- /dev/null +++ b/Notesnook.API/Accessors/SyncItemsRepositoryAccessor.cs @@ -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 Notes { get; } + public SyncItemsRepository Notebooks { get; } + public SyncItemsRepository Shortcuts { get; } + public SyncItemsRepository Relations { get; } + public SyncItemsRepository Reminders { get; } + public SyncItemsRepository Contents { get; } + public SyncItemsRepository Settings { get; } + public SyncItemsRepository Attachments { get; } + public Repository UsersSettings { get; } + public Repository Monographs { get; } + + public SyncItemsRepositoryAccessor(SyncItemsRepository _notes, + SyncItemsRepository _notebooks, + SyncItemsRepository _content, + SyncItemsRepository _settings, + SyncItemsRepository _attachments, + SyncItemsRepository _shortcuts, + SyncItemsRepository _relations, + SyncItemsRepository _reminders, + Repository _usersSettings, + Repository _monographs) + { + Notebooks = _notebooks; + Notes = _notes; + Contents = _content; + Settings = _settings; + Attachments = _attachments; + UsersSettings = _usersSettings; + Monographs = _monographs; + Shortcuts = _shortcuts; + Reminders = _reminders; + Relations = _relations; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Authorization/EmailVerifiedRequirement.cs b/Notesnook.API/Authorization/EmailVerifiedRequirement.cs new file mode 100644 index 0000000..6d2cd51 --- /dev/null +++ b/Notesnook.API/Authorization/EmailVerifiedRequirement.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Notesnook.API.Authorization +{ + public class EmailVerifiedRequirement : AuthorizationHandler, 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; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Authorization/NotesnookUserRequirement.cs b/Notesnook.API/Authorization/NotesnookUserRequirement.cs new file mode 100644 index 0000000..2e8d593 --- /dev/null +++ b/Notesnook.API/Authorization/NotesnookUserRequirement.cs @@ -0,0 +1,18 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Notesnook.API.Authorization +{ + public class NotesnookUserRequirement : AuthorizationHandler, 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; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Authorization/ProUserRequirement.cs b/Notesnook.API/Authorization/ProUserRequirement.cs new file mode 100644 index 0000000..06e0c65 --- /dev/null +++ b/Notesnook.API/Authorization/ProUserRequirement.cs @@ -0,0 +1,18 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; + +namespace Notesnook.API.Authorization +{ + public class ProUserRequirement : AuthorizationHandler, 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; + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Authorization/SyncRequirement.cs b/Notesnook.API/Authorization/SyncRequirement.cs new file mode 100644 index 0000000..cadcc30 --- /dev/null +++ b/Notesnook.API/Authorization/SyncRequirement.cs @@ -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, IAuthorizationRequirement + { + private Dictionary pathErrorPhraseMap = new Dictionary + { + ["/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); + } + + } +} \ No newline at end of file diff --git a/Notesnook.API/Controllers/AnnouncementController.cs b/Notesnook.API/Controllers/AnnouncementController.cs new file mode 100644 index 0000000..d3aad64 --- /dev/null +++ b/Notesnook.API/Controllers/AnnouncementController.cs @@ -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 Announcements { get; set; } + public AnnouncementController(Repository announcements) + { + Announcements = announcements; + } + + [HttpGet("active")] + [AllowAnonymous] + public async Task 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)); + } + } +} diff --git a/Notesnook.API/Controllers/MonographsController.cs b/Notesnook.API/Controllers/MonographsController.cs new file mode 100644 index 0000000..67b0e60 --- /dev/null +++ b/Notesnook.API/Controllers/MonographsController.cs @@ -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 Monographs { get; set; } + private readonly IUnitOfWork unit; + private const int MAX_DOC_SIZE = 15 * 1024 * 1024; + public MonographsController(Repository monographs, IUnitOfWork unitOfWork) + { + Monographs = monographs; + unit = unitOfWork; + } + + [HttpPost] + public async Task 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 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 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 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 DeleteAsync([FromRoute] string id) + { + Monographs.DeleteById(id); + if (!await unit.Commit()) return BadRequest(); + return Ok(); + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Controllers/S3Controller.cs b/Notesnook.API/Controllers/S3Controller.cs new file mode 100644 index 0000000..a15ec91 --- /dev/null +++ b/Notesnook.API/Controllers/S3Controller.cs @@ -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 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 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 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 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 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); + } + } + } +} diff --git a/Notesnook.API/Controllers/UsersController.cs b/Notesnook.API/Controllers/UsersController.cs new file mode 100644 index 0000000..a34570a --- /dev/null +++ b/Notesnook.API/Controllers/UsersController.cs @@ -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 Signup() + { + try + { + await UserService.CreateUserAsync(); + return Ok(); + } + catch (Exception ex) + { + await Slogger.Error(nameof(Signup), "Couldn't sign up.", ex.ToString()); + return BadRequest(new { error = ex.Message }); + } + } + + [HttpGet] + public async Task GetUser() + { + UserResponse response = await UserService.GetUserAsync(); + if (!response.Success) return BadRequest(response); + return Ok(response); + } + + [HttpPatch] + public async Task 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 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 Delete() + { + try + { + var userId = this.User.FindFirstValue("sub"); + + Response response = await this.httpClient.ForwardAsync(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(); + } + } + } +} diff --git a/Notesnook.API/Extensions/AuthorizationResultTransformer.cs b/Notesnook.API/Extensions/AuthorizationResultTransformer.cs new file mode 100644 index 0000000..155b5b8 --- /dev/null +++ b/Notesnook.API/Extensions/AuthorizationResultTransformer.cs @@ -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); + } + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Extensions/TransactionHelper.cs b/Notesnook.API/Extensions/TransactionHelper.cs new file mode 100644 index 0000000..e89bbb6 --- /dev/null +++ b/Notesnook.API/Extensions/TransactionHelper.cs @@ -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 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); + } + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Hubs/SyncHub.cs b/Notesnook.API/Hubs/SyncHub.cs new file mode 100644 index 0000000..941017b --- /dev/null +++ b/Notesnook.API/Hubs/SyncHub.cs @@ -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 + { + 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 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(data), userId, dateSynced); + break; + case "attachment": + Repositories.Attachments.Upsert(JsonSerializer.Deserialize(data), userId, dateSynced); + break; + case "note": + Repositories.Notes.Upsert(JsonSerializer.Deserialize(data), userId, dateSynced); + break; + case "notebook": + Repositories.Notebooks.Upsert(JsonSerializer.Deserialize(data), userId, dateSynced); + break; + case "shortcut": + Repositories.Shortcuts.Upsert(JsonSerializer.Deserialize(data), userId, dateSynced); + break; + case "reminder": + Repositories.Reminders.Upsert(JsonSerializer.Deserialize(data), userId, dateSynced); + break; + case "relation": + Repositories.Relations.Upsert(JsonSerializer.Deserialize(data), userId, dateSynced); + break; + case "settings": + var settings = JsonSerializer.Deserialize(data); + settings.Id = MongoDB.Bson.ObjectId.Parse(userId); + settings.ItemId = userId; + Repositories.Settings.Upsert(settings, userId, dateSynced); + break; + case "vaultKey": + userSettings.VaultKey = JsonSerializer.Deserialize(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 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 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> + { + ["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; } + } +} \ No newline at end of file diff --git a/Notesnook.API/Interfaces/IEncrypted.cs b/Notesnook.API/Interfaces/IEncrypted.cs new file mode 100644 index 0000000..ec13294 --- /dev/null +++ b/Notesnook.API/Interfaces/IEncrypted.cs @@ -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; } + } +} diff --git a/Notesnook.API/Interfaces/IMonograph.cs b/Notesnook.API/Interfaces/IMonograph.cs new file mode 100644 index 0000000..b3bd862 --- /dev/null +++ b/Notesnook.API/Interfaces/IMonograph.cs @@ -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; } + } +} diff --git a/Notesnook.API/Interfaces/IS3Service.cs b/Notesnook.API/Interfaces/IS3Service.cs new file mode 100644 index 0000000..7ad3aa0 --- /dev/null +++ b/Notesnook.API/Interfaces/IS3Service.cs @@ -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 GetObjectSizeAsync(string userId, string name); + string GetUploadObjectUrl(string userId, string name); + string GetDownloadObjectUrl(string userId, string name); + Task StartMultipartUploadAsync(string userId, string name, int parts, string uploadId = null); + Task AbortMultipartUploadAsync(string userId, string name, string uploadId); + Task CompleteMultipartUploadAsync(string userId, CompleteMultipartUploadRequest uploadRequest); + } +} \ No newline at end of file diff --git a/Notesnook.API/Interfaces/ISyncItem.cs b/Notesnook.API/Interfaces/ISyncItem.cs new file mode 100644 index 0000000..d0f6984 --- /dev/null +++ b/Notesnook.API/Interfaces/ISyncItem.cs @@ -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))] + [JsonInterfaceConverter(typeof(InterfaceConverter))] + public interface ISyncItem + { + long DateSynced + { + get; set; + } + + string UserId { get; set; } + string Algorithm { get; set; } + string IV { get; set; } + } +} diff --git a/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs b/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs new file mode 100644 index 0000000..9fee605 --- /dev/null +++ b/Notesnook.API/Interfaces/ISyncItemsRepositoryAccessor.cs @@ -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 Notes { get; } + SyncItemsRepository Notebooks { get; } + SyncItemsRepository Shortcuts { get; } + SyncItemsRepository Reminders { get; } + SyncItemsRepository Relations { get; } + SyncItemsRepository Contents { get; } + SyncItemsRepository Settings { get; } + SyncItemsRepository Attachments { get; } + Repository UsersSettings { get; } + Repository Monographs { get; } + } +} \ No newline at end of file diff --git a/Notesnook.API/Interfaces/IUserService.cs b/Notesnook.API/Interfaces/IUserService.cs new file mode 100644 index 0000000..9078cd7 --- /dev/null +++ b/Notesnook.API/Interfaces/IUserService.cs @@ -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 DeleteUserAsync(string userId, string jti); + Task ResetUserAsync(string userId, bool removeAttachments); + Task GetUserAsync(bool repair = true); + Task SetUserAttachmentsKeyAsync(string userId, IEncrypted key); + } +} \ No newline at end of file diff --git a/Notesnook.API/Interfaces/IUserSettings.cs b/Notesnook.API/Interfaces/IUserSettings.cs new file mode 100644 index 0000000..07cb379 --- /dev/null +++ b/Notesnook.API/Interfaces/IUserSettings.cs @@ -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; } + } +} diff --git a/Notesnook.API/Models/Algorithms.cs b/Notesnook.API/Models/Algorithms.cs new file mode 100644 index 0000000..bd95a06 --- /dev/null +++ b/Notesnook.API/Models/Algorithms.cs @@ -0,0 +1,7 @@ +namespace Notesnook.API.Models +{ + public class Algorithms + { + public static string Default => "xcha-argon2i13-7"; + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/Announcement.cs b/Notesnook.API/Models/Announcement.cs new file mode 100644 index 0000000..15d0b27 --- /dev/null +++ b/Notesnook.API/Models/Announcement.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/EncryptedData.cs b/Notesnook.API/Models/EncryptedData.cs new file mode 100644 index 0000000..1fd5e2f --- /dev/null +++ b/Notesnook.API/Models/EncryptedData.cs @@ -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; } + } +} diff --git a/Notesnook.API/Models/Monograph.cs b/Notesnook.API/Models/Monograph.cs new file mode 100644 index 0000000..e10f07b --- /dev/null +++ b/Notesnook.API/Models/Monograph.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/MultipartUploadMeta.cs b/Notesnook.API/Models/MultipartUploadMeta.cs new file mode 100644 index 0000000..ec8f7ad --- /dev/null +++ b/Notesnook.API/Models/MultipartUploadMeta.cs @@ -0,0 +1,8 @@ +namespace Notesnook.API.Models +{ + public class MultipartUploadMeta + { + public string UploadId { get; set; } + public string[] Parts { get; set; } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/Responses/SignupResponse.cs b/Notesnook.API/Models/Responses/SignupResponse.cs new file mode 100644 index 0000000..d28151c --- /dev/null +++ b/Notesnook.API/Models/Responses/SignupResponse.cs @@ -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; } + } +} diff --git a/Notesnook.API/Models/Responses/UserResponse.cs b/Notesnook.API/Models/Responses/UserResponse.cs new file mode 100644 index 0000000..4f524f5 --- /dev/null +++ b/Notesnook.API/Models/Responses/UserResponse.cs @@ -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; } + } +} diff --git a/Notesnook.API/Models/S3Options.cs b/Notesnook.API/Models/S3Options.cs new file mode 100644 index 0000000..d9b1a77 --- /dev/null +++ b/Notesnook.API/Models/S3Options.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Notesnook.API/Models/SyncItem.cs b/Notesnook.API/Models/SyncItem.cs new file mode 100644 index 0000000..7571905 --- /dev/null +++ b/Notesnook.API/Models/SyncItem.cs @@ -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 { } +} diff --git a/Notesnook.API/Models/UserSettings.cs b/Notesnook.API/Models/UserSettings.cs new file mode 100644 index 0000000..3e3fcc4 --- /dev/null +++ b/Notesnook.API/Models/UserSettings.cs @@ -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; } + } +} diff --git a/Notesnook.API/Notesnook.API.csproj b/Notesnook.API/Notesnook.API.csproj new file mode 100644 index 0000000..839f61e --- /dev/null +++ b/Notesnook.API/Notesnook.API.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + Notesnook.API.Program + 10.0 + linux-x64 + true + + + + + + + + + + + + + + + + + + + + diff --git a/Notesnook.API/Program.cs b/Notesnook.API/Program.cs new file mode 100644 index 0000000..3b3f9d2 --- /dev/null +++ b/Notesnook.API/Program.cs @@ -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() + .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 + }); + }); + } +} diff --git a/Notesnook.API/Properties/launchSettings.json b/Notesnook.API/Properties/launchSettings.json new file mode 100644 index 0000000..86f79f8 --- /dev/null +++ b/Notesnook.API/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/Notesnook.API/Repositories/SyncItemsRepository.cs b/Notesnook.API/Repositories/SyncItemsRepository.cs new file mode 100644 index 0000000..eca78c8 --- /dev/null +++ b/Notesnook.API/Repositories/SyncItemsRepository.cs @@ -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 : Repository where T : SyncItem + { + public SyncItemsRepository(IDbContext dbContext) : base(dbContext) + { + Collection.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Descending(i => i.DateSynced).Ascending(i => i.UserId))); + Collection.Indexes.CreateOne(new CreateIndexModel(Builders.IndexKeys.Ascending((i) => i.ItemId).Ascending(i => i.UserId))); + } + + private readonly List ALGORITHMS = new List { Algorithms.Default }; + private bool IsValidAlgorithm(string algorithm) + { + return ALGORITHMS.Contains(algorithm); + } + + public async Task> 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((i) => ids.Contains(i.Id) && i.UserId == userId, token); + // } + + public void DeleteByUserId(string userId) + { + dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(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); + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Services/S3Service.cs b/Notesnook.API/Services/S3Service.cs new file mode 100644 index 0000000..e3ce084 --- /dev/null +++ b/Notesnook.API/Services/S3Service.cs @@ -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) + { + 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(); + 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 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 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); + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Services/UserService.cs b/Notesnook.API/Services/UserService.cs new file mode 100644 index 0000000..63ebaaa --- /dev/null +++ b/Notesnook.API/Services/UserService.cs @@ -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(this.HttpContextAccessor, $"{Servers.IdentityServer}/signup", HttpMethod.Post); + if (!response.Success || (response.Errors != null && response.Errors.Length > 0)) + { + await Slogger.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.Info(nameof(CreateUserAsync), "New user created.", JsonSerializer.Serialize(response)); + } + + public async Task GetUserAsync(bool repair = true) + { + UserResponse response = await httpClient.ForwardAsync(this.HttpContextAccessor, $"{Servers.IdentityServer.ToString()}/account", HttpMethod.Get); + if (!response.Success) return response; + + SubscriptionResponse subscriptionResponse = await httpClient.ForwardAsync(this.HttpContextAccessor, $"{Servers.SubscriptionServer}/subscriptions", HttpMethod.Get); + if (repair && subscriptionResponse.StatusCode == 404) + { + await Slogger.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.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 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 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('/', '_'); + } + } +} \ No newline at end of file diff --git a/Notesnook.API/Startup.cs b/Notesnook.API/Startup.cs new file mode 100644 index 0000000..f359a4e --- /dev/null +++ b/Notesnook.API/Startup.cs @@ -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(); + services.AddSingleton(dbSettings); + + services.TryAddSingleton(); + + 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(); ; + + 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(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(); + } + + if (!BsonClassMap.IsClassMapRegistered(typeof(EncryptedData))) + { + BsonClassMap.RegisterClassMap(); + } + + if (!BsonClassMap.IsClassMapRegistered(typeof(CallToAction))) + { + BsonClassMap.RegisterClassMap(); + } + + if (!BsonClassMap.IsClassMapRegistered(typeof(Announcement))) + { + BsonClassMap.RegisterClassMap(); + } + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(typeof(Repository<>)); + services.AddScoped(typeof(SyncItemsRepository<>)); + + services.TryAddTransient(); + services.TryAddTransient(); + services.TryAddTransient(); + + 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(); + options.Providers.Add(); + }); + + services.Configure(options => + { + options.Level = CompressionLevel.Fastest; + }); + services.Configure(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(); + realm.Subscribe(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("/hubs/sync", options => + { + options.CloseOnAuthenticationExpiration = false; + options.Transports = HttpTransportType.WebSockets; + }); + }); + } + } +} diff --git a/Notesnook.API/appsettings.Development.json b/Notesnook.API/appsettings.Development.json new file mode 100644 index 0000000..cbc70c4 --- /dev/null +++ b/Notesnook.API/appsettings.Development.json @@ -0,0 +1,13 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "MongoDbSettings": { + "ConnectionString": "mongodb://localhost:27017/notesnook", + "DatabaseName": "notesnook" + } +} diff --git a/Notesnook.sln b/Notesnook.sln new file mode 100644 index 0000000..3dbb2c0 --- /dev/null +++ b/Notesnook.sln @@ -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 diff --git a/Streetwriters.Common/Attributes/JsonInterfaceConverterAttribute.cs b/Streetwriters.Common/Attributes/JsonInterfaceConverterAttribute.cs new file mode 100644 index 0000000..dbc5fbc --- /dev/null +++ b/Streetwriters.Common/Attributes/JsonInterfaceConverterAttribute.cs @@ -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) + { + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Clients.cs b/Streetwriters.Common/Clients.cs new file mode 100644 index 0000000..81fd987 --- /dev/null +++ b/Streetwriters.Common/Clients.cs @@ -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 ClientsMap = new Dictionary + { + { "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, + }; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Converters/InterfaceConverter.cs b/Streetwriters.Common/Converters/InterfaceConverter.cs new file mode 100644 index 0000000..2cf6ed0 --- /dev/null +++ b/Streetwriters.Common/Converters/InterfaceConverter.cs @@ -0,0 +1,35 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Streetwriters.Common.Converters +{ + /// + /// Converts simple interface into an object (assumes that there is only one class of TInterface) + /// + /// Interface type + /// Class type + public class InterfaceConverter : JsonConverter where TClass : TInterface + { + public override TInterface Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize(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; + } + } + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Enums/ApplicationType.cs b/Streetwriters.Common/Enums/ApplicationType.cs new file mode 100644 index 0000000..8439bdb --- /dev/null +++ b/Streetwriters.Common/Enums/ApplicationType.cs @@ -0,0 +1,7 @@ +namespace Streetwriters.Common.Enums +{ + public enum ApplicationType + { + NOTESNOOK = 0 + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Enums/MFAMethods.cs b/Streetwriters.Common/Enums/MFAMethods.cs new file mode 100644 index 0000000..7ad2dd5 --- /dev/null +++ b/Streetwriters.Common/Enums/MFAMethods.cs @@ -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"; + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Enums/SubscriptionProvider.cs b/Streetwriters.Common/Enums/SubscriptionProvider.cs new file mode 100644 index 0000000..ad28b11 --- /dev/null +++ b/Streetwriters.Common/Enums/SubscriptionProvider.cs @@ -0,0 +1,10 @@ +namespace Streetwriters.Common.Enums +{ + public enum SubscriptionProvider + { + STREETWRITERS = 0, + APPLE = 1, + GOOGLE = 2, + PADDLE = 3 + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Enums/SubscriptionType.cs b/Streetwriters.Common/Enums/SubscriptionType.cs new file mode 100644 index 0000000..2c9741d --- /dev/null +++ b/Streetwriters.Common/Enums/SubscriptionType.cs @@ -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 + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Extensions/AppBuilderExtensions.cs b/Streetwriters.Common/Extensions/AppBuilderExtensions.cs new file mode 100644 index 0000000..9f3329d --- /dev/null +++ b/Streetwriters.Common/Extensions/AppBuilderExtensions.cs @@ -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(this IApplicationBuilder app, WampServer server, Action> 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(this IApplicationBuilder app) + { + return app.ApplicationServices.GetRequiredService(); + } + + public static T GetScopedService(this IApplicationBuilder app) + { + using (var scope = app.ApplicationServices.CreateScope()) + { + return scope.ServiceProvider.GetRequiredService(); + } + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Extensions/HttpClientExtensions.cs b/Streetwriters.Common/Extensions/HttpClientExtensions.cs new file mode 100644 index 0000000..d3641b8 --- /dev/null +++ b/Streetwriters.Common/Extensions/HttpClientExtensions.cs @@ -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 SendRequestAsync(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(); + 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 ForwardAsync(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(url, httpContext.Request.Headers, method, content); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Extensions/ServiceCollectionServiceExtensions.cs b/Streetwriters.Common/Extensions/ServiceCollectionServiceExtensions.cs new file mode 100644 index 0000000..83a6dda --- /dev/null +++ b/Streetwriters.Common/Extensions/ServiceCollectionServiceExtensions.cs @@ -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; + } + } +} diff --git a/Streetwriters.Common/Extensions/StringExtensions.cs b/Streetwriters.Common/Extensions/StringExtensions.cs new file mode 100644 index 0000000..4107771 --- /dev/null +++ b/Streetwriters.Common/Extensions/StringExtensions.cs @@ -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); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Extensions/WampRealmExtensions.cs b/Streetwriters.Common/Extensions/WampRealmExtensions.cs new file mode 100644 index 0000000..e017375 --- /dev/null +++ b/Streetwriters.Common/Extensions/WampRealmExtensions.cs @@ -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(this IWampHostedRealm realm, string topicName, Action onNext) + { + return realm.Services.GetSubject(topicName).Subscribe(onNext); + } + + public static IDisposable Subscribe(this IWampHostedRealm realm, string topicName, IMessageHandler handler) + { + return realm.Services.GetSubject(topicName).Subscribe(async (message) => await handler.Process(message)); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Helpers/WampHelper.cs b/Streetwriters.Common/Helpers/WampHelper.cs new file mode 100644 index 0000000..e6ffb70 --- /dev/null +++ b/Streetwriters.Common/Helpers/WampHelper.cs @@ -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 OpenWampChannelAsync(string server, string realmName) + { + DefaultWampChannelFactory channelFactory = new DefaultWampChannelFactory(); + + IWampChannel channel = channelFactory.CreateJsonChannel(server, realmName); + + await channel.Open(); + + return channel.RealmProxy; + } + + public static void PublishMessage(IWampRealmProxy realm, string topicName, T message) + { + var subject = realm.Services.GetSubject(topicName); + subject.OnNext(message); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Interfaces/IClient.cs b/Streetwriters.Common/Interfaces/IClient.cs new file mode 100644 index 0000000..236bdb7 --- /dev/null +++ b/Streetwriters.Common/Interfaces/IClient.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Interfaces/IDocument.cs b/Streetwriters.Common/Interfaces/IDocument.cs new file mode 100644 index 0000000..99aaca3 --- /dev/null +++ b/Streetwriters.Common/Interfaces/IDocument.cs @@ -0,0 +1,10 @@ +namespace Streetwriters.Common.Interfaces +{ + public interface IDocument + { + string Id + { + get; set; + } + } +} diff --git a/Streetwriters.Common/Interfaces/IMessageHandler.cs b/Streetwriters.Common/Interfaces/IMessageHandler.cs new file mode 100644 index 0000000..567e4cd --- /dev/null +++ b/Streetwriters.Common/Interfaces/IMessageHandler.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Streetwriters.Common.Interfaces; + +namespace Streetwriters.Common.Interfaces +{ + public interface IMessageHandler + { + Task Process(T message); + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Interfaces/IOffer.cs b/Streetwriters.Common/Interfaces/IOffer.cs new file mode 100644 index 0000000..f04c7c0 --- /dev/null +++ b/Streetwriters.Common/Interfaces/IOffer.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Interfaces/IResponse.cs b/Streetwriters.Common/Interfaces/IResponse.cs new file mode 100644 index 0000000..d3d2ab1 --- /dev/null +++ b/Streetwriters.Common/Interfaces/IResponse.cs @@ -0,0 +1,8 @@ +namespace Streetwriters.Common.Interfaces +{ + public interface IResponse + { + bool Success { get; set; } + int StatusCode { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Interfaces/ISubscription.cs b/Streetwriters.Common/Interfaces/ISubscription.cs new file mode 100644 index 0000000..f430528 --- /dev/null +++ b/Streetwriters.Common/Interfaces/ISubscription.cs @@ -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))] + 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; } + } +} diff --git a/Streetwriters.Common/Logger.cs b/Streetwriters.Common/Logger.cs new file mode 100644 index 0000000..f784009 --- /dev/null +++ b/Streetwriters.Common/Logger.cs @@ -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 + { + 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 }); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Messages/CreateSubscriptionMessage.cs b/Streetwriters.Common/Messages/CreateSubscriptionMessage.cs new file mode 100644 index 0000000..3f8a6e2 --- /dev/null +++ b/Streetwriters.Common/Messages/CreateSubscriptionMessage.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Messages/DeleteSubscriptionMessage.cs b/Streetwriters.Common/Messages/DeleteSubscriptionMessage.cs new file mode 100644 index 0000000..3dfb90c --- /dev/null +++ b/Streetwriters.Common/Messages/DeleteSubscriptionMessage.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Messages/DeleteUserMessage.cs b/Streetwriters.Common/Messages/DeleteUserMessage.cs new file mode 100644 index 0000000..6c238c2 --- /dev/null +++ b/Streetwriters.Common/Messages/DeleteUserMessage.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Messages/SendSSEMessage.cs b/Streetwriters.Common/Messages/SendSSEMessage.cs new file mode 100644 index 0000000..bd9567f --- /dev/null +++ b/Streetwriters.Common/Messages/SendSSEMessage.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Models/Client.cs b/Streetwriters.Common/Models/Client.cs new file mode 100644 index 0000000..c581ae8 --- /dev/null +++ b/Streetwriters.Common/Models/Client.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Models/GetSubscriptionResponse.cs b/Streetwriters.Common/Models/GetSubscriptionResponse.cs new file mode 100644 index 0000000..c523753 --- /dev/null +++ b/Streetwriters.Common/Models/GetSubscriptionResponse.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Models/MFAConfig.cs b/Streetwriters.Common/Models/MFAConfig.cs new file mode 100644 index 0000000..57c149a --- /dev/null +++ b/Streetwriters.Common/Models/MFAConfig.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Models/Offer.cs b/Streetwriters.Common/Models/Offer.cs new file mode 100644 index 0000000..c20e838 --- /dev/null +++ b/Streetwriters.Common/Models/Offer.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Models/PromoCode.cs b/Streetwriters.Common/Models/PromoCode.cs new file mode 100644 index 0000000..836601e --- /dev/null +++ b/Streetwriters.Common/Models/PromoCode.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Common/Models/Response.cs b/Streetwriters.Common/Models/Response.cs new file mode 100644 index 0000000..c8d317f --- /dev/null +++ b/Streetwriters.Common/Models/Response.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Models/Role.cs b/Streetwriters.Common/Models/Role.cs new file mode 100644 index 0000000..dd657f3 --- /dev/null +++ b/Streetwriters.Common/Models/Role.cs @@ -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; + } + } */ +} diff --git a/Streetwriters.Common/Models/Subscription.cs b/Streetwriters.Common/Models/Subscription.cs new file mode 100644 index 0000000..b6fa613 --- /dev/null +++ b/Streetwriters.Common/Models/Subscription.cs @@ -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; } + } +} diff --git a/Streetwriters.Common/Models/User.cs b/Streetwriters.Common/Models/User.cs new file mode 100644 index 0000000..9dac3eb --- /dev/null +++ b/Streetwriters.Common/Models/User.cs @@ -0,0 +1,12 @@ + + +using AspNetCore.Identity.Mongo.Model; +using Streetwriters.Data.Attributes; + +namespace Streetwriters.Common.Models +{ + [BsonCollection("identity", "users")] + public class User : MongoUser + { + } +} diff --git a/Streetwriters.Common/Models/UserModel.cs b/Streetwriters.Common/Models/UserModel.cs new file mode 100644 index 0000000..cadc52f --- /dev/null +++ b/Streetwriters.Common/Models/UserModel.cs @@ -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; } + } + +} diff --git a/Streetwriters.Common/Servers.cs b/Streetwriters.Common/Servers.cs new file mode 100644 index 0000000..a987200 --- /dev/null +++ b/Streetwriters.Common/Servers.cs @@ -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 + }; + } +} diff --git a/Streetwriters.Common/Streetwriters.Common.csproj b/Streetwriters.Common/Streetwriters.Common.csproj new file mode 100644 index 0000000..e1d8bab --- /dev/null +++ b/Streetwriters.Common/Streetwriters.Common.csproj @@ -0,0 +1,22 @@ + + + + net7.0 + + + + + + + + + + + + + + + + + + diff --git a/Streetwriters.Common/WampServers.cs b/Streetwriters.Common/WampServers.cs new file mode 100644 index 0000000..f771452 --- /dev/null +++ b/Streetwriters.Common/WampServers.cs @@ -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 where T : new() + { + private readonly ConcurrentDictionary 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(string topic, V message) + { + try + { + IWampRealmProxy channel; + if (Channels.ContainsKey(topic)) + channel = Channels[topic]; + else + { + channel = await WampHelper.OpenWampChannelAsync(this.Address, this.Realm); + Channels.TryAdd(topic, channel); + } + if (!channel.Monitor.IsConnected) + { + Channels.TryRemove(topic, out IWampRealmProxy value); + await PublishMessageAsync(topic, message); + return; + } + WampHelper.PublishMessage(channel, topic, message); + } + catch (Exception ex) + { + await Slogger>.Error(nameof(PublishMessageAsync), ex.ToString()); + throw ex; + } + } + } + + public class WampServers + { + public static WampServer MessengerServer { get; } = new WampServer + { + Endpoint = "/wamp", + Address = $"{Servers.MessengerServer.WS()}/wamp", + Realm = "messages", + }; + + public static WampServer SubscriptionServer { get; } = new WampServer + { + Endpoint = "/wamp", + Address = $"{Servers.SubscriptionServer.WS()}/wamp", + Realm = "messages", + }; + + public static WampServer IdentityServer { get; } = new WampServer + { + Endpoint = "/wamp", + Address = $"{Servers.IdentityServer.WS()}/wamp", + Realm = "messages", + }; + + public static WampServer NotesnookServer { get; } = new WampServer + { + 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"; + } +} \ No newline at end of file diff --git a/Streetwriters.Data/Attributes/BsonCollection.cs b/Streetwriters.Data/Attributes/BsonCollection.cs new file mode 100644 index 0000000..1dce1c7 --- /dev/null +++ b/Streetwriters.Data/Attributes/BsonCollection.cs @@ -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; + } + } +} \ No newline at end of file diff --git a/Streetwriters.Data/DbContexts/MongoDbContext.cs b/Streetwriters.Data/DbContexts/MongoDbContext.cs new file mode 100644 index 0000000..ea1e8e2 --- /dev/null +++ b/Streetwriters.Data/DbContexts/MongoDbContext.cs @@ -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> _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>(); + } + + public async Task 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 GetCollection(string databaseName, string collectionName) + { + return MongoClient.GetDatabase(databaseName).GetCollection(collectionName, new MongoCollectionSettings() + { + AssignIdOnInsert = true, + }); + } + + public void AddCommand(Func func) + { + _commands.Add(func); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + } + + public Task DropDatabaseAsync() + { + return MongoClient.DropDatabaseAsync(DbSettings.DatabaseName); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Data/DbSettings.cs b/Streetwriters.Data/DbSettings.cs new file mode 100644 index 0000000..e78d14b --- /dev/null +++ b/Streetwriters.Data/DbSettings.cs @@ -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; } + } +} \ No newline at end of file diff --git a/Streetwriters.Data/Interfaces/IDbContext.cs b/Streetwriters.Data/Interfaces/IDbContext.cs new file mode 100644 index 0000000..c5b43ae --- /dev/null +++ b/Streetwriters.Data/Interfaces/IDbContext.cs @@ -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 func); + Task SaveChanges(); + IMongoCollection GetCollection(string databaseName, string collectionName); + } +} \ No newline at end of file diff --git a/Streetwriters.Data/Interfaces/IDbSettings.cs b/Streetwriters.Data/Interfaces/IDbSettings.cs new file mode 100644 index 0000000..b55f0fe --- /dev/null +++ b/Streetwriters.Data/Interfaces/IDbSettings.cs @@ -0,0 +1,8 @@ +namespace Streetwriters.Data.Interfaces +{ + public interface IDbSettings + { + string DatabaseName { get; set; } + string ConnectionString { get; set; } + } +} \ No newline at end of file diff --git a/Streetwriters.Data/Interfaces/IUnitOfWork.cs b/Streetwriters.Data/Interfaces/IUnitOfWork.cs new file mode 100644 index 0000000..4916d0f --- /dev/null +++ b/Streetwriters.Data/Interfaces/IUnitOfWork.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading.Tasks; + +namespace Streetwriters.Data.Interfaces +{ + public interface IUnitOfWork : IDisposable + { + Task Commit(); + } +} \ No newline at end of file diff --git a/Streetwriters.Data/Repositories/Repository.cs b/Streetwriters.Data/Repositories/Repository.cs new file mode 100644 index 0000000..caa5abd --- /dev/null +++ b/Streetwriters.Data/Repositories/Repository.cs @@ -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 where TEntity : class + { + protected readonly IDbContext dbContext; + protected IMongoCollection Collection { get; set; } + + public Repository(IDbContext _dbContext) + { + dbContext = _dbContext; + Collection = GetCollection(); + } + + private protected IMongoCollection 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(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> filterExpression) + { + dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, filterExpression, obj, new ReplaceOptions { IsUpsert = true }, ct)); + } + + public virtual Task UpsertAsync(TEntity obj, Expression> filterExpression) + { + return Collection.ReplaceOneAsync(filterExpression, obj, new ReplaceOptions { IsUpsert = true }); + } + + public virtual async Task FindOneAsync(Expression> filterExpression) + { + var data = await Collection.FindAsync(filterExpression); + return data.FirstOrDefault(); + } + + public virtual async Task GetAsync(string id) + { + var data = await Collection.FindAsync(Builders.Filter.Eq("_id", ObjectId.Parse(id))); + return data.FirstOrDefault(); + } + + public virtual async Task> FindAsync(Expression> filterExpression) + { + var data = await Collection.FindAsync(filterExpression); + return data.ToList(); + } + + public virtual async Task> GetAllAsync() + { + var all = await Collection.FindAsync(Builders.Filter.Empty); + return all.ToList(); + } + + public virtual void Update(string id, TEntity obj) + { + dbContext.AddCommand((handle, ct) => Collection.ReplaceOneAsync(handle, Builders.Filter.Eq("_id", ObjectId.Parse(id)), obj, cancellationToken: ct)); + } + + public virtual Task UpdateAsync(string id, TEntity obj) + { + return Collection.ReplaceOneAsync(Builders.Filter.Eq("_id", ObjectId.Parse(id)), obj); + } + + public virtual void DeleteById(string id) + { + dbContext.AddCommand((handle, ct) => Collection.DeleteOneAsync(handle, Builders.Filter.Eq("_id", ObjectId.Parse(id)), cancellationToken: ct)); + } + + public virtual Task DeleteByIdAsync(string id) + { + return Collection.DeleteOneAsync(Builders.Filter.Eq("_id", ObjectId.Parse(id))); + } + + public virtual void Delete(Expression> filterExpression) + { + dbContext.AddCommand((handle, ct) => Collection.DeleteOneAsync(handle, filterExpression, cancellationToken: ct)); + } + + public virtual void DeleteMany(Expression> filterExpression) + { + dbContext.AddCommand((handle, ct) => Collection.DeleteManyAsync(handle, filterExpression, cancellationToken: ct)); + } + + public virtual Task DeleteAsync(Expression> filterExpression) + { + return Collection.DeleteOneAsync(filterExpression); + } + + public virtual Task DeleteManyAsync(Expression> filterExpression) + { + return Collection.DeleteManyAsync(filterExpression); + } + } +} \ No newline at end of file diff --git a/Streetwriters.Data/Streetwriters.Data.csproj b/Streetwriters.Data/Streetwriters.Data.csproj new file mode 100644 index 0000000..9e6ade1 --- /dev/null +++ b/Streetwriters.Data/Streetwriters.Data.csproj @@ -0,0 +1,15 @@ + + + + net7.0 + + + + + + + + + + + diff --git a/Streetwriters.Data/UnitOfWork.cs b/Streetwriters.Data/UnitOfWork.cs new file mode 100644 index 0000000..24d0fd0 --- /dev/null +++ b/Streetwriters.Data/UnitOfWork.cs @@ -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 Commit() + { + var changeAmount = await dbContext.SaveChanges(); + return changeAmount > 0; + } + + public void Dispose() + { + this.dbContext.Dispose(); + } + } +} \ No newline at end of file