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": "*"
+}