diff --git a/.github/workflows/test_backend.yml b/.github/workflows/test_backend.yml index a682dbf98..3f063993c 100644 --- a/.github/workflows/test_backend.yml +++ b/.github/workflows/test_backend.yml @@ -53,4 +53,4 @@ jobs: - name: Install Dependencies run: dotnet restore - name: Test - run: dotnet test --no-restore + run: dotnet test --no-restore --filter "SkipInActions!=true" diff --git a/CollAction.Tests/Integration/Endpoint/GraphQlTests.cs b/CollAction.Tests/Integration/Endpoint/GraphQlTests.cs index cd0cb2bef..4a61892f4 100644 --- a/CollAction.Tests/Integration/Endpoint/GraphQlTests.cs +++ b/CollAction.Tests/Integration/Endpoint/GraphQlTests.cs @@ -34,7 +34,7 @@ public GraphQlTests() [Fact] public async Task TestCrowdactionList() { - var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), null, null, null, null, new[] { Category.Community }, Array.Empty(), CrowdactionDisplayPriority.Bottom, CrowdactionStatus.Running, 0, null); + var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), null, null, null, null, null, new[] { Category.Community }, Array.Empty(), CrowdactionDisplayPriority.Bottom, CrowdactionStatus.Running, 0, null); Crowdaction createdCrowdaction = await crowdactionService.CreateCrowdactionInternal(newCrowdaction, CancellationToken.None).ConfigureAwait(false); Assert.NotNull(createdCrowdaction); diff --git a/CollAction.Tests/Integration/Service/CrowdactionServiceTests.cs b/CollAction.Tests/Integration/Service/CrowdactionServiceTests.cs index 3bb322377..3aa22926a 100644 --- a/CollAction.Tests/Integration/Service/CrowdactionServiceTests.cs +++ b/CollAction.Tests/Integration/Service/CrowdactionServiceTests.cs @@ -70,7 +70,7 @@ public async Task TestCrowdactionCreate() public async Task TestCrowdactionUpdate() { var user = await context.Users.FirstAsync().ConfigureAwait(false); - var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), null, null, null, null, new[] { Category.Community }, Array.Empty(), CrowdactionDisplayPriority.Bottom, CrowdactionStatus.Running, 0, user.Id); + var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), null, null, null, null, null, new[] { Category.Community }, Array.Empty(), CrowdactionDisplayPriority.Bottom, CrowdactionStatus.Running, 0, user.Id); Crowdaction crowdaction = await crowdactionService.CreateCrowdactionInternal(newCrowdaction, CancellationToken.None).ConfigureAwait(false); Assert.NotNull(crowdaction); @@ -117,7 +117,7 @@ public async Task TestCrowdactionUpdate() [Fact] public async Task TestCommentCreate() { - var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), null, null, null, null, new[] { Category.Community }, Array.Empty(), CrowdactionDisplayPriority.Bottom, CrowdactionStatus.Running, 0, null); + var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow, DateTime.UtcNow.AddDays(1), null, null, null, null, null, new[] { Category.Community }, Array.Empty(), CrowdactionDisplayPriority.Bottom, CrowdactionStatus.Running, 0, null); Crowdaction crowdaction = await crowdactionService.CreateCrowdactionInternal(newCrowdaction, CancellationToken.None).ConfigureAwait(false); Assert.NotNull(crowdaction); @@ -143,7 +143,7 @@ public async Task TestCrowdactionCommitAnonymous() { // Setup var user = await context.Users.FirstAsync().ConfigureAwait(false); - Crowdaction crowdaction = new Crowdaction($"test-{Guid.NewGuid()}", CrowdactionStatus.Running, user.Id, 10, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), "t", "t", "t", null, null); + Crowdaction crowdaction = new Crowdaction($"test-{Guid.NewGuid()}", CrowdactionStatus.Running, user.Id, 10, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), "t", "t", "t", "t", null, null); context.Crowdactions.Add(crowdaction); await context.SaveChangesAsync().ConfigureAwait(false); @@ -161,7 +161,7 @@ public async Task TestCrowdactionCommitLoggedIn() // Setup var user = await context.Users.FirstAsync().ConfigureAwait(false); var userClaim = await signInManager.CreateUserPrincipalAsync(user).ConfigureAwait(false); - var crowdaction = new Crowdaction($"test-{Guid.NewGuid()}", CrowdactionStatus.Running, user.Id, 10, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), "t", "t", "t", null, null); + var crowdaction = new Crowdaction($"test-{Guid.NewGuid()}", CrowdactionStatus.Running, user.Id, 10, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), "t", "t", "t", "t", null, null); context.Crowdactions.Add(crowdaction); await context.SaveChangesAsync().ConfigureAwait(false); @@ -187,6 +187,7 @@ public async Task TestCrowdactionEmail() start: DateTime.Now.AddDays(-10), end: DateTime.Now.AddDays(30), goal: Guid.NewGuid().ToString(), + instagramUser: "test", creatorComments: Guid.NewGuid().ToString(), proposal: Guid.NewGuid().ToString(), target: 40, @@ -217,7 +218,7 @@ public async Task TestCrowdactionSearch() Category searchCategory = (Category)r.Next(7); for (int i = 0; i < r.Next(10, 30); i++) { - var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow.AddDays(r.Next(-20, 20)), DateTime.UtcNow.AddDays(r.Next(21, 50)), null, null, null, null, new[] { searchCategory }, Array.Empty(), CrowdactionDisplayPriority.Bottom, (CrowdactionStatus)r.Next(3), 0, null); + var newCrowdaction = new NewCrowdactionInternal("test" + Guid.NewGuid(), 100, "test", "test", "test", null, DateTime.UtcNow.AddDays(r.Next(-20, 20)), DateTime.UtcNow.AddDays(r.Next(21, 50)), null, null, null, null, null, new[] { searchCategory }, Array.Empty(), CrowdactionDisplayPriority.Bottom, (CrowdactionStatus)r.Next(3), 0, null); Crowdaction crowdaction = await crowdactionService.CreateCrowdactionInternal(newCrowdaction, CancellationToken.None).ConfigureAwait(false); } diff --git a/CollAction.Tests/Integration/Service/ImageServicesTests.cs b/CollAction.Tests/Integration/Service/ImageServiceTests.cs similarity index 95% rename from CollAction.Tests/Integration/Service/ImageServicesTests.cs rename to CollAction.Tests/Integration/Service/ImageServiceTests.cs index 3408f1277..0d84cbbe5 100644 --- a/CollAction.Tests/Integration/Service/ImageServicesTests.cs +++ b/CollAction.Tests/Integration/Service/ImageServiceTests.cs @@ -13,14 +13,14 @@ namespace CollAction.Tests.Integration.Service { [Trait("Category", "Integration")] - public sealed class ImageServicesTests : IntegrationTestBase + public sealed class ImageServiceTests : IntegrationTestBase { private readonly byte[] testImage = new byte[] { 0x42, 0x4D, 0x1E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1A, 0x00, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, 0xFF, 0x00 }; private readonly MemoryStream imageMs; private readonly IImageService imageService; private readonly Mock upload; - public ImageServicesTests() : base(false) + public ImageServiceTests() : base(false) { imageMs = new MemoryStream(testImage); imageService = Scope.ServiceProvider.GetRequiredService(); diff --git a/CollAction.Tests/Integration/Service/InstagramServiceTests.cs b/CollAction.Tests/Integration/Service/InstagramServiceTests.cs new file mode 100644 index 000000000..49a10e654 --- /dev/null +++ b/CollAction.Tests/Integration/Service/InstagramServiceTests.cs @@ -0,0 +1,30 @@ +using CollAction.Services.Instagram; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace CollAction.Tests.Integration.Service +{ + [Trait("Category", "Integration")] + [Trait("SkipInActions", "true")] // Instagram blocks github actions ips, probably marked as spammer + public sealed class InstagramServiceTests : IntegrationTestBase + { + private readonly IInstagramService instagramService; + + public InstagramServiceTests(): base(false) + { + instagramService = Scope.ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task TestInstagramApi() + { + var result = await instagramService.GetItems("slowfashionseason", CancellationToken.None).ConfigureAwait(false); + Assert.True(result.Any()); + } + } +} diff --git a/CollAction.Tests/Integration/Service/UserServiceTests.cs b/CollAction.Tests/Integration/Service/UserServiceTests.cs index 5fa5614d4..6cd39731e 100644 --- a/CollAction.Tests/Integration/Service/UserServiceTests.cs +++ b/CollAction.Tests/Integration/Service/UserServiceTests.cs @@ -124,7 +124,7 @@ public async Task TestUserManagement() public async Task TestFinishRegistration() { // Setup - var crowdaction = new Crowdaction($"test-{Guid.NewGuid()}", CrowdactionStatus.Running, await context.Users.Select(u => u.Id).FirstAsync().ConfigureAwait(false), 10, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), "t", "t", "t", null, null); + var crowdaction = new Crowdaction($"test-{Guid.NewGuid()}", CrowdactionStatus.Running, await context.Users.Select(u => u.Id).FirstAsync().ConfigureAwait(false), 10, DateTime.UtcNow.AddDays(-1), DateTime.UtcNow.AddDays(1), "t", "t", "t", "t", null, null); context.Crowdactions.Add(crowdaction); await context.SaveChangesAsync().ConfigureAwait(false); diff --git a/CollAction.sln b/CollAction.sln index 5c78aacd9..b06292517 100644 --- a/CollAction.sln +++ b/CollAction.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ ProjectSection(SolutionItems) = preProject .github\workflows\on_push.yml = .github\workflows\on_push.yml .github\workflows\on_tag_version.yml = .github\workflows\on_tag_version.yml + .github\workflows\staging_deploy.yml = .github\workflows\staging_deploy.yml + .github\workflows\test_backend.yml = .github\workflows\test_backend.yml EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{BECC17AF-2D3D-4942-8391-F297062D4752}" diff --git a/CollAction/Controllers/GraphQlController.cs b/CollAction/Controllers/GraphQlController.cs index b5c0b6377..8b852cb4e 100644 --- a/CollAction/Controllers/GraphQlController.cs +++ b/CollAction/Controllers/GraphQlController.cs @@ -30,6 +30,7 @@ public sealed class GraphQlController : Controller private readonly IServiceProvider serviceProvider; private readonly IMemoryCache cache; private readonly ISchema schema; + private const int MaxGraphQlQueryDepth = 21; private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(1); private class CacheKey : IEquatable @@ -140,7 +141,7 @@ private async Task Execute(string query, string? operationName, UserContext = new UserContext(User, context, serviceProvider), ComplexityConfiguration = new ComplexityConfiguration() { - MaxDepth = 20 + MaxDepth = MaxGraphQlQueryDepth }, ValidationRules = validationRules, CancellationToken = cancellation, diff --git a/CollAction/Controllers/ProxyController.cs b/CollAction/Controllers/ProxyController.cs new file mode 100644 index 000000000..c4053d7b5 --- /dev/null +++ b/CollAction/Controllers/ProxyController.cs @@ -0,0 +1,24 @@ +using CollAction.Services.Proxy; +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace CollAction.Controllers +{ + [Route("proxy")] + [ApiController] + public sealed class ProxyController + { + private readonly IProxyService proxyService; + + public ProxyController(IProxyService proxyService) + { + this.proxyService = proxyService; + } + + [HttpGet] + public Task Proxy(Uri url, CancellationToken token) + => proxyService.Proxy(url, token); + } +} diff --git a/CollAction/GraphQl/Mutations/Input/NewCrowdactionInputGraph.cs b/CollAction/GraphQl/Mutations/Input/NewCrowdactionInputGraph.cs index 583b7b6d5..524841355 100644 --- a/CollAction/GraphQl/Mutations/Input/NewCrowdactionInputGraph.cs +++ b/CollAction/GraphQl/Mutations/Input/NewCrowdactionInputGraph.cs @@ -16,6 +16,7 @@ public NewCrowdactionInputGraph() Field(x => x.CreatorComments, true); Field(x => x.Start); Field(x => x.End); + Field(x => x.InstagramUser, true); Field(x => x.BannerImageFileId, true); Field(x => x.CardImageFileId, true); Field(x => x.DescriptiveImageFileId, true); diff --git a/CollAction/GraphQl/Mutations/Input/UpdatedCrowdactionInputGraph.cs b/CollAction/GraphQl/Mutations/Input/UpdatedCrowdactionInputGraph.cs index 273ff7a9e..9081f574b 100644 --- a/CollAction/GraphQl/Mutations/Input/UpdatedCrowdactionInputGraph.cs +++ b/CollAction/GraphQl/Mutations/Input/UpdatedCrowdactionInputGraph.cs @@ -20,6 +20,7 @@ public UpdatedCrowdactionInputGraph() Field(x => x.BannerImageFileId, true); Field(x => x.CardImageFileId, true); Field(x => x.DescriptiveImageFileId, true); + Field(x => x.InstagramUser, true); Field(x => x.DescriptionVideoLink, true); Field(x => x.Tags); Field(x => x.DisplayPriority); diff --git a/CollAction/GraphQl/Queries/CrowdactionCommentGraph.cs b/CollAction/GraphQl/Queries/CrowdactionCommentGraph.cs index 2aaf0d446..6b6ca01f3 100644 --- a/CollAction/GraphQl/Queries/CrowdactionCommentGraph.cs +++ b/CollAction/GraphQl/Queries/CrowdactionCommentGraph.cs @@ -6,7 +6,7 @@ namespace CollAction.GraphQl.Queries { - public class CrowdactionCommentGraph : EfObjectGraphType + public sealed class CrowdactionCommentGraph : EfObjectGraphType { public CrowdactionCommentGraph(IEfGraphQLService graphService) : base(graphService) { diff --git a/CollAction/GraphQl/Queries/CrowdactionGraph.cs b/CollAction/GraphQl/Queries/CrowdactionGraph.cs index 2415feb65..6a8d23b74 100644 --- a/CollAction/GraphQl/Queries/CrowdactionGraph.cs +++ b/CollAction/GraphQl/Queries/CrowdactionGraph.cs @@ -2,11 +2,16 @@ using CollAction.Helpers; using CollAction.Models; using CollAction.Services.Crowdactions; +using CollAction.Services.Instagram; +using CollAction.Services.Instagram.Models; using GraphQL.Authorization; using GraphQL.EntityFramework; using GraphQL.Types; using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; namespace CollAction.GraphQl.Queries { @@ -30,6 +35,7 @@ public CrowdactionGraph(IEfGraphQLService entityFrameworkG Field(x => x.IsComingSoon); Field(x => x.Name); Field(x => x.NumberCrowdactionEmailsSent); + Field(x => x.InstagramUser, true); Field(nameof(Crowdaction.OwnerId)).Resolve(x => x.Source.OwnerId); Field(x => x.Proposal); Field(x => x.RemainingTime); @@ -98,6 +104,23 @@ public CrowdactionGraph(IEfGraphQLService entityFrameworkG return c.Source.Percentage; }); + FieldAsync>>, IEnumerable>( + "instagramWall", + resolve: c => + { + string? instagramUser = c.Source.InstagramUser; + if (instagramUser != null) + { + return c.GetUserContext() + .ServiceProvider + .GetRequiredService() + .GetItems(instagramUser, c.CancellationToken); + } + else + { + return Task.FromResult(Enumerable.Empty()); + } + }); AddNavigationField(nameof(Crowdaction.DescriptiveImage), c => c.Source.DescriptiveImage); AddNavigationField(nameof(Crowdaction.BannerImage), c => c.Source.BannerImage); AddNavigationField(nameof(Crowdaction.CardImage), c => c.Source.CardImage); diff --git a/CollAction/GraphQl/Queries/InstagramWallItemGraph.cs b/CollAction/GraphQl/Queries/InstagramWallItemGraph.cs new file mode 100644 index 000000000..f55c6806a --- /dev/null +++ b/CollAction/GraphQl/Queries/InstagramWallItemGraph.cs @@ -0,0 +1,19 @@ +using CollAction.Services.Instagram.Models; +using GraphQL.Types; + +namespace CollAction.GraphQl.Queries +{ + public sealed class InstagramWallItemGraph : ObjectGraphType + { + public InstagramWallItemGraph() + { + Field("id", resolve: x => x.Source.ShortCode); + Field(x => x.ShortCode); + Field(x => x.Link); + Field(x => x.Date); + Field(x => x.AccessibilityCaption, true); + Field(x => x.Caption, true); + Field(x => x.ThumbnailSrc); + } + } +} diff --git a/CollAction/GraphQl/Queries/QueryGraph.cs b/CollAction/GraphQl/Queries/QueryGraph.cs index dac94a9b9..57e7d2cc3 100644 --- a/CollAction/GraphQl/Queries/QueryGraph.cs +++ b/CollAction/GraphQl/Queries/QueryGraph.cs @@ -3,15 +3,19 @@ using CollAction.Helpers; using CollAction.Models; using CollAction.Services.Crowdactions; +using CollAction.Services.Instagram; +using CollAction.Services.Instagram.Models; using CollAction.Services.User; using GraphQL.Authorization; using GraphQL.EntityFramework; using GraphQL.Types; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Security.Claims; +using System.Threading.Tasks; namespace CollAction.GraphQl.Queries { @@ -123,6 +127,18 @@ public QueryGraph(IEfGraphQLService entityFrameworkGraphQl }, graphType: typeof(ApplicationUserGraph)).AuthorizeWith(AuthorizationConstants.GraphQlAdminPolicy); + FieldAsync>>, IEnumerable>( + "instagramWall", + arguments: new QueryArguments(new QueryArgument>() { Name = "user" }), + resolve: c => + { + string instagramUser = c.GetArgument("user"); + return c.GetUserContext() + .ServiceProvider + .GetRequiredService() + .GetItems(instagramUser, c.CancellationToken); + }); + AddSingleField( name: "user", resolve: c => c.DbContext.Users, diff --git a/CollAction/Migrations/20200612174558_InstagramName.Designer.cs b/CollAction/Migrations/20200612174558_InstagramName.Designer.cs new file mode 100644 index 000000000..b9351cddb --- /dev/null +++ b/CollAction/Migrations/20200612174558_InstagramName.Designer.cs @@ -0,0 +1,688 @@ +// +using System; +using CollAction.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +namespace CollAction.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200612174558_InstagramUser")] + partial class InstagramUser + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn) + .HasAnnotation("ProductVersion", "3.1.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + modelBuilder.Entity("CollAction.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("character varying(250)") + .HasMaxLength(250); + + b.Property("LastName") + .HasColumnType("character varying(250)") + .HasMaxLength(250); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("RepresentsNumberParticipants") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("CollAction.Models.Crowdaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("AnonymousUserParticipants") + .HasColumnType("integer"); + + b.Property("BannerImageFileId") + .HasColumnType("integer"); + + b.Property("CardImageFileId") + .HasColumnType("integer"); + + b.Property("CreatorComments") + .HasColumnType("character varying(20000)") + .HasMaxLength(20000); + + b.Property("Description") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("DescriptionVideoLink") + .HasColumnType("character varying(2048)") + .HasMaxLength(2048); + + b.Property("DescriptiveImageFileId") + .HasColumnType("integer"); + + b.Property("DisplayPriority") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("End") + .HasColumnType("timestamp without time zone"); + + b.Property("FinishJobId") + .HasColumnType("character varying(100)") + .HasMaxLength(100); + + b.Property("Goal") + .IsRequired() + .HasColumnType("character varying(10000)") + .HasMaxLength(10000); + + b.Property("InstagramUser") + .HasColumnType("character varying(30)") + .HasMaxLength(30); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(50)") + .HasMaxLength(50); + + b.Property("NumberCrowdactionEmailsSent") + .HasColumnType("integer"); + + b.Property("OwnerId") + .HasColumnType("text"); + + b.Property("Proposal") + .IsRequired() + .HasColumnType("character varying(300)") + .HasMaxLength(300); + + b.Property("Start") + .HasColumnType("timestamp without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Target") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BannerImageFileId"); + + b.HasIndex("CardImageFileId"); + + b.HasIndex("DescriptiveImageFileId"); + + b.HasIndex("Name") + .IsUnique() + .HasName("IX_Crowdactions_Name"); + + b.HasIndex("OwnerId"); + + b.ToTable("Crowdactions"); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionCategory", b => + { + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CrowdactionId") + .HasColumnType("integer"); + + b.HasKey("Category", "CrowdactionId"); + + b.HasIndex("CrowdactionId"); + + b.ToTable("CrowdactionCategories"); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Comment") + .IsRequired() + .HasColumnType("text"); + + b.Property("CommentedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("CrowdactionId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CrowdactionId"); + + b.HasIndex("UserId"); + + b.ToTable("CrowdactionComments"); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionParticipant", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("CrowdactionId") + .HasColumnType("integer"); + + b.Property("ParticipationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("SubscribedToCrowdactionEmails") + .HasColumnType("boolean"); + + b.Property("UnsubscribeToken") + .HasColumnType("uuid"); + + b.HasKey("UserId", "CrowdactionId"); + + b.HasIndex("CrowdactionId"); + + b.ToTable("CrowdactionParticipants"); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionParticipantCount", b => + { + b.Property("CrowdactionId") + .HasColumnType("integer"); + + b.Property("Count") + .HasColumnType("integer"); + + b.HasKey("CrowdactionId"); + + b.ToTable("CrowdactionParticipantCounts"); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionTag", b => + { + b.Property("TagId") + .HasColumnType("integer"); + + b.Property("CrowdactionId") + .HasColumnType("integer"); + + b.HasKey("TagId", "CrowdactionId"); + + b.HasIndex("CrowdactionId"); + + b.ToTable("CrowdactionTags"); + }); + + modelBuilder.Entity("CollAction.Models.DonationEventLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("EventData") + .IsRequired() + .HasColumnType("json"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DonationEventLog"); + }); + + modelBuilder.Entity("CollAction.Models.ImageFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Date") + .HasColumnType("timestamp without time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Filepath") + .IsRequired() + .HasColumnType("text"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ImageFiles"); + }); + + modelBuilder.Entity("CollAction.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying(30)") + .HasMaxLength(30); + + b.HasKey("Id"); + + b.HasAlternateKey("Name"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("CollAction.Models.UserEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("EventData") + .IsRequired() + .HasColumnType("json"); + + b.Property("EventLoggedAt") + .HasColumnType("timestamp without time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEvents"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasColumnType("character varying(256)") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("CollAction.Models.Crowdaction", b => + { + b.HasOne("CollAction.Models.ImageFile", "BannerImage") + .WithMany() + .HasForeignKey("BannerImageFileId"); + + b.HasOne("CollAction.Models.ImageFile", "CardImage") + .WithMany() + .HasForeignKey("CardImageFileId"); + + b.HasOne("CollAction.Models.ImageFile", "DescriptiveImage") + .WithMany() + .HasForeignKey("DescriptiveImageFileId"); + + b.HasOne("CollAction.Models.ApplicationUser", "Owner") + .WithMany("Crowdactions") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionCategory", b => + { + b.HasOne("CollAction.Models.Crowdaction", "Crowdaction") + .WithMany("Categories") + .HasForeignKey("CrowdactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionComment", b => + { + b.HasOne("CollAction.Models.Crowdaction", "Crowdaction") + .WithMany("Comments") + .HasForeignKey("CrowdactionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CollAction.Models.ApplicationUser", "User") + .WithMany("CrowdactionComments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionParticipant", b => + { + b.HasOne("CollAction.Models.Crowdaction", "Crowdaction") + .WithMany("Participants") + .HasForeignKey("CrowdactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CollAction.Models.ApplicationUser", "User") + .WithMany("Participates") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionParticipantCount", b => + { + b.HasOne("CollAction.Models.Crowdaction", "Crowdaction") + .WithOne("ParticipantCounts") + .HasForeignKey("CollAction.Models.CrowdactionParticipantCount", "CrowdactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollAction.Models.CrowdactionTag", b => + { + b.HasOne("CollAction.Models.Crowdaction", "Crowdaction") + .WithMany("Tags") + .HasForeignKey("CrowdactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CollAction.Models.Tag", "Tag") + .WithMany("CrowdactionTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollAction.Models.DonationEventLog", b => + { + b.HasOne("CollAction.Models.ApplicationUser", "User") + .WithMany("DonationEvents") + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("CollAction.Models.UserEvent", b => + { + b.HasOne("CollAction.Models.ApplicationUser", "User") + .WithMany("UserEvents") + .HasForeignKey("UserId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CollAction.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CollAction.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CollAction.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CollAction.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CollAction/Migrations/20200612174558_InstagramName.cs b/CollAction/Migrations/20200612174558_InstagramName.cs new file mode 100644 index 000000000..e4ea4dc8e --- /dev/null +++ b/CollAction/Migrations/20200612174558_InstagramName.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace CollAction.Migrations +{ + public partial class InstagramUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "DescriptionVideoLink", + table: "Crowdactions", + maxLength: 2048, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AddColumn( + name: "InstagramUser", + table: "Crowdactions", + maxLength: 30, + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "InstagramUser", + table: "Crowdactions"); + + migrationBuilder.AlterColumn( + name: "DescriptionVideoLink", + table: "Crowdactions", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldMaxLength: 2048, + oldNullable: true); + } + } +} diff --git a/CollAction/Migrations/ApplicationDbContextModelSnapshot.cs b/CollAction/Migrations/ApplicationDbContextModelSnapshot.cs index 017b8ebf3..7faafe6a3 100644 --- a/CollAction/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/CollAction/Migrations/ApplicationDbContextModelSnapshot.cs @@ -125,7 +125,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(10000); b.Property("DescriptionVideoLink") - .HasColumnType("text"); + .HasColumnType("character varying(2048)") + .HasMaxLength(2048); b.Property("DescriptiveImageFileId") .HasColumnType("integer"); @@ -147,6 +148,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("character varying(10000)") .HasMaxLength(10000); + b.Property("InstagramUser") + .HasColumnType("character varying(30)") + .HasMaxLength(30); + b.Property("Name") .IsRequired() .HasColumnType("character varying(50)") diff --git a/CollAction/Models/Crowdaction.cs b/CollAction/Models/Crowdaction.cs index a0ab2da4c..399862652 100644 --- a/CollAction/Models/Crowdaction.cs +++ b/CollAction/Models/Crowdaction.cs @@ -11,11 +11,11 @@ namespace CollAction.Models { public sealed class Crowdaction { - public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int target, DateTime start, DateTime end, string description, string goal, string proposal, string? creatorComments, string? descriptionVideoLink, CrowdactionDisplayPriority displayPriority = CrowdactionDisplayPriority.Medium, int? bannerImageFileId = null, int? descriptiveImageFileId = null, int? cardImageFileId = null, int anonymousUserParticipants = 0) : this(name, status, ownerId, target, start, end, description, goal, proposal, creatorComments, descriptionVideoLink, new List(), new List(), displayPriority, bannerImageFileId, descriptiveImageFileId, cardImageFileId, anonymousUserParticipants) + public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int target, DateTime start, DateTime end, string? instagramUser, string description, string goal, string proposal, string? creatorComments, string? descriptionVideoLink, CrowdactionDisplayPriority displayPriority = CrowdactionDisplayPriority.Medium, int? bannerImageFileId = null, int? descriptiveImageFileId = null, int? cardImageFileId = null, int anonymousUserParticipants = 0) : this(name, status, ownerId, target, start, end, instagramUser, description, goal, proposal, creatorComments, descriptionVideoLink, new List(), new List(), displayPriority, bannerImageFileId, descriptiveImageFileId, cardImageFileId, anonymousUserParticipants) { } - public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int target, DateTime start, DateTime end, string description, string goal, string proposal, string? creatorComments, string? descriptionVideoLink, ICollection categories, ICollection tags, CrowdactionDisplayPriority displayPriority = CrowdactionDisplayPriority.Medium, int? bannerImageFileId = null, int? descriptiveImageFileId = null, int? cardImageFileId = null, int anonymousUserParticipants = 0) + public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int target, DateTime start, DateTime end, string? instagramUser, string description, string goal, string proposal, string? creatorComments, string? descriptionVideoLink, ICollection categories, ICollection tags, CrowdactionDisplayPriority displayPriority = CrowdactionDisplayPriority.Medium, int? bannerImageFileId = null, int? descriptiveImageFileId = null, int? cardImageFileId = null, int anonymousUserParticipants = 0) { Name = name; Status = status; @@ -35,6 +35,7 @@ public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int t Categories = categories; Tags = tags; AnonymousUserParticipants = anonymousUserParticipants; + InstagramUser = instagramUser; } [NotMapped] @@ -78,6 +79,7 @@ public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int t [MaxLength(20000)] public string? CreatorComments { get; set; } + [MaxLength(2048)] public string? DescriptionVideoLink { get; set; } public CrowdactionDisplayPriority DisplayPriority { get; set; } @@ -102,6 +104,10 @@ public Crowdaction(string name, CrowdactionStatus status, string? ownerId, int t [ForeignKey("CardImageFileId")] public ImageFile? CardImage { get; set; } + [MinLength(1)] + [MaxLength(30)] + public string? InstagramUser { get; set; } + public CrowdactionParticipantCount? ParticipantCounts { get; set; } public ICollection Participants { get; set; } = new List(); diff --git a/CollAction/Models/CrowdactionComment.cs b/CollAction/Models/CrowdactionComment.cs index 828c6b25f..21aad789d 100644 --- a/CollAction/Models/CrowdactionComment.cs +++ b/CollAction/Models/CrowdactionComment.cs @@ -3,7 +3,7 @@ namespace CollAction.Models { - public class CrowdactionComment + public sealed class CrowdactionComment { public CrowdactionComment(string comment, string? userId, string? anonymousCommentUser, int crowdactionId, DateTime commentedAt, CrowdactionCommentStatus status) { diff --git a/CollAction/Services/AnalyticsOptions.cs b/CollAction/Services/AnalyticsOptions.cs index 6450c461e..76e643eb6 100644 --- a/CollAction/Services/AnalyticsOptions.cs +++ b/CollAction/Services/AnalyticsOptions.cs @@ -2,7 +2,7 @@ namespace CollAction.Services { - public class AnalyticsOptions + public sealed class AnalyticsOptions { [Required] public string GoogleAnalyticsID { get; set; } = null!; diff --git a/CollAction/Services/Crowdactions/CrowdactionService.cs b/CollAction/Services/Crowdactions/CrowdactionService.cs index 7b0b58ac5..f9ebd3e1c 100644 --- a/CollAction/Services/Crowdactions/CrowdactionService.cs +++ b/CollAction/Services/Crowdactions/CrowdactionService.cs @@ -105,6 +105,7 @@ await context.Tags description: newCrowdaction.Description, goal: newCrowdaction.Goal, proposal: newCrowdaction.Proposal, + instagramUser: newCrowdaction.InstagramUser, creatorComments: newCrowdaction.CreatorComments, descriptionVideoLink: newCrowdaction.DescriptionVideoLink?.Replace("www.youtube.com", "www.youtube-nocookie.com", StringComparison.Ordinal), displayPriority: newCrowdaction.DisplayPriority, @@ -187,6 +188,7 @@ await context.Tags description: newCrowdaction.Description, goal: newCrowdaction.Goal, proposal: newCrowdaction.Proposal, + instagramUser: newCrowdaction.InstagramUser, creatorComments: newCrowdaction.CreatorComments, descriptionVideoLink: newCrowdaction.DescriptionVideoLink?.Replace("www.youtube.com", "www.youtube-nocookie.com", StringComparison.Ordinal), displayPriority: CrowdactionDisplayPriority.Medium, @@ -270,6 +272,7 @@ public async Task UpdateCrowdaction(UpdatedCrowdaction update crowdaction.Proposal = updatedCrowdaction.Proposal; crowdaction.Goal = updatedCrowdaction.Goal; crowdaction.CreatorComments = updatedCrowdaction.CreatorComments; + crowdaction.InstagramUser = updatedCrowdaction.InstagramUser; crowdaction.Target = updatedCrowdaction.Target; crowdaction.Start = updatedCrowdaction.Start; crowdaction.End = updatedCrowdaction.End.Date.AddHours(23).AddMinutes(59).AddSeconds(59); diff --git a/CollAction/Services/Crowdactions/Models/NewCrowdaction.cs b/CollAction/Services/Crowdactions/Models/NewCrowdaction.cs index 3d4e87601..b7a864392 100644 --- a/CollAction/Services/Crowdactions/Models/NewCrowdaction.cs +++ b/CollAction/Services/Crowdactions/Models/NewCrowdaction.cs @@ -42,6 +42,10 @@ public sealed class NewCrowdaction [WithinMonthsAfterDateProperty(12, "Start", ErrorMessage = "The deadline must be within a year of the start date")] public DateTime End { get; set; } + [MinLength(1)] + [MaxLength(30)] + public string? InstagramUser { get; set; } + public int? CardImageFileId { get; set; } public int? BannerImageFileId { get; set; } diff --git a/CollAction/Services/Crowdactions/Models/NewCrowdactionInternal.cs b/CollAction/Services/Crowdactions/Models/NewCrowdactionInternal.cs index bb22e41de..515b87fd3 100644 --- a/CollAction/Services/Crowdactions/Models/NewCrowdactionInternal.cs +++ b/CollAction/Services/Crowdactions/Models/NewCrowdactionInternal.cs @@ -1,13 +1,14 @@ -using CollAction.Models; +using CollAction.Migrations; +using CollAction.Models; using System; using System.Collections.Generic; using System.Linq; namespace CollAction.Services.Crowdactions.Models { - public class NewCrowdactionInternal + public sealed class NewCrowdactionInternal { - public NewCrowdactionInternal(string name, int target, string proposal, string description, string goal, string? creatorComments, DateTime start, DateTime end, int? cardImageFileId, int? bannerImageFileId, int? descriptiveImageFileId, string? descriptionVideoLink, IEnumerable categories, IEnumerable tags, CrowdactionDisplayPriority displayPriority, CrowdactionStatus status, int anonymousUserParticipants, string? ownerId) + public NewCrowdactionInternal(string name, int target, string proposal, string description, string goal, string? creatorComments, DateTime start, DateTime end, string? instagramUser, int? cardImageFileId, int? bannerImageFileId, int? descriptiveImageFileId, string? descriptionVideoLink, IEnumerable categories, IEnumerable tags, CrowdactionDisplayPriority displayPriority, CrowdactionStatus status, int anonymousUserParticipants, string? ownerId) { Name = name; Target = target; @@ -21,6 +22,7 @@ public NewCrowdactionInternal(string name, int target, string proposal, string d BannerImageFileId = bannerImageFileId; DescriptiveImageFileId = descriptiveImageFileId; DescriptionVideoLink = descriptionVideoLink; + InstagramUser = instagramUser; Categories = categories; Tags = tags; DisplayPriority = displayPriority; @@ -53,6 +55,8 @@ public NewCrowdactionInternal(string name, int target, string proposal, string d public string? DescriptionVideoLink { get; set; } + public string? InstagramUser { get; } + public IEnumerable Categories { get; set; } public IEnumerable Tags { get; set; } = Enumerable.Empty(); diff --git a/CollAction/Services/Crowdactions/Models/UpdatedCrowdaction.cs b/CollAction/Services/Crowdactions/Models/UpdatedCrowdaction.cs index 2fa7cfee8..913bd3265 100644 --- a/CollAction/Services/Crowdactions/Models/UpdatedCrowdaction.cs +++ b/CollAction/Services/Crowdactions/Models/UpdatedCrowdaction.cs @@ -43,6 +43,10 @@ public sealed class UpdatedCrowdaction [DataType(DataType.Date)] public DateTime End { get; set; } + [MinLength(1)] + [MaxLength(30)] + public string? InstagramUser { get; set; } + public int? CardImageFileId { get; set; } public int? BannerImageFileId { get; set; } diff --git a/CollAction/Services/Initialization/InitializationService.cs b/CollAction/Services/Initialization/InitializationService.cs index c9e7f9fd4..235e237b3 100644 --- a/CollAction/Services/Initialization/InitializationService.cs +++ b/CollAction/Services/Initialization/InitializationService.cs @@ -19,7 +19,7 @@ namespace CollAction.Services.Initialization { - public class InitializationService : IInitializationService + public sealed class InitializationService : IInitializationService { private readonly UserManager userManager; private readonly RoleManager roleManager; @@ -157,6 +157,8 @@ private async Task SeedRandomCrowdactions(IEnumerable users, Ca "https://collaction-production.s3.eu-central-1.amazonaws.com/6e6c12b1-eaae-4811-aa1c-c169d10f1a59.png", }.Select(b => new Uri(b)).Select(b => (b, DownloadFile(b, cancellationToken))).ToList(); + string?[] instagramUsers = new string?[] { "slowfashionseason", "collaction_org", null }; + await Task.WhenAll(descriptiveImages.Select(d => d.descriptiveImageBytes).Concat(bannerImages.Select(b => b.bannerImageBytes))).ConfigureAwait(false); List tags = Enumerable.Range(0, seedOptions.NumberSeededTags) @@ -222,6 +224,7 @@ private async Task SeedRandomCrowdactions(IEnumerable users, Ca bannerImageFileId: bannerImage?.Id, descriptiveImageFileId: descriptiveImage?.Id, cardImageFileId: cardImage?.Id, + instagramUser: instagramUsers[r.Next(instagramUsers.Length)], creatorComments: r.Next(4) == 0 ? null : $"

{string.Join("

", Faker.Lorem.Paragraphs(r.Next(3) + 1))}

", goal: Faker.Company.CatchPhrase(), proposal: Faker.Company.BS(), diff --git a/CollAction/Services/Instagram/IInstagramService.cs b/CollAction/Services/Instagram/IInstagramService.cs new file mode 100644 index 000000000..3e6063f4e --- /dev/null +++ b/CollAction/Services/Instagram/IInstagramService.cs @@ -0,0 +1,12 @@ +using CollAction.Services.Instagram.Models; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace CollAction.Services.Instagram +{ + public interface IInstagramService + { + public Task> GetItems(string instagramUser, CancellationToken token); + } +} diff --git a/CollAction/Services/Instagram/InstagramService.cs b/CollAction/Services/Instagram/InstagramService.cs new file mode 100644 index 000000000..51758a3f6 --- /dev/null +++ b/CollAction/Services/Instagram/InstagramService.cs @@ -0,0 +1,74 @@ +using CollAction.Services.Instagram.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace CollAction.Services.Instagram +{ + public sealed class InstagramService : IInstagramService + { + private readonly IMemoryCache cache; + private readonly HttpClient instagramClient; + private readonly ILogger logger; + private static readonly TimeSpan CacheExpiration = TimeSpan.FromMinutes(30); + + public InstagramService(IMemoryCache cache, HttpClient instagramClient, ILogger logger) + { + this.cache = cache; + this.instagramClient = instagramClient; + this.logger = logger; + } + + public async Task> GetItems(string instagramUser, CancellationToken token) + { + // TODO: Stop using the instagram private api + Uri url = new Uri($"/{instagramUser}/?__a=1", UriKind.Relative); + try + { + return await cache.GetOrCreateAsync( + url, + async (ICacheEntry entry) => + { + entry.SlidingExpiration = CacheExpiration; + logger.LogInformation("Retrieving instagram timeline for {0}", instagramUser); + var result = await instagramClient.GetAsync(url, token).ConfigureAwait(false); + result.EnsureSuccessStatusCode(); + return ParseInstagramResponse(await result.Content.ReadAsStringAsync().ConfigureAwait(false)); + }).ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogError(e, "Error retrieving items from instagram timeline"); + return Enumerable.Empty(); // External APIs can fail here, lets be a little robust here. Don't cache the failures though.. so catch outside the cache + } + } + + private IEnumerable ParseInstagramResponse(string json) + { + logger.LogDebug("Instagram JSON: {0}", json); + dynamic deserialized = JsonConvert.DeserializeObject(json); + var edges = (IEnumerable)(deserialized.graphql?.user?.edge_owner_to_timeline_media?.edges ?? Enumerable.Empty()); + logger.LogInformation("Retrieving instagram timeline, found {0} items", edges.Count()); + return edges + .Select(e => e.node) + .Where(e => e.shortcode != null && e.thumbnail_src != null && e.taken_at_timestamp != null) + .Select(e => + { + var captionEdges = (IEnumerable?)e.edge_media_to_caption?.edges; + string? caption = (string?)captionEdges?.FirstOrDefault()?.node?.text; + return new InstagramWallItem( + (string)e.shortcode, + (string)e.thumbnail_src, + (string?)e.accessibility_caption, + caption, + DateTimeOffset.FromUnixTimeSeconds((long)e.taken_at_timestamp)); + }); + } + } +} diff --git a/CollAction/Services/Instagram/Models/InstagramWallItem.cs b/CollAction/Services/Instagram/Models/InstagramWallItem.cs new file mode 100644 index 000000000..1b88f2c60 --- /dev/null +++ b/CollAction/Services/Instagram/Models/InstagramWallItem.cs @@ -0,0 +1,29 @@ +using System; + +namespace CollAction.Services.Instagram.Models +{ + public sealed class InstagramWallItem + { + public InstagramWallItem(string shortCode, string thumbnailSrc, string? accessibilityCaption, string? caption, DateTimeOffset date) + { + ShortCode = shortCode; + ThumbnailSrc = thumbnailSrc; + AccessibilityCaption = accessibilityCaption; + Caption = caption; + Date = date; + } + + public string ShortCode { get; } + + public string ThumbnailSrc { get; } + + public string? AccessibilityCaption { get; } + + public string? Caption { get; } + + public DateTimeOffset Date { get; } + + public string Link + => $"https://www.instagram.com/p/{ShortCode}"; + } +} diff --git a/CollAction/Services/Proxy/IProxyService.cs b/CollAction/Services/Proxy/IProxyService.cs new file mode 100644 index 000000000..f594a454e --- /dev/null +++ b/CollAction/Services/Proxy/IProxyService.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace CollAction.Services.Proxy +{ + public interface IProxyService + { + public Task Proxy(Uri url, CancellationToken token); + } +} diff --git a/CollAction/Services/Proxy/ProxyService.cs b/CollAction/Services/Proxy/ProxyService.cs new file mode 100644 index 000000000..6c6241188 --- /dev/null +++ b/CollAction/Services/Proxy/ProxyService.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.Net.Http.Headers; +using System; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace CollAction.Services.Proxy +{ + /* + * We're using this as a simple (probably incomplete) privacy proxy, + * to stop people from being directly seen/fingerprinted/tracked by facebook + * when visiting our site when we need to display stuff from instagram/facebook + */ + public sealed class ProxyService : IProxyService + { + private readonly HttpClient proxyClient; + /* + * As a complete list as I can get of facebook/instagram cdns + * We don't want to be an open proxy (for security/liability), + * so lets restrict what hosts are allowed + */ + private static readonly string[] AllowedHosts = + new string[] + { + "fbcdn.net", + "cdninstagram.com", + "fbsbx.com", + "tfbnw.net", + "fb.me", + "facebook.com.edgesuite.net", + "facebook.com.edgekey.net", + "facebook.net.edgekey.net", + "facebook-web-clients.appspot.com", + "fbcdn-profile-a.akamaihd.net", + "fbsbx.com.online-metrix.net", + "instagramstatic-a.akamaihd.net", + "akamaihd.net.edgesuite.net", + "internet.org" + }; + + public ProxyService(HttpClient proxyClient) + { + this.proxyClient = proxyClient; + } + + public async Task Proxy(Uri url, CancellationToken token) + { + if (CanProxy(url)) + { + var result = await proxyClient.GetAsync(url, token).ConfigureAwait(false); + result.EnsureSuccessStatusCode(); + var stream = await result.Content.ReadAsStreamAsync().ConfigureAwait(false); + return new FileStreamResult(stream, new MediaTypeHeaderValue(result.Content.Headers.ContentType.MediaType)) + { + LastModified = result.Content.Headers.LastModified, + EnableRangeProcessing = true + }; + } + else + { + return new StatusCodeResult(405); + } + } + + private static bool CanProxy(Uri url) + => AllowedHosts.Any(h => url.Host.EndsWith(h, StringComparison.Ordinal)); + } +} diff --git a/CollAction/Startup.cs b/CollAction/Startup.cs index 9f471c972..582ea7096 100644 --- a/CollAction/Startup.cs +++ b/CollAction/Startup.cs @@ -1,4 +1,5 @@ using AspNetCore.IServiceCollection.AddIUrlHelper; +using CollAction.Controllers; using CollAction.Data; using CollAction.GraphQl; using CollAction.Helpers; @@ -10,7 +11,9 @@ using CollAction.Services.HtmlValidator; using CollAction.Services.Image; using CollAction.Services.Initialization; +using CollAction.Services.Instagram; using CollAction.Services.Newsletter; +using CollAction.Services.Proxy; using CollAction.Services.Statistics; using CollAction.Services.User; using CollAction.Services.ViewRender; @@ -139,6 +142,14 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddHttpClient( + c => + { + c.BaseAddress = new Uri("https://www.instagram.com"); + }); + services.AddHttpClient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/Frontend/src/api/fragments.ts b/Frontend/src/api/fragments.ts index 86f442788..02b453033 100644 --- a/Frontend/src/api/fragments.ts +++ b/Frontend/src/api/fragments.ts @@ -32,6 +32,16 @@ export const Fragments = { name } } + instagramWall { + id + shortCode + thumbnailSrc + caption + accessibilityCaption + link + date + } + instagramUser goal start end diff --git a/Frontend/src/api/types.ts b/Frontend/src/api/types.ts index 5ce51e098..433c9e899 100644 --- a/Frontend/src/api/types.ts +++ b/Frontend/src/api/types.ts @@ -61,6 +61,16 @@ export enum CrowdactionDisplayPriority { BOTTOM = "BOTTOM", } +export interface IInstagramWallItem { + id: string; + shortCode: string; + thumbnailSrc: string; + caption: string | null; + accessibilityCaption: string | null; + link: string; + date: string; +} + export interface ICrowdactionComment { id: string; comment: string; @@ -87,6 +97,8 @@ export interface ICrowdaction { descriptiveImage: IImageFile; descriptiveImageFileId: string; displayPriority: CrowdactionDisplayPriority; + instagramWall: IInstagramWallItem[]; + instagramUser: string | null; start: string; end: string; goal: string; diff --git a/Frontend/src/components/Admin/Crowdactions/AdminEditCrowdaction.tsx b/Frontend/src/components/Admin/Crowdactions/AdminEditCrowdaction.tsx index 163583b03..713427305 100644 --- a/Frontend/src/components/Admin/Crowdactions/AdminEditCrowdaction.tsx +++ b/Frontend/src/components/Admin/Crowdactions/AdminEditCrowdaction.tsx @@ -76,6 +76,7 @@ export default ({ crowdactionId } : IEditCrowdactionProps): any => { goal: "", status: "", proposal: "", + instagramUser: "", target: "", anonymousUserParticipants: 0, firstCategory: "", @@ -102,6 +103,7 @@ export default ({ crowdactionId } : IEditCrowdactionProps): any => { target: Yup.number().required("Must be filled in").min(1, "Must be a positive number"), anonymousUserParticipants: Yup.number().required("Must be filled in").min(0, "Must be a positive number"), firstCategory: Yup.string(), + instagramUser: Yup.string().max(30, "An instagram username has a maximum length of 30 characters"), secondCategory: Yup.string(), tags: Yup.string(), numberCrowdactionEmailsSent: Yup.number().required("Must be filled in").min(0, "Must be a positive number"), @@ -137,9 +139,10 @@ export default ({ crowdactionId } : IEditCrowdactionProps): any => { proposal: values.proposal, description: values.description, goal: values.goal, - creatorComments: values.creatorComments === "" ? null : values.creatorComments, + creatorComments: values.creatorComments || null, start: values.start, end: values.end, + instagramUser: values.instagramUser || null, descriptionVideoLink: values.descriptionVideoLink === "" ? null : values.descriptionVideoLink, displayPriority: values.displayPriority, numberCrowdactionEmailsSent: values.numberCrowdactionEmailsSent, @@ -168,6 +171,7 @@ export default ({ crowdactionId } : IEditCrowdactionProps): any => { displayPriority: data.crowdaction.displayPriority, start: data.crowdaction.start, end: data.crowdaction.end, + instagramUser: data.crowdaction.instagramUser ?? "", ownerEmail: data.crowdaction.ownerWithEmail?.email ?? "", ownerId: formik.values.ownerId, status: data.crowdaction.status, @@ -292,6 +296,14 @@ export default ({ crowdactionId } : IEditCrowdactionProps): any => { /> + + + + + `${process.env.REACT_APP_BACKEND_URL}/proxy?url=${encodeURIComponent(url)}`; + +export default (props: IInstagramWallProps) => ( +
+ { + props.wallItems.map(item => + ( + +
+
{item.caption}
+
+
{Formatter.date(new Date(item.date))}
+
)) + } +
+); diff --git a/Frontend/src/pages/Home/Home.tsx b/Frontend/src/pages/Home/Home.tsx index d1940bf5e..25dcc9760 100644 --- a/Frontend/src/pages/Home/Home.tsx +++ b/Frontend/src/pages/Home/Home.tsx @@ -9,9 +9,37 @@ import TimeToAct from "../../components/TimeToAct/TimeToAct"; import { useTranslation } from 'react-i18next'; import { Helmet } from "react-helmet"; +import { gql, useQuery } from "@apollo/client"; +import InstagramWall from "../../components/InstagramWall/InstagramWall"; +import { Alert } from "../../components/Alert/Alert"; +import { IInstagramWallItem } from "../../api/types"; +import Loader from "../../components/Loader/Loader"; + +const GET_INSTAGRAM_WALL = gql` + query GetInstagramWall($user: String!) { + instagramWall(user: $user) { + id + shortCode + thumbnailSrc + caption + accessibilityCaption + link + date + } + } +`; const HomePage = () => { const { t } = useTranslation(); + const { data, error, loading } = useQuery( + GET_INSTAGRAM_WALL, + { + variables: { + user: "collaction_org" + } + } + ); + const wallData = data?.instagramWall as IInstagramWallItem[] | null; return ( <> @@ -36,6 +64,12 @@ const HomePage = () => { {t('home.crowdactions.button')} +
+ Instagram: @collaction_org + + { loading && } + { wallData && } +
) } diff --git a/Frontend/src/pages/crowdactions/Create/Create.tsx b/Frontend/src/pages/crowdactions/Create/Create.tsx index f14e3e122..2eb766232 100644 --- a/Frontend/src/pages/crowdactions/Create/Create.tsx +++ b/Frontend/src/pages/crowdactions/Create/Create.tsx @@ -81,6 +81,7 @@ const CreateCrowdactionPage = () => { start: form.startDate, end: form.endDate, goal: form.goal, + instagramUser: form.instagramUser || null, tags: form.tags ? form.tags.split(';') : [], creatorComments: form.comments || null, descriptiveImageFileId: imageId || null, @@ -292,6 +293,16 @@ const CreateCrowdactionPage = () => { fullWidth > + + + diff --git a/Frontend/src/pages/crowdactions/Create/form.ts b/Frontend/src/pages/crowdactions/Create/form.ts index cf59c4399..16e42c689 100644 --- a/Frontend/src/pages/crowdactions/Create/form.ts +++ b/Frontend/src/pages/crowdactions/Create/form.ts @@ -10,6 +10,7 @@ export interface ICrowdactionForm { startDate: string; endDate: string; tags: string; + instagramUser: string; description: string; goal: string; image: any | null; @@ -29,6 +30,7 @@ export const initialValues: ICrowdactionForm = { tags: '', endDate: '', description: '', + instagramUser: '', goal: '', image: null, imageDescription: '', @@ -93,6 +95,7 @@ export const validations = Yup.object({ hashtags: Yup.string() .max(30, 'Please keep the number of hashtags civil, no more then 30 characters') .matches(/^[a-zA-Z_0-9]+(;[a-zA-Z_0-9]+)*$/, 'Don\'t use spaces or #, must contain a letter, can contain digits and underscores. Separate multiple tags with a colon \';\''), + instagramUser: Yup.string().max(30, "An instagram username has a maximum length of 30 characters"), description: Yup.string() .required('Give a succinct description of what you are gathering participants for') .max(10000, 'Please use no more then 10.000 characters'), diff --git a/Frontend/src/pages/crowdactions/Detail/CrowdactionDetails.tsx b/Frontend/src/pages/crowdactions/Detail/CrowdactionDetails.tsx index b888abf42..2948bda3e 100644 --- a/Frontend/src/pages/crowdactions/Detail/CrowdactionDetails.tsx +++ b/Frontend/src/pages/crowdactions/Detail/CrowdactionDetails.tsx @@ -26,6 +26,7 @@ import Formatter from '../../../formatter'; import LazyImage from '../../../components/LazyImage/LazyImage'; import { TextField } from 'formik-material-ui'; import CrowdactionComments from '../../../components/CrowdactionComments/CrowdactionComments'; +import InstagramWall from '../../../components/InstagramWall/InstagramWall'; type TParams = { slug: string; @@ -262,6 +263,14 @@ const CrowdactionDetailsPage = ({ > )} + + {crowdaction.instagramUser && } + diff --git a/Frontend/src/translations/en/common.json b/Frontend/src/translations/en/common.json index b07469cf7..e2bd83099 100644 --- a/Frontend/src/translations/en/common.json +++ b/Frontend/src/translations/en/common.json @@ -40,6 +40,9 @@ }, "share" : { "title" : "Share now" + }, + "follow": { + "title": "Follow us" } }, "about": {