commit 81b824bb796037bb30b383153e29dbfcc62701fe Author: purifetchi <0xlunaric@gmail.com> Date: Sun Jan 12 20:17:43 2025 +0100 feat: initial. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..778e8f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea/ +mizuki.db \ No newline at end of file diff --git a/Controllers/FileController.cs b/Controllers/FileController.cs new file mode 100644 index 0000000..8a20045 --- /dev/null +++ b/Controllers/FileController.cs @@ -0,0 +1,82 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Mizuki.Dtos; +using Mizuki.Services; +using Mizuki.Validators; + +namespace Mizuki.Controllers; + +/// +/// The file api +/// +/// +[Authorize] +[Route("/api/file")] +public class FileController( + UploadService uploadService, + LoginService loginService, + FormFileValidator formFileValidator) +{ + /// + /// Creates a new file. + /// + /// The given file. + /// Either the result, or an error. + [HttpPost] + [Route("upload")] + public async Task, BadRequest>> UploadFile( + IFormFile formFile) + { + var result = await formFileValidator.ValidateAsync(formFile); + if (!result.IsValid) + { + if (result.Errors.Any(e => e.PropertyName == "Length")) + return TypedResults.BadRequest(new FileCreationErrorDto("The file is too big. (Max is 50MiB)")); + + if (result.Errors.Any(e => e.PropertyName == "Filename")) + return TypedResults.BadRequest(new FileCreationErrorDto("Invalid filename.")); + + return TypedResults.BadRequest(new FileCreationErrorDto("Unknown validation error.")); + } + + var user = await loginService.GetActiveUser(); + var upload = await uploadService.CreateUpload(user, formFile); + if (upload is null) + { + return TypedResults.BadRequest(new FileCreationErrorDto("There was a problem processing your upload, please try again later.")); + } + + return TypedResults.Ok(new FileCreationResultDto(upload.Filename)); + } + + /// + /// Lists all of the files for the logged in user. + /// + /// The list of all files this user has. + [HttpGet] + [Route("all")] + public async Task> ListAllFiles() + { + var user = await loginService.GetActiveUser(); + var uploads = await uploadService.GetAllUploadsForUser(user); + + return uploads.Select(u => new FileDto( + u.Filename, + u.OriginalFilename, + u.SizeInBytes)); + } + + /// + /// Deletes a file. + /// + /// The filename of the file. + [HttpGet] + [Route("delete")] + public async Task DeleteFile( + [FromQuery] string filename) + { + var user = await loginService.GetActiveUser(); + await uploadService.DeleteUpload(user, filename); + } +} \ No newline at end of file diff --git a/Controllers/LoginController.cs b/Controllers/LoginController.cs new file mode 100644 index 0000000..0179eec --- /dev/null +++ b/Controllers/LoginController.cs @@ -0,0 +1,81 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Mizuki.Dtos; +using Mizuki.Services; +using Mizuki.Validators; + +namespace Mizuki.Controllers; + +/// +/// The login controller for Mizuki. +/// +[ApiController] +[Route("/api/user")] +public class LoginController( + UserService userService, + LoginService loginService, + LoginDataValidator loginDataValidator) : ControllerBase +{ + /// + /// Logs into Mizuki as a given user. + /// + /// The login data dto. + /// Redirect. + [HttpPost] + [Route("login")] + public async Task Login( + LoginDataDto dto) + { + if (!await userService.CheckPasswordForUser(dto.Username, dto.Password)) + { + return Redirect("/login?error=Invalid username or password."); + } + + var user = await userService.GetUserForUsername(dto.Username); + await loginService.LoginAsUser(user); + + return Redirect("/"); + } + + /// + /// Logs out of Mizuki. + /// + /// Redirect. + [Authorize] + [Route("logout")] + public async Task Logout() + { + await loginService.Logout(); + return Redirect("/"); + } + + /// + /// Registers a new user in Mizuki. + /// + /// The login data dto. + /// Redirect. + [Route("register")] + [HttpPost] + public async Task Register( + LoginDataDto dto) + { + if (await userService.UsernameTaken(dto.Username)) + return Redirect("/register?error=This user already exists."); + + var result = await loginDataValidator.ValidateAsync(dto); + if (!result.IsValid) + { + if (result.Errors.Any(e => e.PropertyName == "Username")) + return Redirect("/register?error=Invalid username."); + + if (result.Errors.Any(e => e.PropertyName == "Password")) + return Redirect("/register?error=Invalid password."); + } + + await userService.CreateUser( + dto.Username, + dto.Password); + + return Redirect("/"); + } +} \ No newline at end of file diff --git a/Controllers/ServeController.cs b/Controllers/ServeController.cs new file mode 100644 index 0000000..10afeb5 --- /dev/null +++ b/Controllers/ServeController.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Mizuki.Services; + +namespace Mizuki.Controllers; + +/// +/// The controller serving the uploaded files. +/// +[ApiController] +[Route("/f")] +public class ServeController( + DriveService driveService, + UploadService uploadService) : ControllerBase +{ + /// + /// Tries to get the file by its filename. + /// + /// The filename. + /// The file, or an error. + [Route("/{filename}")] + public async Task> GetFile( + [FromRoute] string filename) + { + var file = await uploadService.GetByFilename(filename); + if (file is null) + return TypedResults.NotFound(); + + var stream = driveService.OpenFileByFilename(filename); + if (stream is null) + return TypedResults.NotFound(); + + return TypedResults.File( + stream, + fileDownloadName: file.OriginalFilename); + } +} \ No newline at end of file diff --git a/Database/MizukiDbContext.cs b/Database/MizukiDbContext.cs new file mode 100644 index 0000000..0aaf458 --- /dev/null +++ b/Database/MizukiDbContext.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Mizuki.Database.Models; + +namespace Mizuki.Database; + +/// +/// The DB context for Mizuki. +/// +public class MizukiDbContext : DbContext +{ + /// + /// The users. + /// + public virtual DbSet Users { get; set; } = null!; + + /// + /// The files. + /// + public virtual DbSet Uploads { get; set; } = null!; + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + } + + /// + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options.UseSqlite($"Data Source=mizuki.db"); +} \ No newline at end of file diff --git a/Database/Models/Upload.cs b/Database/Models/Upload.cs new file mode 100644 index 0000000..e28fd49 --- /dev/null +++ b/Database/Models/Upload.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace Mizuki.Database.Models; + +/// +/// An uploaded file. +/// +[Index(nameof(Filename), IsUnique = true)] +public class Upload +{ + /// + /// The ID of the uploaded file. + /// + [Key] + public required Guid Id { get; set; } + + /// + /// The filename. + /// + public required string Filename { get; set; } + + /// + /// The filename. + /// + public required string OriginalFilename { get; set; } + + /// + /// The size of the file in bytes. + /// + public required long SizeInBytes { get; set; } + + /// + /// The time of upload. + /// + public required DateTimeOffset TimeOfUpload { get; set; } + + /// + /// The author of the uploaded file. + /// + public required User Author { get; set; } + + /// + /// The ID of the author. + /// + [ForeignKey(nameof(Author))] + public required Guid AuthorId { get; set; } +} \ No newline at end of file diff --git a/Database/Models/User.cs b/Database/Models/User.cs new file mode 100644 index 0000000..a28eb0b --- /dev/null +++ b/Database/Models/User.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace Mizuki.Database.Models; + +/// +/// A user. +/// +public class User +{ + /// + /// The ID of the user. + /// + [Key] + public required Guid Id { get; set; } + + /// + /// The username. + /// + public required string Username { get; set; } + + /// + /// The hashed password. + /// + public required string PasswordHash { get; set; } +} \ No newline at end of file diff --git a/Dtos/FileCreationErrorDto.cs b/Dtos/FileCreationErrorDto.cs new file mode 100644 index 0000000..2130484 --- /dev/null +++ b/Dtos/FileCreationErrorDto.cs @@ -0,0 +1,8 @@ +namespace Mizuki.Dtos; + +/// +/// A file creation error. +/// +/// The error reason. +public class FileCreationErrorDto( + string Reason); \ No newline at end of file diff --git a/Dtos/FileCreationResultDto.cs b/Dtos/FileCreationResultDto.cs new file mode 100644 index 0000000..e504f3b --- /dev/null +++ b/Dtos/FileCreationResultDto.cs @@ -0,0 +1,8 @@ +namespace Mizuki.Dtos; + +/// +/// The file creation result. +/// +/// The filename. +public class FileCreationResultDto( + string Filename); \ No newline at end of file diff --git a/Dtos/FileDto.cs b/Dtos/FileDto.cs new file mode 100644 index 0000000..1226c3f --- /dev/null +++ b/Dtos/FileDto.cs @@ -0,0 +1,12 @@ +namespace Mizuki.Dtos; + +/// +/// A DTO describing a file. +/// +/// The filename. +/// The original filename. +/// The size in bytes. +public record FileDto( + string Filename, + string OriginalFilename, + long SizeInBytes); \ No newline at end of file diff --git a/Dtos/LoginDataDto.cs b/Dtos/LoginDataDto.cs new file mode 100644 index 0000000..5c90360 --- /dev/null +++ b/Dtos/LoginDataDto.cs @@ -0,0 +1,17 @@ +namespace Mizuki.Dtos; + +/// +/// The DTO for login data. +/// +public class LoginDataDto +{ + /// + /// The username. + /// + public required string Username { get; set; } + + /// + /// The password. + /// + public required string Password { get; set; } +} \ No newline at end of file diff --git a/Helpers/Tid.cs b/Helpers/Tid.cs new file mode 100644 index 0000000..5d6009f --- /dev/null +++ b/Helpers/Tid.cs @@ -0,0 +1,71 @@ +using System.Security.Cryptography; + +namespace Mizuki.Helpers; + +/// +/// An AT protocol time-based ID. +/// +public readonly struct Tid +{ + /// + /// The Base32 sort alphabet. + /// + private const string Base32SortAlphabet = "234567abcdefghijklmnopqrstuvwxyz"; + + /// + /// The TID value. + /// + public readonly string Value; + + /// + /// Constructs a TID from an integer representation of the TID. + /// + /// The TID value. + private Tid(ulong tid) + { + Value = NumericTidToStringTid(tid); + } + + /// + /// Constructs a new TID for the current time. + /// + /// The current time. + public static Tid NewTid() + { + var microseconds = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1000; + var randomness = (uint)RandomNumberGenerator.GetInt32(int.MaxValue); + + var value = (ulong)(((microseconds & 0x1F_FFFF_FFFF_FFFF) << 10) | (randomness & 0x3FF)); + + return new Tid(value); + } + + /// + /// An empty TID. + /// + public static Tid Empty => new Tid(0); + + /// + /// Converts a numeric TID to a string TID. + /// + /// The numeric TID. + /// The string TID. + private static string NumericTidToStringTid(ulong tid) + { + var value = 0x7FFF_FFFF_FFFF_FFFF & tid; + var output = ""; + for (var i = 0; i < 13; i++) + { + output = Base32SortAlphabet[(int)(value & 0x1F)] + output; + value >>= 5; + } + + return output; + } + + /// + public override string ToString() + { + return Value; + } +} \ No newline at end of file diff --git a/Migrations/20250112191512_Initial migration.Designer.cs b/Migrations/20250112191512_Initial migration.Designer.cs new file mode 100644 index 0000000..0eb3424 --- /dev/null +++ b/Migrations/20250112191512_Initial migration.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mizuki.Database; + +#nullable disable + +namespace Mizuki.Migrations +{ + [DbContext(typeof(MizukiDbContext))] + [Migration("20250112191512_Initial migration")] + partial class Initialmigration + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Mizuki.Database.Models.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFilename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeInBytes") + .HasColumnType("INTEGER"); + + b.Property("TimeOfUpload") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Filename") + .IsUnique(); + + b.ToTable("Uploads"); + }); + + modelBuilder.Entity("Mizuki.Database.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Mizuki.Database.Models.Upload", b => + { + b.HasOne("Mizuki.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20250112191512_Initial migration.cs b/Migrations/20250112191512_Initial migration.cs new file mode 100644 index 0000000..e717749 --- /dev/null +++ b/Migrations/20250112191512_Initial migration.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mizuki.Migrations +{ + /// + public partial class Initialmigration : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Username = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Uploads", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Filename = table.Column(type: "TEXT", nullable: false), + OriginalFilename = table.Column(type: "TEXT", nullable: false), + SizeInBytes = table.Column(type: "INTEGER", nullable: false), + TimeOfUpload = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Uploads", x => x.Id); + table.ForeignKey( + name: "FK_Uploads_Users_AuthorId", + column: x => x.AuthorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Uploads_AuthorId", + table: "Uploads", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_Uploads_Filename", + table: "Uploads", + column: "Filename", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Uploads"); + + migrationBuilder.DropTable( + name: "Users"); + } + } +} diff --git a/Migrations/MizukiDbContextModelSnapshot.cs b/Migrations/MizukiDbContextModelSnapshot.cs new file mode 100644 index 0000000..30ac0b9 --- /dev/null +++ b/Migrations/MizukiDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Mizuki.Database; + +#nullable disable + +namespace Mizuki.Migrations +{ + [DbContext(typeof(MizukiDbContext))] + partial class MizukiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.0"); + + modelBuilder.Entity("Mizuki.Database.Models.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .HasColumnType("TEXT"); + + b.Property("Filename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFilename") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SizeInBytes") + .HasColumnType("INTEGER"); + + b.Property("TimeOfUpload") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Filename") + .IsUnique(); + + b.ToTable("Uploads"); + }); + + modelBuilder.Entity("Mizuki.Database.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Mizuki.Database.Models.Upload", b => + { + b.HasOne("Mizuki.Database.Models.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Mizuki.csproj b/Mizuki.csproj new file mode 100644 index 0000000..aba276a --- /dev/null +++ b/Mizuki.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Mizuki.sln b/Mizuki.sln new file mode 100644 index 0000000..9cf2950 --- /dev/null +++ b/Mizuki.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mizuki", "Mizuki.csproj", "{84F0FCED-314C-4FAB-B279-E104D79907B6}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {84F0FCED-314C-4FAB-B279-E104D79907B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {84F0FCED-314C-4FAB-B279-E104D79907B6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {84F0FCED-314C-4FAB-B279-E104D79907B6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {84F0FCED-314C-4FAB-B279-E104D79907B6}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..1f20e2d --- /dev/null +++ b/Program.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using FluentValidation; +using Mizuki.Database; +using Mizuki.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddValidatorsFromAssembly( + Assembly.GetCallingAssembly()); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddDbContext(); +builder.Services.AddHttpContextAccessor(); + +var app = builder.Build(); + +builder.Services.AddAuthentication("MizukiAuth") + .AddCookie("MizukiAuth", options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/api/user/logout"; + options.AccessDeniedPath = "/"; + }); + + +app.MapGet("/", () => "Hello World!"); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapRazorPages(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Properties/launchSettings.json b/Properties/launchSettings.json new file mode 100644 index 0000000..a31323e --- /dev/null +++ b/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:25566", + "sslPort": 44302 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5118", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7215;http://localhost:5118", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Services/DriveService.cs b/Services/DriveService.cs new file mode 100644 index 0000000..a58a3fb --- /dev/null +++ b/Services/DriveService.cs @@ -0,0 +1,50 @@ +namespace Mizuki.Services; + +/// +/// The service responsible for managing file uploads. +/// +public class DriveService +{ + /// + /// The path to the uploads folder. + /// + private const string UploadsFolder = "./uploads"; + + /// + /// The drive service. + /// + public DriveService() + { + if (!Directory.Exists(UploadsFolder)) + Directory.CreateDirectory(UploadsFolder); + } + + /// + /// Opens a file by its filename. + /// + /// Its filename. + /// The file's stream. + public Stream? OpenFileByFilename(string filename) + { + var actualFilename = Path.GetFileName(filename); + var path = Path.Combine(UploadsFolder, actualFilename); + if (!File.Exists(path)) + return null; + + return File.OpenRead(path); + } + + /// + /// Deletes a file by its filename. + /// + /// Its filename. + public void DeleteFileByFilename(string filename) + { + var actualFilename = Path.GetFileName(filename); + var path = Path.Combine(UploadsFolder, actualFilename); + if (!File.Exists(path)) + return; + + File.Delete(path); + } +} \ No newline at end of file diff --git a/Services/LoginService.cs b/Services/LoginService.cs new file mode 100644 index 0000000..05f0a95 --- /dev/null +++ b/Services/LoginService.cs @@ -0,0 +1,57 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Mizuki.Database.Models; + +namespace Mizuki.Services; + +/// +/// The login service for Mizuki. +/// +public class LoginService( + IHttpContextAccessor httpContextAccessor, + UserService userService) +{ + /// + /// Logs in as a given user. + /// + /// The user. + public async Task LoginAsUser( + User user) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(ClaimTypes.Name, user.Username) + }; + + var claimsIdentity = new ClaimsIdentity(claims, "MizukiAuth"); + await httpContextAccessor.HttpContext! + .SignInAsync( + "MizukiAuth", + new ClaimsPrincipal(claimsIdentity), + new AuthenticationProperties()); + } + + /// + /// Logs out. + /// + public async Task Logout() + { + await httpContextAccessor.HttpContext! + .SignOutAsync("MizukiAuth"); + } + + /// + /// Gets the active user. + /// + /// The active user. + public async Task GetActiveUser() + { + var username = httpContextAccessor.HttpContext! + .User + .FindFirst(ClaimTypes.Name)! + .ToString(); + + return await userService.GetUserForUsername(username); + } +} \ No newline at end of file diff --git a/Services/UploadService.cs b/Services/UploadService.cs new file mode 100644 index 0000000..fe71f01 --- /dev/null +++ b/Services/UploadService.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Mizuki.Database; +using Mizuki.Database.Models; +using Mizuki.Helpers; + +namespace Mizuki.Services; + +/// +/// The service managing uploads. +/// +public class UploadService( + MizukiDbContext dbContext, + DriveService driveService) +{ + /// + /// Creates a new upload record for a given user. + /// + /// The user. + /// The form file. + /// The created upload. + public async Task CreateUpload( + User user, + IFormFile file) + { + var upload = new Upload + { + Id = Guid.NewGuid(), + + Filename = Tid.NewTid().ToString(), + OriginalFilename = file.FileName, + SizeInBytes = file.Length, + + TimeOfUpload = DateTimeOffset.UtcNow, + + Author = user, + AuthorId = user.Id + }; + + await dbContext.Uploads.AddAsync(upload); + await dbContext.SaveChangesAsync(); + return upload; + } + + /// + /// Deletes an upload. + /// + /// The user. + /// The name of the file that the user wishes to delete. + public async Task DeleteUpload( + User user, + string filename) + { + var upload = await dbContext.Uploads + .Where(u => u.AuthorId == user.Id && u.Filename == filename) + .FirstOrDefaultAsync(); + + if (upload is null) + return; + + dbContext.Uploads.Remove(upload); + await dbContext.SaveChangesAsync(); + + driveService.DeleteFileByFilename(filename); + } + + /// + /// Gets all the uploads for a user. + /// + /// The user. + /// Their uploads. + public async Task> GetAllUploadsForUser( + User user) + { + return await dbContext.Uploads + .OrderByDescending(u => u.TimeOfUpload) + .Where(u => u.AuthorId == user.Id) + .ToListAsync(); + } + + /// + /// Gets an upload by its filename. + /// + /// The filename. + /// The upload, if it exists. + public async Task GetByFilename(string filename) + { + return await dbContext.Uploads + .FirstOrDefaultAsync(u => u.Filename == filename); + } +} \ No newline at end of file diff --git a/Services/UserService.cs b/Services/UserService.cs new file mode 100644 index 0000000..2d18770 --- /dev/null +++ b/Services/UserService.cs @@ -0,0 +1,83 @@ +using Isopoh.Cryptography.Argon2; +using Microsoft.EntityFrameworkCore; +using Mizuki.Database; +using Mizuki.Database.Models; + +namespace Mizuki.Services; + +/// +/// The user service. +/// +/// The Mizuki database context. +public class UserService( + MizukiDbContext dbContext) +{ + /// + /// Check if a user with the given username exists. + /// + /// The username. + /// Whether they exist. + public async Task UsernameTaken( + string username) + { + return await dbContext.Users + .AnyAsync(u => u.Username == username); + } + + /// + /// Gets a user with the given username. + /// + /// The username. + /// The user model. + public async Task GetUserForUsername( + string username) + { + return await dbContext.Users + .FirstAsync(u => u.Username == username); + } + + /// + /// Check if the password is valid for the given user. + /// + /// The user's name. + /// The password. + /// Whether it's valid. + public async Task CheckPasswordForUser( + string username, + string password) + { + var user = await dbContext.Users + .FirstOrDefaultAsync(u => u.Username == username); + + if (user is null) + return false; + + return Argon2.Verify(user.PasswordHash, password); + } + + /// + /// Creates a user with the given username and password. + /// + /// The username. + /// The password. + /// Whether the user was created. + public async Task CreateUser( + string username, + string password) + { + if (await UsernameTaken(username)) + return false; + + var hash = Argon2.Hash(password); + var userModel = new User + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = hash + }; + + await dbContext.Users.AddAsync(userModel); + await dbContext.SaveChangesAsync(); + return true; + } +} \ No newline at end of file diff --git a/Validators/FormFileValidator.cs b/Validators/FormFileValidator.cs new file mode 100644 index 0000000..553fe62 --- /dev/null +++ b/Validators/FormFileValidator.cs @@ -0,0 +1,26 @@ +using FluentValidation; + +namespace Mizuki.Validators; + +/// +/// The validator for uploaded form files. +/// +public class FormFileValidator : AbstractValidator +{ + /// + /// Constructs the rule set for the FormFileValidator. + /// + public FormFileValidator() + { + const int MiB = 1024 * 1024; + + RuleFor(f => f.Length) + .LessThan(50 * MiB); + + RuleFor(f => f.FileName) + .NotEmpty(); + + RuleFor(f => f.FileName) + .Matches(@"^(?!\s)(?!.*\s$)[^<>:""/\\|?*\x00-\x1F]+\.(?!\.)[^<>:""/\\|?*\x00-\x1F]{1,4}$|^(?!\s)(?!.*\s$)[^<>:""/\\|?*\x00-\x1F]+$"); + } +} \ No newline at end of file diff --git a/Validators/LoginDataValidator.cs b/Validators/LoginDataValidator.cs new file mode 100644 index 0000000..6400f86 --- /dev/null +++ b/Validators/LoginDataValidator.cs @@ -0,0 +1,31 @@ +using FluentValidation; +using Mizuki.Dtos; + +namespace Mizuki.Validators; + +/// +/// The validator for the login data. +/// +public class LoginDataValidator : AbstractValidator +{ + /// + /// Constructs the ruleset for the LoginDataValidator. + /// + public LoginDataValidator() + { + RuleFor(x => x.Username) + .NotEmpty(); + + RuleFor(x => x.Username) + .Matches("^[a-zA-Z0-9_.]*$"); + + RuleFor(x => x.Password) + .NotEmpty(); + + RuleFor(x => x.Password) + .MinimumLength(8); + + RuleFor(x => x.Password) + .Matches(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@#$%^&*!])[A-Za-z\d@#$%^&*!]{8,}$"); + } +} \ No newline at end of file diff --git a/appsettings.Development.json b/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/appsettings.json b/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}