feat: initial.

This commit is contained in:
purifetchi 2025-01-12 20:17:43 +01:00
commit 81b824bb79
27 changed files with 1139 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
.idea/
mizuki.db

View file

@ -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;
/// <summary>
/// The file api
/// </summary>
/// <param name="uploadService"></param>
[Authorize]
[Route("/api/file")]
public class FileController(
UploadService uploadService,
LoginService loginService,
FormFileValidator formFileValidator)
{
/// <summary>
/// Creates a new file.
/// </summary>
/// <param name="formFile">The given file.</param>
/// <returns>Either the result, or an error.</returns>
[HttpPost]
[Route("upload")]
public async Task<Results<Ok<FileCreationResultDto>, BadRequest<FileCreationErrorDto>>> 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));
}
/// <summary>
/// Lists all of the files for the logged in user.
/// </summary>
/// <returns>The list of all files this user has.</returns>
[HttpGet]
[Route("all")]
public async Task<IEnumerable<FileDto>> ListAllFiles()
{
var user = await loginService.GetActiveUser();
var uploads = await uploadService.GetAllUploadsForUser(user);
return uploads.Select(u => new FileDto(
u.Filename,
u.OriginalFilename,
u.SizeInBytes));
}
/// <summary>
/// Deletes a file.
/// </summary>
/// <param name="filename">The filename of the file.</param>
[HttpGet]
[Route("delete")]
public async Task DeleteFile(
[FromQuery] string filename)
{
var user = await loginService.GetActiveUser();
await uploadService.DeleteUpload(user, filename);
}
}

View file

@ -0,0 +1,81 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Mizuki.Dtos;
using Mizuki.Services;
using Mizuki.Validators;
namespace Mizuki.Controllers;
/// <summary>
/// The login controller for Mizuki.
/// </summary>
[ApiController]
[Route("/api/user")]
public class LoginController(
UserService userService,
LoginService loginService,
LoginDataValidator loginDataValidator) : ControllerBase
{
/// <summary>
/// Logs into Mizuki as a given user.
/// </summary>
/// <param name="dto">The login data dto.</param>
/// <returns>Redirect.</returns>
[HttpPost]
[Route("login")]
public async Task<RedirectResult> 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("/");
}
/// <summary>
/// Logs out of Mizuki.
/// </summary>
/// <returns>Redirect.</returns>
[Authorize]
[Route("logout")]
public async Task<RedirectResult> Logout()
{
await loginService.Logout();
return Redirect("/");
}
/// <summary>
/// Registers a new user in Mizuki.
/// </summary>
/// <param name="dto">The login data dto.</param>
/// <returns>Redirect.</returns>
[Route("register")]
[HttpPost]
public async Task<RedirectResult> 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("/");
}
}

View file

@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Mizuki.Services;
namespace Mizuki.Controllers;
/// <summary>
/// The controller serving the uploaded files.
/// </summary>
[ApiController]
[Route("/f")]
public class ServeController(
DriveService driveService,
UploadService uploadService) : ControllerBase
{
/// <summary>
/// Tries to get the file by its filename.
/// </summary>
/// <param name="filename">The filename.</param>
/// <returns>The file, or an error.</returns>
[Route("/{filename}")]
public async Task<Results<FileStreamHttpResult, NotFound>> 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);
}
}

View file

@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore;
using Mizuki.Database.Models;
namespace Mizuki.Database;
/// <summary>
/// The DB context for Mizuki.
/// </summary>
public class MizukiDbContext : DbContext
{
/// <summary>
/// The users.
/// </summary>
public virtual DbSet<User> Users { get; set; } = null!;
/// <summary>
/// The files.
/// </summary>
public virtual DbSet<Upload> Uploads { get; set; } = null!;
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>();
modelBuilder.Entity<Upload>();
}
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlite($"Data Source=mizuki.db");
}

49
Database/Models/Upload.cs Normal file
View file

@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace Mizuki.Database.Models;
/// <summary>
/// An uploaded file.
/// </summary>
[Index(nameof(Filename), IsUnique = true)]
public class Upload
{
/// <summary>
/// The ID of the uploaded file.
/// </summary>
[Key]
public required Guid Id { get; set; }
/// <summary>
/// The filename.
/// </summary>
public required string Filename { get; set; }
/// <summary>
/// The filename.
/// </summary>
public required string OriginalFilename { get; set; }
/// <summary>
/// The size of the file in bytes.
/// </summary>
public required long SizeInBytes { get; set; }
/// <summary>
/// The time of upload.
/// </summary>
public required DateTimeOffset TimeOfUpload { get; set; }
/// <summary>
/// The author of the uploaded file.
/// </summary>
public required User Author { get; set; }
/// <summary>
/// The ID of the author.
/// </summary>
[ForeignKey(nameof(Author))]
public required Guid AuthorId { get; set; }
}

25
Database/Models/User.cs Normal file
View file

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace Mizuki.Database.Models;
/// <summary>
/// A user.
/// </summary>
public class User
{
/// <summary>
/// The ID of the user.
/// </summary>
[Key]
public required Guid Id { get; set; }
/// <summary>
/// The username.
/// </summary>
public required string Username { get; set; }
/// <summary>
/// The hashed password.
/// </summary>
public required string PasswordHash { get; set; }
}

View file

@ -0,0 +1,8 @@
namespace Mizuki.Dtos;
/// <summary>
/// A file creation error.
/// </summary>
/// <param name="Reason">The error reason.</param>
public class FileCreationErrorDto(
string Reason);

View file

@ -0,0 +1,8 @@
namespace Mizuki.Dtos;
/// <summary>
/// The file creation result.
/// </summary>
/// <param name="Filename">The filename.</param>
public class FileCreationResultDto(
string Filename);

12
Dtos/FileDto.cs Normal file
View file

@ -0,0 +1,12 @@
namespace Mizuki.Dtos;
/// <summary>
/// A DTO describing a file.
/// </summary>
/// <param name="Filename">The filename.</param>
/// <param name="OriginalFilename">The original filename.</param>
/// <param name="SizeInBytes">The size in bytes.</param>
public record FileDto(
string Filename,
string OriginalFilename,
long SizeInBytes);

17
Dtos/LoginDataDto.cs Normal file
View file

@ -0,0 +1,17 @@
namespace Mizuki.Dtos;
/// <summary>
/// The DTO for login data.
/// </summary>
public class LoginDataDto
{
/// <summary>
/// The username.
/// </summary>
public required string Username { get; set; }
/// <summary>
/// The password.
/// </summary>
public required string Password { get; set; }
}

71
Helpers/Tid.cs Normal file
View file

@ -0,0 +1,71 @@
using System.Security.Cryptography;
namespace Mizuki.Helpers;
/// <summary>
/// An AT protocol time-based ID.
/// </summary>
public readonly struct Tid
{
/// <summary>
/// The Base32 sort alphabet.
/// </summary>
private const string Base32SortAlphabet = "234567abcdefghijklmnopqrstuvwxyz";
/// <summary>
/// The TID value.
/// </summary>
public readonly string Value;
/// <summary>
/// Constructs a TID from an integer representation of the TID.
/// </summary>
/// <param name="tid">The TID value.</param>
private Tid(ulong tid)
{
Value = NumericTidToStringTid(tid);
}
/// <summary>
/// Constructs a new TID for the current time.
/// </summary>
/// <returns>The current time.</returns>
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);
}
/// <summary>
/// An empty TID.
/// </summary>
public static Tid Empty => new Tid(0);
/// <summary>
/// Converts a numeric TID to a string TID.
/// </summary>
/// <param name="tid">The numeric TID.</param>
/// <returns>The string TID.</returns>
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;
}
/// <inheritdoc />
public override string ToString()
{
return Value;
}
}

View file

@ -0,0 +1,88 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AuthorId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeInBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}

View file

@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Mizuki.Migrations
{
/// <inheritdoc />
public partial class Initialmigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Username = table.Column<string>(type: "TEXT", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Uploads",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Filename = table.Column<string>(type: "TEXT", nullable: false),
OriginalFilename = table.Column<string>(type: "TEXT", nullable: false),
SizeInBytes = table.Column<long>(type: "INTEGER", nullable: false),
TimeOfUpload = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
AuthorId = table.Column<Guid>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Uploads");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View file

@ -0,0 +1,85 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AuthorId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("SizeInBytes")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}

22
Mizuki.csproj Normal file
View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
</ItemGroup>
</Project>

16
Mizuki.sln Normal file
View file

@ -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

37
Program.cs Normal file
View file

@ -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<DriveService>();
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<UploadService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddDbContext<MizukiDbContext>();
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();

View file

@ -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"
}
}
}
}

50
Services/DriveService.cs Normal file
View file

@ -0,0 +1,50 @@
namespace Mizuki.Services;
/// <summary>
/// The service responsible for managing file uploads.
/// </summary>
public class DriveService
{
/// <summary>
/// The path to the uploads folder.
/// </summary>
private const string UploadsFolder = "./uploads";
/// <summary>
/// The drive service.
/// </summary>
public DriveService()
{
if (!Directory.Exists(UploadsFolder))
Directory.CreateDirectory(UploadsFolder);
}
/// <summary>
/// Opens a file by its filename.
/// </summary>
/// <param name="filename">Its filename.</param>
/// <returns>The file's stream.</returns>
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);
}
/// <summary>
/// Deletes a file by its filename.
/// </summary>
/// <param name="filename">Its filename.</param>
public void DeleteFileByFilename(string filename)
{
var actualFilename = Path.GetFileName(filename);
var path = Path.Combine(UploadsFolder, actualFilename);
if (!File.Exists(path))
return;
File.Delete(path);
}
}

