├── .github └── workflows │ ├── api-build-and-deply.yaml │ └── app-build-and-deply.yaml ├── .gitignore ├── TrickingLibrary.Api ├── ApiIdentityDbContext.cs ├── BackgroundServices │ ├── SubmissionVoting │ │ ├── ISubmissionVoteSink.cs │ │ ├── SubmissionVotingService.cs │ │ └── VoteForm.cs │ └── VideoEditing │ │ ├── EditVideoMessage.cs │ │ └── VideoEditingBackgroundService.cs ├── CommentCreationContext.cs ├── Controllers │ ├── AdminController.cs │ ├── ApiController.cs │ ├── AuthController.cs │ ├── CategoryController.cs │ ├── CommentController.cs │ ├── DifficultyController.cs │ ├── FileController.cs │ ├── ModerationItemController.cs │ ├── SubmissionsController.cs │ ├── TricksController.cs │ └── UserController.cs ├── Form │ ├── CommentForm.cs │ ├── CreateCategoryForm.cs │ ├── CreateDifficultyForm.cs │ ├── CreateTrickForm.cs │ ├── InviteModeratorForm.cs │ ├── SubmissionForm.cs │ ├── UpdateCategoryForm.cs │ ├── UpdateDifficultyForm.cs │ ├── UpdateStagedTrickForm.cs │ ├── UpdateTrickForm.cs │ └── Validation │ │ ├── CommentFormValidation.cs │ │ ├── CreateCategoryFormValidation.cs │ │ ├── CreateDifficultyFormValidation.cs │ │ ├── CreateTrickFormValidation.cs │ │ ├── InviteModeratorFormValidation.cs │ │ ├── ReviewFormValidation.cs │ │ ├── SubmissionFormValidation.cs │ │ ├── UpdateCategoryFormValidation.cs │ │ ├── UpdateDifficultyFormValidation.cs │ │ ├── UpdateStagedTrickFormValidation.cs │ │ └── UpdateTrickFormValidation.cs ├── IdentityMigrations │ ├── 20201213161909_init.Designer.cs │ ├── 20201213161909_init.cs │ └── ApiIdentityDbContextModelSnapshot.cs ├── ModerationItemReviewContext.cs ├── Pages │ ├── Account │ │ ├── Login.cshtml │ │ ├── Login.cshtml.cs │ │ ├── Moderator.cshtml │ │ ├── Moderator.cshtml.cs │ │ ├── Register.cshtml │ │ └── Register.cshtml.cs │ ├── BasePage.cs │ ├── Shared │ │ └── _Layout.cshtml │ └── _ViewImports.cshtml ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── Email │ │ ├── EmailClient.cs │ │ └── SendGridOptions.cs │ └── Storage │ │ ├── FileSettings.cs │ │ ├── IFileProvider.cs │ │ ├── IS3Client.cs │ │ ├── LinodeS3Client.cs │ │ ├── LocalFileProvider.cs │ │ ├── RegisterService.cs │ │ ├── S3FileProvider.cs │ │ ├── S3Settings.cs │ │ └── TemporaryFileStorage.cs ├── Settings │ └── FileType.cs ├── Startup.cs ├── TrickingLibrary.Api.csproj ├── TrickingLibraryConstants.cs ├── ViewModels │ ├── CategoryViewModels.cs │ ├── CommentViewModels.cs │ ├── DifficultyViewModels.cs │ ├── ModerationItemViewModels.cs │ ├── ReviewViewModels.cs │ ├── SubmissionViewModels.cs │ ├── TrickViewModels.cs │ └── UserViewModels.cs ├── appsettings.Development.json ├── appsettings.Production.json ├── appsettings.json └── test-moderator-invite.http ├── TrickingLibrary.Data ├── AppDbContext.cs ├── FeedQuery.cs ├── Migrations │ ├── 20201120143613_init.Designer.cs │ ├── 20201120143613_init.cs │ └── AppDbContextModelSnapshot.cs ├── QueryExtensions.cs ├── TrickingLibrary.Data.csproj ├── VersionMigrations │ ├── CategoryMigrationContext.cs │ ├── DifficultyMigrationContext.cs │ ├── IEntityMigrationContext.cs │ ├── TrickMigrationContext.cs │ └── VersionMigrationContext.cs ├── script.sql └── scripts.md ├── TrickingLibrary.Models ├── Abstractions │ ├── BaseModel.cs │ ├── Mutable.cs │ └── VersionedModel.cs ├── Category.cs ├── Comment.cs ├── Difficulty.cs ├── Moderation │ ├── ModerationItem.cs │ ├── ModerationTypes.cs │ └── Review.cs ├── Submission.cs ├── SubmissionVote.cs ├── Trick.cs ├── TrickCategory.cs ├── TrickDifficulty.cs ├── TrickRelationship.cs ├── TrickingLibrary.Models.csproj ├── User.cs ├── VersionState.cs └── Video.cs ├── TrickingLibrary.sln ├── hosting ├── api.raw-coding.net ├── app.raw-coding.net ├── tricking-library-api.service └── tricking-library-app.service └── web-client ├── .editorconfig ├── .env.production ├── .gitignore ├── assets └── css │ └── main.scss ├── components ├── _shared.js ├── auth │ └── if-auth.vue ├── comments │ ├── _shared.js │ ├── comment-body.vue │ ├── comment-input.vue │ ├── comment-section.vue │ └── comment.vue ├── content-creation │ ├── _shared.js │ ├── category-form.vue │ ├── content-creation-dialog.vue │ ├── difficulty-form.vue │ ├── submission-steps.vue │ └── trick-steps.vue ├── feed.js ├── front-page │ ├── front-page-category-feed.vue │ ├── front-page-difficulty-feed.vue │ └── front-page-trick-feed.vue ├── item-content-layout.vue ├── moderation.js ├── moderation │ ├── moderation-category-overview.vue │ ├── moderation-difficulty-overview.vue │ ├── moderation-item-feed.vue │ └── simple-info-card.vue ├── nav-bar-search.vue ├── popup.vue ├── profile-completed-tricks.vue ├── submission-feed.vue ├── submission.vue ├── trick-info-card.vue ├── trick-list.vue ├── user-header.vue └── video-player.vue ├── data ├── events.js └── functions.js ├── layouts ├── default.vue └── error.vue ├── middleware ├── admin.js └── mod.js ├── nuxt.config.js ├── package-lock.json ├── package.json ├── pages ├── admin │ └── index.vue ├── category │ └── _category │ │ ├── history.vue │ │ └── index.vue ├── difficulty │ └── _difficulty │ │ ├── history.vue │ │ └── index.vue ├── index.vue ├── moderation │ ├── _modId.vue │ └── index.vue ├── profile │ ├── _username.vue │ └── index.vue └── trick │ └── _trick │ ├── history.vue │ └── index.vue ├── plugins └── axios.js ├── static └── favicon.ico └── store ├── auth.js ├── content-creation.js ├── index.js ├── library.js └── popup.js /.github/workflows/api-build-and-deply.yaml: -------------------------------------------------------------------------------- 1 | name: API Build and Deploy to Linode 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | name: Build our App 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Pull Code 12 | uses: actions/checkout@v2 13 | - uses: actions/setup-dotnet@v1 14 | with: 15 | dotnet-version: '3.1.x' 16 | - name: Restore Dependencies 17 | run: dotnet restore 18 | - name: Create Production Build 19 | run: dotnet publish -c Release --no-restore 20 | - name: Inject Secrets 21 | uses: microsoft/variable-substitution@v1 22 | with: 23 | files: './TrickingLibrary.Api/bin/Release/netcoreapp3.1/publish/appsettings.Production.json' 24 | env: 25 | ConnectionStrings.Default: ${{ secrets.POSTGRESQL_CONNECTION_STRING }} 26 | AdminPassword: ${{ secrets.ADMIN_PASSWORD }} 27 | SendGridOptions.ApiKey: ${{ secrets.SEND_GRID_API_KEY }} 28 | SendGridOptions.From: ${{ secrets.SEND_GRID_FROM }} 29 | S3Settings.AccessKey: ${{ secrets.S3_ACCESS_KEY }} 30 | S3Settings.SecretKey: ${{ secrets.S3_SECRET_KEY }} 31 | S3Settings.ServiceUrl: ${{ secrets.S3_SERVICE_URL }} 32 | S3Settings.Bucket: ${{ secrets.S3_BUCKET }} 33 | - name: Push to Linode 34 | run: | 35 | echo "$ssh_key" > ~/ssh_key 36 | chmod 600 ~/ssh_key 37 | rsync -e "ssh -i ~/ssh_key -o StrictHostKeyChecking=no" -avzr ./TrickingLibrary.Api/bin/Release/netcoreapp3.1/publish/ "$user"@"$target_ip":/var/tricking-library/api 38 | env: 39 | ssh_key: ${{ secrets.CICD_SSH }} 40 | user: ${{ secrets.CICD_USER }} 41 | target_ip: ${{ secrets.LINODE_IP }} 42 | -------------------------------------------------------------------------------- /.github/workflows/app-build-and-deply.yaml: -------------------------------------------------------------------------------- 1 | name: Nuxtjs app Build and Deploy to Linode 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | name: Build our App 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Pull Code 12 | uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2-beta 14 | with: 15 | node-version: '12' 16 | - name: Install Dependencies 17 | run: npm install 18 | working-directory: web-client 19 | - name: Install Dependencies 20 | run: npm run build 21 | working-directory: web-client 22 | - name: Push to Linode 23 | run: | 24 | echo "$ssh_key" > ~/ssh_key 25 | chmod 600 ~/ssh_key 26 | rsync -e "ssh -i ~/ssh_key -o StrictHostKeyChecking=no" -avzr ./web-client/ "$user"@"$target_ip":/var/tricking-library/app 27 | env: 28 | ssh_key: ${{ secrets.CICD_SSH }} 29 | user: ${{ secrets.CICD_USER }} 30 | target_ip: ${{ secrets.LINODE_IP }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | bin/ 3 | obj/ 4 | 5 | ffmpeg/ 6 | wwwroot/ 7 | tempkey.jwk -------------------------------------------------------------------------------- /TrickingLibrary.Api/ApiIdentityDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; 2 | using Microsoft.AspNetCore.Identity.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace TrickingLibrary.Api 6 | { 7 | public class ApiIdentityDbContext : IdentityDbContext, 8 | IDataProtectionKeyContext 9 | { 10 | public ApiIdentityDbContext(DbContextOptions options) : base(options) 11 | { 12 | 13 | } 14 | 15 | public DbSet DataProtectionKeys { get; set; } 16 | } 17 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/BackgroundServices/SubmissionVoting/ISubmissionVoteSink.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace TrickingLibrary.Api.BackgroundServices.SubmissionVoting 4 | { 5 | public interface ISubmissionVoteSink 6 | { 7 | ValueTask Submit(VoteForm voteForm); 8 | } 9 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/BackgroundServices/SubmissionVoting/SubmissionVotingService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Channels; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Hosting; 8 | using Microsoft.Extensions.Logging; 9 | using TrickingLibrary.Data; 10 | using TrickingLibrary.Models; 11 | 12 | namespace TrickingLibrary.Api.BackgroundServices.SubmissionVoting 13 | { 14 | public class SubmissionVotingService : BackgroundService, ISubmissionVoteSink 15 | { 16 | private readonly IServiceProvider _serviceProvider; 17 | private readonly ILogger _logger; 18 | private readonly Channel _channel; 19 | 20 | public SubmissionVotingService( 21 | IServiceProvider serviceProvider, 22 | ILogger logger) 23 | { 24 | _serviceProvider = serviceProvider; 25 | _logger = logger; 26 | _channel = Channel.CreateUnbounded(); 27 | } 28 | 29 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 30 | { 31 | while (await _channel.Reader.WaitToReadAsync(stoppingToken)) 32 | { 33 | var message = await _channel.Reader.ReadAsync(stoppingToken); 34 | try 35 | { 36 | using var scope = _serviceProvider.CreateScope(); 37 | var ctx = scope.ServiceProvider.GetRequiredService(); 38 | 39 | var vote = ctx.SubmissionVotes 40 | .FirstOrDefault(x => x.SubmissionId == message.SubmissionId 41 | && x.UserId == message.UserId); 42 | 43 | if (vote == null) 44 | { 45 | ctx.Add(new SubmissionVote 46 | { 47 | SubmissionId = message.SubmissionId, 48 | UserId = message.UserId, 49 | Value = message.Value, 50 | }); 51 | } 52 | else 53 | { 54 | vote.Value = message.Value; 55 | } 56 | 57 | await ctx.SaveChangesAsync(stoppingToken); 58 | } 59 | catch (Exception e) 60 | { 61 | _logger.LogError(e, "Exception during submission vote processing."); 62 | } 63 | } 64 | } 65 | 66 | public ValueTask Submit(VoteForm voteForm) 67 | { 68 | return _channel.Writer.WriteAsync(voteForm); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/BackgroundServices/SubmissionVoting/VoteForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.BackgroundServices.SubmissionVoting 2 | { 3 | public class VoteForm 4 | { 5 | public int SubmissionId { get; set; } 6 | public int Value { get; set; } 7 | public string UserId { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/BackgroundServices/VideoEditing/EditVideoMessage.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.BackgroundServices.VideoEditing 2 | { 3 | public class EditVideoMessage 4 | { 5 | public int SubmissionId { get; set; } 6 | public string Input { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/CommentCreationContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Text.RegularExpressions; 4 | using System.Threading.Tasks; 5 | using Microsoft.EntityFrameworkCore; 6 | using TrickingLibrary.Api.Form; 7 | using TrickingLibrary.Data; 8 | using TrickingLibrary.Models; 9 | 10 | namespace TrickingLibrary.Api 11 | { 12 | public class CommentCreationContext 13 | { 14 | private readonly AppDbContext _ctx; 15 | private static Regex _tagMatch = new Regex(@"\B(?@[a-zA-Z0-9-_]+)", RegexOptions.Compiled); 16 | private string _userId; 17 | 18 | public CommentCreationContext(AppDbContext ctx) 19 | { 20 | _ctx = ctx; 21 | } 22 | 23 | public class CommentForm 24 | { 25 | public int ParentId { get; set; } 26 | public ParentType ParentType { get; set; } 27 | public string Content { get; set; } 28 | } 29 | 30 | public CommentCreationContext Setup(string userId) 31 | { 32 | if (string.IsNullOrEmpty(userId)) 33 | throw new ArgumentNullException(nameof(userId)); 34 | 35 | _userId = userId; 36 | 37 | return this; 38 | } 39 | 40 | public async Task CreateAsync(CommentForm commentForm) 41 | { 42 | var comment = new Comment(); 43 | 44 | if (commentForm.ParentType == ParentType.ModerationItem) 45 | { 46 | if (!_ctx.ModerationItems.Any(x => x.Id == commentForm.ParentId)) 47 | throw new ParentNotFoundException("Moderation Item not found"); 48 | comment.ModerationItemId = commentForm.ParentId; 49 | } 50 | else if (commentForm.ParentType == ParentType.Submission) 51 | { 52 | if (!_ctx.Submissions.Any(x => x.Id == commentForm.ParentId)) 53 | throw new ParentNotFoundException("Submission not found"); 54 | comment.SubmissionId = commentForm.ParentId; 55 | } 56 | else if (commentForm.ParentType == ParentType.Comment) 57 | { 58 | if (!_ctx.Comments.Any(x => x.Id == commentForm.ParentId)) 59 | throw new ParentNotFoundException("Comment not found"); 60 | comment.ParentId = commentForm.ParentId; 61 | } 62 | 63 | comment.Content = commentForm.Content; 64 | comment.UserId = _userId; 65 | comment.HtmlContent = _tagMatch.Matches(commentForm.Content) 66 | .Aggregate(commentForm.Content, 67 | (content, match) => 68 | { 69 | var tag = match.Groups["tag"].Value; 70 | return content 71 | .Replace(tag, $"{tag}"); 72 | }); 73 | 74 | _ctx.Add(comment); 75 | await _ctx.SaveChangesAsync(); 76 | 77 | comment.User = _ctx.Users.AsNoTracking().FirstOrDefault(x => x.Id == _userId); 78 | 79 | return comment; 80 | } 81 | 82 | public enum ParentType 83 | { 84 | ModerationItem = 0, 85 | Submission = 1, 86 | Comment = 2, 87 | } 88 | 89 | public class ParentNotFoundException : Exception 90 | { 91 | public ParentNotFoundException(string message) : base(message) 92 | { 93 | } 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/AdminController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Security.Claims; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Authorization; 6 | using Microsoft.AspNetCore.Identity; 7 | using Microsoft.AspNetCore.Mvc; 8 | using TrickingLibrary.Api.Services.Email; 9 | 10 | namespace TrickingLibrary.Api.Controllers 11 | { 12 | [Route("/api/admin")] 13 | [Authorize(TrickingLibraryConstants.Policies.Admin)] 14 | public class AdminController : ApiController 15 | { 16 | [HttpGet("moderators")] 17 | public async Task ListModerators([FromServices] UserManager userManager) 18 | { 19 | var users = await userManager.GetUsersForClaimAsync(TrickingLibraryConstants.Claims.ModeratorClaim); 20 | 21 | return Ok(users.Select(x => new 22 | { 23 | x.Id, 24 | x.Email, 25 | })); 26 | } 27 | 28 | [HttpPost("moderators")] 29 | public async Task InviteModerator( 30 | [FromBody] InviteModeratorForm form, 31 | [FromServices] EmailClient emailClient, 32 | [FromServices] UserManager userManager) 33 | { 34 | var existingUser = await userManager.FindByEmailAsync(form.Email); 35 | if (existingUser != null) return BadRequest("User with this email already exists"); 36 | 37 | var moderator = new IdentityUser 38 | { 39 | UserName = form.Email, 40 | Email = form.Email, 41 | }; 42 | 43 | var randomPart = new Random().Next(1000000000, int.MaxValue); 44 | var createResult = await userManager.CreateAsync(moderator, $"{randomPart}a1!A"); 45 | if (!createResult.Succeeded) 46 | { 47 | var errorResponse = createResult.Errors 48 | .Aggregate("Failed to create user:", (a, b) => $"${a} {b.Description}"); 49 | return BadRequest(errorResponse); 50 | } 51 | 52 | await userManager.AddClaimAsync(moderator, TrickingLibraryConstants.Claims.ModeratorClaim); 53 | 54 | var code = await userManager.GeneratePasswordResetTokenAsync(moderator); 55 | 56 | var link = Url.Page("/Account/Moderator", "Get", new 57 | { 58 | email = form.Email, 59 | returnUrl = form.ReturnUrl, 60 | code, 61 | }, protocol: HttpContext.Request.Scheme); 62 | 63 | await emailClient.SendModeratorInviteAsync(form.Email, link); 64 | 65 | return Ok(link); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/ApiController.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Security.Claims; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace TrickingLibrary.Api.Controllers 6 | { 7 | [ApiController] 8 | public class ApiController : ControllerBase 9 | { 10 | protected string UserId => GetClaim(ClaimTypes.NameIdentifier); 11 | protected string Username => GetClaim(ClaimTypes.Name); 12 | protected string Role => GetClaim(TrickingLibraryConstants.Claims.Role); 13 | 14 | private string GetClaim(string claimType) => User.Claims 15 | .FirstOrDefault(x => x.Type.Equals(claimType))?.Value; 16 | } 17 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/AuthController.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Hosting; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.Hosting; 6 | 7 | namespace TrickingLibrary.Api.Controllers 8 | { 9 | [ApiController] 10 | [Route("api/auth")] 11 | public class AuthController : ControllerBase 12 | { 13 | [HttpGet("logout")] 14 | public async Task Logout(string logoutId, 15 | [FromServices] SignInManager signInManager, 16 | [FromServices] IWebHostEnvironment env) 17 | { 18 | await signInManager.SignOutAsync(); 19 | 20 | return Redirect(env.IsDevelopment() ? "https://localhost:3000/" : "/"); 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/CommentController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Linq.Expressions; 5 | using System.Text.RegularExpressions; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Authorization; 8 | using Microsoft.AspNetCore.Mvc; 9 | using TrickingLibrary.Api.Form; 10 | using TrickingLibrary.Api.ViewModels; 11 | using TrickingLibrary.Data; 12 | using TrickingLibrary.Models; 13 | using TrickingLibrary.Models.Abstractions; 14 | 15 | namespace TrickingLibrary.Api.Controllers 16 | { 17 | [Route("api/comments")] 18 | [Authorize] 19 | public class CommentController : ApiController 20 | { 21 | private readonly AppDbContext _ctx; 22 | 23 | public CommentController(AppDbContext ctx) 24 | { 25 | _ctx = ctx; 26 | } 27 | 28 | [HttpGet("{parentId}/{parentType}")] 29 | public IEnumerable GetReplies( 30 | int parentId, 31 | CommentCreationContext.ParentType parentType, 32 | [FromQuery] FeedQuery feedQuery 33 | ) 34 | { 35 | Expression> filter = parentType switch 36 | { 37 | CommentCreationContext.ParentType.ModerationItem => comment => comment.ModerationItemId == parentId, 38 | CommentCreationContext.ParentType.Submission => comment => comment.SubmissionId == parentId, 39 | CommentCreationContext.ParentType.Comment => comment => comment.ParentId == parentId, 40 | _ => throw new ArgumentException(), 41 | }; 42 | 43 | return _ctx.Comments 44 | .Where(filter) 45 | .OrderFeed(feedQuery) 46 | .Select(CommentViewModels.Projection) 47 | .ToList(); 48 | } 49 | 50 | [HttpPost] 51 | public async Task Create( 52 | [FromBody] CommentCreationContext.CommentForm commentForm, 53 | [FromServices] CommentCreationContext commentCreationContext) 54 | { 55 | try 56 | { 57 | var comment = await commentCreationContext 58 | .Setup(UserId) 59 | .CreateAsync(commentForm); 60 | 61 | return Ok(CommentViewModels.Create(comment)); 62 | } 63 | catch (CommentCreationContext.ParentNotFoundException e) 64 | { 65 | return BadRequest(e.Message); 66 | } 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/FileController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.AspNetCore.Mvc; 6 | using TrickingLibrary.Api.BackgroundServices.VideoEditing; 7 | using TrickingLibrary.Api.Services.Storage; 8 | using TrickingLibrary.Api.Settings; 9 | 10 | namespace TrickingLibrary.Api.Controllers 11 | { 12 | [Route("api/files")] 13 | public class FileController : ControllerBase 14 | { 15 | private readonly TemporaryFileStorage _temporaryFileStorage; 16 | 17 | public FileController(TemporaryFileStorage temporaryFileStorage) 18 | { 19 | _temporaryFileStorage = temporaryFileStorage; 20 | } 21 | 22 | [HttpGet("{type}/{file}")] 23 | public IActionResult GetVideo(string type, string file) 24 | { 25 | var mime = type.Equals(nameof(FileType.Image), StringComparison.InvariantCultureIgnoreCase) 26 | ? "image/jpg" 27 | : type.Equals(nameof(FileType.Video), StringComparison.InvariantCultureIgnoreCase) 28 | ? "video/mp4" 29 | : null; 30 | 31 | if (mime == null) 32 | { 33 | return BadRequest(); 34 | } 35 | 36 | var savePath = _temporaryFileStorage.GetSavePath(file); 37 | if (string.IsNullOrEmpty(savePath)) 38 | { 39 | return BadRequest(); 40 | } 41 | 42 | return new FileStreamResult(new FileStream(savePath, FileMode.Open, FileAccess.Read), mime); 43 | } 44 | 45 | [HttpPost] 46 | public Task UploadVideo(IFormFile video) 47 | { 48 | return _temporaryFileStorage.SaveTemporaryFile(video); 49 | } 50 | 51 | [HttpDelete("{fileName}")] 52 | public IActionResult DeleteTemporaryVideo(string fileName) 53 | { 54 | if (!_temporaryFileStorage.TemporaryFileExists(fileName)) 55 | { 56 | return NoContent(); 57 | } 58 | 59 | _temporaryFileStorage.DeleteTemporaryFile(fileName); 60 | 61 | return Ok(); 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/ModerationItemController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.EntityFrameworkCore; 9 | using TrickingLibrary.Api.Form; 10 | using TrickingLibrary.Api.ViewModels; 11 | using TrickingLibrary.Data; 12 | using TrickingLibrary.Data.VersionMigrations; 13 | using TrickingLibrary.Models; 14 | using TrickingLibrary.Models.Moderation; 15 | 16 | namespace TrickingLibrary.Api.Controllers 17 | { 18 | [Route("api/moderation-items")] 19 | public class ModerationItemController : ApiController 20 | { 21 | private readonly AppDbContext _ctx; 22 | 23 | public ModerationItemController(AppDbContext ctx) 24 | { 25 | _ctx = ctx; 26 | } 27 | 28 | [HttpGet] 29 | public object All([FromQuery] FeedQuery feedQuery, int user) 30 | { 31 | var query = _ctx.ModerationItems.Where(x => !x.Deleted); 32 | 33 | if (user == 1) 34 | query = query.Where(x => x.UserId == UserId); 35 | 36 | var moderationItems = query 37 | .Include(x => x.User) 38 | .Include(x => x.Reviews) 39 | .OrderFeed(feedQuery) 40 | .ToList(); 41 | 42 | var targetMapping = new Dictionary(); 43 | foreach (var group in moderationItems.GroupBy(x => x.Type)) 44 | { 45 | var targetIds = group 46 | .Select(m => new[] {m.Target, m.Current}) 47 | .SelectMany(x => x) 48 | .Where(x => x > 0) 49 | .ToArray(); 50 | 51 | if (group.Key == ModerationTypes.Trick) 52 | { 53 | _ctx.Tricks 54 | .Where(t => targetIds.Contains(t.Id)) 55 | .ToList() 56 | .ForEach(trick => targetMapping[ModerationTypes.Trick + trick.Id] = 57 | TrickViewModels.CreateFlat(trick)); 58 | } 59 | else if (group.Key == ModerationTypes.Category) 60 | { 61 | _ctx.Categories 62 | .Where(c => targetIds.Contains(c.Id)) 63 | .ToList() 64 | .ForEach(category => targetMapping[ModerationTypes.Category + category.Id] = 65 | CategoryViewModels.CreateFlat(category)); 66 | } 67 | else if (group.Key == ModerationTypes.Difficulty) 68 | { 69 | _ctx.Difficulties 70 | .Where(d => targetIds.Contains(d.Id)) 71 | .ToList() 72 | .ForEach(difficulty => targetMapping[ModerationTypes.Difficulty + difficulty.Id] = 73 | DifficultyViewModels.CreateFlat(difficulty)); 74 | } 75 | } 76 | 77 | return moderationItems.Select(x => new 78 | { 79 | x.Id, 80 | x.Current, 81 | x.Target, 82 | x.Reason, 83 | x.Type, 84 | Updated = x.Updated.ToLocalTime().ToString("HH:mm dd/MM/yyyy"), 85 | Reviews = x.Reviews.Select(y => y.Status).ToList(), 86 | User = UserViewModels.CreateFlat(x.User), 87 | CurrentObject = x.Current > 0 ? targetMapping[x.Type + x.Current] : null, 88 | TargetObject = x.Target > 0 ? targetMapping[x.Type + x.Target] : null, 89 | }); 90 | } 91 | 92 | [HttpGet("{id}")] 93 | public object Get(int id) => _ctx.ModerationItems 94 | .Where(x => x.Id.Equals(id)) 95 | .Select(ModerationItemViewModels.Projection) 96 | .FirstOrDefault(); 97 | 98 | [HttpGet("{id}/reviews")] 99 | public IEnumerable GetReviews(int id) => 100 | _ctx.Reviews 101 | .Include(x => x.User) 102 | .Where(x => x.ModerationItemId.Equals(id)) 103 | .Select(ReviewViewModels.WithUserProjection) 104 | .ToList(); 105 | 106 | [HttpPut("{id}/reviews")] 107 | [Authorize(TrickingLibraryConstants.Policies.Mod)] 108 | public async Task Review( 109 | int id, 110 | [FromBody] ModerationItemReviewContext.ReviewForm reviewForm, 111 | [FromServices] ModerationItemReviewContext moderationItemReviewContext 112 | ) 113 | { 114 | try 115 | { 116 | await moderationItemReviewContext.Review(id, UserId, reviewForm); 117 | } 118 | catch (VersionMigrationContext.InvalidVersionException e) 119 | { 120 | return BadRequest(e.Message); 121 | } 122 | catch (ModerationItemReviewContext.ModerationItemNotFound) 123 | { 124 | return NoContent(); 125 | } 126 | 127 | return Ok(); 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/SubmissionsController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Channels; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.EntityFrameworkCore; 9 | using TrickingLibrary.Api.BackgroundServices.SubmissionVoting; 10 | using TrickingLibrary.Api.BackgroundServices.VideoEditing; 11 | using TrickingLibrary.Api.Form; 12 | using TrickingLibrary.Api.Services.Storage; 13 | using TrickingLibrary.Api.ViewModels; 14 | using TrickingLibrary.Data; 15 | using TrickingLibrary.Models; 16 | 17 | namespace TrickingLibrary.Api.Controllers 18 | { 19 | [Route("api/submissions")] 20 | public class SubmissionsController : ApiController 21 | { 22 | private readonly AppDbContext _ctx; 23 | 24 | public SubmissionsController(AppDbContext ctx) 25 | { 26 | _ctx = ctx; 27 | } 28 | 29 | [HttpGet] 30 | public IEnumerable All() => 31 | _ctx.Submissions 32 | .Where(x => x.VideoProcessed) 33 | .ToList(); 34 | 35 | [HttpGet("{id}")] 36 | public object Get(int id) => 37 | _ctx.Submissions 38 | .Where(x => x.Id.Equals(id)) 39 | .Include(x => x.Video) 40 | .Include(x => x.User) 41 | .Select(SubmissionViewModels.PerspectiveProjection(UserId)) 42 | .FirstOrDefault(); 43 | 44 | 45 | [HttpGet("best-submission")] 46 | public object ListSubmissionsForTrick(string byTricks) 47 | { 48 | var trickIds = byTricks.Split(';'); 49 | return _ctx.Submissions 50 | .Where(x => trickIds.Contains(x.TrickId)) 51 | .Include(x => x.Video) 52 | .Include(x => x.User) 53 | .OrderByDescending(x => x.Votes.Sum(v => v.Value)) 54 | .Select(SubmissionViewModels.PerspectiveProjection(UserId)) 55 | .FirstOrDefault(); 56 | } 57 | 58 | [HttpPost] 59 | [Authorize] 60 | public async Task Create( 61 | [FromBody] SubmissionForm submissionForm, 62 | [FromServices] Channel channel, 63 | [FromServices] TemporaryFileStorage temporaryFileStorage) 64 | { 65 | if (!temporaryFileStorage.TemporaryFileExists(submissionForm.Video)) 66 | { 67 | return BadRequest(); 68 | } 69 | 70 | var submission = new Submission 71 | { 72 | TrickId = submissionForm.TrickId, 73 | Description = submissionForm.Description, 74 | VideoProcessed = false, 75 | UserId = UserId 76 | }; 77 | 78 | _ctx.Add(submission); 79 | await _ctx.SaveChangesAsync(); 80 | await channel.Writer.WriteAsync(new EditVideoMessage 81 | { 82 | SubmissionId = submission.Id, 83 | Input = submissionForm.Video, 84 | }); 85 | return Ok(submission); 86 | } 87 | 88 | [HttpPut] 89 | public async Task Update([FromBody] Submission submission) 90 | { 91 | if (submission.Id == 0) 92 | { 93 | return null; 94 | } 95 | 96 | _ctx.Add(submission); 97 | await _ctx.SaveChangesAsync(); 98 | return submission; 99 | } 100 | 101 | [HttpDelete("{id}")] 102 | public async Task Delete(int id) 103 | { 104 | var submission = _ctx.Submissions.FirstOrDefault(x => x.Id.Equals(id)); 105 | if (submission == null) 106 | { 107 | return NotFound(); 108 | } 109 | 110 | submission.Deleted = true; 111 | await _ctx.SaveChangesAsync(); 112 | return Ok(); 113 | } 114 | 115 | [HttpPut("{id}/vote")] 116 | [Authorize] 117 | public async Task Vote( 118 | int id, 119 | int value, 120 | [FromServices] ISubmissionVoteSink voteSink 121 | ) 122 | { 123 | if (value != -1 && value != 1) 124 | { 125 | return BadRequest(); 126 | } 127 | 128 | await voteSink.Submit(new VoteForm 129 | { 130 | SubmissionId = id, 131 | UserId = UserId, 132 | Value = value, 133 | }); 134 | 135 | return Ok(); 136 | } 137 | } 138 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Controllers/UserController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Http; 8 | using Microsoft.AspNetCore.Mvc; 9 | using Microsoft.EntityFrameworkCore; 10 | using SixLabors.ImageSharp; 11 | using SixLabors.ImageSharp.Formats.Jpeg; 12 | using SixLabors.ImageSharp.Processing; 13 | using TrickingLibrary.Api.BackgroundServices.VideoEditing; 14 | using TrickingLibrary.Api.Services.Storage; 15 | using TrickingLibrary.Api.Settings; 16 | using TrickingLibrary.Api.ViewModels; 17 | using TrickingLibrary.Data; 18 | using TrickingLibrary.Models; 19 | 20 | namespace TrickingLibrary.Api.Controllers 21 | { 22 | [Route("api/users")] 23 | [Authorize] 24 | public class UserController : ApiController 25 | { 26 | private readonly AppDbContext _ctx; 27 | 28 | public UserController(AppDbContext ctx) 29 | { 30 | _ctx = ctx; 31 | } 32 | 33 | [HttpGet("me")] 34 | public async Task GetMe() 35 | { 36 | var userId = UserId; 37 | if (string.IsNullOrEmpty(userId)) 38 | { 39 | return BadRequest(); 40 | } 41 | 42 | var user = await _ctx.Users 43 | .Where(x => x.Id.Equals(userId)) 44 | .Include(x => x.Submissions) 45 | .ThenInclude(x => x.Votes) 46 | .Select(UserViewModels.ProfileProjection(Role)) 47 | .FirstOrDefaultAsync(); 48 | 49 | if (user != null) return Ok(user); 50 | 51 | var newUser = new User 52 | { 53 | Id = userId, 54 | Username = Username, 55 | }; 56 | 57 | _ctx.Add(newUser); 58 | await _ctx.SaveChangesAsync(); 59 | 60 | return Ok(UserViewModels.ProfileProjection(Role).Compile().Invoke(newUser)); 61 | } 62 | 63 | [AllowAnonymous] 64 | [HttpGet("{username}")] 65 | public object GetUser(string username) => 66 | _ctx.Users 67 | .Where(x => x.Username.ToLower() == username.ToLower()) 68 | .Include(x => x.Submissions) 69 | .ThenInclude(x => x.Votes) 70 | .Select(UserViewModels.Projection) 71 | .FirstOrDefault(); 72 | 73 | [AllowAnonymous] 74 | [HttpGet("{id}/submissions")] 75 | public Task> GetUserSubmissions(string id, [FromQuery] FeedQuery feedQuery) => 76 | _ctx.Submissions 77 | .Include(x => x.Video) 78 | .Include(x => x.User) 79 | .Where(x => x.UserId.Equals(id)) 80 | .OrderFeed(feedQuery) 81 | .Select(SubmissionViewModels.Projection) 82 | .ToListAsync(); 83 | 84 | [HttpPut("me/image")] 85 | public async Task UpdateProfileImage( 86 | IFormFile image, 87 | [FromServices] IFileProvider fileManager) 88 | { 89 | if (image == null) return BadRequest(); 90 | 91 | var userId = UserId; 92 | var user = await _ctx.Users.FirstOrDefaultAsync(x => x.Id.Equals(userId)); 93 | 94 | if (user == null) return NoContent(); 95 | 96 | await using (var stream = new MemoryStream()) 97 | using (var imageProcessor = await Image.LoadAsync(image.OpenReadStream())) 98 | { 99 | imageProcessor.Mutate(x => x.Resize(48, 48)); 100 | var processImage = imageProcessor.SaveAsync(stream, new JpegEncoder()); 101 | var saveImage = fileManager.SaveProfileImageAsync(stream); 102 | await Task.WhenAll(processImage, saveImage); 103 | user.Image = await saveImage; 104 | } 105 | 106 | await _ctx.SaveChangesAsync(); 107 | return Ok(); 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/CommentForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | 4 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/CreateCategoryForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class CreateCategoryForm 4 | { 5 | public string Name { get; set; } 6 | public string Description { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/CreateDifficultyForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class CreateDifficultyForm 4 | { 5 | public string Name { get; set; } 6 | public string Description { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/CreateTrickForm.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace TrickingLibrary.Api.Form 4 | { 5 | public class CreateTrickForm 6 | { 7 | public string Name { get; set; } 8 | public string Description { get; set; } 9 | public int Difficulty { get; set; } 10 | public IEnumerable Prerequisites { get; set; } 11 | public IEnumerable Progressions { get; set; } 12 | public IEnumerable Categories { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/InviteModeratorForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Controllers 2 | { 3 | public class InviteModeratorForm 4 | { 5 | public string Email { get; set; } 6 | public string ReturnUrl { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/SubmissionForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class SubmissionForm 4 | { 5 | public string TrickId { get; set; } 6 | public string Description { get; set; } 7 | public string Video { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/UpdateCategoryForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class UpdateCategoryForm : CreateCategoryForm 4 | { 5 | public int Id { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/UpdateDifficultyForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class UpdateDifficultyForm : CreateDifficultyForm 4 | { 5 | public int Id { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/UpdateStagedTrickForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class UpdateStagedTrickForm : CreateTrickForm 4 | { 5 | public int Id { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/UpdateTrickForm.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Form 2 | { 3 | public class UpdateTrickForm : CreateTrickForm 4 | { 5 | public int Id { get; set; } 6 | public string Reason { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/CommentFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class CommentFormValidation : AbstractValidator 6 | { 7 | public CommentFormValidation() 8 | { 9 | RuleFor(x => x.ParentId).NotEmpty(); 10 | RuleFor(x => x.Content).NotEmpty(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/CreateCategoryFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class CreateCategoryFormValidation : AbstractValidator 6 | { 7 | public CreateCategoryFormValidation() 8 | { 9 | RuleFor(x => x.Name).NotEmpty(); 10 | RuleFor(x => x.Description).NotEmpty(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/CreateDifficultyFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class CreateDifficultyFormValidation : AbstractValidator 6 | { 7 | public CreateDifficultyFormValidation() 8 | { 9 | RuleFor(x => x.Name).NotEmpty(); 10 | RuleFor(x => x.Description).NotEmpty(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/CreateTrickFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class CreateTrickFormValidation : AbstractValidator 6 | { 7 | public CreateTrickFormValidation() 8 | { 9 | RuleFor(x => x.Name).NotEmpty(); 10 | RuleFor(x => x.Description).NotEmpty(); 11 | RuleFor(x => x.Difficulty).NotEmpty(); 12 | RuleFor(x => x.Categories).NotEmpty(); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/InviteModeratorFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Controllers 4 | { 5 | public class InviteModeratorFormValidation : AbstractValidator 6 | { 7 | public InviteModeratorFormValidation() 8 | { 9 | RuleFor(x => x.Email).NotEmpty(); 10 | RuleFor(x => x.ReturnUrl).NotEmpty(); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/ReviewFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | using TrickingLibrary.Models.Moderation; 3 | 4 | namespace TrickingLibrary.Api.Form.Validation 5 | { 6 | public class ReviewFormValidation : AbstractValidator 7 | { 8 | public ReviewFormValidation() 9 | { 10 | RuleFor(x => x.Comment).NotEmpty().When(x => x.Status != ReviewStatus.Approved); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/SubmissionFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class SubmissionFormValidation : AbstractValidator 6 | { 7 | public SubmissionFormValidation() 8 | { 9 | RuleFor(x => x.TrickId).NotEmpty(); 10 | RuleFor(x => x.Description).NotEmpty(); 11 | RuleFor(x => x.Video).NotEmpty(); 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/UpdateCategoryFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class UpdateCategoryFormValidation : AbstractValidator 6 | { 7 | public UpdateCategoryFormValidation() 8 | { 9 | RuleFor(x => x.Id).GreaterThan(0); 10 | RuleFor(x => x.Name).NotEmpty(); 11 | RuleFor(x => x.Description).NotEmpty(); 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/UpdateDifficultyFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class UpdateDifficultyFormValidation : AbstractValidator 6 | { 7 | public UpdateDifficultyFormValidation() 8 | { 9 | RuleFor(x => x.Id).GreaterThan(0); 10 | RuleFor(x => x.Name).NotEmpty(); 11 | RuleFor(x => x.Description).NotEmpty(); 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/UpdateStagedTrickFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class UpdateStagedTrickFormValidation : AbstractValidator 6 | { 7 | public UpdateStagedTrickFormValidation() 8 | { 9 | RuleFor(x => x.Id).NotEqual(0); 10 | RuleFor(x => x.Name).NotEmpty(); 11 | RuleFor(x => x.Description).NotEmpty(); 12 | RuleFor(x => x.Difficulty).NotEmpty(); 13 | RuleFor(x => x.Categories).NotEmpty(); 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Form/Validation/UpdateTrickFormValidation.cs: -------------------------------------------------------------------------------- 1 | using FluentValidation; 2 | 3 | namespace TrickingLibrary.Api.Form.Validation 4 | { 5 | public class UpdateTrickFormValidation : AbstractValidator 6 | { 7 | public UpdateTrickFormValidation() 8 | { 9 | RuleFor(x => x.Id).NotEqual(0); 10 | RuleFor(x => x.Reason).NotEmpty(); 11 | RuleFor(x => x.Name).NotEmpty(); 12 | RuleFor(x => x.Description).NotEmpty(); 13 | RuleFor(x => x.Difficulty).NotEmpty(); 14 | RuleFor(x => x.Categories).NotEmpty(); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ModerationItemReviewContext.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Microsoft.Extensions.Options; 8 | using TrickingLibrary.Data; 9 | using TrickingLibrary.Data.VersionMigrations; 10 | using TrickingLibrary.Models.Moderation; 11 | 12 | namespace TrickingLibrary.Api 13 | { 14 | public class ModerationItemReviewContext 15 | { 16 | private readonly IServiceProvider _serviceProvider; 17 | private readonly IOptionsMonitor _moderationOptionsMonitor; 18 | private readonly SemaphoreSlim _semaphore; 19 | 20 | public ModerationItemReviewContext( 21 | IServiceProvider serviceProvider, 22 | IOptionsMonitor moderationOptionsMonitor) 23 | { 24 | _serviceProvider = serviceProvider; 25 | _moderationOptionsMonitor = moderationOptionsMonitor; 26 | _semaphore = new SemaphoreSlim(1); 27 | } 28 | 29 | public class ReviewForm 30 | { 31 | public string Comment { get; set; } 32 | public ReviewStatus Status { get; set; } 33 | } 34 | 35 | public class ModerationSettings 36 | { 37 | public int GoalCount { get; set; } 38 | } 39 | 40 | public class ModerationItemNotFound : Exception 41 | { 42 | } 43 | 44 | public async Task Review(int moderationItemId, string userId, ReviewForm form) 45 | { 46 | using var scope = _serviceProvider.CreateScope(); 47 | var ctx = scope.ServiceProvider.GetRequiredService(); 48 | var migrationContext = scope.ServiceProvider.GetRequiredService(); 49 | var setting = _moderationOptionsMonitor.CurrentValue; 50 | await _semaphore.WaitAsync(); 51 | 52 | 53 | var modItem = ctx.ModerationItems 54 | .Include(x => x.Reviews) 55 | .FirstOrDefault(x => x.Id == moderationItemId && !x.Deleted); 56 | 57 | if (modItem == null) 58 | { 59 | throw new ModerationItemNotFound(); 60 | } 61 | 62 | var review = modItem.Reviews.FirstOrDefault(x => x.UserId == userId); 63 | 64 | if (review == null) 65 | { 66 | modItem.Reviews.Add(new Review 67 | { 68 | Comment = form.Comment, 69 | Status = form.Status, 70 | UserId = userId, 71 | }); 72 | } 73 | else 74 | { 75 | review.Comment = form.Comment; 76 | review.Status = form.Status; 77 | } 78 | 79 | int goal = setting.GoalCount, score = 0, wait = 0; 80 | foreach (var r in modItem.Reviews) 81 | { 82 | if (r.Status == ReviewStatus.Approved) 83 | score++; 84 | else if (r.Status == ReviewStatus.Rejected) 85 | score--; 86 | else if (r.Status == ReviewStatus.Waiting) 87 | wait++; 88 | } 89 | 90 | if (score >= goal + wait) 91 | { 92 | migrationContext.Setup(modItem).Migrate(); 93 | modItem.Deleted = true; 94 | } 95 | else if (score <= -goal - wait) 96 | { 97 | // todo cleanup target 98 | modItem.Deleted = true; 99 | modItem.Rejected = true; 100 | } 101 | 102 | modItem.Updated = DateTime.UtcNow; 103 | await ctx.SaveChangesAsync(); 104 | 105 | _semaphore.Release(); 106 | } 107 | } 108 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Account/Login.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model TrickingLibrary.Api.Pages.Account.Login 3 | 4 | @{ 5 | ViewData["Title"] = "Login"; 6 | Layout = "_Layout"; 7 | } 8 | 9 |
10 | 11 |
12 | 13 | 14 | 15 |
16 |
17 | 18 | 19 | 20 |
21 | @if (Model.CustomErrors.Count > 0) 22 | { 23 |
24 | @foreach (var error in Model.CustomErrors) 25 | { 26 |
@error
27 | } 28 |
29 | } 30 |
31 | 32 |
33 |
34 |
35 | Create Account 36 |
37 |
-------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Account/Login.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.AspNetCore.Identity; 5 | using Microsoft.AspNetCore.Mvc; 6 | using Microsoft.AspNetCore.Mvc.RazorPages; 7 | using Microsoft.Extensions.Hosting; 8 | 9 | namespace TrickingLibrary.Api.Pages.Account 10 | { 11 | public class Login : BasePage 12 | { 13 | [BindProperty] public LoginForm Form { get; set; } 14 | 15 | public void OnGet(string returnUrl) 16 | { 17 | Form = new LoginForm {ReturnUrl = returnUrl}; 18 | } 19 | 20 | public async Task OnPostAsync([FromServices] SignInManager signInManager) 21 | { 22 | if (!ModelState.IsValid) 23 | return Page(); 24 | 25 | var signInResult = await signInManager 26 | .PasswordSignInAsync(Form.Username, Form.Password, true, false); 27 | 28 | if (signInResult.Succeeded) 29 | { 30 | return Redirect(Form.ReturnUrl); 31 | } 32 | 33 | CustomErrors.Add("Invalid login attempt, please try again."); 34 | 35 | return Page(); 36 | } 37 | 38 | public class LoginForm 39 | { 40 | [Required] public string ReturnUrl { get; set; } 41 | [Required] public string Username { get; set; } 42 | 43 | [Required] 44 | [DataType(DataType.Password)] 45 | public string Password { get; set; } 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Account/Moderator.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model TrickingLibrary.Api.Pages.Account.Moderator 3 | 4 | @{ 5 | ViewData["Title"] = "Register"; 6 | Layout = "_Layout"; 7 | } 8 | 9 |
10 | 11 | 12 | 13 | 14 |
15 | 16 |
17 |
18 | 19 | 20 | 21 |
22 |
23 | 24 | 25 | 26 |
27 |
28 | 29 | 30 | 31 |
32 | @if (Model.CustomErrors.Count > 0) 33 | { 34 |
35 | @foreach (var error in Model.CustomErrors) 36 | { 37 |
@error
38 | } 39 |
40 | } 41 |
42 | 43 |
44 |
-------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Account/Moderator.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.Mvc.RazorPages; 6 | 7 | namespace TrickingLibrary.Api.Pages.Account 8 | { 9 | public class Moderator : BasePage 10 | { 11 | [BindProperty] public ModeratorRegisterForm Form { get; set; } 12 | 13 | public void OnGet(string code, string email, string returnUrl) 14 | { 15 | Form = new ModeratorRegisterForm 16 | { 17 | Email = email, 18 | Code = code, 19 | ReturnUrl = returnUrl, 20 | }; 21 | } 22 | 23 | public async Task OnPostAsync( 24 | [FromServices] UserManager userManager, 25 | [FromServices] SignInManager signInManager) 26 | { 27 | if (!ModelState.IsValid) 28 | return Page(); 29 | 30 | var existingUser = await userManager.FindByNameAsync(Form.Username); 31 | if (existingUser != null) 32 | { 33 | CustomErrors.Add("Username already taken."); 34 | return Page(); 35 | } 36 | 37 | var user = await userManager.FindByNameAsync(Form.Email); 38 | var resetPasswordResult = await userManager.ResetPasswordAsync(user, Form.Code, Form.Password); 39 | if (resetPasswordResult.Succeeded) 40 | { 41 | user.UserName = Form.Username; 42 | await userManager.UpdateAsync(user); 43 | 44 | await signInManager.SignInAsync(user, true); 45 | 46 | return Redirect(Form.ReturnUrl); 47 | } 48 | 49 | CustomErrors.Add("Failed to create account."); 50 | 51 | return Page(); 52 | } 53 | 54 | public class ModeratorRegisterForm 55 | { 56 | [Required] public string ReturnUrl { get; set; } 57 | [Required] public string Email { get; set; } 58 | [Required] public string Code { get; set; } 59 | [Required] public string Username { get; set; } 60 | 61 | [Required] 62 | [DataType(DataType.Password)] 63 | public string Password { get; set; } 64 | 65 | [Required] 66 | [DataType(DataType.Password)] 67 | [Compare(nameof(Password))] 68 | public string ConfirmPassword { get; set; } 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Account/Register.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model TrickingLibrary.Api.Pages.Account.Register 3 | 4 | @{ 5 | ViewData["Title"] = "Register"; 6 | Layout = "_Layout"; 7 | } 8 | 9 |
10 | 11 | @if (Model.CustomErrors.Count > 0) 12 | { 13 |
14 | @foreach (var error in Model.CustomErrors) 15 | { 16 |
@error
17 | } 18 |
19 | } 20 |
21 | 22 | 23 | 24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 |
35 |
36 | 37 | 38 | 39 |
40 |
41 | 42 |
43 |
44 |
45 | Back to Login 46 |
47 |
-------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Account/Register.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Identity; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.AspNetCore.Mvc.RazorPages; 6 | 7 | namespace TrickingLibrary.Api.Pages.Account 8 | { 9 | public class Register : BasePage 10 | { 11 | [BindProperty] public RegisterForm Form { get; set; } 12 | 13 | public void OnGet(string returnUrl) 14 | { 15 | Form = new RegisterForm {ReturnUrl = returnUrl}; 16 | } 17 | 18 | public async Task OnPostAsync( 19 | [FromServices] UserManager userManager, 20 | [FromServices] SignInManager signInManager) 21 | { 22 | if (!ModelState.IsValid) 23 | return Page(); 24 | 25 | var user = new IdentityUser(Form.Username) {Email = Form.Email}; 26 | 27 | var createUserResult = await userManager.CreateAsync(user, Form.Password); 28 | 29 | if (createUserResult.Succeeded) 30 | { 31 | await signInManager.SignInAsync(user, true); 32 | 33 | return Redirect(Form.ReturnUrl); 34 | } 35 | 36 | foreach (var error in createUserResult.Errors) 37 | { 38 | CustomErrors.Add(error.Description); 39 | } 40 | 41 | return Page(); 42 | } 43 | 44 | public class RegisterForm 45 | { 46 | [Required] public string ReturnUrl { get; set; } 47 | [Required] public string Email { get; set; } 48 | [Required] public string Username { get; set; } 49 | 50 | [Required] 51 | [DataType(DataType.Password)] 52 | public string Password { get; set; } 53 | 54 | [Required] 55 | [DataType(DataType.Password)] 56 | [Compare(nameof(Password))] 57 | public string ConfirmPassword { get; set; } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/BasePage.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore.Mvc.RazorPages; 3 | 4 | namespace TrickingLibrary.Api.Pages 5 | { 6 | public class BasePage : PageModel 7 | { 8 | public IList CustomErrors { get; set; } = new List(); 9 | } 10 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | @ViewData["Title"] 7 | 8 | 102 | 103 | 104 | 105 |

Tricking Library

106 | @RenderBody() 107 | 108 | 109 | -------------------------------------------------------------------------------- /TrickingLibrary.Api/Pages/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers" -------------------------------------------------------------------------------- /TrickingLibrary.Api/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:63905", 7 | "sslPort": 44396 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "TrickingLibrary.Api": { 19 | "commandName": "Project", 20 | "launchBrowser": true, 21 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Email/EmailClient.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.Extensions.Options; 3 | using SendGrid; 4 | using SendGrid.Helpers.Mail; 5 | 6 | namespace TrickingLibrary.Api.Services.Email 7 | { 8 | public class EmailClient 9 | { 10 | private readonly IOptionsMonitor _optionsMonitor; 11 | private readonly SendGridClient _client; 12 | 13 | public EmailClient(IOptionsMonitor optionsMonitor) 14 | { 15 | _optionsMonitor = optionsMonitor; 16 | _client = new SendGridClient(_optionsMonitor.CurrentValue.ApiKey); 17 | } 18 | 19 | public Task SendModeratorInviteAsync(string email, string link) 20 | { 21 | var htmlContent = $@"You are invited to be a moderator on Tricking Library 22 | 23 | follow the link to register."; 24 | var msg = MailHelper.CreateSingleEmail( 25 | new EmailAddress(_optionsMonitor.CurrentValue.From), 26 | new EmailAddress(email), 27 | "Tricking Library Moderator Invite", 28 | "", 29 | htmlContent 30 | ); 31 | return _client.SendEmailAsync(msg); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Email/SendGridOptions.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Services.Email 2 | { 3 | public class SendGridOptions 4 | { 5 | public string ApiKey { get; set; } 6 | public string From { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/FileSettings.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Services.Storage 2 | { 3 | public class FileSettings 4 | { 5 | public string VideoUrl { get; set; } 6 | public string ImageUrl { get; set; } 7 | public string Provider { get; set; } 8 | public string WorkingDirectory { get; set; } 9 | public string FFMPEGPath { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/IFileProvider.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using TrickingLibrary.Api.Settings; 4 | 5 | namespace TrickingLibrary.Api.Services.Storage 6 | { 7 | public interface IFileProvider 8 | { 9 | public Task SaveProfileImageAsync(Stream fileStream); 10 | public Task SaveVideoAsync(Stream fileStream); 11 | public Task SaveThumbnailAsync(Stream fileStream); 12 | } 13 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/IS3Client.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | 4 | namespace TrickingLibrary.Api.Services.Storage 5 | { 6 | public interface IS3Client 7 | { 8 | Task SaveFile(string fileName, string mime, Stream fileStream); 9 | } 10 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/LinodeS3Client.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using Amazon.S3; 4 | using Amazon.S3.Model; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace TrickingLibrary.Api.Services.Storage 8 | { 9 | public class LinodeS3Client : IS3Client 10 | { 11 | private readonly S3Settings _settings; 12 | 13 | public LinodeS3Client(IOptionsMonitor optionsMonitor) 14 | { 15 | _settings = optionsMonitor.CurrentValue; 16 | } 17 | 18 | public async Task SaveFile(string fileName, string mime, Stream fileStream) 19 | { 20 | using var client = Client; 21 | var request = new PutObjectRequest 22 | { 23 | BucketName = _settings.Bucket, 24 | Key = $"{_settings.Root}/{fileName}", 25 | ContentType = mime, 26 | InputStream = fileStream, 27 | CannedACL = S3CannedACL.PublicRead, 28 | }; 29 | await client.PutObjectAsync(request); 30 | return ObjectUrl(fileName); 31 | } 32 | 33 | private string ObjectUrl(string fileName) => 34 | $"{_settings.ServiceUrl}/{_settings.Bucket}/{_settings.Root}/{fileName}"; 35 | 36 | private AmazonS3Client Client => new AmazonS3Client( 37 | _settings.AccessKey, 38 | _settings.SecretKey, 39 | new AmazonS3Config 40 | { 41 | ServiceURL = _settings.ServiceUrl, 42 | } 43 | ); 44 | } 45 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/LocalFileProvider.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace TrickingLibrary.Api.Services.Storage 7 | { 8 | public class LocalFileProvider : IFileProvider 9 | { 10 | private readonly IWebHostEnvironment _env; 11 | private readonly FileSettings _settings; 12 | 13 | public LocalFileProvider( 14 | IOptionsMonitor fileSettingsMonitor, 15 | IWebHostEnvironment env) 16 | { 17 | _settings = fileSettingsMonitor.CurrentValue; 18 | _env = env; 19 | } 20 | 21 | public async Task SaveProfileImageAsync(Stream fileStream) 22 | { 23 | var fileName = TrickingLibraryConstants.Files.GenerateProfileFileName(); 24 | await SaveFile(fileStream, fileName); 25 | return $"{_settings.ImageUrl}/{fileName}"; 26 | } 27 | 28 | public async Task SaveVideoAsync(Stream fileStream) 29 | { 30 | var fileName = TrickingLibraryConstants.Files.GenerateConvertedFileName(); 31 | await SaveFile(fileStream, fileName); 32 | return $"{_settings.VideoUrl}/{fileName}"; 33 | } 34 | 35 | public async Task SaveThumbnailAsync(Stream fileStream) 36 | { 37 | var fileName = TrickingLibraryConstants.Files.GenerateThumbnailFileName(); 38 | await SaveFile(fileStream, fileName); 39 | return $"{_settings.ImageUrl}/{fileName}"; 40 | } 41 | 42 | private async Task SaveFile(Stream fileStream, string fileName) 43 | { 44 | var savePath = Path.Combine(_env.WebRootPath, fileName); 45 | await using (var stream = File.Create(savePath)) 46 | { 47 | await fileStream.CopyToAsync(stream); 48 | } 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/RegisterService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.Configuration; 3 | using TrickingLibrary.Api; 4 | using TrickingLibrary.Api.BackgroundServices.VideoEditing; 5 | using TrickingLibrary.Api.Services.Storage; 6 | using TrickingLibrary.Api.Settings; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace Microsoft.Extensions.DependencyInjection 10 | { 11 | public static class RegisterService 12 | { 13 | public static IServiceCollection AddFileServices(this IServiceCollection services, IConfiguration config) 14 | { 15 | var settingsSection = config.GetSection(nameof(FileSettings)); 16 | var settings = settingsSection.Get(); 17 | services.Configure(settingsSection); 18 | 19 | services.AddSingleton(); 20 | if (settings.Provider.Equals(TrickingLibraryConstants.Files.Providers.Local)) 21 | { 22 | services.AddSingleton(); 23 | } 24 | else if (settings.Provider.Equals(TrickingLibraryConstants.Files.Providers.S3)) 25 | { 26 | services.Configure(config.GetSection(nameof(S3Settings))); 27 | services.AddSingleton(); 28 | services.AddSingleton(); 29 | } 30 | else 31 | { 32 | throw new Exception($"Invalid File Manager Provider: {settings.Provider}"); 33 | } 34 | 35 | return services; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/S3FileProvider.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | 4 | namespace TrickingLibrary.Api.Services.Storage 5 | { 6 | public class S3FileProvider : IFileProvider 7 | { 8 | private readonly IS3Client _client; 9 | 10 | public S3FileProvider(IS3Client client) 11 | { 12 | _client = client; 13 | } 14 | 15 | public Task SaveProfileImageAsync(Stream fileStream) 16 | { 17 | var fileName = TrickingLibraryConstants.Files.GenerateProfileFileName(); 18 | return _client.SaveFile(fileName, "image/jpg", fileStream); 19 | } 20 | 21 | public Task SaveVideoAsync(Stream fileStream) 22 | { 23 | var fileName = TrickingLibraryConstants.Files.GenerateConvertedFileName(); 24 | return _client.SaveFile(fileName, "video/mp4", fileStream); 25 | } 26 | 27 | public Task SaveThumbnailAsync(Stream fileStream) 28 | { 29 | var fileName = TrickingLibraryConstants.Files.GenerateThumbnailFileName(); 30 | return _client.SaveFile(fileName, "image/jpg", fileStream); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/S3Settings.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Services.Storage 2 | { 3 | public class S3Settings 4 | { 5 | public string AccessKey { get; set; } 6 | public string SecretKey { get; set; } 7 | public string ServiceUrl { get; set; } 8 | public string Bucket { get; set; } 9 | public string Root { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Services/Storage/TemporaryFileStorage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace TrickingLibrary.Api.Services.Storage 8 | { 9 | public class TemporaryFileStorage 10 | { 11 | private readonly FileSettings _settings; 12 | 13 | public TemporaryFileStorage(IOptionsMonitor optionsMonitor) 14 | { 15 | _settings = optionsMonitor.CurrentValue; 16 | } 17 | 18 | public async Task SaveTemporaryFile(IFormFile video) 19 | { 20 | var fileName = string.Concat( 21 | TrickingLibraryConstants.Files.TempPrefix, 22 | DateTime.Now.Ticks, 23 | Path.GetExtension(video.FileName) 24 | ); 25 | var savePath = GetSavePath(fileName); 26 | 27 | await using (var fileStream = new FileStream(savePath, FileMode.Create, FileAccess.Write)) 28 | { 29 | await video.CopyToAsync(fileStream); 30 | } 31 | 32 | return fileName; 33 | } 34 | 35 | public bool TemporaryFileExists(string fileName) 36 | { 37 | var path = GetSavePath(fileName); 38 | return File.Exists(path); 39 | } 40 | 41 | public void DeleteTemporaryFile(string fileName) 42 | { 43 | var path = GetSavePath(fileName); 44 | if (File.Exists(path)) 45 | { 46 | File.Delete(path); 47 | } 48 | } 49 | 50 | public string GetSavePath(string fileName) 51 | { 52 | return Path.Combine(_settings.WorkingDirectory, fileName); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/Settings/FileType.cs: -------------------------------------------------------------------------------- 1 | namespace TrickingLibrary.Api.Settings 2 | { 3 | public enum FileType 4 | { 5 | Image, 6 | Video, 7 | } 8 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/TrickingLibrary.Api.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | af4900d4-9fae-4d2e-b3ba-4f40f4ff2ac2 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | all 24 | runtime; build; native; contentfiles; analyzers; buildtransitive 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /TrickingLibrary.Api/TrickingLibraryConstants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Security.Claims; 3 | 4 | namespace TrickingLibrary.Api 5 | { 6 | public struct TrickingLibraryConstants 7 | { 8 | public struct Policies 9 | { 10 | // public const string Anon = nameof(Anon); 11 | // public const string User = IdentityServerConstants.LocalApi.PolicyName; 12 | public const string Mod = nameof(Mod); 13 | public const string Admin = nameof(Admin); 14 | } 15 | 16 | public struct IdentityResources 17 | { 18 | public const string RoleScope = "role"; 19 | } 20 | 21 | public struct Claims 22 | { 23 | public const string Role = "role"; 24 | public static readonly Claim ModeratorClaim = new Claim(Role, Roles.Mod); 25 | } 26 | 27 | public struct Roles 28 | { 29 | public const string Mod = nameof(Mod); 30 | public const string Admin = nameof(Admin); 31 | } 32 | 33 | public struct Files 34 | { 35 | public struct Providers 36 | { 37 | public const string Local = nameof(Local); 38 | public const string S3 = nameof(S3); 39 | } 40 | 41 | public const string TempPrefix = "temp_"; 42 | public const string ConvertedPrefix = "c"; 43 | public const string ThumbnailPrefix = "t"; 44 | public const string ProfilePrefix = "p"; 45 | 46 | public static string GenerateConvertedFileName() => $"{ConvertedPrefix}{DateTime.Now.Ticks}.mp4"; 47 | public static string GenerateThumbnailFileName() => $"{ThumbnailPrefix}{DateTime.Now.Ticks}.jpg"; 48 | public static string GenerateProfileFileName() => $"{ProfilePrefix}{DateTime.Now.Ticks}.jpg"; 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/CategoryViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using TrickingLibrary.Models; 5 | 6 | namespace TrickingLibrary.Api.ViewModels 7 | { 8 | public static class CategoryViewModels 9 | { 10 | public static readonly Func CreateFlat = FlatProjection.Compile(); 11 | 12 | public static Expression> FlatProjection => 13 | category => new 14 | { 15 | category.Id, 16 | category.Slug, 17 | category.Name, 18 | category.State, 19 | category.Version, 20 | }; 21 | 22 | public static readonly Func Create = Projection.Compile(); 23 | 24 | public static Expression> Projection => 25 | category => new 26 | { 27 | category.Id, 28 | category.Slug, 29 | category.Name, 30 | category.Description, 31 | category.Version, 32 | category.State, 33 | Updated = category.Updated.ToLocalTime().ToString("HH:mm dd/MM/yyyy"), 34 | Tricks = category.Tricks.AsQueryable() 35 | .Where(x => x.Active) 36 | .Select(x => x.TrickId) 37 | .ToList(), 38 | }; 39 | } 40 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/CommentViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using TrickingLibrary.Models; 4 | 5 | namespace TrickingLibrary.Api.ViewModels 6 | { 7 | public static class CommentViewModels 8 | { 9 | public static readonly Func Create = Projection.Compile(); 10 | 11 | public static Expression> Projection => 12 | comment => new 13 | { 14 | comment.Id, 15 | comment.ParentId, 16 | comment.Content, 17 | comment.HtmlContent, 18 | User = UserViewModels.CreateFlat(comment.User), 19 | }; 20 | } 21 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/DifficultyViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using TrickingLibrary.Models; 5 | 6 | namespace TrickingLibrary.Api.ViewModels 7 | { 8 | public static class DifficultyViewModels 9 | { 10 | public static readonly Func CreateFlat = ProjectionFlat.Compile(); 11 | 12 | public static Expression> ProjectionFlat => 13 | difficulty => new 14 | { 15 | difficulty.Id, 16 | difficulty.Name, 17 | difficulty.State, 18 | difficulty.Slug, 19 | difficulty.Version, 20 | }; 21 | 22 | public static readonly Func Create = Projection.Compile(); 23 | 24 | public static Expression> Projection => 25 | difficulty => new 26 | { 27 | difficulty.Id, 28 | difficulty.Name, 29 | difficulty.Description, 30 | difficulty.Slug, 31 | difficulty.Version, 32 | difficulty.State, 33 | Updated = difficulty.Updated.ToLocalTime().ToString("HH:mm dd/MM/yyyy"), 34 | Tricks = difficulty.Tricks.AsQueryable() 35 | .Where(x => x.Active) 36 | .Select(x => x.TrickId) 37 | .ToList(), 38 | }; 39 | } 40 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/ModerationItemViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using TrickingLibrary.Models; 5 | using TrickingLibrary.Models.Moderation; 6 | 7 | namespace TrickingLibrary.Api.ViewModels 8 | { 9 | public static class ModerationItemViewModels 10 | { 11 | public static readonly Func Create = Projection.Compile(); 12 | 13 | public static Expression> Projection => 14 | modItem => new 15 | { 16 | modItem.Id, 17 | modItem.Current, 18 | modItem.Target, 19 | modItem.Reason, 20 | modItem.Type, 21 | Updated = modItem.Updated.ToLocalTime().ToString("HH:mm dd/MM/yyyy"), 22 | }; 23 | } 24 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/ReviewViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using TrickingLibrary.Models.Moderation; 4 | 5 | namespace TrickingLibrary.Api.ViewModels 6 | { 7 | public static class ReviewViewModels 8 | { 9 | public static readonly Func Create = Projection.Compile(); 10 | 11 | public static Expression> Projection => 12 | review => new 13 | { 14 | review.Id, 15 | review.ModerationItemId, 16 | review.Comment, 17 | review.Status, 18 | }; 19 | 20 | public static readonly Func CreateWithUser = WithUserProjection.Compile(); 21 | 22 | public static Expression> WithUserProjection => 23 | review => new 24 | { 25 | review.Id, 26 | review.ModerationItemId, 27 | review.Comment, 28 | review.Status, 29 | User = UserViewModels.CreateFlat(review.User), 30 | }; 31 | } 32 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/SubmissionViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using TrickingLibrary.Models; 5 | 6 | namespace TrickingLibrary.Api.ViewModels 7 | { 8 | public static class SubmissionViewModels 9 | { 10 | public static readonly Func Created = Projection.Compile(); 11 | 12 | public static Expression> Projection => 13 | submissions => new 14 | { 15 | submissions.Id, 16 | submissions.Description, 17 | Video = submissions.Video.VideoLink, 18 | Thumb = submissions.Video.ThumbLink, 19 | Created = submissions.Created 20 | .ToLocalTime() 21 | .ToString("HH:mm dd/MM/yyyy"), 22 | Score = submissions.Votes.AsQueryable().Sum(x => x.Value), 23 | Vote = 0, 24 | User = new 25 | { 26 | submissions.User.Image, 27 | submissions.User.Username, 28 | }, 29 | }; 30 | 31 | public static Expression> PerspectiveProjection(string userId) 32 | { 33 | if (string.IsNullOrEmpty(userId)) 34 | { 35 | return Projection; 36 | } 37 | 38 | return submissions => new 39 | { 40 | submissions.Id, 41 | submissions.Description, 42 | Video = submissions.Video.VideoLink, 43 | Thumb = submissions.Video.ThumbLink, 44 | Created = submissions.Created 45 | .ToLocalTime() 46 | .ToString("HH:mm dd/MM/yyyy"), 47 | Score = submissions.Votes.AsQueryable().Sum(x => x.Value), 48 | Vote = submissions.Votes.AsQueryable() 49 | .Where(x => x.UserId == userId) 50 | .Select(x => x.Value) 51 | .FirstOrDefault(), 52 | User = new 53 | { 54 | submissions.User.Image, 55 | submissions.User.Username, 56 | }, 57 | }; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/TrickViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using TrickingLibrary.Models; 5 | 6 | namespace TrickingLibrary.Api.ViewModels 7 | { 8 | public static class TrickViewModels 9 | { 10 | public static readonly Func Create = Projection.Compile(); 11 | 12 | public static Expression> Projection => 13 | trick => new 14 | { 15 | trick.Id, 16 | trick.Slug, 17 | trick.Name, 18 | trick.Description, 19 | Difficulty = trick.TrickDifficulties.AsQueryable() 20 | .Where(x => x.Active) 21 | .Select(x => x.DifficultyId) 22 | .FirstOrDefault(), 23 | trick.Version, 24 | Categories = trick.TrickCategories 25 | .AsQueryable() 26 | .Where(x => x.Active) 27 | .Select(x => x.CategoryId) 28 | .ToList(), 29 | Prerequisites = trick.Prerequisites 30 | .AsQueryable() 31 | .Where(x => x.Active) 32 | .Select(x => x.PrerequisiteId) 33 | .ToList(), 34 | Progressions = trick.Progressions 35 | .AsQueryable() 36 | .Where(x => x.Active) 37 | .Select(x => x.ProgressionId) 38 | .ToList(), 39 | User = UserViewModels.CreateFlat(trick.User), 40 | }; 41 | 42 | public static readonly Func CreateFull = FullProjection.Compile(); 43 | 44 | public static Expression> FullProjection => 45 | trick => new 46 | { 47 | trick.Id, 48 | trick.Slug, 49 | trick.Name, 50 | trick.Description, 51 | trick.State, 52 | Difficulty = trick.TrickDifficulties.AsQueryable() 53 | .OrderByDescending(x => x.Active) 54 | .Select(x => x.DifficultyId) 55 | .FirstOrDefault(), 56 | trick.Version, 57 | Categories = trick.TrickCategories 58 | .AsQueryable() 59 | .Select(x => x.CategoryId) 60 | .ToList(), 61 | Prerequisites = trick.Prerequisites 62 | .AsQueryable() 63 | .Select(x => x.PrerequisiteId) 64 | .ToList(), 65 | Progressions = trick.Progressions 66 | .AsQueryable() 67 | .Select(x => x.ProgressionId) 68 | .ToList(), 69 | User = UserViewModels.CreateFlat(trick.User), 70 | }; 71 | 72 | public static readonly Func CreateFlat = FlatProjection.Compile(); 73 | 74 | public static Expression> FlatProjection => 75 | trick => new 76 | { 77 | trick.Id, 78 | trick.Slug, 79 | trick.Name, 80 | trick.Description, 81 | trick.State, 82 | trick.Version, 83 | }; 84 | } 85 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/ViewModels/UserViewModels.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using TrickingLibrary.Models; 5 | 6 | namespace TrickingLibrary.Api.ViewModels 7 | { 8 | public static class UserViewModels 9 | { 10 | public static Expression> Projection => 11 | user => new 12 | { 13 | user.Id, 14 | user.Username, 15 | user.Image, 16 | Submissions = user.Submissions.AsQueryable().Select(x => new 17 | { 18 | x.Id, 19 | x.TrickId, 20 | Score = x.Votes.Sum(v => v.Value), 21 | }) 22 | .ToList(), 23 | }; 24 | 25 | public static readonly Func CreateFlatCache = FlatProjection.Compile(); 26 | public static object CreateFlat(User user) => CreateFlatCache(user); 27 | 28 | public static Expression> FlatProjection => 29 | user => new 30 | { 31 | user.Id, 32 | user.Username, 33 | user.Image, 34 | }; 35 | 36 | public static Expression> ProfileProjection(string role) => 37 | user => new 38 | { 39 | user.Id, 40 | user.Username, 41 | user.Image, 42 | Submissions = user.Submissions.AsQueryable().Select(x => new 43 | { 44 | x.Id, 45 | x.TrickId, 46 | Score = x.Votes.Sum(v => v.Value), 47 | }) 48 | .ToList(), 49 | Role = role, 50 | }; 51 | } 52 | } -------------------------------------------------------------------------------- /TrickingLibrary.Api/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "Default": "Host=127.0.0.1;Port=5666;Database=tricking_library;User Id=postgres;Password=password;" 4 | }, 5 | "CookieDomain": "localhost", 6 | "Logging": { 7 | "LogLevel": { 8 | "Default": "Information", 9 | "Microsoft": "Warning", 10 | "Microsoft.Hosting.Lifetime": "Information" 11 | } 12 | }, 13 | "FileSettings": { 14 | "VideoUrl": "https://localhost:5001/api/files/video", 15 | "ImageUrl": "https://localhost:5001/api/files/image", 16 | "Provider": "S3", 17 | "FFMPEGPath": "D:\\WS\\Rider\\TrickingLibrary\\TrickingLibrary.Api\\ffmpeg\\ffmpeg.exe", 18 | "WorkingDirectory": "D:\\WS\\Rider\\TrickingLibrary\\TrickingLibrary.Api\\wwwroot" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /TrickingLibrary.Api/appsettings.Production.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "Default": "[secret]" 4 | }, 5 | "AdminPassword": "[secret]", 6 | "CookieDomain": ".raw-coding.net", 7 | "Logging": { 8 | "LogLevel": { 9 | "Default": "Information", 10 | "Microsoft": "Warning", 11 | "Microsoft.Hosting.Lifetime": "Information" 12 | } 13 | }, 14 | "FileSettings": { 15 | "VideoUrl": "[secret]", 16 | "ImageUrl": "[secret]", 17 | "Provider": "S3", 18 | "FFMPEGPath": "ffmpeg", 19 | "WorkingDirectory": "/tmp/tricking_library" 20 | }, 21 | "SendGridOptions": { 22 | "ApiKey": "[secret]", 23 | "From": "[secret]" 24 | }, 25 | "S3Settings": { 26 | "AccessKey": "[secret]", 27 | "SecretKey": "[secret]", 28 | "ServiceUrl": "[secret]", 29 | "Bucket": "[secret]", 30 | "Root": "tricking-library" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /TrickingLibrary.Api/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "ModerationSettings": { 10 | "GoalCount": 1 11 | }, 12 | "AllowedHosts": "*" 13 | } 14 | -------------------------------------------------------------------------------- /TrickingLibrary.Api/test-moderator-invite.http: -------------------------------------------------------------------------------- 1 | POST https://localhost:5001/api/admin/moderators 2 | Content-Type: application/json 3 | 4 | { 5 | "Email": "atwieslander@gmail.com", 6 | "ReturnUrl": "https://localhost:3000/" 7 | } 8 | 9 | ### 10 | GET https://localhost:5001/api/admin/moderators 11 | Accept: application/json 12 | 13 | ### -------------------------------------------------------------------------------- /TrickingLibrary.Data/AppDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using TrickingLibrary.Models; 3 | using TrickingLibrary.Models.Moderation; 4 | 5 | namespace TrickingLibrary.Data 6 | { 7 | public class AppDbContext : DbContext 8 | { 9 | public AppDbContext(DbContextOptions options) : base(options) 10 | { 11 | } 12 | 13 | public DbSet Tricks { get; set; } 14 | public DbSet Submissions { get; set; } 15 | public DbSet SubmissionVotes { get; set; } 16 | public DbSet Difficulties { get; set; } 17 | public DbSet TrickDifficulties { get; set; } 18 | public DbSet TrickRelationships { get; set; } 19 | public DbSet Categories { get; set; } 20 | public DbSet TrickCategories { get; set; } 21 | public DbSet