Merge remote-tracking branch 'origin/develop' into develop

This commit is contained in:
2026-07-06 18:27:57 +02:00
49 changed files with 878 additions and 412 deletions

320
.github/agents.md vendored Normal file
View File

@@ -0,0 +1,320 @@
# MilkyShots Agent Guidelines
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
---
## Project Overview
MilkyShots is a .NET 8 media-library application composed of four projects:
| Project | Role |
|---|---|
| **Butter** | Shared class library DTOs, enums, MIME-type tables, settings definitions |
| **Lactose** | ASP.NET Core Web API backend controllers, EF Core models, repositories, services, background jobs |
| **MilkStream** | Blazor WebAssembly host serves static WASM files and provides dynamic configuration |
| **MilkStream.Client** | Blazor WASM client Razor components, frontend services, SCSS styles (runs in browser) |
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
---
## Technology Stack
- **.NET 8 / C# 12** — target framework and language version for all projects.
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
- **JWT Bearer** authentication + refresh-token rotation.
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
- **Docker / docker-compose** for containerised deployment.
---
## Solution Structure
```
MilkyShots/
├── Butter/ # Shared library
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
│ ├── Settings/ # Settings enum + extensions
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
├── Lactose/ # Web API
│ ├── Authorization/ # Claims transformation + authorization handlers
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
│ ├── Controllers/ # API controllers
│ ├── Jobs/ # Background job infrastructure + concrete jobs
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
│ ├── Migrations/ # EF Core migrations (generated do NOT hand-edit)
│ ├── Models/ # EF Core entity classes
│ ├── Repositories/ # Interfaces + implementations for data access
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
│ └── Utils/ # Small helpers / extension methods
├── MilkStream/ # Blazor WASM static host
│ ├── Program.cs # Minimal API serves WASM files + dynamic /appsettings.json endpoint
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
├── Services/ # Frontend services (LoginService, MediaService, …)
├── Styles/ # SCSS source files
└── wwwroot/ # Static assets bundled into WASM
```
---
## Naming Conventions
### C# identifiers
| Category | Pattern | Example |
|---|---|---|
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
| Repository interfaces | `I…Repository` | `IAssetRepository` |
| Repository implementations | `…Repository` | `AssetRepository` |
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
| Controller classes | `…Controller` | `AssetController` |
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
### Files and directories
- One public type per file; file name equals the type name.
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories``Lactose/Repositories/`).
---
## C# Coding Style
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
- Prefer **primary constructors** (C# 12) for constructor injection:
```csharp
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository
) : ControllerBase { … }
```
- Use **`var`** for local variables when the type is obvious from the right-hand side.
- Use **object initialisers** when creating entity or DTO instances.
- Use the **`required`** keyword on properties that must be set during object construction.
- Default string properties to `string.Empty`, not `null`.
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
### Documentation comments
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
- **Mapper classes** must have a `<summary>` on the class and on each method.
- Controllers may omit XML comments on action methods (use inline comments instead).
### Logging
- Inject `ILogger<T>` into every controller and service.
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
- Log-level conventions:
| Level | When to use |
|---|---|
| `LogTrace` | Detailed per-request data (IDs, field values) |
| `LogDebug` | Medium-detail operational info |
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
| `LogError` | Exceptions and failures |
---
## Entity / Model Rules
- Primary keys are **`Guid`** annotated with `[Key]`.
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
---
## Repository Pattern
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
- Standard method set:
| Method | Signature |
|---|---|
| Find by PK | `T? Find(Guid id)` |
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
| Insert | `void Insert(T entity)` |
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
| Save | `void Save()` → `context.SaveChanges()` |
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
- Register all repositories as **`Transient`** in `Program.cs`.
---
## Mapper / DTO Rules
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
- Mapping methods are **extension methods** on the model type:
```csharp
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
```
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
- Never put business logic in mappers — only field assignments.
---
## Controller Rules
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
- HTTP verb → CRUD mapping:
| Verb | Operation |
|---|---|
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
| `[HttpPut]` | Create a new resource |
| `[HttpPost("{id}")]` | Update a specific resource |
| `[HttpPost]` | Bulk update |
| `[HttpDelete("{id}")]` | Delete a specific resource |
| `[HttpDelete]` | Bulk delete |
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
---
## Authentication & Authorisation
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
- Access is controlled by three roles (in ascending privilege order):
| Role | Description |
|---|---|
| `User` | Read-only access to publicly shared / explicitly shared content |
| `Curator` | Can create/update/delete their own content |
| `Admin` | Unrestricted access |
- `[Authorize]` with no roles = any authenticated user.
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
Note the space after the comma in the role list — this is the project's established style.
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
---
## Background Jobs System
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
- Override `TaskJob(CancellationToken token)` to implement job logic.
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
---
## Settings System
- Runtime settings are persisted in the `Settings` database table.
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
- When adding a new setting:
1. Add an entry to `Settings` enum and `AsString()`.
2. Add the default entry to `DefaultSettings.json`.
3. React to changes via `SettingsRepository.SettingChanged` event.
---
## Frontend (MilkStream.Client / Blazor WASM)
- Use **Blazor WebAssembly** the client runs entirely in the browser.
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
### Dynamic Configuration
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
---
## Docker / Deployment
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
- Data-protection keys for MilkStream are persisted via a named Docker volume.
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
---
## Configuration & Secrets
- `appsettings.json` contains placeholder/default values only.
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
---
## Git & Commit Conventions
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
| Prefix | Purpose |
|---|---|
| `feat:` | New feature |
| `fix:` | Bug fix |
| `refactor:` | Code restructuring without behaviour change |
| `perf:` | Performance improvement |
| `doc:` | Documentation changes |
| `style:` | Formatting / style only |
| `test:` | Tests |
| `chore:` / `ci:` | Build system, tooling, CI |
| `revert:` | Revert a previous commit |
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
---
## TODOs & Known Gaps (as of initial analysis)
The following items appear as `TODO` comments in the codebase and should be tracked:
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
- `TagController.Get` (ancestors traversal) — not protected against circular references.
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ obj/
riderModule.iml
/_ReSharper.Caches/
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
storageImages/

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:eab007eb-4f50-4961-9bce-5c4940a7cdff
2:0:80498c22-67da-4c4a-8106-c4789470ef83
3:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
.</data>
</component>
</project>

14
.idea/.idea.MilkyShots/.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />
<option name="button1Title" value="" />
<option name="button1Url" value="" />
<option name="button2Title" value="" />
<option name="button2Url" value="" />
<option name="customApplicationId" value="" />
</component>
</project>

View File

@@ -21,6 +21,32 @@ builder.Configuration.AddJsonFile("appsettings.json", true, true)
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
// CORS: allow the Blazor WASM frontend to call this API from the browser.
// A single "*" entry in CorsAllowedOrigins means "allow any origin" (useful for
// self-hosted deployments where the host IP is not known ahead of time).
// When set via an environment variable, use a semicolon-separated list
// (e.g. CorsAllowedOrigins=http://host1;http://host2) which replaces the entire array.
string? configuredAllowedOrigins = builder.Configuration["CorsAllowedOrigins"];
string[] allowedOrigins;
if (!string.IsNullOrWhiteSpace(configuredAllowedOrigins))
allowedOrigins = configuredAllowedOrigins.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
else
allowedOrigins = builder.Configuration.GetSection("CorsAllowedOrigins").Get<string[]>() ?? [];
if (allowedOrigins.Length == 0)
throw new InvalidOperationException(
"CORS configuration is invalid. Configure 'CorsAllowedOrigins' with at least one origin, or use a single '*' entry to allow any origin.");
var allowAnyOrigin = allowedOrigins.Length == 1 && allowedOrigins[0] == "*";
builder.Services.AddCors(options => {
options.AddPolicy("MilkStreamPolicy", policy => {
if (allowAnyOrigin)
policy.AllowAnyOrigin();
else
policy.WithOrigins(allowedOrigins);
policy.AllowAnyMethod()
.AllowAnyHeader();
});
});
builder.Services.ConfigureOptions<SignKeyProvider>();
string? user = builder.Configuration["DatabaseCredentials:UserID"];
@@ -169,6 +195,7 @@ if (app.Environment.IsDevelopment()) {
c.SwaggerEndpoint("/swagger/v1/swagger.json", "AuthCore API V1");
});
}
app.UseCors("MilkStreamPolicy");
app.UseAuthentication();
app.UseAuthorization();
//app.UseHttpsRedirection();

View File

@@ -6,6 +6,7 @@
}
},
"AllowedHosts": "*",
"CorsAllowedOrigins": [ "http://localhost:5269", "http://localhost:8080"],
"DatabaseCredentials": {
"UserID": "root",
"Password": "testOnlyDb",

View File

@@ -1,6 +1,6 @@
using Butter.Dtos;
namespace MilkStream;
namespace MilkStream.Client;
public static class AuthHelpers {
public static AuthInfo ToAuthInfo(this AuthResultDto authResult) {

View File

@@ -1,4 +1,4 @@
namespace MilkStream;
namespace MilkStream.Client;
public class AuthInfo {
public required Guid? UserId { get; set; }

View File

@@ -1,4 +1,4 @@
<Router AppAssembly="typeof(Program).Assembly">
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
<FocusOnNavigate RouteData="routeData" Selector="h1"/>

View File

@@ -1,5 +1,5 @@
@using Butter.Dtos.Folder
@using MilkStream.Services
@using MilkStream.Client.Services
@inject FoldersService FoldersService
@inject NavigationManager NavigationManager
@@ -27,21 +27,25 @@
public FolderFullDto Folder { get; set; } = new FolderFullDto();
protected override void OnInitialized() {
FoldersService.SaveFolders += (_, _) => FoldersService.UpdateFolder(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
BasePath = Folder.BasePath
}
);
FoldersService.SaveFolders += async (_, _) => {
try {
await FoldersService.UpdateFolderAsync(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
BasePath = Folder.BasePath
}
);
} catch (Exception) { /* fire-and-forget: failure is silent to avoid cascading errors */ }
};
}
void OnEdit() {
editing = true;
}
void OnConfirmEdit() {
FoldersService.UpdateFolder(
async Task OnConfirmEdit() {
await FoldersService.UpdateFolderAsync(
Folder.Id,
new FolderUpdateDto() {
Active = Folder.Active,
@@ -51,8 +55,8 @@
editing = false;
}
void OnDelete() {
FoldersService.RemoveFolder(Folder.Id);
async Task OnDelete() {
await FoldersService.RemoveFolderAsync(Folder.Id);
NavigationManager.Refresh(true);
}

View File

@@ -1,4 +1,4 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
<NavMenu/>
<main class="container-xxl d-flex flex-grow-1">

View File

@@ -0,0 +1,105 @@
@using MilkStream.Client.Services
@using Butter.Types
@inherits LayoutComponentBase
@inject NavigationManager navigation
@inject UserService userService
@inject LoginService loginService
@inject ILocalStorageService localStorage
<div class="navbar navbar-expand-sm bg-dark-subtle">
<div class="container-xl">
<a class="mx-2 navbar-brand" href="">
<img src="img/MilkyShotLogoWhite.svg" alt="Milky Shot Logo" class="logo mx-2" href=""/>
<span class="text m-1">Milky Shot</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="#">Cosplayers</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Albums</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Photos</a>
</li>
</ul>
@if (!LoggedIn) {
<button class="btn btn-primary mx-1" @onclick="OnLoginClick">
Login
</button>
<button class="btn btn-primary mx-1" @onclick="OnRegisterClick">
Register
</button>
} else {
<div class="d-flex">
<input class="form-control form-control-sm ms-1"
style="border-top-right-radius: 0; border-bottom-right-radius: 0" type="search"
placeholder="Search anything..." aria-label="search"/>
<button class="btn btn-sm btn-outline-success me-1"
style="border-top-left-radius: 0; border-bottom-left-radius: 0" type="submit">
<i class="bi bi-search-heart"></i>
</button>
</div>
<div class="mx-2">@loginService.LoggedUser?.Username</div>
<div class="btn-group">
<button class="btn btn-lg btn-outline-light" type="button">
<i class="bi bi-person-circle"></i>
</button>
<button class="btn btn-lg btn-outline-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
</ul>
</div>
}
</div>
</div>
</div>
@code{
bool LoggedIn => loginService.IsLoggedIn;
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
protected override async Task OnInitializedAsync() {
AuthInfo? auth = null;
try {
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
} catch (Exception) {
// The stored value may be a stale DPAPI-encrypted blob from the old Blazor Server
// app. It is not valid JSON so deserialization will throw. Clear the bad entry and
// continue as a logged-out user; the user can log in again normally.
await localStorage.RemoveItemAsync("auth");
}
if (auth != null) {
loginService.AuthInfo = auth;
loginService.LoggedUser = await userService.GetUserAsync();
}
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
}
void OnLoginClick() {
if (loginService.LoggedUser != null) StateHasChanged();
else navigation.NavigateTo("/login");
}
void OnRegisterClick() => navigation.NavigateTo("/register");
void OnSettingsClick() => navigation.NavigateTo("/settings");
async Task OnLogout() {
_ = loginService.Logout();
await localStorage.RemoveItemAsync("auth");
navigation.NavigateTo("/", true);
}
}

View File

@@ -0,0 +1,7 @@
@page "/Error"
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<p>Please try refreshing the page. If the issue persists, contact support.</p>

View File

@@ -1,12 +1,10 @@
@page "/"
@using Butter.Dtos;
@using MilkStream.Components.Layout
@using MilkStream.Services
@page "/"
@using Butter.Dtos
@using MilkStream.Client.Components.Layout
@using MilkStream.Client.Services
@inject NavigationManager navigationManager
@inject LoginService loginService
@inject ProtectedSessionStorage sessionStorage
@inject ProtectedLocalStorage localStorage
<PageTitle>Home</PageTitle>
@@ -39,14 +37,9 @@
bool isLoading = true;
List<MediaDto> mediaList = new();
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
loginService.LoggedUserChanged += (sender, dto) => StateHasChanged();
//do nothing for now
await Task.Delay(1);
isLoading = false;
StateHasChanged();
}
protected override void OnInitialized() {
loginService.LoggedUserChanged += (sender, dto) => StateHasChanged();
isLoading = false;
}
}

View File

@@ -1,12 +1,10 @@
@page "/login"
@using Butter.Dtos
@using Microsoft.AspNetCore.Mvc.RazorPages
@using MilkStream.Services
@using MilkStream.Client.Services
@inject NavigationManager navigation
@inject LoginService loginService
@inject ProtectedSessionStorage sessionStorageService
@inject ProtectedLocalStorage protectedLocalStorage
@inject NavigationManager navigation
@inject LoginService loginService
@inject ILocalStorageService localStorageService
<PageTitle>Login</PageTitle>
@@ -33,10 +31,6 @@
string password = string.Empty;
bool isLoginFailed;
protected override void OnInitialized() {
//if (loginService.IsLoggedIn) navigation.NavigateTo("/", true);
}
void Register_OnClick() => navigation.NavigateTo("register");
async Task Login_OnClick() {
@@ -44,7 +38,7 @@
var result = await loginService.Login(username, password);
if (result.Item1) { // Login successful
isLoginFailed = false;
await protectedLocalStorage.SetAsync("auth", result.Item2!);
await localStorageService.SetItemAsync("auth", result.Item2!);
navigation.NavigateTo("/", true); // Redirect to home page
} else {
isLoginFailed = true;

View File

@@ -1,5 +1,5 @@
@page "/Register"
@using MilkStream.Services
@using MilkStream.Client.Services
@inject LoginService loginService
@inject NavigationManager navigation

View File

@@ -2,8 +2,8 @@
@using Butter.Dtos.Folder
@using Butter.Dtos.Settings
@using Butter.Settings
@using MilkStream.Components.SettingBoxes
@using MilkStream.Services
@using MilkStream.Client.Components.SettingBoxes
@using MilkStream.Client.Services
@using System.Linq;
@inject NavigationManager NavigationManager
@@ -41,29 +41,28 @@
break;
}
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnSaveChanges"><i class="bi bi-floppy2 mx-1"></i>Save Changes</button>
</div>
} else {
<div class="alert alert-danger my-2 my-sm-5 mx-auto" style="max-width: fit-content" role="alert">
No settings available.
</div>
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnSaveChanges"><i class="bi bi-floppy2 mx-1"></i>Save Changes</button>
</div>
<hr/>
<h3>Folders</h3>
@if (folders.Any()) {
foreach (var folder in folders) {
<FolderBox Folder="folder"/>
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnAddFolder"><i class="bi bi-folder-plus mx-1"></i>Add folder</button>
</div>
} else {
<div class="alert alert-danger my-2 my-sm-5 mx-auto" style="max-width: fit-content" role="alert">
No folders available.
</div>
}
<div>
<button class="btn btn-outline-primary my-2" @onclick="OnAddFolder"><i class="bi bi-folder-plus mx-1"></i>Add folder</button>
</div>
@code {
List<SettingDto> settings = new();
@@ -77,14 +76,9 @@
};
}
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender) {
if (LoginService.IsLoggedIn)
await LoadData();
else
LoginService.LoggedUserChanged += async (_, _) => await LoadData();
StateHasChanged();
}
protected async override Task OnInitializedAsync() {
if (LoginService.IsLoggedIn)
await LoadData();
}
async Task LoadData() {
@@ -95,11 +89,12 @@
void OnSaveChanges() {
SettingsService.OnBeginSave();
FoldersService.OnSaveFolders();
StateHasChanged();
}
void OnAddFolder() {
FoldersService.CreateFolder(new FolderCreateDto() { BasePath = "New Folder" });
async Task OnAddFolder() {
await FoldersService.CreateFolderAsync(new FolderCreateDto() { BasePath = "New Folder" });
NavigationManager.Refresh(true);
}
}

View File

@@ -1,5 +1,5 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@using MilkStream.Client.Services
@inject SettingsService SettingsService
@@ -12,6 +12,9 @@
}
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
}
}

View File

@@ -1,5 +1,5 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@using MilkStream.Client.Services
@using System.Globalization
@inherits SettingBox
@inject SettingsService SettingsService
@@ -19,7 +19,10 @@
private float currentValue;
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (float.TryParse(Setting.Value, out float value)) {
currentValue = value;
} else {

View File

@@ -1,5 +1,5 @@
@using Butter.Dtos.Settings
@using MilkStream.Services
@using MilkStream.Client.Services
@inherits SettingBox
@inject SettingsService SettingsService
@@ -18,7 +18,10 @@
private int currentValue;
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (int.TryParse(Setting.Value, out int value)) { currentValue = value; } else {
currentValue = 0; // Default value if parsing fails
}

View File

@@ -1,6 +1,6 @@
@using Butter.Dtos.Settings
@using Butter.Settings
@using MilkStream.Services
@using MilkStream.Client.Services
@inherits SettingBox
@inject SettingsService SettingsService
@@ -18,7 +18,10 @@
private bool isChecked;
protected override void OnInitialized() {
SettingsService.BeginSave += (sender, args) => SettingsService.UpdateSetting(GetSetting());
SettingsService.BeginSave += async (sender, args) => {
try { await SettingsService.UpdateSettingAsync(GetSetting()); }
catch (Exception) { /* fire-and-forget: exception logged by SettingsService */ }
};
if (bool.TryParse(Setting.Value, out var parsedValue)) {
isChecked = parsedValue;
}

View File

@@ -1,11 +1,10 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using Blazored.LocalStorage
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage
@using Microsoft.JSInterop
@using MilkStream
@using MilkStream.Components
@using MilkStream.Client
@using MilkStream.Client.Components

View File

@@ -0,0 +1 @@
global using System.Net.Http.Json;

View File

@@ -1,8 +1,8 @@
using MilkStream.Services;
using MilkStream.Client.Services;
using System.Net;
using Microsoft.IdentityModel.JsonWebTokens;
namespace MilkStream;
namespace MilkStream.Client;
/// <summary>
/// Can be added to an HTTP client to automatically refresh JWT tokens when they expire.
@@ -13,7 +13,13 @@ public class JwtTokenRefresher(LoginService loginService, ILogger<JwtTokenRefres
HttpRequestMessage request,
CancellationToken cancellationToken
) {
var jwt = new JsonWebToken(request.Headers.Authorization?.Parameter);
var authParam = request.Headers.Authorization?.Parameter;
// If there is no Authorization header the request is anonymous — skip token refresh.
if (string.IsNullOrEmpty(authParam)) {
return await base.SendAsync(request, cancellationToken);
}
var jwt = new JsonWebToken(authParam);
// Check if the JWT is valid and not going to expire within 1 minute otherwise send the request as is.
if (jwt.ValidTo >= DateTime.UtcNow.AddMinutes(1)) {
logger.LogDebug("JWT Token valid up to {JwtValidTo}, no need to refresh.", jwt.ValidTo);

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.18" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Butter\Butter.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MilkStream.Client;
using MilkStream.Client.Components;
using MilkStream.Client.Services;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configure ServiceOptions from client-side appsettings.json (wwwroot/appsettings.json).
builder.Services.Configure<ServiceOptions>(options => {
options.BaseUrl = builder.Configuration["BaseUrl"] ?? string.Empty;
});
// Default (unauthenticated) HttpClient used by LoginService.
builder.Services.AddHttpClient();
// Named client with the JWT token-refresh DelegatingHandler for authenticated services.
builder.Services.AddHttpClient("MilkstreamClient")
.AddHttpMessageHandler<JwtTokenRefresher>();
// Register the token-refresh handler and all application services.
builder.Services.AddScoped<JwtTokenRefresher>();
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<FoldersService>();
// Browser local storage (replaces server-side ProtectedLocalStorage).
builder.Services.AddBlazoredLocalStorage();
await builder.Build().RunAsync();

View File

@@ -1,7 +1,7 @@
using Butter.Dtos.Folder;
using Microsoft.Extensions.Options;
namespace MilkStream.Services;
namespace MilkStream.Client.Services;
public sealed class FoldersService(
IOptions<ServiceOptions> options,
@@ -28,19 +28,18 @@ public sealed class FoldersService(
return null;
}
public void CreateFolder(FolderCreateDto folder) {
var response = Client.PutAsJsonAsync("/api/folder", folder).Result;
public async Task CreateFolderAsync(FolderCreateDto folder) {
var response = await Client.PutAsJsonAsync("/api/folder", folder);
response.EnsureSuccessStatusCode();
}
public void UpdateFolder(Guid id, FolderUpdateDto folder) {
var response = Client.PostAsJsonAsync($"/api/folder/{id}", folder).Result;
public async Task UpdateFolderAsync(Guid id, FolderUpdateDto folder) {
var response = await Client.PostAsJsonAsync($"/api/folder/{id}", folder);
response.EnsureSuccessStatusCode();
}
public void RemoveFolder(Guid folderId) {
var response = Client.DeleteAsync($"/api/folder/{folderId}").Result;
public async Task RemoveFolderAsync(Guid folderId) {
var response = await Client.DeleteAsync($"/api/folder/{folderId}");
response.EnsureSuccessStatusCode();
}
}

View File

@@ -2,7 +2,7 @@ using Butter.Dtos;
using Butter.Dtos.User;
using Microsoft.Extensions.Options;
namespace MilkStream.Services;
namespace MilkStream.Client.Services;
public sealed class LoginService : ServiceBase {
#region AuthInfo
@@ -41,7 +41,7 @@ public sealed class LoginService : ServiceBase {
void NotifyLoggedUserChanged() => LoggedUserChanged?.Invoke(this, LoggedUser);
public bool IsLoggedIn => LoggedUser != null;
public bool IsLoggedIn => LoggedUser != null;
#endregion
@@ -70,9 +70,12 @@ public sealed class LoginService : ServiceBase {
var response = await Client.PostAsJsonAsync("api/auth/login", loginDto);
logger.LogInformation("Result: {ResponseStatusCode}", response.StatusCode);
return response.IsSuccessStatusCode ?
(true, response.Content.ReadFromJsonAsync<AuthResultDto>().Result!.ToAuthInfo()) :
(false, null);
if (response.IsSuccessStatusCode) {
var authResult = await response.Content.ReadFromJsonAsync<AuthResultDto>();
return (true, authResult!.ToAuthInfo());
}
return (false, null);
}
public async Task<AuthInfo?> Reauthenticate() {
@@ -105,7 +108,7 @@ public sealed class LoginService : ServiceBase {
}
public async Task<bool> Register(string username, string email, string password) {
logger.LogInformation("Attempting to register user with username: {Username}, email: {Email}", username, email);
logger.LogInformation("Attempting to register user with username: {Username}", username);
var registerDto = new UserRegisterDto() {
Username = username,

View File

@@ -1,8 +1,6 @@
using Microsoft.Extensions.Options;
using MilkStream.Components.Pages;
using System.Net.Http.Headers;
namespace MilkStream.Services;
namespace MilkStream.Client.Services;
public sealed class MediaService : AuthServiceBase {
protected override HttpClient Client { get; init; }

View File

@@ -1,12 +1,6 @@
using Microsoft.Extensions.Options;
// ReSharper disable VirtualMemberCallInConstructor
// Rationale: The virtual call in question is the SetAuthorizationHeader call on AuthServiceBase ctor.
// This is intended to set the authorization header based on the current authentication state.
// alternatively we would need to wait for the next time that the event fires to be able to get authorization info.
// In theory, it should be safe to not call it, but only because JS Context takes a split second to be established.
// In practice, I'd rather not risk it and I still don't want to seal the call in case we need to extend it later.
namespace MilkStream.Services;
namespace MilkStream.Client.Services;
public class ServiceOptions {
public string BaseUrl { get; set; } = string.Empty;
@@ -29,7 +23,7 @@ public abstract class ServiceBase {
/// <param name="httpClientFactory"></param>
/// <param name="logger"></param>
protected ServiceBase(IOptions<ServiceOptions> options, IHttpClientFactory httpClientFactory, ILogger logger) {
Logger = logger;
Logger = logger;
Client = httpClientFactory.CreateClient();
Client.BaseAddress = new Uri(options.Value.BaseUrl);
@@ -58,7 +52,7 @@ public abstract class AuthServiceBase : ServiceBase {
LoginService loginService,
ILogger logger
) : base(options, httpClientFactory, logger) {
Client = httpClientFactory.CreateClient("MilkStreamClient");
Client = httpClientFactory.CreateClient("MilkstreamClient");
Client.BaseAddress = new Uri(options.Value.BaseUrl);
// Subscribe to the AuthInfoChanged event to set the authorization header when the auth info changes.
loginService.AuthInfoChanged += SetAuthorizationHeader;

View File

@@ -1,7 +1,7 @@
using Butter.Dtos.Settings;
using Microsoft.Extensions.Options;
namespace MilkStream.Services;
namespace MilkStream.Client.Services;
public sealed class SettingsService : AuthServiceBase {
protected override ILogger Logger { get; init; }
@@ -26,18 +26,15 @@ public sealed class SettingsService : AuthServiceBase {
return null;
}
public void UpdateSetting(SettingDto setting) {
public async Task UpdateSettingAsync(SettingDto setting) {
Logger.Log(LogLevel.Information, $"Updating setting {setting.Name} to {setting.Value}");
var response = Client.PostAsJsonAsync("api/settings", setting).Result;
var response = await Client.PostAsJsonAsync("api/settings", setting);
response.EnsureSuccessStatusCode();
}
public void UpdateSettings(List<SettingDto> settings) {
public async Task UpdateSettingsAsync(List<SettingDto> settings) {
foreach (var setting in settings) {
UpdateSetting(setting);
await UpdateSettingAsync(setting);
}
}
}

View File

@@ -1,9 +1,7 @@
using Butter.Dtos.User;
using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage;
using Microsoft.Extensions.Options;
using System.Net;
namespace MilkStream.Services;
namespace MilkStream.Client.Services;
public sealed class UserService : AuthServiceBase {
protected override ILogger Logger { get; init; }

View File

@@ -1,4 +1,4 @@
namespace MilkStream;
namespace MilkStream.Client;
public class StringExtensions {

View File

@@ -0,0 +1,3 @@
{
"BaseUrl": ""
}

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html data-bs-theme="dark" lang="en">
<head>
@@ -6,22 +6,27 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<link href="lib/css/bootstrap.css" rel="stylesheet"/>
<link href="css/MilkyShot.css"/> @*autogenerates from SCSS in styles*@
<link href="css/MilkyShot.css" rel="stylesheet"/>
<link rel="stylesheet" href="app.css"/>
<link rel="stylesheet" href="/css/Milky.styles.css"/>
@* ReSharper disable once Html.PathError *@
<link rel="stylesheet" href="MilkStream.styles.css"/>
@* the above one is the autogenerated css from the component isolations *@
<link rel="stylesheet" href="MilkStream.Client.styles.css"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css">
<link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<title>Milkstream</title>
</head>
<body>
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<div id="app">
<div class="d-flex align-items-center flex-column m-5">
<div class="spinner-border text-info m-2"></div>
<div class="m-2">
<strong class="text-info-emphasis">Loading...</strong>
</div>
</div>
</div>
<script src="lib/js/bootstrap.bundle.min.js"></script>
<script src="_framework/blazor.web.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>

View File

@@ -1,115 +0,0 @@
@using MilkStream.Services
@using Butter.Types
@inherits LayoutComponentBase
@inject NavigationManager navigation
@inject UserService userService
@inject LoginService loginService
@inject ProtectedLocalStorage localStorage
<div class="navbar navbar-expand-sm bg-dark-subtle">
<div class="container-xl">
<a class="mx-2 navbar-brand" href="">
<img src="img/MilkyShotLogoWhite.svg" alt="Milky Shot Logo" class="logo mx-2" href=""/>
<span class="text m-1">Milky Shot</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="#">Cosplayers</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Albums</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Photos</a>
</li>
</ul>
@if (isConnected) {
if (!LoggedIn) {
<button class="btn btn-primary mx-1" @onclick="OnLoginClick">
Login
</button>
<button class="btn btn-primary mx-1" @onclick="OnRegisterClick">
Register
</button>
} else {
<div class="d-flex">
<input class="form-control form-control-sm ms-1"
style="border-top-right-radius: 0; border-bottom-right-radius: 0" type="search"
placeholder="Search anything..." aria-label="search"/>
<button class="btn btn-sm btn-outline-success me-1"
style="border-top-left-radius: 0; border-bottom-left-radius: 0" type="submit">
<i class="bi bi-search-heart"></i>
</button>
</div>
<div class="mx-2">@loginService.LoggedUser?.Username</div>
<div class="btn-group">
<button class="btn btn-lg btn-outline-light" type="button">
<i class="bi bi-person-circle"></i>
</button>
<button class="btn btn-lg btn-outline-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
</ul>
</div>
}
}
</div>
</div>
</div>
@code{
bool LoggedIn => loginService.IsLoggedIn;
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
bool isConnected;
bool needsUpdate;
protected override void OnInitialized() {
base.OnInitialized();
loginService.LoggedUserChanged += (sender, dto) => needsUpdate = true;
LoadStateAsync().ConfigureAwait(false);
}
protected async override Task OnAfterRenderAsync(bool firstRender) {
if (firstRender || needsUpdate) {
isConnected = true;
await LoadStateAsync();
needsUpdate = false;
StateHasChanged();
}
}
private async Task LoadStateAsync() {
var auth = await localStorage.GetAsync<AuthInfo>("auth");
if (auth.Success == false) return;
loginService.AuthInfo = auth.Value;
loginService.LoggedUser = await userService.GetUserAsync();//.ConfigureAwait(false);
}
async Task OnLoginClick() {
loginService.LoggedUser = await userService.GetUserAsync();
if (loginService.LoggedUser != null) needsUpdate = true;
else navigation.NavigateTo("/login");
}
void OnRegisterClick() => navigation.NavigateTo("/register");
void OnSettingsClick() => navigation.NavigateTo("/settings");
async Task OnLogout() {
_ = loginService.Logout();
await localStorage.DeleteAsync("auth");
navigation.NavigateTo("/", true);
}
}

View File

@@ -1,36 +0,0 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId) {
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() => RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
@@ -8,6 +8,8 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["MilkStream/MilkStream.csproj", "MilkStream/"]
COPY ["MilkStream.Client/MilkStream.Client.csproj", "MilkStream.Client/"]
COPY ["Butter/Butter.csproj", "Butter/"]
RUN dotnet restore "MilkStream/MilkStream.csproj"
COPY . .
WORKDIR "/src/MilkStream"
@@ -20,8 +22,4 @@ RUN dotnet publish "./MilkStream.csproj" -c $BUILD_CONFIGURATION -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p /home/app/.aspnet/DataProtection-Keys
RUN chown -R $APP_UID /home/app/
RUN chmod -R 770 /home/app/.aspnet/DataProtection-Keys
VOLUME ["/home/app/.aspnet/DataProtection-Keys"]
ENTRYPOINT ["dotnet", "MilkStream.dll"]

View File

@@ -8,72 +8,70 @@
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Butter\Butter.csproj" />
<PackageReference Include="AspNetCore.SassCompiler" Version="1.89.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.18" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.SassCompiler" Version="1.89.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.18" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.18" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
<ProjectReference Include="..\MilkStream.Client\MilkStream.Client.csproj" />
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css" />
<_ContentIncludedByDefault Remove="wwwroot\bootstrap\bootstrap.min.css.map" />
</ItemGroup>
<ItemGroup>
<None Include="wwwroot\lib\css\bootstrap-grid.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.css.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.min.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.min.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.min.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap.css" />
<None Include="wwwroot\lib\css\bootstrap.css.map" />
<None Include="wwwroot\lib\css\bootstrap.min.css" />
<None Include="wwwroot\lib\css\bootstrap.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap.rtl.min.css.map" />
<None Include="wwwroot\lib\js\bootstrap.bundle.js" />
<None Include="wwwroot\lib\js\bootstrap.bundle.js.map" />
<None Include="wwwroot\lib\js\bootstrap.bundle.min.js" />
<None Include="wwwroot\lib\js\bootstrap.bundle.min.js.map" />
<None Include="wwwroot\lib\js\bootstrap.esm.js" />
<None Include="wwwroot\lib\js\bootstrap.esm.js.map" />
<None Include="wwwroot\lib\js\bootstrap.esm.min.js" />
<None Include="wwwroot\lib\js\bootstrap.esm.min.js.map" />
<None Include="wwwroot\lib\js\bootstrap.js" />
<None Include="wwwroot\lib\js\bootstrap.js.map" />
<None Include="wwwroot\lib\js\bootstrap.min.js" />
<None Include="wwwroot\lib\js\bootstrap.min.js.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.css.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.min.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap-grid.rtl.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.min.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap-reboot.rtl.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.min.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap-utilities.rtl.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap.css" />
<None Include="wwwroot\lib\css\bootstrap.css.map" />
<None Include="wwwroot\lib\css\bootstrap.min.css" />
<None Include="wwwroot\lib\css\bootstrap.min.css.map" />
<None Include="wwwroot\lib\css\bootstrap.rtl.css" />
<None Include="wwwroot\lib\css\bootstrap.rtl.css.map" />
<None Include="wwwroot\lib\css\bootstrap.rtl.min.css" />
<None Include="wwwroot\lib\css\bootstrap.rtl.min.css.map" />
<None Include="wwwroot\lib\js\bootstrap.bundle.js" />
<None Include="wwwroot\lib\js\bootstrap.bundle.js.map" />
<None Include="wwwroot\lib\js\bootstrap.bundle.min.js" />
<None Include="wwwroot\lib\js\bootstrap.bundle.min.js.map" />
<None Include="wwwroot\lib\js\bootstrap.esm.js" />
<None Include="wwwroot\lib\js\bootstrap.esm.js.map" />
<None Include="wwwroot\lib\js\bootstrap.esm.min.js" />
<None Include="wwwroot\lib\js\bootstrap.esm.min.js.map" />
<None Include="wwwroot\lib\js\bootstrap.js" />
<None Include="wwwroot\lib\js\bootstrap.js.map" />
<None Include="wwwroot\lib\js\bootstrap.min.js" />
<None Include="wwwroot\lib\js\bootstrap.min.js.map" />
</ItemGroup>
</Project>

View File

@@ -1,51 +1,33 @@
using MilkStream;
using MilkStream.Components;
using MilkStream.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Configuration.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true);
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddHttpClient();
builder.Services.AddSession();
builder.WebHost.UseStaticWebAssets();
#if DEBUG // Enable the Sass compiler in debug mode for hot reload support.
builder.Services.AddSassCompiler();
#endif
//TODO: this probably needs a better system for the settings.
builder.Services.AddHttpClient("MilkstreamClient").AddHttpMessageHandler<JwtTokenRefresher>();
builder.Services.Configure<ServiceOptions>(options => { options.BaseUrl = builder.Configuration["BaseUrl"]!; });
builder.Services.AddScoped<LoginService>();
builder.Services.AddScoped<MediaService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<FoldersService>();
//http listener for all requests with automatic JWT token refresh on 401 Unauthorized responses.
builder.Services.AddScoped<JwtTokenRefresher>();
builder.Services.AddLogging();
builder.WebHost.UseStaticWebAssets();
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options => {
options.IdleTimeout = TimeSpan.FromMinutes(30);
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment()) {
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
app.UseHttpsRedirection();
}
// Dynamically serve appsettings.json so the WASM client gets the correct BaseUrl
// regardless of the network environment (resolves the Docker-vs-browser network mismatch:
// the static wwwroot/appsettings.json would contain the Docker-internal hostname which the
// browser cannot resolve; this endpoint shadows that file and injects the externally
// reachable URL read from the server's own configuration).
app.MapGet("/appsettings.json", (IConfiguration config) => {
var lactoseBaseUrl = config["LactoseBaseUrl"] ?? string.Empty;
return Results.Json(new { BaseUrl = lactoseBaseUrl });
});
// Serve the Blazor WASM framework files from MilkStream.Client.
app.UseBlazorFrameworkFiles();
// Serve static files from the host's wwwroot (bootstrap, CSS, images, etc.).
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
// Fall back to index.html for all unmatched routes (SPA client-side routing).
app.MapFallbackToFile("index.html");
app.Run();

View File

@@ -6,5 +6,5 @@
}
},
"DetailedErrors": true,
"BaseUrl": "http://localhost:5162/"
"LactoseBaseUrl": "http://localhost:5162/"
}

View File

@@ -5,6 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"DetailedErrors": true,
"BaseUrl": "http://192.168.51.101:5162/"
"DetailedErrors": true
}

View File

@@ -6,13 +6,12 @@
}
},
"AllowedHosts": "*",
"BaseUrl": "http://lactose:8080/",
"LactoseBaseUrl": "http://localhost:5162/",
"SassCompiler": {
"Source": "Styles",
"Target": "wwwroot/css",
"Arguments": "--style=compressed",
"GenerateScopedCss": true,
"ScopedCssFolders": ["Views", "Pages", "Shared", "Components"],
"GenerateScopedCss": false,
"IncludePaths": [],
"Configurations": {
"Debug": {

View File

@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lactose", "Lactose\Lactose.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream", "MilkStream\MilkStream.csproj", "{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MilkStream.Client", "MilkStream.Client\MilkStream.Client.csproj", "{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Butter", "Butter\Butter.csproj", "{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}"
EndProject
Global
@@ -25,6 +27,10 @@ Global
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3A0FAEB-B4D2-4984-986A-3DE2DA32ADEF}.Release|Any CPU.Build.0 = Release|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1A2B3C4D-5E6F-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CB1B8C0E-0F47-456E-ACD7-22F772EBF948}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -5,3 +5,91 @@
_A place where to look for images_
**TODO: fill more information here**
## Configuration
### CORS allowed origins
The API uses a CORS policy to control which browser origins may call it.
Allowed origins are configured via the `CorsAllowedOrigins` key.
**`appsettings.json` (default / development)**
```json
{
"CorsAllowedOrigins": [ "http://localhost:5269", "http://localhost:8080" ]
}
```
**Environment variable (Docker / production)**
Set the `CorsAllowedOrigins` environment variable to a **semicolon-separated** list of
origins. This value **replaces** the entire array from `appsettings.json`, so you must
list every origin you want to allow:
```
CorsAllowedOrigins=http://myserver:8080
# or multiple origins:
CorsAllowedOrigins=http://myserver:8080;https://myserver.example.com
```
To allow any origin (useful when the server IP is unknown at deploy time):
```
CorsAllowedOrigins=*
```
> **⚠️ Security warning:** Setting `CorsAllowedOrigins=*` disables CORS origin
> checking entirely — **any** website can make authenticated requests to the API from
> a visitor's browser. Only use this option in fully trusted, self-hosted environments
> where you control all network access. For any internet-facing deployment always
> specify explicit origins instead.
This is already set up in `docker-compose.yml` — override it with your server's
hostname/IP if you are not running on localhost.
### Configuring CORS for the MilkStream frontend
MilkStream is the Blazor WASM frontend that talks to the Lactose API. Because the
browser fetches the API directly, the **origin of MilkStream as seen by the browser**
must be listed in `CorsAllowedOrigins` on the Lactose service.
The two services are mapped in `docker-compose.yml` as follows:
| Service | Container port | Host port | Browser URL (default) |
|------------|---------------|-----------|----------------------|
| `lactose` | 8080 | **5162** | `http://yourserver:5162` |
| `milkstream`| 8080 | **8080** | `http://yourserver:8080` |
MilkStream's URL (`http://yourserver:8080`) must be added to Lactose's allowed origins,
and `LactoseBaseUrl` on MilkStream must point to the URL the **browser** uses to reach
Lactose (`http://yourserver:5162`).
**Example — running on `myserver.local`:**
```yaml
# docker-compose.yml (relevant excerpts)
lactose:
environment:
- CorsAllowedOrigins=http://myserver.local:8080 # MilkStream origin seen by the browser
milkstream:
environment:
- LactoseBaseUrl=http://myserver.local:5162/ # Lactose URL seen by the browser
```
**Example — running on localhost (default):**
```yaml
lactose:
environment:
- CorsAllowedOrigins=http://localhost:8080
milkstream:
environment:
- LactoseBaseUrl=http://localhost:5162/
```
If you expose MilkStream on a non-standard port or via HTTPS, adjust both values
accordingly and list the exact `scheme://host:port` that appears in the browser's
address bar.

View File

@@ -8,6 +8,10 @@
dockerfile: Lactose/Dockerfile
environment:
- ASPNETCORE_ENVIRONMENT=Development
# CorsAllowedOrigins must be the URL the *browser* uses to reach MilkStream.
# Use semicolons to specify multiple origins (e.g. http://host1;http://host2).
# Override this with your server's hostname/IP if not running on localhost.
- CorsAllowedOrigins=http://localhost:8080
ports:
- "5162:8080"
depends_on:
@@ -34,11 +38,12 @@
build:
context: .
dockerfile: MilkStream/Dockerfile
environment:
# LactoseBaseUrl must be the address the *browser* can reach Lactose at.
# Override this with your server's hostname/IP if not running on localhost.
- LactoseBaseUrl=http://localhost:5162/
ports:
- "8080:8080"
volumes:
- "data_protection_keys:/home/app/.aspnet/DataProtection-Keys"
volumes:
pg_dbdata:
data_protection_keys: