diff --git a/.gitignore b/.gitignore index d3b8328..5d1db60 100644 --- a/.gitignore +++ b/.gitignore @@ -336,3 +336,10 @@ ASALocalRun/ # Project specific postgres_data .env + +# VSCode +.vscode + +# MacOS +.DS_Store +Thumbs.db diff --git a/TeacherWorkout.Api/Controllers/FileController.cs b/TeacherWorkout.Api/Controllers/FileController.cs new file mode 100644 index 0000000..7b3dc5b --- /dev/null +++ b/TeacherWorkout.Api/Controllers/FileController.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using TeacherWorkout.Data; + +namespace TeacherWorkout.Api.Controllers +{ + [Route("file")] + [ApiController] + public class FileController(TeacherWorkoutContext context) : ControllerBase + { + private readonly TeacherWorkoutContext _context = context; + + [HttpGet("{id}")] + public async Task GetImage(string id) + { + var fileBlob = await _context.FileBlobs.FindAsync(id); + if (fileBlob == null) + { + return NotFound(); + } + + return File(fileBlob.Content, fileBlob.Mimetype); + } + } +} diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs index 57e6954..736396b 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutMutation.cs @@ -1,9 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using System.IO; using GraphQL; using GraphQL.Types; +using GraphQL.Upload.AspNetCore; +using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using TeacherWorkout.Api.GraphQL.Resolvers; using TeacherWorkout.Api.GraphQL.Types.Inputs; using TeacherWorkout.Api.GraphQL.Types.Payloads; +using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Lessons; using TeacherWorkout.Domain.Models.Inputs; using TeacherWorkout.Domain.Models.Payloads; @@ -15,7 +21,9 @@ public class TeacherWorkoutMutation : ObjectGraphType { public TeacherWorkoutMutation(CompleteStep completeStep, CreateTheme createTheme, - UpdateTheme updateTheme) + UpdateTheme updateTheme, + SingleUpload singleUpload, + IConfiguration configuration) { Name = "Mutation"; @@ -50,6 +58,32 @@ public TeacherWorkoutMutation(CompleteStep completeStep, var input = context.GetArgument("input"); return updateTheme.Execute(input); }); + + + Field("singleUpload") + .Argument>(Name = "file") + .Resolve(context => + { + var file = context.GetArgument("file"); + + var maxFileSizeMb = configuration.GetValue("TeacherWorkout:MaxFileSizeMb", 5); + if (file.Length > maxFileSizeMb * 1024 * 1024) + { + throw new ValidationException($"File size exceeds the limit of {maxFileSizeMb}MB."); + } + + using var memoryStream = new MemoryStream(); + file.CopyTo(memoryStream); + var fileBytes = memoryStream.ToArray(); + + return singleUpload.Execute(new SingleUploadInput + { + Content = fileBytes, + Mimetype = file.ContentType, + FileName = file.FileName, + }); + }); + } } -} \ No newline at end of file +} diff --git a/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs b/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs index 3efd832..3112c16 100644 --- a/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs +++ b/TeacherWorkout.Api/GraphQL/TeacherWorkoutQuery.cs @@ -1,7 +1,9 @@ +using GraphQL; using GraphQL.Types; using TeacherWorkout.Api.GraphQL.Types; using TeacherWorkout.Api.GraphQL.Utils; using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Lessons; using TeacherWorkout.Domain.Themes; @@ -12,7 +14,8 @@ public class TeacherWorkoutQuery : ObjectGraphType public TeacherWorkoutQuery(GetThemes getThemes, GetLessons getLessons, GetLessonStatuses getLessonStatuses, - GetStep getStep) + GetStep getStep, + GetFileBlobs getFileBlobs) { Name = "Query"; @@ -35,6 +38,10 @@ public TeacherWorkoutQuery(GetThemes getThemes, Field>>("lessonStatuses") .Argument>>>(Name = "lessonIds", Description = "Id's of leassons") .Resolve(context => getLessonStatuses.Execute(context.ToInput())); + + Field>>("recentImageUploads") + .Argument>("limit", "The number of recent images to return.") + .Resolve(context => getFileBlobs.Execute(context.GetArgument("limit"))); } } } diff --git a/TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs b/TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs new file mode 100644 index 0000000..5edd139 --- /dev/null +++ b/TeacherWorkout.Api/GraphQL/Types/FileBlobType.cs @@ -0,0 +1,16 @@ +using GraphQL.Types; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Api.GraphQL.Types +{ + public class FileBlobType : ObjectGraphType + { + public FileBlobType() + { + Name = "FileBlob"; + + Field(x => x.Id, nullable: false).Description("The unique identifier of the file blob."); + Field(x => x.CreatedAt, nullable: false).Description("The creation time of the file blob."); + } + } +} diff --git a/TeacherWorkout.Api/GraphQL/Types/ImageType.cs b/TeacherWorkout.Api/GraphQL/Types/ImageType.cs index 9f53ff9..e5d4b56 100644 --- a/TeacherWorkout.Api/GraphQL/Types/ImageType.cs +++ b/TeacherWorkout.Api/GraphQL/Types/ImageType.cs @@ -9,8 +9,9 @@ public ImageType() { Name = "Image"; - Field(x => x.Url, true).Description("URL to the image."); + Field(x => x.Url, true).Description("URL to the image. If null, use FileBlob ID to generate an URL: /file/"); Field(x => x.Description, true).Description("Image description for accessibility."); + Field(x => x.FileBlobId, true).Description("Reference to local file. If null, use Url property."); } } } \ No newline at end of file diff --git a/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs b/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs index 77689dc..d8ce245 100644 --- a/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs +++ b/TeacherWorkout.Api/GraphQL/Types/Inputs/ThemeCreateInputType.cs @@ -9,8 +9,11 @@ public ThemeCreateInputType() { Name = "ThemeCreateInput"; - Field(x => x.ThumbnailId, type: typeof(IdGraphType)); Field(x => x.Title); + Field(x => x.FileBlobId, true, type: typeof(IdGraphType)) + .Description("Id of uploaded image blob (precedes and overwrites ThumbnailId)"); + Field(x => x.ThumbnailId, true, type: typeof(IdGraphType)) + .Description("Id of existing thumbnail"); } } } diff --git a/TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs b/TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs new file mode 100644 index 0000000..fcd57c9 --- /dev/null +++ b/TeacherWorkout.Api/GraphQL/Types/Payloads/SingleUploadPayloadType.cs @@ -0,0 +1,16 @@ +using System; +using GraphQL.Types; +using TeacherWorkout.Domain.Models.Payloads; + +namespace TeacherWorkout.Api.GraphQL.Types.Payloads +{ + public class SingleUploadPayloadType : ObjectGraphType + { + public SingleUploadPayloadType() + { + Name = "SingleUploadPayload"; + + Field(x => x.FileBlobId).Description("The ID of the created file blob."); + } + } +} diff --git a/TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs b/TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs new file mode 100644 index 0000000..5714226 --- /dev/null +++ b/TeacherWorkout.Api/Jobs/Config/DeleteOldFileBlobsConfig.cs @@ -0,0 +1,7 @@ +namespace TeacherWorkout.Api.Jobs.Config +{ + public record DeleteOldFileBlobsConfig : RecurringJobConfig + { + public int DaysInThePast { get; set; } + } +} \ No newline at end of file diff --git a/TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs b/TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs new file mode 100644 index 0000000..1f2a02a --- /dev/null +++ b/TeacherWorkout.Api/Jobs/Config/RecurringJobConfig.cs @@ -0,0 +1,8 @@ +namespace TeacherWorkout.Api.Jobs.Config +{ + public record RecurringJobConfig + { + public bool IsEnabled { get; set; } + public string CronExpression { get; set; } + } +} diff --git a/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs b/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs new file mode 100644 index 0000000..b6d1ac8 --- /dev/null +++ b/TeacherWorkout.Api/Jobs/DeleteOldFileBlobsJob.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; +using TeacherWorkout.Api.Jobs.Interfaces; +using TeacherWorkout.Domain.FileBlobs; +using TeacherWorkout.Api.Jobs.Config; + +namespace TeacherWorkout.Api.Jobs +{ + + public class DeleteOldFileBlobsJob(IFileBlobRepository repository, IOptions config) : IDeleteOldFileBlobsJob + { + private readonly IFileBlobRepository _repository = repository; + private readonly DeleteOldFileBlobsConfig _config = config.Value; + + public void Run() + { + ; + _repository.DeleteOldEntries(_config.DaysInThePast); + } + } +} diff --git a/TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs b/TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs new file mode 100644 index 0000000..cb261c5 --- /dev/null +++ b/TeacherWorkout.Api/Jobs/Interfaces/IDeleteOldFileBlobsJob.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace TeacherWorkout.Api.Jobs.Interfaces +{ + public interface IDeleteOldFileBlobsJob + { + void Run(); + } +} \ No newline at end of file diff --git a/TeacherWorkout.Api/Startup.cs b/TeacherWorkout.Api/Startup.cs index f750d6d..498de55 100644 --- a/TeacherWorkout.Api/Startup.cs +++ b/TeacherWorkout.Api/Startup.cs @@ -2,6 +2,8 @@ using System.Linq; using GraphQL; using GraphQL.Types; +using Hangfire; +using Hangfire.PostgreSql; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; @@ -9,6 +11,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using TeacherWorkout.Api.GraphQL; +using TeacherWorkout.Api.Jobs; +using TeacherWorkout.Api.Jobs.Config; +using TeacherWorkout.Api.Jobs.Interfaces; using TeacherWorkout.Data; using TeacherWorkout.Domain.Common; @@ -41,18 +46,36 @@ public void ConfigureServices(IServiceCollection services) AddOperations(services); AddRepositories(services, "TeacherWorkout.Data"); + services.AddControllers(); services.AddHttpContextAccessor(); - services.AddGraphQL(b => b - .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true) - .AddGraphTypes() - .AddSystemTextJson()); + services + .AddGraphQLUpload() + .AddGraphQL(b => b + .AddErrorInfoProvider(opt => opt.ExposeExceptionDetails = true) + .AddGraphTypes() + .AddSystemTextJson()); services.AddDbContext(options => options.UseNpgsql(Configuration.GetConnectionString("TeacherWorkoutContext"))); + + services.AddHangfire(configuration => + { + configuration.SetDataCompatibilityLevel(CompatibilityLevel.Version_170); + configuration.UseSimpleAssemblyNameTypeSerializer(); + configuration.UseRecommendedSerializerSettings(); + + // Initialize JobStorage + configuration.UsePostgreSqlStorage(c => + c.UseNpgsqlConnection(Configuration.GetConnectionString("TeacherWorkoutContext"))); + }); + services.AddHangfireServer(config => config.WorkerCount = 1); + + services.Configure(Configuration.GetSection("TeacherWorkout:RecurringJobs:DeleteOldFileBlobs")); + services.AddScoped(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherWorkoutContext db) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherWorkoutContext db, IServiceProvider serviceProvider) { app.UseCors(); @@ -66,9 +89,23 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, TeacherW } app.UseRouting(); + app.UseEndpoints(endpoints => endpoints.MapControllers()); - app.UseGraphQL(); + app.UseGraphQLUpload() + .UseGraphQL(); app.UseGraphQLGraphiQL(); + + var deleteOldFileBlobsConfig = Configuration.GetSection("TeacherWorkout:RecurringJobs:DeleteOldFileBlobs") + .Get(); + if (deleteOldFileBlobsConfig.IsEnabled) + { + RecurringJob.AddOrUpdate( + nameof(DeleteOldFileBlobsJob), + job => job.Run(), + deleteOldFileBlobsConfig.CronExpression, + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc } + ); + } } private static void AddOperations(IServiceCollection services) diff --git a/TeacherWorkout.Api/TeacherWorkout.Api.csproj b/TeacherWorkout.Api/TeacherWorkout.Api.csproj index 962310a..2a5013b 100644 --- a/TeacherWorkout.Api/TeacherWorkout.Api.csproj +++ b/TeacherWorkout.Api/TeacherWorkout.Api.csproj @@ -7,6 +7,9 @@ + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/TeacherWorkout.Api/appsettings.json b/TeacherWorkout.Api/appsettings.json index 397ab92..f6dc121 100644 --- a/TeacherWorkout.Api/appsettings.json +++ b/TeacherWorkout.Api/appsettings.json @@ -10,5 +10,15 @@ "ConnectionStrings": { //connection string for release (docker-compose) enviroment "TeacherWorkoutContext": "Server=postgres;Port=5432;Database=teacher_workout;User Id=docker;Password=docker;" + }, + "TeacherWorkout": { + "MaxFileSizeMb": 5, + "RecurringJobs": { + "DeleteOldFileBlobs": { + "IsEnabled": "true", + "CronExpression": "0 0 * * *", + "DaysInThePast": "1" + } + } } } diff --git a/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs new file mode 100644 index 0000000..9763331 --- /dev/null +++ b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.Designer.cs @@ -0,0 +1,160 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeacherWorkout.Data; + +#nullable disable + +namespace TeacherWorkout.Data.Migrations +{ + [DbContext(typeof(TeacherWorkoutContext))] + [Migration("20231130130831_AddFileBlobsTable")] + partial class AddFileBlobsTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.FileBlob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Mimetype") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FileBlobs"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FileBlobId") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FileBlobId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Duration") + .HasColumnType("integer"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("ThemeId") + .HasColumnType("text"); + + b.Property("ThumbnailId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ThumbnailId"); + + b.HasIndex("ThemeId", "State"); + + b.ToTable("Lessons"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Theme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ThumbnailId") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ThumbnailId"); + + b.ToTable("Themes"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => + { + b.HasOne("TeacherWorkout.Domain.Models.FileBlob", "FileBlob") + .WithMany() + .HasForeignKey("FileBlobId"); + + b.Navigation("FileBlob"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => + { + b.HasOne("TeacherWorkout.Domain.Models.Theme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.HasOne("TeacherWorkout.Domain.Models.Image", "Thumbnail") + .WithMany() + .HasForeignKey("ThumbnailId"); + + b.Navigation("Theme"); + + b.Navigation("Thumbnail"); + }); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.Theme", b => + { + b.HasOne("TeacherWorkout.Domain.Models.Image", "Thumbnail") + .WithMany() + .HasForeignKey("ThumbnailId"); + + b.Navigation("Thumbnail"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs new file mode 100644 index 0000000..ed5242e --- /dev/null +++ b/TeacherWorkout.Data/Migrations/20231130130831_AddFileBlobsTable.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeacherWorkout.Data.Migrations +{ + public partial class AddFileBlobsTable : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FileBlobId", + table: "Images", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "FileBlobs", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Content = table.Column(type: "bytea", nullable: false), + Mimetype = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + }, + constraints: table => + { + table.PrimaryKey("PK_FileBlobs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Images_FileBlobId", + table: "Images", + column: "FileBlobId"); + + migrationBuilder.AddForeignKey( + name: "FK_Images_FileBlobs_FileBlobId", + table: "Images", + column: "FileBlobId", + principalTable: "FileBlobs", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Images_FileBlobs_FileBlobId", + table: "Images"); + + migrationBuilder.DropIndex( + name: "IX_Images_FileBlobId", + table: "Images"); + + migrationBuilder.DropTable( + name: "FileBlobs"); + + migrationBuilder.DropColumn( + name: "FileBlobId", + table: "Images"); + } + } +} diff --git a/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs b/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs index fdce1f2..8320b8a 100644 --- a/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs +++ b/TeacherWorkout.Data/Migrations/TeacherWorkoutContextModelSnapshot.cs @@ -1,10 +1,13 @@ // +using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; using TeacherWorkout.Data; +#nullable disable + namespace TeacherWorkout.Data.Migrations { [DbContext(typeof(TeacherWorkoutContext))] @@ -14,9 +17,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("Relational:MaxIdentifierLength", 63) - .HasAnnotation("ProductVersion", "5.0.10") - .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn); + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeacherWorkout.Domain.Models.FileBlob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("Content") + .HasColumnType("bytea"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Mimetype") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FileBlobs"); + }); modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => { @@ -27,11 +54,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Description") .HasColumnType("text"); + b.Property("FileBlobId") + .HasColumnType("text"); + b.Property("Url") .HasColumnType("text"); b.HasKey("Id"); + b.HasIndex("FileBlobId"); + b.ToTable("Images"); }); @@ -87,6 +119,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Themes"); }); + modelBuilder.Entity("TeacherWorkout.Domain.Models.Image", b => + { + b.HasOne("TeacherWorkout.Domain.Models.FileBlob", "FileBlob") + .WithMany() + .HasForeignKey("FileBlobId"); + + b.Navigation("FileBlob"); + }); + modelBuilder.Entity("TeacherWorkout.Domain.Models.Lesson", b => { b.HasOne("TeacherWorkout.Domain.Models.Theme", "Theme") diff --git a/TeacherWorkout.Data/Repositories/FileBlobRepository.cs b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs new file mode 100644 index 0000000..6b84479 --- /dev/null +++ b/TeacherWorkout.Data/Repositories/FileBlobRepository.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using TeacherWorkout.Domain.FileBlobs; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Data.Repositories +{ + public class FileBlobRepository(TeacherWorkoutContext context, ILogger customLogger) : IFileBlobRepository + { + private readonly TeacherWorkoutContext _context = context; + private readonly ILogger _logger = customLogger; + + + public void Add(FileBlob fileBlob) + { + _context.FileBlobs.Add(fileBlob); + _context.SaveChanges(); + } + + public FileBlob Find(string id) + { + return _context.FileBlobs.FirstOrDefault(i => i.Id == id); + } + + public List FindRecent(string[] mimetypes, int? limit) + { + return _context.FileBlobs + .Where(i => mimetypes.Contains(i.Mimetype)) + .OrderByDescending(i => i.CreatedAt) + .Take(limit ?? 5) + .ToList(); + } + + public void DeleteOldEntries(int daysInThePast) + { + var cutoffDate = DateTime.Now.AddDays(-daysInThePast).ToUniversalTime(); + var oldEntries = _context.FileBlobs + .Where(fb => fb.CreatedAt < cutoffDate && !_context.Images.Any(i => i.FileBlobId == fb.Id)); + _logger.LogInformation("Deleting {EntryCount} old file blobs", oldEntries.Count()); + _context.FileBlobs.RemoveRange(oldEntries); + _context.SaveChanges(); + } + } +} diff --git a/TeacherWorkout.Data/TeacherWorkoutContext.cs b/TeacherWorkout.Data/TeacherWorkoutContext.cs index 72a0195..65a12eb 100644 --- a/TeacherWorkout.Data/TeacherWorkoutContext.cs +++ b/TeacherWorkout.Data/TeacherWorkoutContext.cs @@ -10,6 +10,7 @@ public class TeacherWorkoutContext : DbContext public DbSet Lessons { get; set; } public DbSet Themes { get; set; } public DbSet Images { get; set; } + public DbSet FileBlobs { get; set; } public TeacherWorkoutContext(DbContextOptions options) : base(options) { @@ -45,6 +46,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .Property(l => l.Id) .ValueGeneratedOnAdd() .HasValueGenerator(); + + modelBuilder.Entity() + .Property(l => l.Id) + .ValueGeneratedOnAdd() + .HasValueGenerator(); } } } diff --git a/TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs b/TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs new file mode 100644 index 0000000..7e73205 --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/GetFileBlobs.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Domain.FileBlobs +{ + public class GetFileBlobs(IFileBlobRepository repository) : IOperation> + { + private readonly IFileBlobRepository _repository = repository; + + public List Execute(int limit) + { + return _repository.FindRecent(ImageUtils.ContentTypes, limit); + } + } +} diff --git a/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs new file mode 100644 index 0000000..5a92459 --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/IFileBlobRepository.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using TeacherWorkout.Domain.Models; + +namespace TeacherWorkout.Domain.FileBlobs +{ + public interface IFileBlobRepository + { + void Add(FileBlob fileBlob); + FileBlob Find(string id); + List FindRecent(string[] mimetypes, int? limit); + void DeleteOldEntries(int daysInThePast); + } +} \ No newline at end of file diff --git a/TeacherWorkout.Domain/FileBlobs/ImageUtils.cs b/TeacherWorkout.Domain/FileBlobs/ImageUtils.cs new file mode 100644 index 0000000..734d2cf --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/ImageUtils.cs @@ -0,0 +1,8 @@ +namespace TeacherWorkout.Domain.FileBlobs +{ + public static class ImageUtils + { + public static readonly string[] Extensions = [".jpg", ".jpeg", ".png", ".gif"]; + public static readonly string[] ContentTypes = ["image/jpeg", "image/png", "image/gif"]; + } +} diff --git a/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs new file mode 100644 index 0000000..46bad5a --- /dev/null +++ b/TeacherWorkout.Domain/FileBlobs/SingleUpload.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.Models; +using TeacherWorkout.Domain.Models.Inputs; +using TeacherWorkout.Domain.Models.Payloads; + +namespace TeacherWorkout.Domain.FileBlobs +{ + public class SingleUpload(IFileBlobRepository fileBlobRepository) : IOperation + { + private readonly IFileBlobRepository _fileBlobRepository = fileBlobRepository; + + public SingleUploadPayload Execute(SingleUploadInput input) + { + FileBlob fileBlob = new() + { + Content = input.Content, + Mimetype = input.Mimetype.ToLower(), + Description = input.FileName, + CreatedAt = DateTime.UtcNow + }; + + // Validate extension + string extension = System.IO.Path.GetExtension(input.FileName); + if (!ImageUtils.Extensions.Contains(extension.ToLower())) + { + throw new ValidationException("Invalid image extension"); + } + + // Validate content type + if (!ImageUtils.ContentTypes.Contains(fileBlob.Mimetype)) + { + throw new ValidationException("Invalid image content type"); + } + + _fileBlobRepository.Add(fileBlob); + + return new SingleUploadPayload + { + FileBlobId = fileBlob.Id + }; + } + } +} diff --git a/TeacherWorkout.Domain/Models/FileBlob.cs b/TeacherWorkout.Domain/Models/FileBlob.cs new file mode 100644 index 0000000..5300fa2 --- /dev/null +++ b/TeacherWorkout.Domain/Models/FileBlob.cs @@ -0,0 +1,15 @@ + +using System; +using TeacherWorkout.Domain.Common; + +namespace TeacherWorkout.Domain.Models +{ + public class FileBlob : IIdentifiable + { + public string Id { get; set; } + public byte[] Content { get; set; } + public string Mimetype { get; set; } + public string Description { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Models/Image.cs b/TeacherWorkout.Domain/Models/Image.cs index c8110e3..db459e4 100644 --- a/TeacherWorkout.Domain/Models/Image.cs +++ b/TeacherWorkout.Domain/Models/Image.cs @@ -1,3 +1,4 @@ +using System.IO; using TeacherWorkout.Domain.Common; namespace TeacherWorkout.Domain.Models @@ -9,5 +10,8 @@ public class Image : IIdentifiable public string Description { get; set; } public string Url { get; set; } + + public string FileBlobId { get; set; } + public FileBlob FileBlob { get; set; } } } \ No newline at end of file diff --git a/TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs b/TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs new file mode 100644 index 0000000..5a024ef --- /dev/null +++ b/TeacherWorkout.Domain/Models/Inputs/SingleUploadInput.cs @@ -0,0 +1,10 @@ +namespace TeacherWorkout.Domain.Models.Inputs +{ + public class SingleUploadInput + { + public string FileName { get; set; } + public string Mimetype { get; set; } + public string Encoding { get; set; } + public byte[] Content { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs b/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs index 3912466..cd8b1ab 100644 --- a/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs +++ b/TeacherWorkout.Domain/Models/Inputs/ThemeCreateInput.cs @@ -5,5 +5,6 @@ public class ThemeCreateInput public string Title { get; set; } public string ThumbnailId { get; set; } + public string FileBlobId { get; set; } } } diff --git a/TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs b/TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs new file mode 100644 index 0000000..e03417c --- /dev/null +++ b/TeacherWorkout.Domain/Models/Payloads/SingleUploadPayload.cs @@ -0,0 +1,7 @@ +namespace TeacherWorkout.Domain.Models.Payloads +{ + public class SingleUploadPayload + { + public string FileBlobId { get; set; } + } +} diff --git a/TeacherWorkout.Domain/Themes/CreateTheme.cs b/TeacherWorkout.Domain/Themes/CreateTheme.cs index 80a6159..0b073c9 100644 --- a/TeacherWorkout.Domain/Themes/CreateTheme.cs +++ b/TeacherWorkout.Domain/Themes/CreateTheme.cs @@ -1,4 +1,6 @@ +using System.ComponentModel.DataAnnotations; using TeacherWorkout.Domain.Common; +using TeacherWorkout.Domain.FileBlobs; using TeacherWorkout.Domain.Images; using TeacherWorkout.Domain.Models; using TeacherWorkout.Domain.Models.Inputs; @@ -6,32 +8,42 @@ namespace TeacherWorkout.Domain.Themes { - public class CreateTheme : IOperation + public class CreateTheme(IThemeRepository themeRepository, + IImageRepository imageRepository, + IFileBlobRepository fileBlobRepository) : IOperation { - private readonly IThemeRepository _themeRepository; - private readonly IImageRepository _imageRepository; + private readonly IThemeRepository _themeRepository = themeRepository; + private readonly IImageRepository _imageRepository = imageRepository; + private readonly IFileBlobRepository _fileBlobRepository = fileBlobRepository; - public CreateTheme(IThemeRepository themeRepository, - IImageRepository imageRepository) - { - _themeRepository = themeRepository; - _imageRepository = imageRepository; - } - public ThemeCreatePayload Execute(ThemeCreateInput input) { var theme = new Theme { - Title = input.Title, - Thumbnail = _imageRepository.Find(input.ThumbnailId) + Title = input.Title }; + if (!string.IsNullOrEmpty(input.FileBlobId)) + { + var fileBlob = _fileBlobRepository.Find(input.FileBlobId) ?? throw new ValidationException("FileBlob ID not found"); + var thumbnail = new Image + { + Description = fileBlob.Description, + FileBlob = fileBlob + }; + theme.Thumbnail = thumbnail; + } + else + { + theme.Thumbnail = _imageRepository.Find(input.ThumbnailId) ?? throw new ValidationException("Thumbnail ID not found"); + } + _themeRepository.Insert(theme); - + return new ThemeCreatePayload { Theme = theme }; } } -} \ No newline at end of file +} diff --git a/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs b/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs index 178484d..5a0dda1 100644 --- a/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs +++ b/TeacherWorkout.Migrator/TeacherWorkoutSeeder.cs @@ -12,8 +12,8 @@ public class TeacherWorkoutSeeder { private readonly TeacherWorkoutContext _context; - private readonly List _images = new() - { + private readonly List _images = + [ new() { Id = "1", @@ -86,13 +86,12 @@ public class TeacherWorkoutSeeder { Id = "11", Description = "Beautiful dog photo", - Url = - "https://commons.wikimedia.org/wiki/Category:Quality_images_of_dogs#/media/File:Canis_lupus_PO.jpg" + FileBlobId = "FileBlob_1", } - }; + ]; - private readonly List _themes = new() - { + private readonly List _themes = + [ new() { Id = "1", @@ -159,10 +158,10 @@ public class TeacherWorkoutSeeder Title = "Accusamus et iusto", ThumbnailId = "11" } - }; + ]; - private readonly List _lessons = new() - { + private readonly List _lessons = + [ new() { Id = "1", @@ -295,7 +294,19 @@ public class TeacherWorkoutSeeder Unit = DurationUnit.Minutes } } - }; + ]; + + private readonly List _fileBlobs = + [ + new() + { + Id = "FileBlob_1", + Content = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), + Mimetype = "image/png", + Description = "Tiny image", + CreatedAt = DateTime.Now.ToUniversalTime() + } + ]; public TeacherWorkoutSeeder(TeacherWorkoutContext context) { @@ -315,6 +326,7 @@ public async Task Seed() await _context.AddRangeAsync(_images); await _context.AddRangeAsync(_themes); await _context.AddRangeAsync(_lessons); + await _context.AddRangeAsync(_fileBlobs); await _context.SaveChangesAsync(); } diff --git a/TeacherWorkout.Specs/Features/Themes.feature b/TeacherWorkout.Specs/Features/Themes.feature index 71c6a60..7c9bd6e 100644 --- a/TeacherWorkout.Specs/Features/Themes.feature +++ b/TeacherWorkout.Specs/Features/Themes.feature @@ -1,15 +1,15 @@ Feature: Themes As a user I want to be able to list themes - + Scenario: Admin user can create a theme Given Ion is an admin - When Ion creates a theme + When Ion creates a theme with image Then the theme was created successfully Scenario: Anonymous user can list themes Given Ion is an admin And Vasile is an anonymous user - And Ion creates a theme + And Ion creates a theme with image When Vasile requests themes Then Vasile receives the theme diff --git a/TeacherWorkout.Specs/Features/Themes.feature.cs b/TeacherWorkout.Specs/Features/Themes.feature.cs index cb4828e..91f6802 100644 --- a/TeacherWorkout.Specs/Features/Themes.feature.cs +++ b/TeacherWorkout.Specs/Features/Themes.feature.cs @@ -102,7 +102,7 @@ public void AdminUserCanCreateATheme() testRunner.Given("Ion is an admin", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); #line hidden #line 7 - testRunner.When("Ion creates a theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); + testRunner.When("Ion creates a theme with image", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); #line hidden #line 8 testRunner.Then("the theme was created successfully", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); @@ -136,7 +136,7 @@ public void AnonymousUserCanListThemes() testRunner.And("Vasile is an anonymous user", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); #line hidden #line 13 - testRunner.And("Ion creates a theme", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); + testRunner.And("Ion creates a theme with image", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); #line hidden #line 14 testRunner.When("Vasile requests themes", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); diff --git a/TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql b/TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql new file mode 100644 index 0000000..df46baf --- /dev/null +++ b/TeacherWorkout.Specs/GraphQL/Mutation/SingleUpload.graphql @@ -0,0 +1,5 @@ +mutation SingleUpload($file: Upload!) { + singleUpload(file: $file) { + fileBlobId + } +} diff --git a/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs b/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs index c9453e5..6e13548 100644 --- a/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs +++ b/TeacherWorkout.Specs/Steps/ThemeStepDefinitions.cs @@ -1,25 +1,36 @@ +using System; using System.Threading.Tasks; using FluentAssertions; +using Newtonsoft.Json.Linq; +using SpecFlow.Internal.Json; +using TeacherWorkout.Domain.Models; using TeacherWorkout.Specs.Extensions; using TechTalk.SpecFlow; +using Xunit.Abstractions; namespace TeacherWorkout.Specs.Steps { [Binding] - public class ThemeStepDefinitions + public class ThemeStepDefinitions(ScenarioContext scenarioContext) { - private readonly ScenarioContext _scenarioContext; + private readonly ScenarioContext _scenarioContext = scenarioContext; - public ThemeStepDefinitions(ScenarioContext scenarioContext) + [Given(@"Ion creates a theme with image")] + [When(@"Ion creates a theme with image")] + public async Task GivenIonCreatesAThemeWithImage() { - _scenarioContext = scenarioContext; - } + FileBlob imageFile = new() + { + Id = "FileBlob_1", + Content = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), + Mimetype = "image/png", + Description = "tiny_image.png", + CreatedAt = DateTime.Now.ToUniversalTime() + }; + string uploadJson = await ((TeacherWorkoutApiClient)_scenarioContext["Ion"]).UploadImage(imageFile); + string fileBlobId = JObject.Parse(uploadJson)["data"]["singleUpload"]["fileBlobId"].ToString(); - [Given(@"Ion creates a theme")] - [When(@"Ion creates a theme")] - public async Task GivenIonCreatesATheme() - { - _scenarioContext["theme-create-response"] = await ((TeacherWorkoutApiClient) _scenarioContext["Ion"]).ThemeCreateAsync(); + _scenarioContext["theme-create-response"] = await ((TeacherWorkoutApiClient) _scenarioContext["Ion"]).ThemeCreateAsync(fileBlobId); } [When(@"Vasile requests themes")] diff --git a/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs b/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs index 3a0f3c2..1b77fe2 100644 --- a/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs +++ b/TeacherWorkout.Specs/TeacherWorkoutApiClient.cs @@ -1,7 +1,10 @@ +using System; +using System.Globalization; using System.IO; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using TeacherWorkout.Domain.Models; using TeacherWorkout.Specs.Extensions; namespace TeacherWorkout.Specs @@ -15,7 +18,8 @@ enum Queries enum Mutations { - ThemeCreate + ThemeCreate, + SingleUpload } private readonly HttpClient _client; @@ -25,18 +29,38 @@ public TeacherWorkoutApiClient(HttpClient client) _client = client; } + public async Task UploadImage(FileBlob imageFile) + { + + var operations = new StringContent(new {query = GraphQL("Mutation", "SingleUpload"), variables = new {file = (string)null}}.ToJson(), Encoding.UTF8, "application/json"); + var map = new StringContent("{\"0\": [\"variables.file\"]}", Encoding.UTF8, "application/json"); + var imageBytes = new ByteArrayContent(imageFile.Content); + imageBytes.Headers.Add("Content-Type", imageFile.Mimetype); + + var multipartContent = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)) + { + { operations, "operations" }, + { map, "map" }, + { imageBytes, "0", imageFile.Description } + }; + var response = await _client.PostAsync("http://localhost/graphql", multipartContent); + + return await response.Content.ReadAsStringAsync(); + } + public async Task ThemesAsync() { return await SendRequest(QueryFor(Queries.Themes), new {}); } - public async Task ThemeCreateAsync() + public async Task ThemeCreateAsync(string fileBlobId) { return await SendRequest(MutationFor(Mutations.ThemeCreate), new { input = new { - title = "foo" + title = "foo", + fileBlobId, } }); }