Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
320
.github/agents.md
vendored
Normal file
320
.github/agents.md
vendored
Normal 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
1
.gitignore
vendored
@@ -4,3 +4,4 @@ obj/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
|
||||
storageImages/
|
||||
11
.idea/.idea.MilkyShots/.idea/db-forest-config.xml
generated
Normal file
11
.idea/.idea.MilkyShots/.idea/db-forest-config.xml
generated
Normal 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
14
.idea/.idea.MilkyShots/.idea/discord.xml
generated
Normal 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>
|
||||
@@ -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();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"CorsAllowedOrigins": [ "http://localhost:5269", "http://localhost:8080"],
|
||||
"DatabaseCredentials": {
|
||||
"UserID": "root",
|
||||
"Password": "testOnlyDb",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Butter.Dtos;
|
||||
|
||||
namespace MilkStream;
|
||||
namespace MilkStream.Client;
|
||||
|
||||
public static class AuthHelpers {
|
||||
public static AuthInfo ToAuthInfo(this AuthResultDto authResult) {
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MilkStream;
|
||||
namespace MilkStream.Client;
|
||||
|
||||
public class AuthInfo {
|
||||
public required Guid? UserId { get; set; }
|
||||
@@ -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"/>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<NavMenu/>
|
||||
<main class="container-xxl d-flex flex-grow-1">
|
||||
105
MilkStream.Client/Components/Layout/NavMenu.razor
Normal file
105
MilkStream.Client/Components/Layout/NavMenu.razor
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
7
MilkStream.Client/Components/Pages/Error.razor
Normal file
7
MilkStream.Client/Components/Pages/Error.razor
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/Register"
|
||||
@using MilkStream.Services
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
@inject LoginService loginService
|
||||
@inject NavigationManager navigation
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
1
MilkStream.Client/GlobalUsings.cs
Normal file
1
MilkStream.Client/GlobalUsings.cs
Normal file
@@ -0,0 +1 @@
|
||||
global using System.Net.Http.Json;
|
||||
@@ -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);
|
||||
20
MilkStream.Client/MilkStream.Client.csproj
Normal file
20
MilkStream.Client/MilkStream.Client.csproj
Normal 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>
|
||||
35
MilkStream.Client/Program.cs
Normal file
35
MilkStream.Client/Program.cs
Normal 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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace MilkStream;
|
||||
namespace MilkStream.Client;
|
||||
|
||||
public class StringExtensions {
|
||||
|
||||
3
MilkStream.Client/wwwroot/appsettings.json
Normal file
3
MilkStream.Client/wwwroot/appsettings.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"BaseUrl": ""
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
}
|
||||
},
|
||||
"DetailedErrors": true,
|
||||
"BaseUrl": "http://localhost:5162/"
|
||||
"LactoseBaseUrl": "http://localhost:5162/"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,5 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"DetailedErrors": true,
|
||||
"BaseUrl": "http://192.168.51.101:5162/"
|
||||
"DetailedErrors": true
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
88
README.md
88
README.md
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user