├── src └── LinkyLink │ ├── host.json │ ├── Infrastructure │ ├── IBlackListChecker.cs │ ├── EnvironmentBlackListChecker.cs │ ├── Hasher.cs │ └── HeaderTelemetryInitializer.cs │ ├── local.settings.sample.json │ ├── Models │ ├── UserInfo.cs │ ├── LinkBundle.cs │ └── OpenGraphResult.cs │ ├── Startup.cs │ ├── LinkyLink.csproj │ ├── GetLinks.cs │ ├── UpdateList.cs │ ├── LinkOperations.cs │ ├── ValidatePage.cs │ ├── SaveLinks.cs │ └── DeleteLinks.cs ├── global.json ├── docs ├── func_start.png ├── postman_localhost.png └── postman_response.png ├── .vscode ├── extensions.json ├── settings.json ├── launch.json └── tasks.json ├── theurlist_localhost_env.json ├── tests └── LinkyLink.Tests │ ├── Helpers │ ├── Constants.cs │ ├── StubTelemetryChannel.cs │ └── TestBase.cs │ ├── LinkyLink.Tests.csproj │ ├── ValidatePageTests.cs │ ├── HasherTests.cs │ ├── BlackListCheckerTests.cs │ ├── CodeCoverage.runsettings │ ├── DeleteLinksTests.cs │ ├── GetLinksTests.cs │ ├── UpdateListTests.cs │ └── SaveLinksTests.cs ├── LICENSE ├── LinkyLink.sln ├── theurlist_collection.json ├── README.md └── .gitignore /src/LinkyLink/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0" 3 | } -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "3.1.100" 4 | } 5 | } -------------------------------------------------------------------------------- /docs/func_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/backend-csharp/HEAD/docs/func_start.png -------------------------------------------------------------------------------- /docs/postman_localhost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/backend-csharp/HEAD/docs/postman_localhost.png -------------------------------------------------------------------------------- /docs/postman_response.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/the-urlist/backend-csharp/HEAD/docs/postman_response.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-vscode.csharp" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/LinkyLink/Infrastructure/IBlackListChecker.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace LinkyLink.Infrastructure 4 | { 5 | public interface IBlackListChecker 6 | { 7 | Task Check(string value); 8 | } 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.projectRuntime": "~2", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.templateFilter": "Core", 5 | "azureFunctions.projectSubpath": "src/LinkyLink", 6 | "azureFunctions.deploySubpath": "src/LinkyLink/bin/Release/netcoreapp2.2/publish", 7 | "azureFunctions.preDeployTask": "publish", 8 | "debug.internalConsoleOptions": "neverOpen" 9 | } -------------------------------------------------------------------------------- /theurlist_localhost_env.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "ec5d368d-6120-4350-a3aa-4d0293c172b9", 3 | "name": "Localhost", 4 | "values": [ 5 | { 6 | "key": "host", 7 | "value": "http://localhost:7071", 8 | "type": "text", 9 | "description": "", 10 | "enabled": true 11 | } 12 | ], 13 | "_postman_variable_scope": "environment", 14 | "_postman_exported_at": "2019-05-02T02:10:03.603Z", 15 | "_postman_exported_using": "Postman/6.7.4" 16 | } -------------------------------------------------------------------------------- /src/LinkyLink/local.settings.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "", 5 | "URL_BLACKLIST": "", 6 | "FUNCTIONS_WORKER_RUNTIME": "dotnet", 7 | "APPINSIGHTS_INSTRUMENTATIONKEY": "<--your application insights key-->", 8 | "LinkLinkConnection": "<--your cosmosdb connection string-->" 9 | }, 10 | "Host": { 11 | "LocalHttpPort": 7071, 12 | "CORS": "*" 13 | } 14 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/Helpers/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace LinkyLink.Tests.Helpers 2 | { 3 | public class Constants 4 | { 5 | // see https://github.com/Azure/azure-functions-host/blob/v2.0.12303/src/WebJobs.Script.WebHost/Security/Authentication/SecurityConstants.cs 6 | public const string FunctionsAuthLevelClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/authlevel"; 7 | public const string FunctionsAuthLevelKeyNameClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/keyid"; 8 | } 9 | } -------------------------------------------------------------------------------- /src/LinkyLink/Models/UserInfo.cs: -------------------------------------------------------------------------------- 1 | namespace LinkyLink.Models 2 | { 3 | public struct UserInfo 4 | { 5 | public static UserInfo Empty = new UserInfo("", ""); 6 | //X-MS-CLIENT-PRINCIPAL-IDP 7 | public string IDProvider { get; } 8 | //http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress 9 | public string HashedID { get; } 10 | public UserInfo(string provider, string hashedID) 11 | { 12 | IDProvider = provider; 13 | HashedID = hashedID; 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/LinkyLink/Startup.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Microsoft.Azure.Functions.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using LinkyLink.Infrastructure; 5 | using Microsoft.ApplicationInsights.Extensibility; 6 | 7 | [assembly: FunctionsStartup(typeof(LinkyLink.Startup))] 8 | namespace LinkyLink 9 | { 10 | public class Startup : FunctionsStartup 11 | { 12 | public override void Configure(IFunctionsHostBuilder builder) 13 | { 14 | builder.Services.AddSingleton(new EnvironmentBlackListChecker()); 15 | builder.Services.AddTransient(); 16 | builder.Services.AddSingleton(); 17 | builder.Services.AddSingleton(); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /src/LinkyLink/Infrastructure/EnvironmentBlackListChecker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | 5 | namespace LinkyLink.Infrastructure 6 | { 7 | 8 | public class EnvironmentBlackListChecker : IBlackListChecker 9 | { 10 | private readonly string[] _blackList; 11 | 12 | public EnvironmentBlackListChecker(string key = "URL_BLACKLIST") 13 | { 14 | string settingsValue = Environment.GetEnvironmentVariable(key); 15 | this._blackList = settingsValue != null ? settingsValue.Split(',') : Array.Empty(); 16 | } 17 | 18 | public Task Check(string value) 19 | { 20 | if (string.IsNullOrEmpty(value)) throw new ArgumentNullException(nameof(value)); 21 | 22 | return Task.FromResult(_blackList.Any()? _blackList.Contains(value): true); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/LinkyLink.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | false 5 | latest 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | runtime; build; native; contentfiles; analyzers 16 | all 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 The Urlist 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/LinkyLink/Models/LinkBundle.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace LinkyLink.Models 5 | { 6 | public class LinkBundle 7 | { 8 | public LinkBundle(string userId, string vanityUrl, string description, IDictionary[] links) 9 | { 10 | UserId = userId; 11 | VanityUrl = vanityUrl; 12 | Description = description; 13 | Links = links; 14 | } 15 | 16 | [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] 17 | public string Id { get; set; } 18 | 19 | [JsonProperty("userId", NullValueHandling = NullValueHandling.Ignore)] 20 | public string UserId { get; set; } 21 | 22 | [JsonProperty("vanityUrl", NullValueHandling = NullValueHandling.Ignore)] 23 | public string VanityUrl { get; set; } 24 | 25 | [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] 26 | public string Description { get; set; } 27 | 28 | [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] 29 | public IDictionary[] Links { get; set; } 30 | } 31 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for C# debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach to C# Functions", 9 | "type": "coreclr", 10 | "request": "attach", 11 | "processId": "${command:azureFunctions.pickProcess}" 12 | }, 13 | { 14 | "name": "Tests (console)", 15 | "type": "coreclr", 16 | "request": "launch", 17 | "preLaunchTask": "build tests", 18 | "program": "${workspaceFolder}/bin/Debug/netcoreapp2.2/LinkyLink.Tests.dll", 19 | "args": [], 20 | "cwd": "${workspaceFolder}", 21 | // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window 22 | "console": "internalConsole", 23 | "stopAtEntry": false, 24 | "internalConsoleOptions": "openOnSessionStart" 25 | }, 26 | ] 27 | } -------------------------------------------------------------------------------- /src/LinkyLink/LinkyLink.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netcoreapp3.1 4 | v3 5 | latest 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PreserveNewest 19 | 20 | 21 | PreserveNewest 22 | Never 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/LinkyLink/Infrastructure/Hasher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Cryptography; 3 | 4 | namespace LinkyLink.Infrastructure 5 | { 6 | public class Hasher 7 | { 8 | protected const string HASHER_KEY = "HASHER_KEY"; 9 | protected const string HASHER_SALT = "HASHER_SALT"; 10 | 11 | private readonly string _key; 12 | private readonly string _salt; 13 | 14 | public Hasher() 15 | : this(Environment.GetEnvironmentVariable(HASHER_KEY), 16 | Environment.GetEnvironmentVariable(HASHER_SALT)) 17 | { } 18 | 19 | public Hasher(string key, string salt) 20 | { 21 | _salt = salt; 22 | _key = key; 23 | } 24 | 25 | public virtual string HashString(string data) 26 | { 27 | if (string.IsNullOrEmpty(data)) throw new ArgumentException("Data parameter was null or empty", "data"); 28 | 29 | byte[] keyByte = System.Text.Encoding.UTF8.GetBytes(_key); 30 | byte[] messageBytes = System.Text.Encoding.UTF8.GetBytes(data); 31 | using var hmacsha256 = new HMACSHA384(keyByte); 32 | byte[] hashmessage = hmacsha256.ComputeHash(messageBytes); 33 | 34 | return Convert.ToBase64String(hashmessage); 35 | } 36 | 37 | public virtual bool Verify(string data, string hashedData) 38 | { 39 | return hashedData == HashString(data); 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/ValidatePageTests.cs: -------------------------------------------------------------------------------- 1 | using FakeItEasy; 2 | using LinkyLink.Tests.Helpers; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.Logging; 6 | using System.Threading.Tasks; 7 | using Xunit; 8 | 9 | namespace LinkyLink.Tests 10 | { 11 | public class ValidatePageTests : TestBase 12 | { 13 | [Fact] 14 | public async Task ValidatePage_Empty_Payload_Returns_BadRequest() 15 | { 16 | // Arrange 17 | HttpRequest req = this.AuthenticatedRequest; 18 | req.Body = this.GetHttpRequestBodyStream(""); 19 | 20 | ILogger fakeLogger = A.Fake(); 21 | 22 | // Act 23 | IActionResult result = await _linkOperations.ValidatePage(req, fakeLogger); 24 | 25 | // Assert 26 | Assert.IsType(result); 27 | 28 | BadRequestObjectResult badRequestResult = result as BadRequestObjectResult; 29 | Assert.IsType(badRequestResult.Value); 30 | 31 | ProblemDetails problemDetails = badRequestResult.Value as ProblemDetails; 32 | 33 | Assert.Equal("Could not validate links", problemDetails.Title); 34 | Assert.Equal(problemDetails.Status, StatusCodes.Status400BadRequest); 35 | 36 | A.CallTo(fakeLogger) 37 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Error) 38 | .MustHaveHappened(); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/LinkyLink/Infrastructure/HeaderTelemetryInitializer.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Security.Claims; 3 | using Microsoft.ApplicationInsights.Channel; 4 | using Microsoft.ApplicationInsights.DataContracts; 5 | using Microsoft.ApplicationInsights.Extensibility; 6 | using Microsoft.AspNetCore.Http; 7 | 8 | namespace LinkyLink.Infrastructure 9 | { 10 | public class HeaderTelemetryInitializer : ITelemetryInitializer 11 | { 12 | private IHttpContextAccessor _contextAccessor; 13 | 14 | public HeaderTelemetryInitializer(IHttpContextAccessor contextAccessor) 15 | { 16 | _contextAccessor = contextAccessor; 17 | } 18 | 19 | public void Initialize(ITelemetry telemetry) 20 | { 21 | var requestTelemetry = telemetry as RequestTelemetry; 22 | 23 | if (requestTelemetry == null) return; 24 | 25 | var context = _contextAccessor.HttpContext; 26 | 27 | foreach (var kvp in context.Request.Headers) 28 | { 29 | requestTelemetry.Properties.Add($"header-{kvp.Key}", kvp.Value.ToString()); 30 | } 31 | 32 | requestTelemetry.Properties.Add("IsAuthenticated", $"{context.User?.Identity.IsAuthenticated}"); 33 | requestTelemetry.Properties.Add("IdentityCount", $"{context.User?.Identities.Count()}"); 34 | 35 | if (context.User.Identities.Any()) 36 | { 37 | foreach (ClaimsIdentity identity in context.User.Identities) 38 | { 39 | foreach (var claim in identity.Claims) 40 | { 41 | requestTelemetry.Properties.Add($"{identity.AuthenticationType}-{claim.Type}", claim.Value); 42 | } 43 | } 44 | 45 | } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /src/LinkyLink/Models/OpenGraphResult.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using HtmlAgilityPack; 3 | using Newtonsoft.Json; 4 | using OpenGraphNet; 5 | 6 | namespace LinkyLink.Models 7 | { 8 | public class OpenGraphResult 9 | { 10 | public OpenGraphResult() { } 11 | 12 | public OpenGraphResult(string id, OpenGraph graph, params HtmlNode[] nodes) 13 | { 14 | Id = id; 15 | nodes = nodes.Where(n => n != null).ToArray(); 16 | //Use og:title else fallback to html title tag 17 | var title = nodes.SingleOrDefault(n => n.Name == "title")?.InnerText.Trim(); 18 | Title = string.IsNullOrEmpty(graph.Title) ? title : HtmlEntity.DeEntitize(graph.Title); 19 | 20 | Image = graph.Metadata["og:image"].FirstOrDefault()?.Value; 21 | 22 | //Default to og:description else fallback to description meta tag 23 | var descriptionData = string.Empty; 24 | var descriptionNode = nodes.FirstOrDefault(n => n.Attributes.Contains("content") 25 | && n.Attributes.Contains("name") 26 | && n.Attributes["name"].Value == "description"); 27 | 28 | Description = HtmlEntity.DeEntitize(graph.Metadata["og:description"].FirstOrDefault()?.Value) ?? descriptionNode?.Attributes["content"].Value; 29 | } 30 | 31 | [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)] 32 | public string Id { get; set; } 33 | 34 | [JsonProperty("title", NullValueHandling = NullValueHandling.Ignore)] 35 | public string Title { get; set; } 36 | 37 | [JsonProperty("description", NullValueHandling = NullValueHandling.Ignore)] 38 | public string Description { get; set; } 39 | 40 | [JsonProperty("image", NullValueHandling = NullValueHandling.Ignore)] 41 | public string Image { get; set; } 42 | } 43 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/Helpers/StubTelemetryChannel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | using Microsoft.ApplicationInsights.Channel; 4 | 5 | namespace LinkyLink.Tests.Helpers 6 | { 7 | public delegate void TelemetryAction(ITelemetry telemetry); 8 | 9 | public sealed class StubTelemetryChannel : ITelemetryChannel 10 | { 11 | /// 12 | /// Initializes a new instance of the class. 13 | /// 14 | public StubTelemetryChannel() 15 | { 16 | this.OnSend = telemetry => { }; 17 | } 18 | 19 | /// 20 | /// Gets or sets a value indicating whether this channel is in developer mode. 21 | /// 22 | public bool? DeveloperMode { get; set; } 23 | 24 | /// 25 | /// Gets or sets a value indicating the channel's URI. To this URI the telemetry is expected to be sent. 26 | /// 27 | public string EndpointAddress { get; set; } 28 | 29 | /// 30 | /// Gets or sets a value indicating whether to throw an error. 31 | /// 32 | public bool ThrowError { get; set; } 33 | 34 | /// 35 | /// Gets or sets the callback invoked by the method. 36 | /// 37 | public TelemetryAction OnSend { get; set; } 38 | 39 | /// 40 | /// Implements the method by invoking the callback. 41 | /// 42 | public void Send(ITelemetry item) 43 | { 44 | if (this.ThrowError) 45 | { 46 | throw new Exception("test error"); 47 | } 48 | 49 | this.OnSend(item); 50 | } 51 | 52 | /// 53 | /// Implements the method. 54 | /// 55 | public void Dispose() 56 | { 57 | } 58 | 59 | /// 60 | /// Mock for the Flush method in . 61 | /// 62 | public void Flush() 63 | { 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/HasherTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using LinkyLink.Infrastructure; 3 | using LinkyLink.Tests.Helpers; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace LinkyLink.Tests 8 | { 9 | public class HasherTests : TestBase 10 | { 11 | private readonly ITestOutputHelper output; 12 | 13 | public HasherTests(ITestOutputHelper output) 14 | { 15 | this.output = output; 16 | } 17 | 18 | [Theory] 19 | [InlineData("securestring")] 20 | [InlineData("secretdata")] 21 | [InlineData("Data")] 22 | public void HashString_Hashes_Provided_String(string dataToProtect) 23 | { 24 | // Arrange 25 | Environment.SetEnvironmentVariable("HASHER_KEY", "somekey"); 26 | Environment.SetEnvironmentVariable("HASHER_SALT", "somesalt"); 27 | 28 | Hasher hasher = new Hasher(); 29 | 30 | //Act 31 | var hashedData = hasher.HashString(dataToProtect); 32 | output.WriteLine($"{dataToProtect} hashed into {hashedData}"); 33 | 34 | //Assert 35 | Assert.NotEqual(dataToProtect, hashedData); 36 | } 37 | 38 | [Fact] 39 | public void HashString_Throws_When_Parameter_Is_Empty() 40 | { 41 | // Arrange 42 | Environment.SetEnvironmentVariable("HASHER_KEY", "somekey"); 43 | Environment.SetEnvironmentVariable("HASHER_SALT", "somesalt"); 44 | 45 | Hasher hasher = new Hasher(); 46 | 47 | //Act 48 | var exp = Assert.Throws(() => hasher.HashString(string.Empty)); 49 | Assert.Equal("Data parameter was null or empty (Parameter 'data')", exp.Message); 50 | } 51 | 52 | [Theory] 53 | [InlineData("securestring")] 54 | [InlineData("secretdata")] 55 | [InlineData("Data")] 56 | public void Verify_Matches_Hashed_Data(string dataToProtect) 57 | { 58 | // Arrange 59 | Environment.SetEnvironmentVariable("HASHER_KEY", "somekey"); 60 | Environment.SetEnvironmentVariable("HASHER_SALT", "somesalt"); 61 | 62 | Hasher hasher = new Hasher(); 63 | 64 | //Act 65 | var hashedData = hasher.HashString(dataToProtect); 66 | 67 | //Assert 68 | Assert.True(hasher.Verify(dataToProtect, hashedData)); 69 | 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/BlackListCheckerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using LinkyLink.Infrastructure; 4 | using LinkyLink.Tests.Helpers; 5 | using Xunit; 6 | 7 | namespace LinkyLink.Tests 8 | { 9 | public class EnvironmentBlackListCheckerTests : TestBase 10 | { 11 | [Fact] 12 | public async Task Check_Returns_False_On_Empty_Key() { 13 | EnvironmentBlackListChecker checker = new EnvironmentBlackListChecker(string.Empty); 14 | Assert.True(await checker.Check("somevalue")); 15 | } 16 | 17 | [Theory] 18 | [InlineData("value1")] 19 | [InlineData("key")] 20 | public async Task Check_Always_Returns_True_For_Empty_Setting(string value) 21 | { 22 | // Arrange 23 | Environment.SetEnvironmentVariable("key", "value"); 24 | EnvironmentBlackListChecker checker = new EnvironmentBlackListChecker(); 25 | 26 | // Act 27 | bool result = await checker.Check(value); 28 | 29 | // Assert 30 | Assert.True(result); 31 | } 32 | 33 | [Theory] 34 | [InlineData("value1")] 35 | [InlineData("key")] 36 | public async Task Check_Returns_True_For_Missing_Environment_Variable(string value) 37 | { 38 | // Arrange 39 | EnvironmentBlackListChecker checker = new EnvironmentBlackListChecker(); 40 | 41 | // Act 42 | bool result = await checker.Check(value); 43 | 44 | // Assert 45 | Assert.True(result); 46 | } 47 | 48 | [Fact] 49 | public async Task Check_Throws_Exception_On_Empty_BlackList_Value() 50 | { 51 | // Arrange 52 | string key = "key"; 53 | Environment.SetEnvironmentVariable(key, "value"); 54 | EnvironmentBlackListChecker checker = new EnvironmentBlackListChecker(key); 55 | 56 | // Act 57 | await Assert.ThrowsAsync(() => checker.Check(string.Empty)); 58 | } 59 | 60 | [Fact] 61 | public async Task Check_Compares_Input_To_Blacklist() 62 | { 63 | // Arrange 64 | string key = "key"; 65 | Environment.SetEnvironmentVariable(key, "1,2,3,4,5,6"); 66 | EnvironmentBlackListChecker checker = new EnvironmentBlackListChecker(key); 67 | 68 | // Act 69 | bool result_1 = await checker.Check("1"); 70 | bool result_2 = await checker.Check("10"); 71 | 72 | // Assert 73 | Assert.True(result_1); 74 | Assert.False(result_2); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/CodeCoverage.runsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | .*LinkyLink\.dll$ 13 | 14 | 15 | .*LinkyLink.\Tests\.dll$ 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | ^System\.Diagnostics\.DebuggerHiddenAttribute$ 32 | ^System\.Diagnostics\.DebuggerNonUserCodeAttribute$ 33 | ^System\.Runtime\.CompilerServices.CompilerGeneratedAttribute$ 34 | ^System\.CodeDom\.Compiler.GeneratedCodeAttribute$ 35 | ^System\.Diagnostics\.CodeAnalysis.ExcludeFromCodeCoverageAttribute$ 36 | 37 | 38 | 39 | 40 | 41 | 42 | .*microsoft.* 43 | 44 | 45 | 46 | 47 | True 48 | True 49 | True 50 | False 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/DeleteLinksTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture; 2 | using FakeItEasy; 3 | using LinkyLink.Tests.Helpers; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.Azure.Documents; 7 | using Microsoft.Azure.WebJobs; 8 | using Microsoft.Extensions.Logging; 9 | using System.Collections.Generic; 10 | using System.Linq; 11 | using System.Threading.Tasks; 12 | using Xunit; 13 | 14 | namespace LinkyLink.Tests 15 | { 16 | public class DeleteLinksTests : TestBase 17 | { 18 | [Fact] 19 | public async Task DeleteLink_Request_Missing_Auth_Credentials_Should_Return_UnAuthorized() 20 | { 21 | // Arrange 22 | IEnumerable docs = Fixture.CreateMany(); 23 | ILogger dummyLogger = A.Dummy(); 24 | Binder dummyBinder = A.Fake(); 25 | 26 | RemoveAuthFromContext(); 27 | 28 | // Act 29 | IActionResult result = await _linkOperations.DeleteLink(this.DefaultRequest, docs, null, "vanityUrl", dummyBinder, dummyLogger); 30 | AddAuthToContext(); 31 | 32 | // Assert 33 | Assert.IsType(result); 34 | } 35 | 36 | [Fact] 37 | public async Task DeleteLink_Authenticated_Request_With_Emtpy_Collection_Should_Return_NotFound() 38 | { 39 | // Arrange 40 | IEnumerable docs = Enumerable.Empty(); 41 | ILogger fakeLogger = A.Fake(); 42 | Binder fakeBinder = A.Fake(); 43 | 44 | // Act 45 | IActionResult result = await _linkOperations.DeleteLink(this.AuthenticatedRequest, docs, null, "userid", fakeBinder, fakeLogger); 46 | 47 | // Assert 48 | Assert.IsType(result); 49 | 50 | A.CallTo(fakeLogger) 51 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Information) 52 | .MustHaveHappened(); 53 | } 54 | 55 | [Fact] 56 | public async Task DeleteLink_User_Cant_Remove_Document_Owned_By_Others_Should_Return_Forbidden() 57 | { 58 | // Arrange 59 | IEnumerable docs = Fixture.CreateMany(1); 60 | ILogger fakeLogger = A.Fake(); 61 | Binder fakeBinder = A.Fake(); 62 | 63 | // Act 64 | IActionResult result = await _linkOperations.DeleteLink(this.AuthenticatedRequest, docs, null, "userid", fakeBinder, fakeLogger); 65 | 66 | // Assert 67 | Assert.IsType(result); 68 | 69 | StatusCodeResult statusResult = result as StatusCodeResult; 70 | Assert.Equal(statusResult.StatusCode, StatusCodes.Status403Forbidden); 71 | 72 | A.CallTo(fakeLogger) 73 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Warning) 74 | .MustHaveHappened(); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/LinkyLink/GetLinks.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Azure.WebJobs; 3 | using Microsoft.Azure.WebJobs.Extensions.Http; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.Extensions.Logging; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using Microsoft.Azure.Documents; 9 | using LinkyLink.Models; 10 | 11 | namespace LinkyLink 12 | { 13 | public partial class LinkOperations 14 | { 15 | [FunctionName(nameof(GetLinks))] 16 | public IActionResult GetLinks( 17 | [HttpTrigger(AuthorizationLevel.Function, "GET", Route = "links/{vanityUrl}")] HttpRequest req, 18 | [CosmosDB( 19 | databaseName: "linkylinkdb", 20 | collectionName: "linkbundles", 21 | ConnectionStringSetting = "LinkLinkConnection", 22 | SqlQuery = "SELECT * FROM linkbundles lb WHERE LOWER(lb.vanityUrl) = LOWER({vanityUrl})" 23 | )] IEnumerable documents, 24 | string vanityUrl, 25 | ILogger log) 26 | { 27 | if (!documents.Any()) 28 | { 29 | log.LogInformation($"Bundle for {vanityUrl} not found."); 30 | return new NotFoundResult(); 31 | } 32 | 33 | LinkBundle doc = documents.Single(); 34 | return new OkObjectResult(doc); 35 | } 36 | 37 | [FunctionName(nameof(GetBundlesForUser))] 38 | public IActionResult GetBundlesForUser( 39 | [HttpTrigger(AuthorizationLevel.Function, "GET", Route = "links/user/{userId}")] HttpRequest req, 40 | [CosmosDB( 41 | databaseName: "linkylinkdb", 42 | collectionName: "linkbundles", 43 | ConnectionStringSetting = "LinkLinkConnection", 44 | SqlQuery = "SELECT c.userId, c.vanityUrl, c.description, ARRAY_LENGTH(c.links) as linkCount FROM c where c.userId = {userId}" 45 | )] IEnumerable documents, 46 | string userId, 47 | ILogger log) 48 | { 49 | string twitterHandle = GetAccountInfo().HashedID; 50 | if (string.IsNullOrEmpty(twitterHandle) || twitterHandle != userId) 51 | { 52 | log.LogInformation("Client is not authorized"); 53 | return new UnauthorizedResult(); 54 | } 55 | 56 | if (!documents.Any()) 57 | { 58 | log.LogInformation($"No links for user: '{userId}' found."); 59 | 60 | return new NotFoundResult(); 61 | } 62 | var results = documents.Select(d => new 63 | { 64 | userId = d.GetPropertyValue("userId"), 65 | vanityUrl = d.GetPropertyValue("vanityUrl"), 66 | description = d.GetPropertyValue("description"), 67 | linkCount = d.GetPropertyValue("linkCount") 68 | }); 69 | return new OkObjectResult(results); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/LinkyLink/UpdateList.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using LinkyLink.Models; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.JsonPatch; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.Azure.Documents; 11 | using Microsoft.Azure.Documents.Client; 12 | using Microsoft.Azure.WebJobs; 13 | using Microsoft.Azure.WebJobs.Extensions.Http; 14 | using Microsoft.Extensions.Logging; 15 | using Newtonsoft.Json; 16 | 17 | namespace LinkyLink 18 | { 19 | public partial class LinkOperations 20 | { 21 | [FunctionName(nameof(UpdateList))] 22 | public async Task UpdateList( 23 | [HttpTrigger(AuthorizationLevel.Function, "PATCH", Route = "links/{vanityUrl}")] HttpRequest req, 24 | [CosmosDB( 25 | databaseName: "linkylinkdb", 26 | collectionName: "linkbundles", 27 | ConnectionStringSetting = "LinkLinkConnection", 28 | SqlQuery = "SELECT * FROM linkbundles lb WHERE LOWER(lb.vanityUrl) = LOWER({vanityUrl})" 29 | )] IEnumerable documents, 30 | [CosmosDB(ConnectionStringSetting = "LinkLinkConnection")] IDocumentClient docClient, 31 | string vanityUrl, 32 | ILogger log) 33 | { 34 | string handle = GetAccountInfo().HashedID; 35 | if (string.IsNullOrEmpty(handle)) return new UnauthorizedResult(); 36 | 37 | if (!documents.Any()) 38 | { 39 | log.LogInformation($"Bundle for {vanityUrl} not found."); 40 | return new NotFoundResult(); 41 | } 42 | 43 | try 44 | { 45 | string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); 46 | if (string.IsNullOrEmpty(requestBody)) 47 | { 48 | log.LogError("Request body is empty."); 49 | return new BadRequestResult(); 50 | } 51 | 52 | JsonPatchDocument patchDocument = JsonConvert.DeserializeObject>(requestBody); 53 | 54 | if (!patchDocument.Operations.Any()) 55 | { 56 | log.LogError("Request body contained no operations."); 57 | return new NoContentResult(); 58 | } 59 | 60 | LinkBundle bundle = documents.Single(); 61 | patchDocument.ApplyTo(bundle); 62 | 63 | Uri collUri = UriFactory.CreateDocumentCollectionUri("linkylinkdb", "linkbundles"); 64 | RequestOptions reqOptions = new RequestOptions { PartitionKey = new PartitionKey(vanityUrl) }; 65 | await docClient.UpsertDocumentAsync(collUri, bundle, reqOptions); 66 | } 67 | catch (JsonSerializationException ex) 68 | { 69 | log.LogError(ex, ex.Message); 70 | return new BadRequestResult(); 71 | } 72 | catch (Exception ex) 73 | { 74 | log.LogError(ex, ex.Message); 75 | return new StatusCodeResult(StatusCodes.Status500InternalServerError); 76 | } 77 | 78 | return new NoContentResult(); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /LinkyLink.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{82C75364-A22E-483E-8FEC-94B29906957F}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LinkyLink", "src\LinkyLink\LinkyLink.csproj", "{72319227-C2B1-4D10-BA9B-049C8A84B722}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{5C9B6C88-9EED-48D3-8B69-12009DA029EE}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LinkyLink.Tests", "tests\LinkyLink.Tests\LinkyLink.Tests.csproj", "{8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Debug|x86 = Debug|x86 19 | Release|Any CPU = Release|Any CPU 20 | Release|x64 = Release|x64 21 | Release|x86 = Release|x86 22 | EndGlobalSection 23 | GlobalSection(SolutionProperties) = preSolution 24 | HideSolutionNode = FALSE 25 | EndGlobalSection 26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 27 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Debug|x64.ActiveCfg = Debug|Any CPU 30 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Debug|x64.Build.0 = Debug|Any CPU 31 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Debug|x86.ActiveCfg = Debug|Any CPU 32 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Debug|x86.Build.0 = Debug|Any CPU 33 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Release|x64.ActiveCfg = Release|Any CPU 36 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Release|x64.Build.0 = Release|Any CPU 37 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Release|x86.ActiveCfg = Release|Any CPU 38 | {72319227-C2B1-4D10-BA9B-049C8A84B722}.Release|x86.Build.0 = Release|Any CPU 39 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Debug|x64.ActiveCfg = Debug|Any CPU 42 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Debug|x64.Build.0 = Debug|Any CPU 43 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Debug|x86.ActiveCfg = Debug|Any CPU 44 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Debug|x86.Build.0 = Debug|Any CPU 45 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Release|Any CPU.ActiveCfg = Release|Any CPU 46 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Release|Any CPU.Build.0 = Release|Any CPU 47 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Release|x64.ActiveCfg = Release|Any CPU 48 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Release|x64.Build.0 = Release|Any CPU 49 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Release|x86.ActiveCfg = Release|Any CPU 50 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808}.Release|x86.Build.0 = Release|Any CPU 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {72319227-C2B1-4D10-BA9B-049C8A84B722} = {82C75364-A22E-483E-8FEC-94B29906957F} 54 | {8ED7EC24-4DB6-4C89-887C-24BAD0DFD808} = {5C9B6C88-9EED-48D3-8B69-12009DA029EE} 55 | EndGlobalSection 56 | EndGlobal 57 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "dependsOn": "build", 7 | "options": { 8 | "cwd": "${workspaceFolder}/src/LinkyLink/bin/Debug/netcoreapp2.2/" 9 | }, 10 | "command": "host start", 11 | "isBackground": true, 12 | "problemMatcher": "$func-watch" 13 | }, 14 | { 15 | "label": "runFunctionsHost", 16 | "type": "shell", 17 | "dependsOn": "build", 18 | "options": { 19 | "cwd": "${workspaceFolder}/src/LinkyLink/bin/Debug/netcoreapp2.2/" 20 | }, 21 | "command": "func host start", 22 | "isBackground": true, 23 | "problemMatcher": "$func-watch" 24 | }, 25 | { 26 | "label": "publish", 27 | "command": "dotnet", 28 | "type": "process", 29 | "args": [ 30 | "publish", 31 | "--configuration", 32 | "Release", 33 | "${workspaceFolder}/src/LinkyLink/LinkyLink.csproj" 34 | ], 35 | "dependsOn": "clean release", 36 | "problemMatcher": "$msCompile" 37 | }, 38 | { 39 | "label": "build", 40 | "command": "dotnet", 41 | "type": "process", 42 | "args": [ 43 | "build", 44 | "${workspaceFolder}/src/LinkyLink/LinkyLink.csproj" 45 | ], 46 | "dependsOn": "clean", 47 | "group": { 48 | "kind": "build", 49 | "isDefault": true 50 | }, 51 | "problemMatcher": "$msCompile" 52 | }, 53 | { 54 | "label": "clean", 55 | "command": "dotnet", 56 | "type": "process", 57 | "args": [ 58 | "clean", 59 | "${workspaceFolder}/src/LinkyLink/LinkyLink.csproj" 60 | ], 61 | "problemMatcher": "$msCompile" 62 | }, 63 | { 64 | "label": "clean release", 65 | "command": "dotnet", 66 | "type": "process", 67 | "args": [ 68 | "clean", 69 | "--configuration", 70 | "Release", 71 | "${workspaceFolder}/src/LinkyLink/LinkyLink.csproj" 72 | ], 73 | "problemMatcher": "$msCompile" 74 | }, 75 | { 76 | "label": "clean solution", 77 | "command": "dotnet", 78 | "type": "process", 79 | "args": [ 80 | "clean", 81 | "${workspaceFolder}/LinkyLink.sln" 82 | ], 83 | "problemMatcher": "$msCompile" 84 | }, 85 | { 86 | "label": "build tests", 87 | "command": "dotnet", 88 | "type": "process", 89 | "args": [ 90 | "build", 91 | "${workspaceFolder}/tests/LinkyLink.Tests/LinkyLink.Tests.csproj" 92 | ], 93 | "problemMatcher": "$msCompile" 94 | }, 95 | { 96 | "label": "run tests", 97 | "command": "dotnet", 98 | "type": "process", 99 | "args": [ 100 | "test", 101 | "${workspaceFolder}/tests/LinkyLink.Tests/LinkyLink.Tests.csproj" 102 | ], 103 | "problemMatcher": "$msCompile", 104 | "group": { 105 | "kind": "test", 106 | "isDefault": true 107 | } 108 | } 109 | ] 110 | } -------------------------------------------------------------------------------- /theurlist_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "be94656c-bd40-4bde-aa58-59953157dfd8", 4 | "name": "The URList API", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Save Bundle", 10 | "request": { 11 | "method": "POST", 12 | "header": [ 13 | { 14 | "key": "Content-Type", 15 | "name": "Content-Type", 16 | "value": "application/json", 17 | "type": "text" 18 | } 19 | ], 20 | "body": { 21 | "mode": "raw", 22 | "raw": "{\n \"links\": [{\n \"id\": \"cjsorrho200023h5poelwd47z\",\n \"url\": \"facebook.com\",\n \"title\": \"Facebook - Log In or Sign Up\",\n \"description\": \"Create an account or log into Facebook. Connect with friends, family and other people you know. Share photos and videos, send messages and get updates.\",\n \"image\": \"//www.facebook.com/images/fb_icon_325x325.png\"\n }, {\n \"id\": \"cjsorr5bs00003h5pzs3iu49c\",\n \"url\": \"microsoft.com\",\n \"title\": \"Microsoft - Official Home Page\",\n \"description\": \"At Microsoft our mission and values are to help people and businesses throughout the world realize their full potential.\",\n \"image\": \"\"\n }, {\n \"id\": \"cjsorrc5d00013h5p3e1f8tgk\",\n \"url\": \"google.com\",\n \"title\": \"Google\",\n \"description\": \"Search the world's information, including webpages, images, videos and more. Google has many special features to help you find exactly what you're looking for.\",\n \"image\": \"\"\n }],\n \"vanityUrl\": \"postman-test\",\n \"description\": \"\",\n \"userId\": \"cecilphillip\"\n}" 23 | }, 24 | "url": { 25 | "raw": "{{host}}/api/links", 26 | "host": [ 27 | "{{host}}" 28 | ], 29 | "path": [ 30 | "api", 31 | "links" 32 | ] 33 | } 34 | }, 35 | "response": [] 36 | }, 37 | { 38 | "name": "Get bundle for vanity url", 39 | "event": [ 40 | { 41 | "listen": "prerequest", 42 | "script": { 43 | "id": "07b46808-7be2-4622-af17-736318e7b710", 44 | "exec": [ 45 | "pm.variables.set('vanityUrl', \"postman-test\");" 46 | ], 47 | "type": "text/javascript" 48 | } 49 | }, 50 | { 51 | "listen": "test", 52 | "script": { 53 | "id": "0db94e0a-48d4-4831-96a2-af7998e97bbc", 54 | "exec": [ 55 | "" 56 | ], 57 | "type": "text/javascript" 58 | } 59 | } 60 | ], 61 | "request": { 62 | "method": "GET", 63 | "header": [], 64 | "body": { 65 | "mode": "raw", 66 | "raw": "" 67 | }, 68 | "url": { 69 | "raw": "{{host}}/api/links/{{vanityUrl}}", 70 | "host": [ 71 | "{{host}}" 72 | ], 73 | "path": [ 74 | "api", 75 | "links", 76 | "{{vanityUrl}}" 77 | ] 78 | } 79 | }, 80 | "response": [] 81 | }, 82 | { 83 | "name": "Validate Page", 84 | "request": { 85 | "method": "POST", 86 | "header": [ 87 | { 88 | "key": "Content-Type", 89 | "name": "Content-Type", 90 | "value": "application/json", 91 | "type": "text" 92 | } 93 | ], 94 | "body": { 95 | "mode": "raw", 96 | "raw": "{\n\t\"url\" : \"marketplace.visualstudio.com/items? itemName=sdras vue-vscode-extensionpack\",\n\t\"id\" : \"1\"\n}" 97 | }, 98 | "url": { 99 | "raw": "{{host}}/api/validatePage", 100 | "host": [ 101 | "{{host}}" 102 | ], 103 | "path": [ 104 | "api", 105 | "validatePage" 106 | ] 107 | } 108 | }, 109 | "response": [] 110 | } 111 | ] 112 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Urlist - Backend 2 | 3 | [![Build status](https://burkeknowswords.visualstudio.com/The%20Urlist/_apis/build/status/Serverless%20Backend%20Build)](https://burkeknowswords.visualstudio.com/The%20Urlist/_build/latest?definitionId=8) 4 | [![License](https://img.shields.io/badge/license-MIT-orange.svg)](https://raw.githubusercontent.com/the-urlist/backend-csharp/master/LICENSE) 5 | 6 | The backend for this project was built as a serverless API using Azure Functions and .NET. All the data is stored in a Cosmos DB collection using the SQL API. 7 | 8 | - [.NET Core](https://dotnet.microsoft.com?WT.mc_id=theurlist-github-cephilli) 9 | - [Azure Functions](https://azure.microsoft.com/services/functions/?WT.mc_id=theurlist-github-cephilli) 10 | - [Azure Cosmos DB](https://azure.microsoft.com/services/cosmos-db?WT.mc_id=theurlist-github-cephilli) 11 | 12 | ## Build and run the backend locally 13 | 14 | ### Get the prerequisites 15 | 16 | - Install the [Azure Functions Core tools](https://docs.microsoft.com/azure/azure-functions/functions-run-local?WT.mc_id=theurlist-github-cephilli#install-the-azure-functions-core-tools) for your operating system 17 | - Install the [.NET Core SDK](https://dotnet.microsoft.com/download?WT.mc_id=theurlist-github-cephilli). This repo is pinned to use version 2.2.x of the SDK. 18 | - Install [Visual Studio Code](https://code.visualstudio.com/?WT.mc_id=theurlist-github-cephilli) or [Visual Studio Community edition](https://visualstudio.microsoft.com/vs?WT.mc_id=theurlist-github-cephilli) 19 | - Install the [C# extension for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=ms-vscode.csharp&WT.mc_id=theurlist-github-cephilli) 20 | - Install the [Azure Functions for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions?WT.mc_id=theurlist-github-cephilli) extension 21 | 22 | #### Optional 23 | 24 | - Install [Postman](https://www.getpostman.com/) 25 | 26 | ### Run the serverless backend 27 | 28 | Navigate into backend folder 29 | 30 | ```bash 31 | cd /src/LinkyLink 32 | ``` 33 | 34 | Build the project 35 | 36 | ```bash 37 | dotnet build 38 | ``` 39 | 40 | Rename the `local.settings.sample.json` file to `local.settings.json` 41 | 42 | Linux & MacOS 43 | 44 | ```bash 45 | mv local.settings.sample.json local.settings.json 46 | ``` 47 | 48 | Windows command line 49 | 50 | ```bash 51 | REN local.settings.sample.json local.settings.json 52 | ``` 53 | 54 | Update the `local.settings.json` file with your Application Insights key to the `APPINSIGHTS_INSTRUMENTATIONKEY` setting. You could also just remove this if you don't want to use Application Inisghts. 55 | 56 | [Create an a Cosmos DB instance](https://docs.microsoft.com/en-us/azure/cosmos-db/how-to-manage-database-account?WT.mc_id=theurlist-github-cephilli) in Azure using the SQL API. Update the `local.settings.json` file with your Cosmos DB connection string in the `LinkLinkConnection` settings. This database will initially be empty. If you try out the API with Postman (see below), the collection and sample documents will be created for you automatically. Otherwise it's structure will be created when you create your first list through the frontend. 57 | 58 | Start the function via the command line 59 | 60 | ```bash 61 | func start 62 | ``` 63 | 64 | ![func start](docs/func_start.png) 65 | 66 | Alternatively, start a debuging session in `Visual Studio` or `Visual Studio Code`. 67 | 68 | ### Try out the API with Postman 69 | 70 | - Start up Postman and import the `theurlist_collection.json` file that's in the `backend` folder 71 | - Next import the `theurlist_localhost_env.json` file. That includes the Localhost environment settings. 72 | - Set your environment to `Localhost` 73 | 74 | ![postman](docs/postman_localhost.png) 75 | 76 | - Run `Save Bundle` to add some data to Cosmos DB. The structure (collection, documents, etc.) in the database will be created for you if it does not exsist yet. Next run `Get bundle for vanity url` to retrieve the entry you just created. 77 | 78 | If everything was setup correctly, your should see a response that resembles the following. 79 | 80 | ![postman](docs/postman_response.png) 81 | -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/GetLinksTests.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | using LinkyLink.Tests.Helpers; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Microsoft.AspNetCore.Mvc; 6 | using AutoFixture; 7 | using FakeItEasy; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Azure.Documents; 10 | using LinkyLink.Models; 11 | 12 | namespace LinkyLink.Tests 13 | { 14 | public class GetLinksTest : TestBase 15 | { 16 | [Fact] 17 | public void GetLinks_Emtpy_Collection_Should_Return_NotFound() 18 | { 19 | // Arrange 20 | IEnumerable docs = Enumerable.Empty(); 21 | ILogger fakeLogger = A.Fake(); 22 | 23 | // Act 24 | IActionResult result = _linkOperations.GetLinks(this.DefaultRequest, docs, "vanityUrl", fakeLogger); 25 | 26 | // Assert 27 | Assert.IsType(result); 28 | 29 | A.CallTo(fakeLogger) 30 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Information) 31 | .MustHaveHappened(); 32 | } 33 | 34 | [Fact] 35 | public void GetLinks_Non_Emtpy_Collection_Should_Return_Single_Document() 36 | { 37 | // Arrange 38 | var docs = Fixture.CreateMany(1); 39 | 40 | // Act 41 | IActionResult result = _linkOperations.GetLinks(this.DefaultRequest, docs, string.Empty, A.Dummy()); 42 | 43 | // Assert 44 | Assert.IsType(result); 45 | Assert.Equal(docs.Single(), (result as OkObjectResult).Value); 46 | } 47 | 48 | [Fact] 49 | public void GetBundlesForUser_Request_Missing_Auth_Credentials_Should_Return_UnAuthorized() 50 | { 51 | // Arrange 52 | ILogger fakeLogger = A.Fake(); 53 | RemoveAuthFromContext(); 54 | 55 | // Act 56 | IActionResult result = _linkOperations.GetBundlesForUser(this.DefaultRequest, A.Dummy>(), "userid", fakeLogger); 57 | AddAuthToContext(); 58 | 59 | // Assert 60 | Assert.IsType(result); 61 | 62 | A.CallTo(fakeLogger) 63 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Information) 64 | .MustHaveHappened(); 65 | } 66 | 67 | [Fact] 68 | public void GetBundlesForUser_Authenticated_Request_With_Emtpy_Collection_Should_Return_NotFound() 69 | { 70 | // Arrange 71 | IEnumerable docs = Enumerable.Empty(); 72 | ILogger fakeLogger = A.Fake(); 73 | 74 | // Act 75 | IActionResult result = _linkOperations.GetBundlesForUser(this.AuthenticatedRequest, docs, _hasher.HashString("someone@linkylink.com"), fakeLogger); 76 | 77 | // Assert 78 | Assert.IsType(result); 79 | 80 | A.CallTo(fakeLogger) 81 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Information) 82 | .MustHaveHappened(); 83 | } 84 | 85 | [Fact] 86 | public void GetBundlesForUser_Authenticated_Request_With_Collection_Should_Return_Formatted_Results() 87 | { 88 | // Arrange 89 | var docs = Fixture.CreateMany(); 90 | 91 | // Act 92 | IActionResult result = _linkOperations.GetBundlesForUser(this.AuthenticatedRequest, docs, _hasher.HashString("someone@linkylink.com"), A.Dummy()); 93 | 94 | //Assert 95 | Assert.IsType(result); 96 | 97 | OkObjectResult okResult = result as OkObjectResult; 98 | IEnumerable resultData = okResult.Value as IEnumerable; 99 | 100 | Assert.Equal(docs.Count(), resultData.Count()); 101 | 102 | foreach (dynamic item in resultData) 103 | { 104 | Assert.True(item.GetType().GetProperty("userId") != null); 105 | Assert.True(item.GetType().GetProperty("vanityUrl") != null); 106 | Assert.True(item.GetType().GetProperty("description") != null); 107 | Assert.True(item.GetType().GetProperty("linkCount") != null); 108 | } 109 | } 110 | } 111 | } -------------------------------------------------------------------------------- /src/LinkyLink/LinkOperations.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Security.Claims; 6 | using System.Threading.Tasks; 7 | using LinkyLink.Infrastructure; 8 | using LinkyLink.Models; 9 | using Microsoft.ApplicationInsights; 10 | using Microsoft.ApplicationInsights.DataContracts; 11 | using Microsoft.ApplicationInsights.Extensibility; 12 | using Microsoft.AspNetCore.Http; 13 | using Microsoft.Azure.WebJobs; 14 | using Microsoft.Extensions.Primitives; 15 | using Microsoft.WindowsAzure.Storage; 16 | using Microsoft.WindowsAzure.Storage.Blob; 17 | using QRCoder; 18 | using static QRCoder.PayloadGenerator; 19 | 20 | namespace LinkyLink 21 | { 22 | public partial class LinkOperations 23 | { 24 | protected IHttpContextAccessor _contextAccessor; 25 | protected IBlackListChecker _blackListChecker; 26 | protected Hasher _hasher; 27 | protected TelemetryClient _telemetryClient; 28 | protected const string CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 29 | protected const string VANITY_REGEX = @"^([\w\d-])+(/([\w\d-])+)*$"; 30 | protected const string QRCODECONTAINER = "qrcodes"; 31 | 32 | public LinkOperations(IHttpContextAccessor contextAccessor, IBlackListChecker blackListChecker, Hasher hasher) 33 | { 34 | _contextAccessor = contextAccessor; 35 | _blackListChecker = blackListChecker; 36 | _hasher = hasher; 37 | TelemetryConfiguration telemetryConfiguration = TelemetryConfiguration.CreateDefault(); 38 | telemetryConfiguration.TelemetryInitializers.Add(new HeaderTelemetryInitializer(contextAccessor)); 39 | _telemetryClient = new TelemetryClient(telemetryConfiguration); 40 | } 41 | 42 | protected UserInfo GetAccountInfo() 43 | { 44 | var socialIdentities = _contextAccessor.HttpContext.User 45 | .Identities.Where(id => !id.AuthenticationType.Equals("WebJobsAuthLevel", StringComparison.InvariantCultureIgnoreCase)); 46 | 47 | if (socialIdentities.Any()) 48 | { 49 | var provider = _contextAccessor.HttpContext.Request.Headers["X-MS-CLIENT-PRINCIPAL-IDP"].FirstOrDefault(); 50 | 51 | var primaryIdentity = socialIdentities.First(); 52 | var email = primaryIdentity.Claims.SingleOrDefault(c => c.Type == ClaimTypes.Email).Value; 53 | var userInfo = new UserInfo(provider, _hasher.HashString(email)); 54 | 55 | var evt = new EventTelemetry("UserInfo Retrieved"); 56 | evt.Properties.Add("Provider", provider); 57 | evt.Properties.Add("EmailAquired", (string.IsNullOrEmpty(email).ToString())); 58 | _telemetryClient.TrackEvent(evt); 59 | 60 | return userInfo; 61 | } 62 | 63 | return UserInfo.Empty; ; 64 | } 65 | 66 | private static async Task GenerateQRCodeAsync(LinkBundle linkDocument, HttpRequest req, Binder binder) 67 | { 68 | req.Headers.TryGetValue("Origin", out StringValues origin); 69 | Url generator = new Url($"{origin.ToString()}/{linkDocument.VanityUrl}"); 70 | string payload = generator.ToString(); 71 | 72 | QRCodeGenerator qrGenerator = new QRCodeGenerator(); 73 | QRCodeData qrCodeData = qrGenerator.CreateQrCode(payload, QRCodeGenerator.ECCLevel.Q); 74 | 75 | PngByteQRCode qrCode = new PngByteQRCode(qrCodeData); 76 | byte[] qrCodeAsPngByteArr = qrCode.GetGraphic(20); 77 | 78 | var attributes = new Attribute[] 79 | { 80 | new BlobAttribute(blobPath: $"{QRCODECONTAINER}/{linkDocument.VanityUrl}.png", FileAccess.Write), 81 | new StorageAccountAttribute("AzureWebJobsStorage") 82 | }; 83 | 84 | using (var writer = await binder.BindAsync(attributes).ConfigureAwait(false)) 85 | 86 | { 87 | writer.Write(qrCodeAsPngByteArr); 88 | } 89 | } 90 | 91 | private static async Task DeleteQRCodeAsync(string vanityUrl, Binder binder) 92 | { 93 | StorageAccountAttribute storageAccountAttribute = new StorageAccountAttribute("AzureWebJobsStorage"); 94 | CloudStorageAccount storageAccount = await binder.BindAsync(storageAccountAttribute); 95 | 96 | CloudBlobClient client = storageAccount.CreateCloudBlobClient(); 97 | CloudBlobContainer container = client.GetContainerReference(QRCODECONTAINER); 98 | 99 | CloudBlockBlob blob = container.GetBlockBlobReference($"{vanityUrl}.png"); 100 | await blob.DeleteIfExistsAsync(); 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/UpdateListTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture; 2 | using FakeItEasy; 3 | using FakeItEasy.Core; 4 | using LinkyLink.Models; 5 | using LinkyLink.Tests.Helpers; 6 | using Microsoft.ApplicationInsights; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.JsonPatch; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.Azure.Documents; 11 | using Microsoft.Azure.Documents.Client; 12 | using Microsoft.Extensions.Logging; 13 | using Newtonsoft.Json; 14 | using System; 15 | using System.Collections.Generic; 16 | using System.Linq; 17 | using System.Threading; 18 | using System.Threading.Tasks; 19 | using Xunit; 20 | 21 | namespace LinkyLink.Tests 22 | { 23 | public class UpdateListTests : TestBase 24 | { 25 | [Fact] 26 | public async Task UpdateList_Request_Missing_Auth_Credentials_Should_Return_UnAuthorized() 27 | { 28 | // Arrange 29 | var docs = Fixture.CreateMany(); 30 | RemoveAuthFromContext(); 31 | 32 | // Act 33 | IActionResult result = await _linkOperations.UpdateList(this.DefaultRequest, docs, null, "vanityUrl", A.Dummy()); 34 | AddAuthToContext(); 35 | 36 | // Assert 37 | Assert.IsType(result); 38 | } 39 | 40 | [Fact] 41 | public async Task UpdateList_Request_With_Emtpy_Collection_Should_Return_NotFound() 42 | { 43 | // Arrange 44 | IEnumerable docs = Enumerable.Empty(); 45 | ILogger fakeLogger = A.Fake(); 46 | 47 | // Act 48 | IActionResult result = await _linkOperations.UpdateList(this.AuthenticatedRequest, docs, null, "vanityUrl", fakeLogger); 49 | 50 | // Assert 51 | Assert.IsType(result); 52 | 53 | A.CallTo(fakeLogger) 54 | .Where(call => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Information) 55 | .MustHaveHappened(); 56 | } 57 | 58 | [Fact] 59 | public async Task UpdateList_Applies_JsonPatch_To_Bundle() 60 | { 61 | // Arrange 62 | JsonPatchDocument patchReqDocument = new JsonPatchDocument(); 63 | patchReqDocument.Replace(d => d.Description, "Description"); 64 | patchReqDocument.Replace(d => d.Links, this.Fixture.CreateMany>()); 65 | 66 | HttpRequest req = this.AuthenticatedRequest; 67 | req.Body = this.GetHttpRequestBodyStream(JsonConvert.SerializeObject(patchReqDocument)); 68 | 69 | IEnumerable docs = this.Fixture.CreateMany(1); 70 | IDocumentClient docClient = this.Fixture.Create(); 71 | 72 | LinkBundle captured = null; 73 | A.CallTo(() => docClient.UpsertDocumentAsync(A.Ignored, A.Ignored, A.Ignored, false, default)) 74 | .Invokes((IFakeObjectCall callOb) => 75 | { 76 | captured = callOb.Arguments[1] as LinkBundle; 77 | }); 78 | string vanityUrl = "vanity"; 79 | 80 | // Act 81 | IActionResult result = await _linkOperations.UpdateList(req, docs, docClient, vanityUrl, A.Dummy()); 82 | 83 | // Assert 84 | Assert.Equal("Description", captured.Description); 85 | Assert.IsType(result); 86 | } 87 | 88 | [Theory] 89 | [InlineData("", typeof(BadRequestResult))] 90 | [InlineData("[]", typeof(NoContentResult))] 91 | [InlineData("{}", typeof(BadRequestResult))] 92 | public async Task UpdateList_Empty_Operation_Does_Not_Call_DocumentClient(string payload, Type returnType) 93 | { 94 | // Arrange 95 | HttpRequest req = this.AuthenticatedRequest; 96 | req.Body = this.GetHttpRequestBodyStream(payload); 97 | 98 | IEnumerable docs = this.Fixture.CreateMany(1); 99 | IDocumentClient docClient = this.Fixture.Create(); 100 | ILogger logger = this.Fixture.Create(); 101 | string vanityUrl = "vanity"; 102 | 103 | // Act 104 | IActionResult result = await _linkOperations.UpdateList(req, docs, docClient, vanityUrl, logger); 105 | 106 | // Assert 107 | Assert.IsType(returnType, result); 108 | 109 | A.CallTo(docClient) 110 | .Where((IFakeObjectCall call) => call.Method.Name == "UpsertDocumentAsync") 111 | .MustNotHaveHappened(); 112 | 113 | A.CallTo(logger) 114 | .Where((IFakeObjectCall call) => call.Method.Name == "Log" && call.GetArgument("logLevel") == LogLevel.Error) 115 | .MustHaveHappened(); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/LinkyLink/ValidatePage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Azure.WebJobs; 6 | using Microsoft.Azure.WebJobs.Extensions.Http; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.Extensions.Logging; 9 | using Newtonsoft.Json; 10 | using OpenGraphNet; 11 | using System.Linq; 12 | using Newtonsoft.Json.Linq; 13 | using System.Collections.Generic; 14 | using HtmlAgilityPack; 15 | using LinkyLink.Models; 16 | 17 | namespace LinkyLink 18 | { 19 | public partial class LinkOperations 20 | { 21 | [FunctionName(nameof(ValidatePage))] 22 | public async Task ValidatePage( 23 | [HttpTrigger(AuthorizationLevel.Function, "POST", Route = "validatePage")] HttpRequest req, 24 | ILogger log) 25 | { 26 | string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); 27 | dynamic data = JsonConvert.DeserializeObject(requestBody); 28 | 29 | try 30 | { 31 | if (data is JArray) 32 | { 33 | // expecting a JSON array of objects with url(string), id(string) 34 | IEnumerable result = await GetMultipleGraphResults(req, data, log); 35 | return new OkObjectResult(result); 36 | } 37 | else if (data is JObject) 38 | { 39 | // expecting a JSON object with url(string), id(string) 40 | OpenGraphResult result = await GetGraphResult(req, data, log); 41 | return new OkObjectResult(result); 42 | } 43 | 44 | log.LogError("Invalid playload"); 45 | ProblemDetails problemDetails = new ProblemDetails 46 | { 47 | Title = "Could not validate links", 48 | Detail = "Payload must be a valid JSON object or array", 49 | Status = StatusCodes.Status400BadRequest, 50 | Type = "/linkylink/clientissue", 51 | Instance = req.Path 52 | }; 53 | return new BadRequestObjectResult(problemDetails); 54 | } 55 | catch (Exception ex) 56 | { 57 | log.LogError(ex, ex.Message); 58 | ProblemDetails problemDetails = new ProblemDetails 59 | { 60 | Title = "Could not validate links", 61 | Detail = ex.Message, 62 | Status = StatusCodes.Status400BadRequest, 63 | Type = "/linkylink/clientissue", 64 | Instance = req.Path 65 | }; 66 | return new BadRequestObjectResult(problemDetails); 67 | } 68 | } 69 | 70 | private async Task GetGraphResult(HttpRequest req, dynamic singleLinkItem, ILogger log) 71 | { 72 | string url = singleLinkItem.url, id = singleLinkItem.id; 73 | if (!string.IsNullOrEmpty(url) && !string.IsNullOrEmpty(id)) 74 | { 75 | /** 76 | This check is a hack in support of adding our own URLs to lists. Rendered URLs return no Open Graph 77 | metadata and deep links return HTTP 404s when hosting in Blob storage. So we skip the HTTP request. 78 | */ 79 | if (!req.Host.HasValue || !url.Contains(req.Host.Host)) 80 | { 81 | try 82 | { 83 | OpenGraph graph = await OpenGraph.ParseUrlAsync(url, "Urlist"); 84 | HtmlDocument doc = new HtmlDocument(); 85 | doc.LoadHtml(graph.OriginalHtml); 86 | var descriptionMetaTag = doc.DocumentNode.SelectSingleNode("//meta[@name='description']"); 87 | var titleTag = doc.DocumentNode.SelectSingleNode("//head/title"); 88 | return new OpenGraphResult(id, graph, descriptionMetaTag, titleTag); 89 | } 90 | catch (Exception ex) 91 | { 92 | log.LogError(ex, "Processing URL {URL} failed. {Message}", url, ex.Message); 93 | } 94 | } 95 | } 96 | return new OpenGraphResult { Id = id }; 97 | } 98 | 99 | private async Task> GetMultipleGraphResults(HttpRequest req, dynamic multiLinkItem, ILogger log) 100 | { 101 | log.LogInformation("Running batch url validation"); 102 | IEnumerable allResults = 103 | await Task.WhenAll((multiLinkItem as JArray).Select(item => GetGraphResult(req, item, log))); 104 | 105 | return allResults; 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/LinkyLink/SaveLinks.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net; 5 | using System.Security.Cryptography; 6 | using System.Text.RegularExpressions; 7 | using System.Threading.Tasks; 8 | using LinkyLink.Models; 9 | using Microsoft.ApplicationInsights.DataContracts; 10 | using Microsoft.AspNetCore.Http; 11 | using Microsoft.AspNetCore.Mvc; 12 | using Microsoft.Azure.Documents; 13 | using Microsoft.Azure.WebJobs; 14 | using Microsoft.Azure.WebJobs.Extensions.Http; 15 | using Microsoft.Extensions.Logging; 16 | using Newtonsoft.Json; 17 | 18 | namespace LinkyLink 19 | { 20 | public partial class LinkOperations 21 | { 22 | [FunctionName(nameof(SaveLinks))] 23 | public async Task SaveLinks( 24 | [HttpTrigger(AuthorizationLevel.Function, "POST", Route = "links")] HttpRequest req, 25 | [CosmosDB( 26 | databaseName: "linkylinkdb", 27 | collectionName: "linkbundles", 28 | ConnectionStringSetting = "LinkLinkConnection", 29 | CreateIfNotExists = true 30 | )] IAsyncCollector documents, 31 | Binder binder, 32 | ILogger log) 33 | { 34 | try 35 | { 36 | string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); 37 | var linkDocument = JsonConvert.DeserializeObject(requestBody); 38 | 39 | if (!ValidatePayLoad(linkDocument, req, out ProblemDetails problems)) 40 | { 41 | log.LogError(problems.Detail); 42 | return new BadRequestObjectResult(problems); 43 | } 44 | 45 | string handle = GetAccountInfo().HashedID; 46 | linkDocument.UserId = handle; 47 | EnsureVanityUrl(linkDocument); 48 | 49 | Match match = Regex.Match(linkDocument.VanityUrl, VANITY_REGEX, RegexOptions.IgnoreCase); 50 | 51 | if (!match.Success) 52 | { 53 | // does not match 54 | return new BadRequestResult(); 55 | } 56 | 57 | if (!await _blackListChecker.Check(linkDocument.VanityUrl)) 58 | { 59 | ProblemDetails blacklistProblems = new ProblemDetails 60 | { 61 | Title = "Could not create link bundle", 62 | Detail = "Vanity link is invalid", 63 | Status = StatusCodes.Status400BadRequest, 64 | Type = "/linkylink/clientissue", 65 | Instance = req.Path 66 | }; 67 | 68 | log.LogError(problems.Detail); 69 | return new BadRequestObjectResult(blacklistProblems); 70 | } 71 | 72 | await documents.AddAsync(linkDocument); 73 | 74 | string payload = req.Host + linkDocument.VanityUrl; 75 | 76 | await GenerateQRCodeAsync(linkDocument, req, binder); 77 | 78 | return new CreatedResult($"/{linkDocument.VanityUrl}", linkDocument); 79 | } 80 | catch (DocumentClientException ex) when (ex.StatusCode == HttpStatusCode.Conflict) 81 | { 82 | log.LogError(ex, ex.Message); 83 | 84 | ProblemDetails exceptionDetail = new ProblemDetails 85 | { 86 | Title = "Could not create link bundle", 87 | Detail = "Vanity link already in use", 88 | Status = StatusCodes.Status400BadRequest, 89 | Type = "/linkylink/clientissue", 90 | Instance = req.Path 91 | }; 92 | return new BadRequestObjectResult(exceptionDetail); 93 | } 94 | catch (Exception ex) 95 | { 96 | log.LogError(ex, ex.Message); 97 | return new StatusCodeResult(StatusCodes.Status500InternalServerError); 98 | } 99 | } 100 | 101 | private void EnsureVanityUrl(LinkBundle linkDocument) 102 | { 103 | if (string.IsNullOrWhiteSpace(linkDocument.VanityUrl)) 104 | { 105 | var code = new char[7]; 106 | var rng = new RNGCryptoServiceProvider(); 107 | 108 | var bytes = new byte[sizeof(uint)]; 109 | for (int i = 0; i < code.Length; i++) 110 | { 111 | rng.GetBytes(bytes); 112 | uint num = BitConverter.ToUInt32(bytes, 0) % (uint)CHARACTERS.Length; 113 | code[i] = CHARACTERS[(int)num]; 114 | } 115 | 116 | linkDocument.VanityUrl = new String(code); 117 | 118 | _telemetryClient.TrackEvent(new EventTelemetry { Name = "Custom Vanity Generated" }); 119 | } 120 | 121 | // force lowercase 122 | linkDocument.VanityUrl = linkDocument.VanityUrl.ToLower(); 123 | } 124 | 125 | private static bool ValidatePayLoad(LinkBundle linkDocument, HttpRequest req, out ProblemDetails problems) 126 | { 127 | bool isValid = (linkDocument != null) && linkDocument.Links.Count() > 0; 128 | problems = null; 129 | 130 | if (!isValid) 131 | { 132 | problems = new ProblemDetails() 133 | { 134 | Title = "Payload is invalid", 135 | Detail = "No links provided", 136 | Status = StatusCodes.Status400BadRequest, 137 | Type = "/linkylink/clientissue", 138 | Instance = req.Path 139 | }; 140 | } 141 | return isValid; 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/LinkyLink/DeleteLinks.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | using Microsoft.Azure.WebJobs; 3 | using Microsoft.Azure.WebJobs.Extensions.Http; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.Extensions.Logging; 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using Microsoft.Azure.Documents; 9 | using Microsoft.Azure.Documents.Client; 10 | using System; 11 | using System.Threading.Tasks; 12 | using System.IO; 13 | using Newtonsoft.Json; 14 | using Microsoft.Azure.Documents.Linq; 15 | 16 | namespace LinkyLink 17 | { 18 | public partial class LinkOperations 19 | { 20 | [FunctionName(nameof(DeleteLink))] 21 | public async Task DeleteLink( 22 | [HttpTrigger(AuthorizationLevel.Function, "DELETE", Route = "links/{vanityUrl}")] HttpRequest req, 23 | [CosmosDB( 24 | databaseName: "linkylinkdb", 25 | collectionName: "linkbundles", 26 | ConnectionStringSetting = "LinkLinkConnection", 27 | SqlQuery = "SELECT * FROM linkbundles lb WHERE lb.vanityUrl = {vanityUrl}" 28 | )] IEnumerable documents, 29 | [CosmosDB(ConnectionStringSetting = "LinkLinkConnection")] DocumentClient docClient, 30 | string vanityUrl, 31 | Binder binder, 32 | ILogger log) 33 | { 34 | string handle = GetAccountInfo().HashedID; 35 | 36 | //not logged in? Bye... 37 | if (string.IsNullOrEmpty(handle)) return new UnauthorizedResult(); 38 | 39 | if (!documents.Any()) 40 | { 41 | log.LogInformation($"Bundle for {vanityUrl} not found."); 42 | return new NotFoundResult(); 43 | } 44 | 45 | Document doc = documents.Single(); 46 | 47 | try 48 | { 49 | string userId = doc.GetPropertyValue("userId"); 50 | 51 | if (!handle.Equals(userId, StringComparison.InvariantCultureIgnoreCase)) 52 | { 53 | log.LogWarning($"{userId} is trying to delete {vanityUrl} but is not the owner."); 54 | return new StatusCodeResult(StatusCodes.Status403Forbidden); 55 | } 56 | 57 | RequestOptions reqOptions = new RequestOptions { PartitionKey = new PartitionKey(vanityUrl) }; 58 | await docClient.DeleteDocumentAsync(doc.SelfLink, reqOptions); 59 | 60 | await DeleteQRCodeAsync(vanityUrl, binder); 61 | } 62 | catch (Exception ex) 63 | { 64 | log.LogError(ex, ex.Message); 65 | return new StatusCodeResult(StatusCodes.Status500InternalServerError); 66 | } 67 | return new NoContentResult(); 68 | } 69 | 70 | [FunctionName(nameof(DeleteLinks))] 71 | public async Task DeleteLinks( 72 | [HttpTrigger(AuthorizationLevel.Function, "DELETE", Route = "links")] HttpRequest req, 73 | [CosmosDB(ConnectionStringSetting = "LinkLinkConnection")] DocumentClient docClient, 74 | Binder binder, 75 | ILogger log) 76 | { 77 | string handle = GetAccountInfo().HashedID; 78 | 79 | //not logged in? Bye... 80 | if (string.IsNullOrEmpty(handle)) return new UnauthorizedResult(); 81 | 82 | string requestBody = await new StreamReader(req.Body).ReadToEndAsync(); 83 | IEnumerable vanityUrls = JsonConvert.DeserializeObject>(requestBody); 84 | string queryValues = string.Join(",", vanityUrls.Select(url => $"\"{url}\"")); 85 | 86 | log.LogInformation($"Request to remove the following collections: {queryValues}"); 87 | string sql = $"SELECT c._self, c.userId c.vanityUrl from c WHERE c.vanityUrl IN ({queryValues}) "; 88 | 89 | int deleteCount = 0; 90 | string resultMessage = string.Empty; 91 | 92 | try 93 | { 94 | FeedOptions feedOpts = new FeedOptions { EnableCrossPartitionQuery = true }; 95 | Uri collUri = UriFactory.CreateDocumentCollectionUri("linkylinkdb", "linkbundles"); 96 | var docQuery = docClient.CreateDocumentQuery(collUri, sql, feedOpts).AsDocumentQuery(); 97 | 98 | while (docQuery.HasMoreResults) 99 | { 100 | var docs = await docQuery.ExecuteNextAsync(); 101 | foreach (var doc in docs) 102 | { 103 | string userId = doc.GetPropertyValue("userId"); 104 | string vanityUrl = doc.GetPropertyValue("vanityUrl"); 105 | 106 | if (!handle.Equals(userId, StringComparison.InvariantCultureIgnoreCase)) 107 | { 108 | log.LogWarning($"{userId} is trying to delete {vanityUrl} but is not the owner."); 109 | log.LogWarning($"Skipping deletion of collection: {vanityUrl}."); 110 | continue; 111 | } 112 | RequestOptions reqOptions = new RequestOptions { PartitionKey = new PartitionKey(doc.vanityUrl) }; 113 | await docClient.DeleteDocumentAsync(doc._self, reqOptions); 114 | await DeleteQRCodeAsync(vanityUrl, binder); 115 | deleteCount++; 116 | } 117 | } 118 | resultMessage = (deleteCount == vanityUrls.Count()) ? "All collections removed" : "Some colletions were not removed"; 119 | } 120 | catch (Exception ex) 121 | { 122 | log.LogError(ex, ex.Message); 123 | return new StatusCodeResult(StatusCodes.Status500InternalServerError); 124 | } 125 | return new OkObjectResult(new { deleted = deleteCount, message = resultMessage }); 126 | } 127 | } 128 | } -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/SaveLinksTests.cs: -------------------------------------------------------------------------------- 1 | using AutoFixture; 2 | using FakeItEasy; 3 | using LinkyLink.Models; 4 | using LinkyLink.Tests.Helpers; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.Azure.WebJobs; 8 | using Microsoft.Extensions.Logging; 9 | using Newtonsoft.Json; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using Xunit; 13 | 14 | namespace LinkyLink.Tests 15 | { 16 | public class SaveLinksTests : TestBase 17 | { 18 | [Fact] 19 | public async Task SaveLinks_Empty_Payload_Returns_BadRequest() 20 | { 21 | // Arrange 22 | ILogger fakeLogger = A.Fake(); 23 | Binder fakeBinder = A.Fake(); 24 | HttpRequest req = this.DefaultRequest; 25 | req.Body = this.GetHttpRequestBodyStream(""); 26 | IAsyncCollector collector = A.Fake>(); 27 | 28 | // Act 29 | IActionResult result = await _linkOperations.SaveLinks(req, collector, fakeBinder, fakeLogger); 30 | 31 | // Assert 32 | Assert.IsType(result); 33 | A.CallTo(() => collector.AddAsync(A.Ignored, CancellationToken.None)).MustNotHaveHappened(); 34 | } 35 | 36 | [Theory] 37 | [InlineData("new url")] 38 | [InlineData("url.com")] 39 | [InlineData("my@$(Surl@F(@LV((")] 40 | [InlineData("someurl/")] 41 | [InlineData(".com.com")] 42 | public async Task SaveLinks_Returns_BadRequest_If_Vanity_Url_Fails_Regex(string vanityUrl) 43 | { 44 | // Arrange 45 | ILogger fakeLogger = A.Fake(); 46 | Binder fakeBinder = A.Fake(); 47 | HttpRequest req = this.DefaultRequest; 48 | 49 | LinkBundle payload = this.Fixture.Create(); 50 | payload.VanityUrl = vanityUrl; 51 | 52 | req.Body = this.GetHttpRequestBodyStream(JsonConvert.SerializeObject(payload)); 53 | IAsyncCollector collector = A.Fake>(); 54 | 55 | // Act 56 | IActionResult result = await _linkOperations.SaveLinks(req, collector, fakeBinder, fakeLogger); 57 | 58 | // Assert 59 | Assert.IsType(result); 60 | A.CallTo(() => collector.AddAsync(A.Ignored, CancellationToken.None)).MustNotHaveHappened(); 61 | } 62 | 63 | [Fact] 64 | public async Task SaveLinks_Valid_Payload_Returns_CreateRequest() 65 | { 66 | // Arrange 67 | ILogger fakeLogger = A.Fake(); 68 | Binder fakeBinder = A.Fake(); 69 | LinkBundle bundle = Fixture.Create(); 70 | 71 | HttpRequest req = this.AuthenticatedRequest; 72 | req.Body = this.GetHttpRequestBodyStream(JsonConvert.SerializeObject(bundle)); 73 | IAsyncCollector collector = A.Fake>(); 74 | 75 | // Act 76 | IActionResult result = await _linkOperations.SaveLinks(req, collector, fakeBinder, fakeLogger); 77 | 78 | // Assert 79 | Assert.IsType(result); 80 | 81 | CreatedResult createdResult = result as CreatedResult; 82 | LinkBundle createdBundle = createdResult.Value as LinkBundle; 83 | Assert.Equal(_hasher.HashString("someone@linkylink.com"), createdBundle.UserId); 84 | 85 | A.CallTo(() => collector.AddAsync(A.That.Matches(b => b.UserId == _hasher.HashString("someone@linkylink.com")), 86 | default)).MustHaveHappened(); 87 | } 88 | 89 | [Theory] 90 | [InlineData("lower")] 91 | [InlineData("UPPER")] 92 | [InlineData("MiXEd")] 93 | public async Task SaveLinks_Converts_VanityUrl_To_LowerCase(string vanityUrl) 94 | { 95 | // Arrange 96 | ILogger fakeLogger = A.Fake(); 97 | Binder fakeBinder = A.Fake(); 98 | LinkBundle bundle = Fixture.Create(); 99 | bundle.VanityUrl = vanityUrl; 100 | 101 | HttpRequest req = this.AuthenticatedRequest; 102 | req.Body = this.GetHttpRequestBodyStream(JsonConvert.SerializeObject(bundle)); 103 | IAsyncCollector collector = A.Fake>(); 104 | 105 | // Act 106 | IActionResult result = await _linkOperations.SaveLinks(req, collector, fakeBinder, fakeLogger); 107 | 108 | // Assert 109 | Assert.IsType(result); 110 | 111 | CreatedResult createdResult = result as CreatedResult; 112 | LinkBundle createdBundle = createdResult.Value as LinkBundle; 113 | Assert.Equal(vanityUrl.ToLower(), createdBundle.VanityUrl); 114 | 115 | A.CallTo(() => collector.AddAsync(A.That.Matches(b => b.VanityUrl == vanityUrl.ToLower()), 116 | default)).MustHaveHappened(); 117 | } 118 | 119 | [Fact] 120 | public async Task SaveLinks_Populates_VanityUrl_If_Not_Provided() 121 | { 122 | // Arrange 123 | ILogger fakeLogger = A.Fake(); 124 | Binder fakeBinder = A.Fake(); 125 | LinkBundle bundle = Fixture.Create(); 126 | bundle.VanityUrl = string.Empty; 127 | 128 | HttpRequest req = this.AuthenticatedRequest; 129 | req.Body = this.GetHttpRequestBodyStream(JsonConvert.SerializeObject(bundle)); 130 | IAsyncCollector collector = A.Fake>(); 131 | 132 | // Act 133 | IActionResult result = await _linkOperations.SaveLinks(req, collector, fakeBinder, fakeLogger); 134 | 135 | // Assert 136 | Assert.IsType(result); 137 | 138 | CreatedResult createdResult = result as CreatedResult; 139 | LinkBundle createdBundle = createdResult.Value as LinkBundle; 140 | Assert.False(string.IsNullOrEmpty(createdBundle.VanityUrl)); 141 | Assert.Equal(createdBundle.VanityUrl.ToLower(), createdBundle.VanityUrl); 142 | 143 | A.CallTo(() => collector.AddAsync(A.That.Matches(b => !string.IsNullOrEmpty(b.VanityUrl)), 144 | default)).MustHaveHappened(); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /tests/LinkyLink.Tests/Helpers/TestBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Security.Claims; 4 | using System.Text; 5 | 6 | using Microsoft.ApplicationInsights.Extensibility; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Http.Internal; 9 | using Microsoft.Azure.Documents; 10 | 11 | using Newtonsoft.Json; 12 | using AutoFixture; 13 | using AutoFixture.AutoFakeItEasy; 14 | 15 | using LinkyLink.Infrastructure; 16 | 17 | namespace LinkyLink.Tests.Helpers 18 | { 19 | public abstract class TestBase 20 | { 21 | private const string HASHER_KEY = "TestHasherKey"; 22 | private const string HASHER_SALT = "TestHasherSalt"; 23 | private IBlackListChecker _blackListChecker = new EnvironmentBlackListChecker(); 24 | 25 | protected IFixture Fixture { get; set; } 26 | protected LinkOperations _linkOperations; 27 | protected readonly Hasher _hasher = new Hasher(HASHER_KEY, HASHER_SALT); 28 | 29 | public TestBase() 30 | { 31 | var httpContextAccessor = new HttpContextAccessor 32 | { 33 | HttpContext = CreateContext(true) 34 | }; 35 | _linkOperations = new LinkOperations(httpContextAccessor, _blackListChecker, _hasher); 36 | 37 | this.Fixture = new Fixture() 38 | .Customize(new AutoFakeItEasyCustomization()); 39 | 40 | Fixture.Register(() => 41 | { 42 | Document doc = new Document(); 43 | doc.SetPropertyValue("userId", Fixture.Create()); 44 | doc.SetPropertyValue("vanityUrl", Fixture.Create()); 45 | doc.SetPropertyValue("description", Fixture.Create()); 46 | doc.SetPropertyValue("linkCount", Fixture.Create()); 47 | return doc; 48 | }); 49 | } 50 | 51 | protected Stream GetHttpRequestBodyStream(object bodyContent) 52 | { 53 | #pragma warning disable IDE0059 54 | byte[] bytes = default; 55 | #pragma warning restore IDE0059 56 | 57 | if (bodyContent is string bodyString) 58 | { 59 | bytes = Encoding.UTF8.GetBytes(bodyString); 60 | } 61 | else if (bodyContent is byte[] bodyBytes) 62 | { 63 | bytes = bodyBytes; 64 | } 65 | else 66 | { 67 | string bodyJson = JsonConvert.SerializeObject(bodyContent); 68 | bytes = Encoding.UTF8.GetBytes(bodyJson); 69 | } 70 | return new MemoryStream(bytes); 71 | } 72 | 73 | private HttpRequest _defaultRequest; 74 | protected HttpRequest DefaultRequest 75 | { 76 | get 77 | { 78 | if (_defaultRequest == null) 79 | { 80 | ClaimsIdentity identity = new ClaimsIdentity("WebJobsAuthLevel"); 81 | identity.AddClaim(new Claim(Constants.FunctionsAuthLevelClaimType, "Function")); 82 | identity.AddClaim(new Claim(Constants.FunctionsAuthLevelKeyNameClaimType, "default")); 83 | 84 | ClaimsPrincipal principal = new ClaimsPrincipal(identity); 85 | 86 | var context = new DefaultHttpContext 87 | { 88 | User = principal 89 | }; 90 | 91 | _defaultRequest = new DefaultHttpRequest(context); 92 | } 93 | return _defaultRequest; 94 | } 95 | } 96 | 97 | private HttpRequest _authenticatedRequest; 98 | protected HttpRequest AuthenticatedRequest 99 | { 100 | get 101 | { 102 | if (_authenticatedRequest == null) 103 | { 104 | var context = CreateContext(true); 105 | _authenticatedRequest = new DefaultHttpRequest(context); 106 | } 107 | return _authenticatedRequest; 108 | } 109 | } 110 | 111 | private TelemetryConfiguration _defaultTestConfiguration; 112 | protected TelemetryConfiguration DefaultTestConfiguration 113 | { 114 | get 115 | { 116 | if (_defaultTestConfiguration == null) 117 | { 118 | _defaultTestConfiguration = new TelemetryConfiguration 119 | { 120 | TelemetryChannel = new StubTelemetryChannel(), 121 | InstrumentationKey = Guid.NewGuid().ToString() 122 | }; 123 | } 124 | return _defaultTestConfiguration; 125 | } 126 | } 127 | 128 | protected void RemoveAuthFromContext() 129 | { 130 | var httpContextAccessor = new HttpContextAccessor 131 | { 132 | HttpContext = CreateContext() 133 | }; 134 | _linkOperations = new LinkOperations(httpContextAccessor, _blackListChecker, _hasher); 135 | } 136 | 137 | protected void AddAuthToContext() 138 | { 139 | var httpContextAccessor = new HttpContextAccessor 140 | { 141 | HttpContext = CreateContext(true) 142 | }; 143 | _linkOperations = new LinkOperations(httpContextAccessor, _blackListChecker, _hasher); 144 | } 145 | 146 | private static HttpContext CreateContext(bool authenticated = false) 147 | { 148 | ClaimsIdentity defaultIdentity = new ClaimsIdentity("WebJobsAuthLevel"); 149 | defaultIdentity.AddClaim(new Claim(Constants.FunctionsAuthLevelClaimType, "Function")); 150 | defaultIdentity.AddClaim(new Claim(Constants.FunctionsAuthLevelKeyNameClaimType, "default")); 151 | defaultIdentity.AddClaim(new Claim(ClaimTypes.Email, "someone@linkylink.com")); 152 | 153 | ClaimsPrincipal principal = new ClaimsPrincipal(defaultIdentity); 154 | 155 | if (authenticated) 156 | { 157 | ClaimsIdentity twitterIdentity = new ClaimsIdentity("twitter"); 158 | twitterIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "1111")); 159 | twitterIdentity.AddClaim(new Claim(ClaimTypes.Name, "First Last")); 160 | twitterIdentity.AddClaim(new Claim(ClaimTypes.Upn, "userid")); 161 | twitterIdentity.AddClaim(new Claim(ClaimTypes.Email, "someone@linkylink.com")); 162 | principal.AddIdentity(twitterIdentity); 163 | } 164 | 165 | var context = new DefaultHttpContext 166 | { 167 | User = principal 168 | }; 169 | 170 | return context; 171 | } 172 | } 173 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/visualstudio,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=visualstudio,visualstudiocode 3 | 4 | ### VisualStudioCode ### 5 | .vscode/* 6 | !.vscode/settings.json 7 | !.vscode/tasks.json 8 | !.vscode/launch.json 9 | !.vscode/extensions.json 10 | 11 | ### VisualStudioCode Patch ### 12 | # Ignore all local history of files 13 | .history 14 | 15 | ### VisualStudio ### 16 | ## Ignore Visual Studio temporary files, build results, and 17 | ## files generated by popular Visual Studio add-ons. 18 | ## 19 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 20 | 21 | # User-specific files 22 | *.rsuser 23 | *.suo 24 | *.user 25 | *.userosscache 26 | *.sln.docstates 27 | 28 | # User-specific files (MonoDevelop/Xamarin Studio) 29 | *.userprefs 30 | 31 | # Mono auto generated files 32 | mono_crash.* 33 | 34 | # Build results 35 | [Dd]ebug/ 36 | [Dd]ebugPublic/ 37 | [Rr]elease/ 38 | [Rr]eleases/ 39 | x64/ 40 | x86/ 41 | [Aa][Rr][Mm]/ 42 | [Aa][Rr][Mm]64/ 43 | bld/ 44 | [Bb]in/ 45 | [Oo]bj/ 46 | [Ll]og/ 47 | 48 | # Visual Studio 2015/2017 cache/options directory 49 | .vs/ 50 | # Uncomment if you have tasks that create the project's static files in wwwroot 51 | #wwwroot/ 52 | 53 | # Visual Studio 2017 auto generated files 54 | Generated\ Files/ 55 | 56 | # MSTest test Results 57 | [Tt]est[Rr]esult*/ 58 | [Bb]uild[Ll]og.* 59 | 60 | # NUNIT 61 | *.VisualState.xml 62 | TestResult.xml 63 | 64 | # Build Results of an ATL Project 65 | [Dd]ebugPS/ 66 | [Rr]eleasePS/ 67 | dlldata.c 68 | 69 | # Benchmark Results 70 | BenchmarkDotNet.Artifacts/ 71 | 72 | # .NET Core 73 | project.lock.json 74 | project.fragment.lock.json 75 | artifacts/ 76 | 77 | # StyleCop 78 | StyleCopReport.xml 79 | 80 | # Files built by Visual Studio 81 | *_i.c 82 | *_p.c 83 | *_h.h 84 | *.ilk 85 | *.meta 86 | *.obj 87 | *.iobj 88 | *.pch 89 | *.pdb 90 | *.ipdb 91 | *.pgc 92 | *.pgd 93 | *.rsp 94 | *.sbr 95 | *.tlb 96 | *.tli 97 | *.tlh 98 | *.tmp 99 | *.tmp_proj 100 | *_wpftmp.csproj 101 | *.log 102 | *.vspscc 103 | *.vssscc 104 | .builds 105 | *.pidb 106 | *.svclog 107 | *.scc 108 | 109 | # Chutzpah Test files 110 | _Chutzpah* 111 | 112 | # Visual C++ cache files 113 | ipch/ 114 | *.aps 115 | *.ncb 116 | *.opendb 117 | *.opensdf 118 | *.sdf 119 | *.cachefile 120 | *.VC.db 121 | *.VC.VC.opendb 122 | 123 | # Visual Studio profiler 124 | *.psess 125 | *.vsp 126 | *.vspx 127 | *.sap 128 | 129 | # Visual Studio Trace Files 130 | *.e2e 131 | 132 | # TFS 2012 Local Workspace 133 | $tf/ 134 | 135 | # Guidance Automation Toolkit 136 | *.gpState 137 | 138 | # ReSharper is a .NET coding add-in 139 | _ReSharper*/ 140 | *.[Rr]e[Ss]harper 141 | *.DotSettings.user 142 | 143 | # JustCode is a .NET coding add-in 144 | .JustCode 145 | 146 | # TeamCity is a build add-in 147 | _TeamCity* 148 | 149 | # DotCover is a Code Coverage Tool 150 | *.dotCover 151 | 152 | # AxoCover is a Code Coverage Tool 153 | .axoCover/* 154 | !.axoCover/settings.json 155 | 156 | # Visual Studio code coverage results 157 | *.coverage 158 | *.coveragexml 159 | 160 | # NCrunch 161 | _NCrunch_* 162 | .*crunch*.local.xml 163 | nCrunchTemp_* 164 | 165 | # MightyMoose 166 | *.mm.* 167 | AutoTest.Net/ 168 | 169 | # Web workbench (sass) 170 | .sass-cache/ 171 | 172 | # Installshield output folder 173 | [Ee]xpress/ 174 | 175 | # DocProject is a documentation generator add-in 176 | DocProject/buildhelp/ 177 | DocProject/Help/*.HxT 178 | DocProject/Help/*.HxC 179 | DocProject/Help/*.hhc 180 | DocProject/Help/*.hhk 181 | DocProject/Help/*.hhp 182 | DocProject/Help/Html2 183 | DocProject/Help/html 184 | 185 | # Click-Once directory 186 | publish/ 187 | 188 | # Publish Web Output 189 | *.[Pp]ublish.xml 190 | *.azurePubxml 191 | # Note: Comment the next line if you want to checkin your web deploy settings, 192 | # but database connection strings (with potential passwords) will be unencrypted 193 | *.pubxml 194 | *.publishproj 195 | 196 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 197 | # checkin your Azure Web App publish settings, but sensitive information contained 198 | # in these scripts will be unencrypted 199 | PublishScripts/ 200 | 201 | # NuGet Packages 202 | *.nupkg 203 | # The packages folder can be ignored because of Package Restore 204 | **/[Pp]ackages/* 205 | # except build/, which is used as an MSBuild target. 206 | !**/[Pp]ackages/build/ 207 | # Uncomment if necessary however generally it will be regenerated when needed 208 | #!**/[Pp]ackages/repositories.config 209 | # NuGet v3's project.json files produces more ignorable files 210 | *.nuget.props 211 | *.nuget.targets 212 | 213 | # Microsoft Azure Build Output 214 | csx/ 215 | *.build.csdef 216 | 217 | # Microsoft Azure Emulator 218 | ecf/ 219 | rcf/ 220 | 221 | # Windows Store app package directories and files 222 | AppPackages/ 223 | BundleArtifacts/ 224 | Package.StoreAssociation.xml 225 | _pkginfo.txt 226 | *.appx 227 | *.appxbundle 228 | *.appxupload 229 | 230 | # Visual Studio cache files 231 | # files ending in .cache can be ignored 232 | *.[Cc]ache 233 | # but keep track of directories ending in .cache 234 | !?*.[Cc]ache/ 235 | 236 | # Others 237 | ClientBin/ 238 | ~$* 239 | *~ 240 | *.dbmdl 241 | *.dbproj.schemaview 242 | *.jfm 243 | *.pfx 244 | *.publishsettings 245 | orleans.codegen.cs 246 | 247 | # Including strong name files can present a security risk 248 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 249 | #*.snk 250 | 251 | # Since there are multiple workflows, uncomment next line to ignore bower_components 252 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 253 | #bower_components/ 254 | 255 | # RIA/Silverlight projects 256 | Generated_Code/ 257 | 258 | # Backup & report files from converting an old project file 259 | # to a newer Visual Studio version. Backup files are not needed, 260 | # because we have git ;-) 261 | _UpgradeReport_Files/ 262 | Backup*/ 263 | UpgradeLog*.XML 264 | UpgradeLog*.htm 265 | ServiceFabricBackup/ 266 | *.rptproj.bak 267 | 268 | # SQL Server files 269 | *.mdf 270 | *.ldf 271 | *.ndf 272 | 273 | # Business Intelligence projects 274 | *.rdl.data 275 | *.bim.layout 276 | *.bim_*.settings 277 | *.rptproj.rsuser 278 | *- Backup*.rdl 279 | 280 | # Microsoft Fakes 281 | FakesAssemblies/ 282 | 283 | # GhostDoc plugin setting file 284 | *.GhostDoc.xml 285 | 286 | # Node.js Tools for Visual Studio 287 | .ntvs_analysis.dat 288 | node_modules/ 289 | 290 | # Visual Studio 6 build log 291 | *.plg 292 | 293 | # Visual Studio 6 workspace options file 294 | *.opt 295 | 296 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 297 | *.vbw 298 | 299 | # Visual Studio LightSwitch build output 300 | **/*.HTMLClient/GeneratedArtifacts 301 | **/*.DesktopClient/GeneratedArtifacts 302 | **/*.DesktopClient/ModelManifest.xml 303 | **/*.Server/GeneratedArtifacts 304 | **/*.Server/ModelManifest.xml 305 | _Pvt_Extensions 306 | 307 | # Paket dependency manager 308 | .paket/paket.exe 309 | paket-files/ 310 | 311 | # FAKE - F# Make 312 | .fake/ 313 | 314 | # CodeRush personal settings 315 | .cr/personal 316 | 317 | # Python Tools for Visual Studio (PTVS) 318 | __pycache__/ 319 | *.pyc 320 | 321 | # Cake - Uncomment if you are using it 322 | # tools/** 323 | # !tools/packages.config 324 | 325 | # Tabs Studio 326 | *.tss 327 | 328 | # Telerik's JustMock configuration file 329 | *.jmconfig 330 | 331 | # BizTalk build output 332 | *.btp.cs 333 | *.btm.cs 334 | *.odx.cs 335 | *.xsd.cs 336 | 337 | # OpenCover UI analysis results 338 | OpenCover/ 339 | 340 | # Azure Stream Analytics local run output 341 | ASALocalRun/ 342 | 343 | # MSBuild Binary and Structured Log 344 | *.binlog 345 | 346 | # NVidia Nsight GPU debugger configuration file 347 | *.nvuser 348 | 349 | # MFractors (Xamarin productivity tool) working folder 350 | .mfractor/ 351 | 352 | # Local History for Visual Studio 353 | .localhistory/ 354 | 355 | # BeatPulse healthcheck temp database 356 | healthchecksdb 357 | 358 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 359 | MigrationBackup/ 360 | 361 | # End of https://www.gitignore.io/api/visualstudio,visualstudiocode 362 | 363 | 364 | .DS_Store 365 | local.settings.json 366 | --------------------------------------------------------------------------------