57
Services/LoginService.cs Normal file
View file

@ -0,0 +1,57 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Mizuki.Database.Models;
namespace Mizuki.Services;
/// <summary>
/// The login service for Mizuki.
/// </summary>
public class LoginService(
IHttpContextAccessor httpContextAccessor,
UserService userService)
{
/// <summary>
/// Logs in as a given user.
/// </summary>
/// <param name="user">The user.</param>
public async Task LoginAsUser(
User user)
{
var claims = new List<Claim>
{
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());
}
/// <summary>
/// Logs out.
/// </summary>
public async Task Logout()
{
await httpContextAccessor.HttpContext!
.SignOutAsync("MizukiAuth");
}
/// <summary>
/// Gets the active user.
/// </summary>
/// <returns>The active user.</returns>
public async Task<User> GetActiveUser()
{
var username = httpContextAccessor.HttpContext!
.User
.FindFirst(ClaimTypes.Name)!
.ToString();
return await userService.GetUserForUsername(username);
}
}

90
Services/UploadService.cs Normal file
View file

@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using Mizuki.Database;
using Mizuki.Database.Models;
using Mizuki.Helpers;
namespace Mizuki.Services;
/// <summary>
/// The service managing uploads.
/// </summary>
public class UploadService(
MizukiDbContext dbContext,
DriveService driveService)
{
/// <summary>
/// Creates a new upload record for a given user.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="file">The form file.</param>
/// <returns>The created upload.</returns>
public async Task<Upload?> 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;
}
/// <summary>
/// Deletes an upload.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="filename">The name of the file that the user wishes to delete.</param>
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);
}
/// <summary>
/// Gets all the uploads for a user.
/// </summary>
/// <param name="user">The user.</param>
/// <returns>Their uploads.</returns>
public async Task<IEnumerable<Upload>> GetAllUploadsForUser(
User user)
{
return await dbContext.Uploads
.OrderByDescending(u => u.TimeOfUpload)
.Where(u => u.AuthorId == user.Id)
.ToListAsync();
}
/// <summary>
/// Gets an upload by its filename.
/// </summary>
/// <param name="filename">The filename.</param>
/// <returns>The upload, if it exists.</returns>
public async Task<Upload?> GetByFilename(string filename)
{
return await dbContext.Uploads
.FirstOrDefaultAsync(u => u.Filename == filename);
}
}

83
Services/UserService.cs Normal file
View file

@ -0,0 +1,83 @@
using Isopoh.Cryptography.Argon2;
using Microsoft.EntityFrameworkCore;
using Mizuki.Database;
using Mizuki.Database.Models;
namespace Mizuki.Services;
/// <summary>
/// The user service.
/// </summary>
/// <param name="dbContext">The Mizuki database context.</param>
public class UserService(
MizukiDbContext dbContext)
{
/// <summary>
/// Check if a user with the given username exists.
/// </summary>
/// <param name="username">The username.</param>
/// <returns>Whether they exist.</returns>
public async Task<bool> UsernameTaken(
string username)
{
return await dbContext.Users
.AnyAsync(u => u.Username == username);
}
/// <summary>
/// Gets a user with the given username.
/// </summary>
/// <param name="username">The username.</param>
/// <returns>The user model.</returns>
public async Task<User> GetUserForUsername(
string username)
{
return await dbContext.Users
.FirstAsync(u => u.Username == username);
}
/// <summary>
/// Check if the password is valid for the given user.
/// </summary>
/// <param name="username">The user's name.</param>
/// <param name="password">The password.</param>
/// <returns>Whether it's valid.</returns>
public async Task<bool> 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);
}
/// <summary>
/// Creates a user with the given username and password.
/// </summary>
/// <param name="username">The username.</param>
/// <param name="password">The password.</param>
/// <returns>Whether the user was created.</returns>
public async Task<bool> 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;
}
}

View file

@ -0,0 +1,26 @@
using FluentValidation;
namespace Mizuki.Validators;
/// <summary>
/// The validator for uploaded form files.
/// </summary>
public class FormFileValidator : AbstractValidator<IFormFile>
{
/// <summary>
/// Constructs the rule set for the FormFileValidator.
/// </summary>
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]+$");
}
}

View file

@ -0,0 +1,31 @@
using FluentValidation;
using Mizuki.Dtos;
namespace Mizuki.Validators;
/// <summary>
/// The validator for the login data.
/// </summary>
public class LoginDataValidator : AbstractValidator<LoginDataDto>
{
/// <summary>
/// Constructs the ruleset for the LoginDataValidator.
/// </summary>
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,}$");
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

9
appsettings.json Normal file
View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}