Compare commits
110 Commits
851cdfa924
...
feature/10
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cbbea3c7 | |||
| cace8640e9 | |||
| a3ce98a984 | |||
| 4f3e3a28cc | |||
| 657ffaae11 | |||
| 700d5a63a7 | |||
| 9531ac2e23 | |||
| 288da412d9 | |||
| 1b34520a66 | |||
| 23b285cf49 | |||
| 27707b120c | |||
| 14978087e3 | |||
| 3021788271 | |||
| 358bf0918e | |||
| 656784af00 | |||
| c8b71f290b | |||
| 8f109bf92f | |||
| 268b380867 | |||
| 71889ddfac | |||
| 0a74ff8770 | |||
| f158181ccf | |||
| e585abc378 | |||
| ccf16b6abc | |||
| 7dcb78db97 | |||
| 26984f4452 | |||
| f32d0678df | |||
| 05b99ab6c5 | |||
|
|
88a0098314 | ||
|
|
8b53899648 | ||
|
|
e2b0eaba37 | ||
|
|
e1504dbf72 | ||
|
|
b67b2260fe | ||
|
|
06fd26e641 | ||
|
|
74c74cbbdf | ||
|
|
774ee0d7c7 | ||
|
|
45b3c56ce2 | ||
|
|
b0fa104642 | ||
|
|
d4cd305c28 | ||
|
|
671783fd16 | ||
|
|
674710986f | ||
|
|
77a6ee9747 | ||
|
|
1f2bf02d02 | ||
|
|
c5b8c93a39 | ||
| b9251c3e19 | |||
| 0d1ce9feb3 | |||
| 0673017765 | |||
| 08c389026a | |||
| bbb2ed8f73 | |||
| fe33fe2044 | |||
| a449c792c1 | |||
| 9323111d0d | |||
| c2326414b4 | |||
| 747ad77f4c | |||
| f77a4574ac | |||
| c64c1947e6 | |||
| 1b444dab93 | |||
| 50bf0a2a78 | |||
| 2335e8e23a | |||
| d02a010083 | |||
| 6676347353 | |||
| c9391d840b | |||
| 51323b4a64 | |||
| 842ed76345 | |||
| f3ed658dd0 | |||
| 6c83b8395b | |||
| e58524ce19 | |||
| 9dced21554 | |||
| 3fdf55b53c | |||
| f5b28e5a21 | |||
| 7a380d7df5 | |||
| f7d6348ea9 | |||
| 46871299ca | |||
| c281746fdc | |||
| be7532dd74 | |||
| 108b53a7e2 | |||
| 38793efe6f | |||
| 4205188855 | |||
| d2daed27a8 | |||
| 87b69d5ee8 | |||
| e0d46aa87b | |||
| dbe55be63f | |||
| 28f9e7faae | |||
| 820d3b1c8b | |||
| 278648aae6 | |||
| 9d9491253b | |||
| 499c6375f6 | |||
| 2b150060f4 | |||
| b93cb73e0a | |||
| d2d584d509 | |||
| 8bcb408a5b | |||
| 445b030160 | |||
| 8195808500 | |||
| 393ad136f6 | |||
| 00acfef28c | |||
| efeed2b668 | |||
| efc6379bb3 | |||
| 20a456591d | |||
| 1b301ff2c0 | |||
| 50c58e22ff | |||
| ad2f08698d | |||
| e05ab3beaf | |||
| 48b6d4f149 | |||
| 89edf290ff | |||
| c77096b0f9 | |||
| e333d0e9db | |||
| 324c1b1a17 | |||
|
|
ee3511c09a | ||
|
|
41c262d157 | ||
|
|
837491fa4a | ||
|
|
5a2f61d261 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ storageImages/
|
||||
dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -24,11 +24,12 @@ dotnet build # compiles SCSS automatically
|
||||
- Aggressive gradient mixins in `Styles/_gradients.scss`
|
||||
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
|
||||
|
||||
## Projects (4 in solution)
|
||||
## Projects (5 in solution)
|
||||
|
||||
| Project | Role | Entrypoint |
|
||||
|---|---|---|
|
||||
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
|
||||
| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — |
|
||||
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
|
||||
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
|
||||
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
|
||||
@@ -47,12 +48,13 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **Npgsql legacy timestamps:** `AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true)` is set in `Lactose/Program.cs`. Do not remove without understanding the timestamp implications.
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
|
||||
- **Microsoft.EntityFrameworkCore.Tools v8.0.15 vs EF Core Design v10.0.9:** The Tools package is outdated but the `dotnet-ef` CLI (from `dotnet-tools.json`) and Design package are both v10.0.9 — CLI should work fine.
|
||||
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
|
||||
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
|
||||
- **`SharedWith.Any()` EF Core translation:** Cannot be translated to SQL through many-to-many join tables (`AlbumAsset`, `AssetUser`). When visibility filtering includes `SharedWith.Any(u => u.Id == userId)`, you must materialize records first with `.Include().ThenInclude()` chains, then filter client-side in C#. The two-pass pagination approach preserves page-limited loading — see `PersonRepository.SearchQuery` and `AlbumRepository.SearchQuery` for the pattern.
|
||||
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001, `DateTime.Today` as error MS002, `DateTime.Date` as warning MS003, and non-UTC `SpecifyKind` as error MS004. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
|
||||
|
||||
## XML docs enforced
|
||||
|
||||
@@ -65,6 +67,7 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
- **Repository `Save()` must be called explicitly** after insert/update/delete
|
||||
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
|
||||
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
|
||||
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1–250).
|
||||
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
|
||||
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
|
||||
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
|
||||
@@ -90,8 +93,56 @@ dotnet ef migrations add <Name> --project Lactose
|
||||
dotnet ef database update --project Lactose
|
||||
```
|
||||
|
||||
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v8.0.15 vs Design v10.0.9 mismatch). Example:
|
||||
|
||||
```csharp
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.IsPubliclyShared, e.OwnerId })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
```
|
||||
|
||||
- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search.
|
||||
- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`.
|
||||
|
||||
## Tests
|
||||
|
||||
No test project found in solution.
|
||||
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
|
||||
|
||||
- **`Lactose/WepApiTest.http`** — 67 numbered tests covering all API endpoints across 10 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin). Self-contained — all test data is created at runtime and cleaned up.
|
||||
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
|
||||
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
|
||||
- Server must be running locally (default `http://localhost:5162`).
|
||||
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.
|
||||
|
||||
## PR workflow
|
||||
|
||||
- Before starting work, ask the user whether they want the fix in a PR or directly on `develop` (for quick/trivial fixes).
|
||||
- Larger work always goes in a PR.
|
||||
- Always mark the PR as **draft** until the user confirms they are satisfied and have tested it works.
|
||||
- When making the PR ready (removing draft status), mark it ready for review and request:
|
||||
- **REDCODE** when the PR contains frontend changes
|
||||
- **Fastwind** when the PR contains backend changes
|
||||
- **Both** when both areas are touched
|
||||
|
||||
## Data sizes
|
||||
|
||||
Approximate record counts in the database:
|
||||
|
||||
| Entity | Count |
|
||||
|---|---|
|
||||
| Assets | 1,000,000+ |
|
||||
| Albums | 10,000+ |
|
||||
| People | 1,000+ |
|
||||
| Users | 100+ |
|
||||
| Maintainers | 10+ |
|
||||
| Curators | single digit |
|
||||
| Admins | single digit |
|
||||
|
||||
## Gitea labels
|
||||
|
||||
Labels live at two levels:
|
||||
- **Org level** (`MilkyShots` org): generic issue types (`bug`, `enhancement`, `help wanted`, `question`, `duplicate`, `invalid`, `wontfix`)
|
||||
- **Repo level** (`MilkyShots/MilkyShots`): project-specific labels (`area:frontend`, `area:backend`, `page:*`, `type:refactor`, `priority:*`, etc.)
|
||||
- When labelling issues, check both levels with the `label_read` tool (`list_org_labels` / `list_repo_labels`).
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,25 +9,29 @@ public class AlbumAssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,21 @@ public class AlbumCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public List<Guid>? Assets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,29 +9,29 @@ public class AlbumPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the album.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person name associated with the album.
|
||||
/// </summary>
|
||||
public string? PersonName { get; set; }
|
||||
public string? PersonName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the ID of the cover asset for this album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of assets in the album.
|
||||
/// </summary>
|
||||
public int AssetCount { get; set; }
|
||||
public int AssetCount { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
@@ -8,4 +8,10 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// Gets or sets whether to return only albums with no person assigned.
|
||||
/// </summary>
|
||||
public bool Unassigned { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user ID to filter albums by.
|
||||
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||
/// </summary>
|
||||
public Guid? AssetUploadedBy { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Album;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,25 +9,25 @@ public class AlbumUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the album.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the album.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public string? Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID associated with the album.
|
||||
/// </summary>
|
||||
public Guid? Person { get; set; }
|
||||
public Guid? Person { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether to remove the person assignment from the album.
|
||||
/// </summary>
|
||||
public bool RemovePerson { get; set; }
|
||||
public bool RemovePerson { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the cover asset ID for the album.
|
||||
/// </summary>
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of asset IDs in the album.
|
||||
/// </summary>
|
||||
public List<Guid>? Assets { get; set; }
|
||||
public List<Guid>? Assets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,41 +9,42 @@ public class AssetCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// Null for scanner-created assets (system uploader will be used).
|
||||
/// </summary>
|
||||
public Guid Owner { get; set; }
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset.
|
||||
/// </summary>
|
||||
public EAssetType AssetType { get; set; }
|
||||
public EAssetType AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Private;
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in seconds (for video/audio).
|
||||
/// </summary>
|
||||
public int? Duration { get; set; }
|
||||
public int? Duration { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the frame rate (for video).
|
||||
/// </summary>
|
||||
public int? FrameRate { get; set; }
|
||||
public int? FrameRate { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,41 +10,37 @@ public class AssetDto : AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the asset (e.g., Image, Video).
|
||||
/// </summary>
|
||||
public EAssetType AssetType { get; set; }
|
||||
public EAssetType AssetType { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public bool IsPublic { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the owner of the asset.
|
||||
/// </summary>
|
||||
public required UserInfoDto Owner { get; set; }
|
||||
public required UserInfoDto UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the last updated date of the asset.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date of the asset.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
public new string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the file size of the asset in bytes.
|
||||
/// </summary>
|
||||
public long FileSize { get; set; }
|
||||
public long FileSize { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the duration of the asset in seconds (for video/audio).
|
||||
/// </summary>
|
||||
public float Duration { get; set; }
|
||||
public float Duration { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the frame rate of the asset (for video).
|
||||
/// </summary>
|
||||
public float FrameRate { get; set; }
|
||||
public float FrameRate { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,45 +9,49 @@ public class AssetPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the asset.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
public Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the MIME type of the asset.
|
||||
/// </summary>
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution width of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionWidth { get; set; }
|
||||
public int ResolutionWidth { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the resolution height of the asset in pixels.
|
||||
/// </summary>
|
||||
public int ResolutionHeight { get; set; }
|
||||
public int ResolutionHeight { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated thumbnail.
|
||||
/// </summary>
|
||||
public bool HasThumbnail { get; set; }
|
||||
public bool HasThumbnail { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset has a generated preview.
|
||||
/// </summary>
|
||||
public bool HasPreview { get; set; }
|
||||
public bool HasPreview { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album titles associated with the asset.
|
||||
/// </summary>
|
||||
public List<string>? AlbumNames { get; set; }
|
||||
public List<string>? AlbumNames { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
|
||||
/// </summary>
|
||||
public List<Guid>? AlbumIds { get; set; }
|
||||
public List<Guid>? AlbumIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
|
||||
/// </summary>
|
||||
public List<string>? CosplayerNames { get; set; }
|
||||
public List<string>? CosplayerNames { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
|
||||
/// </summary>
|
||||
public List<Guid>? CosplayerIds { get; set; }
|
||||
public List<Guid>? CosplayerIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the original filename of the asset.
|
||||
/// </summary>
|
||||
public string? FileName { get; set; }
|
||||
public string? FileName { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Asset;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,15 +7,15 @@ namespace Butter.Dtos.Asset;
|
||||
/// </summary>
|
||||
public class AssetUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the new owner ID for the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public Guid? Owner { get; set; }
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the deletion date to set on the asset.
|
||||
/// </summary>
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
public DateTime? DeletedAt { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public bool? IsPublic { get; set; }
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ namespace Butter.Dtos;
|
||||
/// Represents pagination parameters for a query.
|
||||
/// </summary>
|
||||
public class PagedParametersDto {
|
||||
|
||||
/// <summary>
|
||||
/// Contains the global constant for max pages.
|
||||
/// </summary>
|
||||
public const int MaxPageSize = 250;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the page number (zero-based).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,25 @@ public class PersonCreateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
@@ -39,7 +40,19 @@ public class PersonDetailedDto {
|
||||
/// </summary>
|
||||
public int TotalAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of album previews associated with this person.
|
||||
/// </summary>
|
||||
public List<AlbumPreviewDto>? Albums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of users who maintain this person.
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainerUserIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the usernames of users who maintain this person.
|
||||
/// </summary>
|
||||
public List<string>? MaintainerUsernames { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,29 +9,33 @@ public class PersonPreviewDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the person.
|
||||
/// </summary>
|
||||
public required Guid Id { get; set; }
|
||||
public required Guid Id { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID used as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of albums associated with this person.
|
||||
/// </summary>
|
||||
public int TotalAlbums { get; set; }
|
||||
public int TotalAlbums { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Butter.Types;
|
||||
|
||||
namespace Butter.Dtos.Person;
|
||||
|
||||
/// <summary>
|
||||
@@ -7,21 +9,29 @@ public class PersonUpdateDto {
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the person.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the asset ID to use as the profile picture.
|
||||
/// </summary>
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
public Guid? ProfileAssetId { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropX { get; set; }
|
||||
public float? ProfileCropX { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the vertical crop offset for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropY { get; set; }
|
||||
public float? ProfileCropY { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the zoom level for the profile picture.
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level.
|
||||
/// </summary>
|
||||
public EVisibility? Visibility { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainerUserIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -75,7 +75,11 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int PublicAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of non-publicly-shared assets.
|
||||
/// Gets or sets the number of assets with protected visibility (authenticated only).
|
||||
/// </summary>
|
||||
public int ProtectedAssets { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of private assets.
|
||||
/// </summary>
|
||||
public int PrivateAssets { get; set; }
|
||||
|
||||
@@ -116,10 +120,9 @@ public class StatsDto {
|
||||
/// </summary>
|
||||
public int AlbumsMissingCover { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
|
||||
/// Gets or sets the number of people/cosplayers without a profile picture.
|
||||
/// </summary>
|
||||
/// <remarks>Not wired yet — reserved for future use.</remarks>
|
||||
public int CosplayersMissingCover { get; set; }
|
||||
public int CosplayersMissingProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the top tags by asset count.
|
||||
|
||||
@@ -46,4 +46,13 @@ public class UserInfoDto {
|
||||
/// Gets or sets the access level of the user.
|
||||
/// </summary>
|
||||
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
|
||||
/// </summary>
|
||||
public List<string>? MaintainedPersonNames { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ public string? OldPassword { get; set; }
|
||||
/// Gets or sets the new access level.
|
||||
/// </summary>
|
||||
public EAccessLevel? AccessLevel { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
|
||||
/// </summary>
|
||||
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ public enum Settings {
|
||||
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
||||
ThumbnailQuality,
|
||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||
PreviewQuality
|
||||
PreviewQuality,
|
||||
/// <summary>User ID used for scanner-created asset attribution.</summary>
|
||||
SystemUploaderId
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -59,6 +61,7 @@ public static class SettingsExtensions {
|
||||
Settings.JobBatchSize => "Job Batch Size",
|
||||
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||
Settings.PreviewQuality => "Preview Quality",
|
||||
Settings.SystemUploaderId => "System Uploader Id",
|
||||
_ => setting.ToString() // Fallback to the enum name
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace Butter.Types;
|
||||
/// </summary>
|
||||
public enum EAccessLevel {
|
||||
/// <summary>Standard user with basic permissions.</summary>
|
||||
User,
|
||||
User = 0,
|
||||
/// <summary>Maintainer — scoped cosplayer management and tag management.</summary>
|
||||
Maintainer = 1,
|
||||
/// <summary>Curator with elevated permissions for managing assets.</summary>
|
||||
Curator,
|
||||
Curator = 2,
|
||||
/// <summary>Administrator with full system access.</summary>
|
||||
Admin
|
||||
Admin = 3
|
||||
}
|
||||
|
||||
13
Butter/Types/EVisibility.cs
Normal file
13
Butter/Types/EVisibility.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace Butter.Types;
|
||||
|
||||
/// <summary>
|
||||
/// Defines visibility levels for entities in the system.
|
||||
/// </summary>
|
||||
public enum EVisibility {
|
||||
/// <summary>Visible to everyone, including anonymous users.</summary>
|
||||
Public = 0,
|
||||
/// <summary>Visible to authenticated users only.</summary>
|
||||
Protected = 1,
|
||||
/// <summary>Visible only to authorized users (maintainers, curators, admins).</summary>
|
||||
Private = 2
|
||||
}
|
||||
@@ -15,9 +15,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Diagnostics;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Lactose.Analyzers;
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CodeActions;
|
||||
using Microsoft.CodeAnalysis.CodeFixes;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Editing;
|
||||
using System.Collections.Immutable;
|
||||
using System.Composition;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Lactose.Analyzers;
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ public class LactoseDbContext : DbContext {
|
||||
/// Gets or sets the job records DbSet.
|
||||
/// </summary>
|
||||
public DbSet<JobRecord> JobRecords { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the person-maintainer join table.
|
||||
/// </summary>
|
||||
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
||||
@@ -60,18 +64,30 @@ public class LactoseDbContext : DbContext {
|
||||
/// <inheritdoc />
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
||||
//Asset Relationships
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets);
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Uploader).WithMany(e => e.UploadedAssets).HasForeignKey(e => e.UploadedBy);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
||||
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
// People
|
||||
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||
.HasDatabaseName("IX_People_Name_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
// Albums
|
||||
modelBuilder.Entity<Album>().HasIndex(a => a.Title)
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm")
|
||||
.HasMethod("gin")
|
||||
.HasOperators("gin_trgm_ops");
|
||||
//Face Relationships
|
||||
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
|
||||
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
|
||||
@@ -79,12 +95,16 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
|
||||
modelBuilder.Entity<Person>().HasMany(e => e.Maintainers).WithMany(e => e.MaintainedPersons)
|
||||
.UsingEntity<PersonMaintainer>(
|
||||
j => j.HasOne(pm => pm.User).WithMany().HasForeignKey(pm => pm.UserId),
|
||||
j => j.HasOne(pm => pm.Person).WithMany().HasForeignKey(pm => pm.PersonId)
|
||||
);
|
||||
//Tag Relationships
|
||||
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
||||
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
||||
//User Relationships
|
||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
|
||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
|
||||
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
|
||||
//Folder Relationships
|
||||
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="albumRepository">Album repository.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="personRepository">Person repository.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/[controller]")]
|
||||
@@ -24,7 +25,8 @@ public class AlbumController(
|
||||
//ILogger<AlbumController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAlbumRepository albumRepository,
|
||||
IAssetRepository assetRepository
|
||||
IAssetRepository assetRepository,
|
||||
IPersonRepository personRepository
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets an album by its ID with filtered assets based on access level.
|
||||
@@ -36,37 +38,12 @@ public class AlbumController(
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = albumRepository.FindVisible(id, uid, accessLevel);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// If the album has no assets, return an empty list
|
||||
if (album.Assets == null) return Ok(album.ToAlbumFullDto());
|
||||
|
||||
//Storage for assets that will be sent out;
|
||||
List<Asset> assets = new();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User:
|
||||
// Only publicly shared assets or owned by user
|
||||
assets.AddRange(album.Assets.Where(asset => (asset.IsPubliclyShared || asset.OwnerId == uid) && asset.DeletedAt == null));
|
||||
// Add shared assets
|
||||
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid) && asset.DeletedAt == null));
|
||||
break;
|
||||
case EAccessLevel.Curator:
|
||||
// all assets minus deleted
|
||||
assets.AddRange(album.Assets.Where(asset => asset.DeletedAt == null));
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// All assets
|
||||
assets.AddRange(album.Assets);
|
||||
break;
|
||||
}
|
||||
|
||||
// changes the retrieved album to the new filtered
|
||||
album.Assets = assets.OrderBy(a => a.OriginalFilename).ToList();
|
||||
return Ok(album.ToAlbumFullDto());
|
||||
return Ok(album.ToAlbumFullDto(accessLevel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -75,36 +52,25 @@ public class AlbumController(
|
||||
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
|
||||
/// <returns>A list of album previews accessible to the user.</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 0) return BadRequest();
|
||||
|
||||
|
||||
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
|
||||
|
||||
var albums = albumRepository.SearchQuery(
|
||||
pagingOptions.Search ?? string.Empty,
|
||||
pagingOptions.Page,
|
||||
pagingOptions.PageSize,
|
||||
pagingOptions.SortBy,
|
||||
pagingOptions.SortAsc,
|
||||
pagingOptions.Unassigned
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy
|
||||
).ToList();
|
||||
|
||||
switch (accessLevel) {
|
||||
default:
|
||||
case EAccessLevel.User:
|
||||
List<Album> filteredAlbums;
|
||||
// Only publicly shared albums or owned by user
|
||||
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList();
|
||||
// Add shared albums
|
||||
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid))));
|
||||
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin:
|
||||
// All albums
|
||||
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
||||
}
|
||||
return Ok(albums);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -114,7 +80,7 @@ public class AlbumController(
|
||||
/// <param name="albumDto">The album update data.</param>
|
||||
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -124,22 +90,19 @@ public class AlbumController(
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
// Maintainer scope check — must maintain the album's person
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums they don't own
|
||||
if(album.UserOwnerId != uid) return Forbid();
|
||||
break;
|
||||
// Admins and Curator can update any album
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin:
|
||||
break;
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// Update album properties
|
||||
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
|
||||
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||
|
||||
@@ -156,7 +119,7 @@ public class AlbumController(
|
||||
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -167,9 +130,13 @@ public class AlbumController(
|
||||
case EAccessLevel.User:
|
||||
// Users can't update albums
|
||||
return Forbid();
|
||||
case EAccessLevel.Maintainer:
|
||||
// Maintainers can only update albums of persons they maintain
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
if (albums.Count == 0) return Forbid();
|
||||
break;
|
||||
case EAccessLevel.Curator:
|
||||
// Curators can only update albums they own
|
||||
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
|
||||
// Curators can update any album
|
||||
break;
|
||||
case EAccessLevel.Admin:
|
||||
// Admins can update any album
|
||||
@@ -180,7 +147,7 @@ public class AlbumController(
|
||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||
album.UpdatedAt = DateTime.UtcNow;
|
||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||
album.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
|
||||
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||
}
|
||||
@@ -197,14 +164,10 @@ public class AlbumController(
|
||||
/// <param name="albumDto">The album creation data.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPut]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
// If not admin, the owner of a new album is the current user
|
||||
albumDto.Owner = accessLevel == EAccessLevel.Admin ? albumDto.Owner : uid;
|
||||
|
||||
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
|
||||
|
||||
var album = new Album {
|
||||
@@ -212,7 +175,7 @@ public class AlbumController(
|
||||
Title = albumDto.Name,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
PersonOwnerId = albumDto.Person ?? null,
|
||||
UserOwnerId = albumDto.Owner,
|
||||
Visibility = albumDto.Visibility,
|
||||
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
|
||||
Assets = assets
|
||||
};
|
||||
@@ -229,18 +192,23 @@ public class AlbumController(
|
||||
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var albums = albumRepository.FindBulk(albumIds).ToList();
|
||||
|
||||
// If the user is not an admin, filter out albums they do not own
|
||||
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
|
||||
// Filter by access level
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer:
|
||||
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||
break;
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
// If no albums are left after filtering, return a 403
|
||||
if (albumIds.Count == 0) return Forbid();
|
||||
if (albums.Count == 0) return Forbid();
|
||||
|
||||
albums.ForEach(albumRepository.Remove);
|
||||
albumRepository.Save();
|
||||
@@ -254,7 +222,7 @@ public class AlbumController(
|
||||
/// <param name="id">The album ID.</param>
|
||||
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public ActionResult Delete(Guid id) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
@@ -263,8 +231,13 @@ public class AlbumController(
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
// If the user is not the owner or an admin, return a 403
|
||||
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) return Forbid();
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||
return Forbid();
|
||||
case EAccessLevel.User:
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
albumRepository.Remove(album);
|
||||
albumRepository.Save();
|
||||
|
||||
@@ -18,13 +18,15 @@ namespace Lactose.Controllers;
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="userRepository">User repository.</param>
|
||||
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AssetController(
|
||||
ILogger<AssetController> logger,
|
||||
LactoseAuthService authService,
|
||||
IAssetRepository assetRepository,
|
||||
IUserRepository userRepository
|
||||
IUserRepository userRepository,
|
||||
IPersonRepository personRepository
|
||||
) : ControllerBase {
|
||||
/// <summary>
|
||||
/// Gets an asset by its ID with full details, respecting access level.
|
||||
@@ -44,50 +46,14 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = assetRepository.FindVisible(id, uid, accessLevel);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
logger.LogWarning($"Request asset {id} not found or not visible!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
//Not publicly shared && Not shared with the user
|
||||
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
|
||||
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
//Deleted asset
|
||||
if (asset.DeletedAt != null) {
|
||||
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator: {
|
||||
//Deleted asset owned by the user
|
||||
if (asset.DeletedAt != null)
|
||||
if (asset.Owner?.Id != uid) {
|
||||
logger.LogTrace(
|
||||
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
|
||||
);
|
||||
|
||||
return NotFound();
|
||||
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Admin: {
|
||||
//Admins will see this regardless
|
||||
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
|
||||
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
|
||||
|
||||
logger.LogTrace(
|
||||
$"""
|
||||
@@ -95,11 +61,11 @@ public class AssetController(
|
||||
dto.Id: {dto.Id}
|
||||
dto.FileName: {dto.FileName}
|
||||
dto.AssetType: {dto.AssetType}
|
||||
dto.IsPublic: {dto.IsPublic}
|
||||
dto.Visibility: {dto.Visibility}
|
||||
dto.CreatedAt: {dto.CreatedAt}
|
||||
dto.UpdatedAt: {dto.UpdatedAt}
|
||||
dto.DeletedAt: {dto.DeletedAt}
|
||||
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
|
||||
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -124,14 +90,15 @@ public class AssetController(
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1) {
|
||||
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
|
||||
logger.LogWarning("Invalid pagination parameters provided");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var assets = assetRepository.GetAssets(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page + 1, searchOptionsDto.PageSize, out int total
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
uid, accessLevel
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -146,21 +113,7 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.User: {
|
||||
assets = assets.Where(
|
||||
a => a.DeletedAt == null && (a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null)
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case EAccessLevel.Curator:
|
||||
case EAccessLevel.Admin: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var dtoList = assets.ToAssetPreviewDto().ToList();
|
||||
var dtoList = assets.ToAssetPreviewDto(accessLevel).ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
@@ -174,32 +127,35 @@ public class AssetController(
|
||||
/// <param name="dto">The asset update data.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
string log = "";
|
||||
|
||||
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
|
||||
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
|
||||
asset.Visibility = dto.Visibility ?? asset.Visibility;
|
||||
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
|
||||
|
||||
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
||||
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
||||
|
||||
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
|
||||
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
|
||||
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
|
||||
}
|
||||
|
||||
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||
|
||||
@@ -215,7 +171,7 @@ public class AssetController(
|
||||
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
@@ -234,13 +190,19 @@ public class AssetController(
|
||||
}
|
||||
|
||||
//If the user is not an admin, they can only update their own assets
|
||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(
|
||||
x => {
|
||||
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
|
||||
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
|
||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||
|
||||
if (accesslevel == EAccessLevel.Admin) {
|
||||
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -254,18 +216,19 @@ public class AssetController(
|
||||
/// <param name="id">The asset ID.</param>
|
||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
var asset = assetRepository.Find(id);
|
||||
var asset = assetRepository.FindWithAlbums(id);
|
||||
|
||||
if (asset == null) {
|
||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
||||
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
||||
return Unauthorized();
|
||||
}
|
||||
@@ -283,7 +246,7 @@ public class AssetController(
|
||||
/// <param name="ids">The list of asset IDs to delete.</param>
|
||||
/// <returns>200 on success, or an error response.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Curator,Admin")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
@@ -302,7 +265,10 @@ public class AssetController(
|
||||
}
|
||||
|
||||
//If the user is not an admin, they can only delete their own assets
|
||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
||||
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||
|
||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||
|
||||
@@ -322,23 +288,22 @@ public class AssetController(
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||
|
||||
if (userRepository.Find(dto.Owner) == null) {
|
||||
logger.LogWarning($"User {dto.Owner} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
if (dto.UploadedBy.HasValue) {
|
||||
if (userRepository.Find(dto.UploadedBy.Value) == null) {
|
||||
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
|
||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
|
||||
return Unauthorized();
|
||||
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
|
||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: This makes exactly zero sense. How are we supposed to create an asset externally here?
|
||||
// Does this even need to exist?
|
||||
var asset = new Asset {
|
||||
Id = Guid.NewGuid(),
|
||||
Type = dto.AssetType,
|
||||
IsPubliclyShared = dto.IsPublic,
|
||||
OwnerId = dto.Owner,
|
||||
Visibility = dto.Visibility,
|
||||
UploadedBy = dto.UploadedBy,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
MimeType = dto.MimeType,
|
||||
ResolutionWidth = dto.ResolutionWidth,
|
||||
@@ -355,4 +320,14 @@ public class AssetController(
|
||||
|
||||
return Ok(asset.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
|
||||
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
|
||||
/// </summary>
|
||||
/// <param name="uid">The maintainer's user ID.</param>
|
||||
/// <param name="asset">The asset to check.</param>
|
||||
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
|
||||
private bool IsMaintainedByUser(Guid uid, Asset asset) =>
|
||||
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Jobs;
|
||||
using Butter.Types;
|
||||
using Lactose.Jobs;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -35,7 +35,8 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
||||
/// </summary>
|
||||
[HttpGet("past")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
|
||||
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
|
||||
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace Lactose.Controllers;
|
||||
/// </summary>
|
||||
/// <param name="authService">Authentication service.</param>
|
||||
/// <param name="mediaRepository">Media repository.</param>
|
||||
/// <param name="assetRepository">Asset repository.</param>
|
||||
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -43,7 +44,7 @@ public class MediaController(
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||
}
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id);
|
||||
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -65,7 +66,7 @@ public class MediaController(
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id);
|
||||
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
@@ -87,7 +88,7 @@ public class MediaController(
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
}
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
||||
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
return PhysicalFile(data.Path, data.MimeType);
|
||||
|
||||
@@ -27,42 +27,19 @@ public class PersonController(
|
||||
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <param name="albumPaging">Pagination parameters for albums.</param>
|
||||
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
|
||||
/// <returns>The person details with albums, or 404 if not found.</returns>
|
||||
[HttpGet("{id}")]
|
||||
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedParametersDto albumPaging) {
|
||||
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.Find(id);
|
||||
var person = personRepository.FindVisible(id, uid, accessLevel,
|
||||
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||
albumPaging.Page, albumPaging.PageSize);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (person.Albums != null && accessLevel == EAccessLevel.User) {
|
||||
var owned = person.Albums.Where(a => a.UserOwnerId == uid).ToList();
|
||||
var publicShared = person.Albums
|
||||
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is { IsPubliclyShared: true, DeletedAt: null }))
|
||||
.ToList();
|
||||
var shared = person.Albums
|
||||
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is {
|
||||
DeletedAt: null, SharedWith: { Count: > 0 }
|
||||
} && asset.SharedWith!.Any(u => u.Id == uid)))
|
||||
.ToList();
|
||||
|
||||
person.Albums = owned
|
||||
.Union(publicShared)
|
||||
.Union(shared)
|
||||
.DistinctBy(a => a.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (person.Albums != null) {
|
||||
person.Albums = person.Albums
|
||||
.Skip(albumPaging.Page * albumPaging.PageSize)
|
||||
.Take(albumPaging.PageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return Ok(person.ToPersonDetailedDto());
|
||||
return Ok(person.ToPersonDetailedDto(accessLevel));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,11 +48,12 @@ public class PersonController(
|
||||
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
|
||||
/// <returns>A paginated list of person previews.</returns>
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||
|
||||
var people = personRepository.SearchQuery(
|
||||
pagedSearch.Search ?? string.Empty,
|
||||
pagedSearch.Page,
|
||||
@@ -84,8 +62,7 @@ public class PersonController(
|
||||
pagedSearch.SortAsc,
|
||||
uid ?? default,
|
||||
accessLevel
|
||||
).Select(p => p.ToPersonPreviewDto())
|
||||
.ToList();
|
||||
).ToList();
|
||||
return Ok(people);
|
||||
}
|
||||
|
||||
@@ -96,16 +73,31 @@ public class PersonController(
|
||||
/// <param name="dto">The updated person data.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpPost("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||
var uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var person = personRepository.Find(id);
|
||||
if (person == null) return NotFound();
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer) {
|
||||
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
|
||||
return Forbid();
|
||||
}
|
||||
|
||||
person.Name = dto.Name;
|
||||
person.ProfileAssetId = dto.ProfileAssetId;
|
||||
person.ProfileCropX = dto.ProfileCropX;
|
||||
person.ProfileCropY = dto.ProfileCropY;
|
||||
person.ProfileCropZoom = dto.ProfileCropZoom;
|
||||
|
||||
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
|
||||
person.Visibility = dto.Visibility.Value;
|
||||
|
||||
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
|
||||
|
||||
person.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
personRepository.Save();
|
||||
@@ -143,7 +135,6 @@ public class PersonController(
|
||||
/// <summary>
|
||||
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="id">The person ID.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
@@ -170,7 +161,6 @@ public class PersonController(
|
||||
/// <summary>
|
||||
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of person IDs to delete.</param>
|
||||
/// <returns>200 on success.</returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
|
||||
@@ -70,7 +70,7 @@ public class TagController(
|
||||
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
||||
pagedSearch.Search ??= string.Empty;
|
||||
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
||||
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
|
||||
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
|
||||
|
||||
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
||||
List<TagDto> tagsDto = [];
|
||||
@@ -92,7 +92,7 @@ public class TagController(
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <param name="tagDto">The updated tag data.</param>
|
||||
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost("{id}")]
|
||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
||||
|
||||
@@ -108,7 +108,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPost]
|
||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
||||
|
||||
@@ -127,7 +127,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="id">The tag ID.</param>
|
||||
/// <returns>200 on success, 404 if not found.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete("{id}")]
|
||||
public ActionResult Delete([FromRoute] Guid id) {
|
||||
IStatusCodeActionResult result = DeleteTag(id);
|
||||
@@ -142,7 +142,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="ids">The list of tag IDs to delete.</param>
|
||||
/// <returns>200 on success, or the first error response.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpDelete]
|
||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||
foreach (Guid id in ids) {
|
||||
@@ -160,7 +160,7 @@ public class TagController(
|
||||
/// </summary>
|
||||
/// <param name="tagDto">The tag creation data.</param>
|
||||
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
|
||||
[Authorize(Roles = "Admin, Curator")]
|
||||
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||
[HttpPut]
|
||||
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
||||
|
||||
|
||||
@@ -83,26 +83,28 @@ namespace Lactose.Controllers {
|
||||
return Unauthorized("Unauthorized");
|
||||
}
|
||||
|
||||
//TODO: Lower level of access should fill less information of users
|
||||
logger.LogDebug($"Sending user with id {id}");
|
||||
|
||||
User? user = userRepository.Find(id);
|
||||
User? user = userRepository.FindWithMaintainedPersons(id);
|
||||
|
||||
if (user is null) return NotFound();
|
||||
|
||||
return Ok(user.ToGetUsersDto());
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all users.
|
||||
/// Gets all users (admin/curator only).
|
||||
/// </summary>
|
||||
/// <returns>A list of all users.</returns>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[Authorize(Roles = "Admin,Curator")]
|
||||
[HttpGet]
|
||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.Admin;
|
||||
|
||||
logger.LogDebug("Sending Users list");
|
||||
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
|
||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||
}
|
||||
|
||||
|
||||
@@ -168,10 +170,14 @@ namespace Lactose.Controllers {
|
||||
}
|
||||
|
||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||
|
||||
if (dto.MaintainedPersonIds != null) {
|
||||
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
|
||||
}
|
||||
}
|
||||
|
||||
userRepository.Save();
|
||||
return Ok(user.ToGetUsersDto());
|
||||
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -16,7 +16,6 @@ WORKDIR "/src/Lactose"
|
||||
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
FROM build AS publish
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ public sealed class FileSystemCrawlJob : Job {
|
||||
OriginalPath = finfo.FullName,
|
||||
OriginalFilename = finfo.Name,
|
||||
Type = type.Value,
|
||||
IsPubliclyShared = false,
|
||||
Visibility = EVisibility.Private,
|
||||
CreatedAt = finfo.CreationTimeUtc,
|
||||
UpdatedAt = finfo.LastWriteTimeUtc,
|
||||
MimeType = type.Value switch {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Butter.Types;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
@@ -147,4 +146,22 @@ public abstract class Job {
|
||||
/// Raised when the job is done, regardless of the final status (Completed, Canceled, or Failed).
|
||||
/// </summary>
|
||||
public virtual event EventHandler<JobStatus>? Done;
|
||||
|
||||
/// <summary>
|
||||
/// Raises the StartedWaiting event.
|
||||
/// </summary>
|
||||
/// <param name="e">The job status.</param>
|
||||
protected virtual void OnStartedWaiting(JobStatus e) { StartedWaiting?.Invoke(this, e); }
|
||||
|
||||
/// <summary>
|
||||
/// Raises the FinishedWaiting event.
|
||||
/// </summary>
|
||||
/// <param name="e">The job status.</param>
|
||||
protected virtual void OnFinishedWaiting(JobStatus e) { FinishedWaiting?.Invoke(this, e); }
|
||||
|
||||
/// <summary>
|
||||
/// Raises the ProgressChanged event.
|
||||
/// </summary>
|
||||
/// <param name="e">The job status.</param>
|
||||
protected virtual void OnProgressChanged(JobStatus e) { ProgressChanged?.Invoke(this, e); }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Repositories;
|
||||
using Lactose.Utils;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using CoenM.ImageHash.HashAlgorithms;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
using CoenM.ImageHash.HashAlgorithms;
|
||||
using Lactose.Utils;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using System.Collections;
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Butter.Dtos.Settings;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using Butter.Dtos.Settings;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
@@ -14,19 +14,17 @@
|
||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.15">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -8,44 +9,45 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class AlbumMapper {
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album) => new AlbumFullDto {
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>An AlbumPreviewDto object.</returns>
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel) => new AlbumPreviewDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count ?? 0,
|
||||
Images = album.Assets!.Select(asset => asset.Id).ToList(),
|
||||
AssetPreviews = album.Assets!.Select(asset => new AlbumAssetPreviewDto {
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel) => new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
|
||||
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = asset.OriginalFilename
|
||||
}).ToList()
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumPreviewDto object.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <returns>An AlbumPreviewDto object.</returns>
|
||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count ?? 0
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
@@ -9,41 +10,44 @@ namespace Lactose.Mapper;
|
||||
/// </summary>
|
||||
public static class AssetsMapper {
|
||||
/// <summary>
|
||||
/// Maps an Asset object to a GetFullAssetDto object.
|
||||
/// Maps an Asset object to a AssetDto object.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <returns>A GetFullAssetDto object.</returns>
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>A AssetDto object.</returns>
|
||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
|
||||
Id = asset.Id,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
AssetType = asset.Type,
|
||||
IsPublic = asset.IsPubliclyShared,
|
||||
CreatedAt = asset.CreatedAt,
|
||||
UpdatedAt = asset.UpdatedAt,
|
||||
DeletedAt = asset.DeletedAt,
|
||||
Owner = asset.Owner!.ToGetUsersDto(),
|
||||
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
|
||||
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
|
||||
MimeType = asset.MimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
FileSize = asset.FileSize,
|
||||
Duration = asset.Duration ?? 0,
|
||||
FrameRate = asset.FrameRate ?? 0
|
||||
FrameRate = asset.FrameRate ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Asset object to a GetAssetPreviewDto object.
|
||||
/// Maps an Asset object to a AssetPreviewDto object.
|
||||
/// </summary>
|
||||
/// <param name="asset">The asset to map.</param>
|
||||
/// <returns>A GetAssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <returns>A AssetPreviewDto object.</returns>
|
||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel) => new AssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
MimeType = asset.MimeType,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = asset.OriginalFilename,
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
|
||||
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
|
||||
CosplayerNames = asset.Albums?
|
||||
@@ -55,14 +59,16 @@ public static class AssetsMapper {
|
||||
.Where(a => a.PersonOwner != null)
|
||||
.Select(a => a.PersonOwner!.Id)
|
||||
.Distinct()
|
||||
.ToList()
|
||||
.ToList(),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps a collection of Asset objects to a collection of GetAssetPreviewDto objects.
|
||||
/// Maps a collection of Asset objects to a collection of AssetPreviewDto objects.
|
||||
/// </summary>
|
||||
/// <param name="assets">The collection of assets to map.</param>
|
||||
/// <returns>A collection of GetAssetPreviewDto objects.</returns>
|
||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets)
|
||||
=> assets.Select(ToAssetPreviewDto);
|
||||
/// <param name="accessLevel">The access level of the user.</param>
|
||||
/// <returns>A collection of AssetPreviewDto objects.</returns>
|
||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel)
|
||||
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -12,9 +13,11 @@ public static class PersonMapper {
|
||||
/// Maps a Person to a PersonDetailedDto with album previews and stats.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A PersonDetailedDto.</returns>
|
||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person) {
|
||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel) {
|
||||
var albums = person.Albums ?? [];
|
||||
var maintainers = person.Maintainers ?? [];
|
||||
|
||||
return new PersonDetailedDto {
|
||||
Id = person.Id,
|
||||
@@ -25,7 +28,10 @@ public static class PersonMapper {
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = albums.Count,
|
||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||
Albums = albums.Select(ToAlbumPreviewDto).ToList()
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel)).ToList(),
|
||||
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,24 +39,16 @@ public static class PersonMapper {
|
||||
/// Maps a Person to a PersonPreviewDto.
|
||||
/// </summary>
|
||||
/// <param name="person">The person to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A PersonPreviewDto.</returns>
|
||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person) => new() {
|
||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person, EAccessLevel accessLevel) => new() {
|
||||
Id = person.Id,
|
||||
Name = person.Name,
|
||||
ProfileAssetId = person.ProfileAssetId,
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = person.Albums?.Count ?? 0
|
||||
};
|
||||
|
||||
private static AlbumPreviewDto ToAlbumPreviewDto(Album album) => new() {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Owner = album.UserOwnerId,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(asset => asset.DeletedAt == null) ?? 0
|
||||
TotalAlbums = person.Albums?.Count ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos.User;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Mapper;
|
||||
@@ -7,23 +8,33 @@ namespace Lactose.Mapper;
|
||||
/// Provides mapping methods for User objects.
|
||||
/// </summary>
|
||||
public static class UsersMapper {
|
||||
/// <summary>
|
||||
/// Maps a User object to a UserInfoDto object.
|
||||
/// </summary>
|
||||
/// <param name="user">The User object to map.</param>
|
||||
/// <returns>A UserInfoDto object containing the mapped data.</returns>
|
||||
public static UserInfoDto ToGetUsersDto(this User user) {
|
||||
return new UserInfoDto {
|
||||
Id = user.Id,
|
||||
Email = user.Email,
|
||||
Username = user.Username,
|
||||
CreatedAt = user.CreatedAt,
|
||||
IsBanned = user.BannedAt != null,
|
||||
BannedAt = user.BannedAt,
|
||||
LastLogin = user.LastLogin,
|
||||
UpdatedAt = user.UpdatedAt,
|
||||
DeletedAt = user.DeletedAt,
|
||||
AccessLevel = user.AccessLevel,
|
||||
};
|
||||
}
|
||||
/// <summary>
|
||||
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
|
||||
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
|
||||
/// when the viewer is an admin or viewing their own profile.
|
||||
/// </summary>
|
||||
/// <param name="user">The User object to map.</param>
|
||||
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
|
||||
/// <returns>A UserInfoDto object containing the mapped data.</returns>
|
||||
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
|
||||
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
|
||||
bool isSelf = viewerId == user.Id;
|
||||
bool showSensitive = isAdmin || isSelf;
|
||||
|
||||
return new UserInfoDto {
|
||||
Id = user.Id,
|
||||
Username = user.Username,
|
||||
Email = showSensitive ? user.Email : null,
|
||||
CreatedAt = user.CreatedAt,
|
||||
UpdatedAt = showSensitive ? user.UpdatedAt : null,
|
||||
LastLogin = showSensitive ? user.LastLogin : null,
|
||||
BannedAt = showSensitive ? user.BannedAt : null,
|
||||
DeletedAt = isAdmin ? user.DeletedAt : null,
|
||||
IsBanned = showSensitive && user.BannedAt != null,
|
||||
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
|
||||
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
|
||||
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
581
Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs
generated
Normal file
581
Lactose/Migrations/20260712160403_AddAssetsSearchVisibilityIndex.Designer.cs
generated
Normal file
@@ -0,0 +1,581 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260712160403_AddAssetsSearchVisibilityIndex")]
|
||||
partial class AddAssetsSearchVisibilityIndex
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.Property<Guid>("SharedAssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SharedWithId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||
|
||||
b.HasIndex("SharedWithId");
|
||||
|
||||
b.ToTable("AssetUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.HasIndex("IsPubliclyShared", "OwnerId")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedAssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedWithId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetsSearchVisibilityIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets",
|
||||
columns: new[] { "IsPubliclyShared", "OwnerId" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
594
Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs
generated
Normal file
594
Lactose/Migrations/20260712162406_AddTrigramIndexes.Designer.cs
generated
Normal file
@@ -0,0 +1,594 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260712162406_AddTrigramIndexes")]
|
||||
partial class AddTrigramIndexes
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.Property<Guid>("SharedAssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SharedWithId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||
|
||||
b.HasIndex("SharedWithId");
|
||||
|
||||
b.ToTable("AssetUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
|
||||
b.HasIndex("IsPubliclyShared", "OwnerId")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedAssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedWithId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
50
Lactose/Migrations/20260712162406_AddTrigramIndexes.cs
Normal file
50
Lactose/Migrations/20260712162406_AddTrigramIndexes.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrigramIndexes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
|
||||
.Annotation("Npgsql:PostgresExtension:vector", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_People_Name_Trgm",
|
||||
table: "People",
|
||||
column: "Name")
|
||||
.Annotation("Npgsql:IndexMethod", "gin")
|
||||
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Albums_Title_Trgm",
|
||||
table: "Albums",
|
||||
column: "Title")
|
||||
.Annotation("Npgsql:IndexMethod", "gin")
|
||||
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_People_Name_Trgm",
|
||||
table: "People");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Albums_Title_Trgm",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:vector", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,")
|
||||
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
591
Lactose/Migrations/20260714154509_AuthRedesign_VisibilityAccessLevel.Designer.cs
generated
Normal file
591
Lactose/Migrations/20260714154509_AuthRedesign_VisibilityAccessLevel.Designer.cs
generated
Normal file
@@ -0,0 +1,591 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260714154509_AuthRedesign_VisibilityAccessLevel")]
|
||||
partial class AuthRedesign_VisibilityAccessLevel
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AuthRedesign_VisibilityAccessLevel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Albums_Users_UserOwnerId",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_Users_OwnerId",
|
||||
table: "Assets");
|
||||
|
||||
// Remap old EAccessLevel values: old Admin (2) → new Admin (3) first, then old Curator (1) → new Curator (2)
|
||||
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 3 WHERE \"AccessLevel\" = 2");
|
||||
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 2 WHERE \"AccessLevel\" = 1");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AssetUser");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Albums_UserOwnerId",
|
||||
table: "Albums");
|
||||
|
||||
// Add Visibility columns with default Protected (1) before dropping IsPubliclyShared,
|
||||
// so we can map old values via SQL
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Visibility",
|
||||
table: "People",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Visibility",
|
||||
table: "Assets",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Visibility",
|
||||
table: "Albums",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
// Map old IsPubliclyShared = true assets back to Public (0)
|
||||
// Assets that were private default to Protected (1) from the column default
|
||||
migrationBuilder.Sql("UPDATE \"Assets\" SET \"Visibility\" = 0 WHERE \"IsPubliclyShared\" = true");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsPubliclyShared",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UserOwnerId",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "OwnerId",
|
||||
table: "Assets",
|
||||
newName: "UploadedBy");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Assets_OwnerId",
|
||||
table: "Assets",
|
||||
newName: "IX_Assets_UploadedBy");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PersonMaintainers",
|
||||
columns: table => new
|
||||
{
|
||||
PersonId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PersonMaintainers", x => new { x.PersonId, x.UserId });
|
||||
table.ForeignKey(
|
||||
name: "FK_PersonMaintainers_People_PersonId",
|
||||
column: x => x.PersonId,
|
||||
principalTable: "People",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PersonMaintainers_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets",
|
||||
columns: new[] { "Visibility", "UploadedBy" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PersonMaintainers_UserId",
|
||||
table: "PersonMaintainers",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_Users_UploadedBy",
|
||||
table: "Assets",
|
||||
column: "UploadedBy",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Assets_Users_UploadedBy",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PersonMaintainers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Visibility",
|
||||
table: "People");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Visibility",
|
||||
table: "Assets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Visibility",
|
||||
table: "Albums");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "UploadedBy",
|
||||
table: "Assets",
|
||||
newName: "OwnerId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Assets_UploadedBy",
|
||||
table: "Assets",
|
||||
newName: "IX_Assets_OwnerId");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsPubliclyShared",
|
||||
table: "Assets",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "UserOwnerId",
|
||||
table: "Albums",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AssetUser",
|
||||
columns: table => new
|
||||
{
|
||||
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetUser_Assets_SharedAssetsId",
|
||||
column: x => x.SharedAssetsId,
|
||||
principalTable: "Assets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AssetUser_Users_SharedWithId",
|
||||
column: x => x.SharedWithId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleForSearch",
|
||||
table: "Assets",
|
||||
columns: new[] { "IsPubliclyShared", "OwnerId" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Albums_UserOwnerId",
|
||||
table: "Albums",
|
||||
column: "UserOwnerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AssetUser_SharedWithId",
|
||||
table: "AssetUser",
|
||||
column: "SharedWithId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Albums_Users_UserOwnerId",
|
||||
table: "Albums",
|
||||
column: "UserOwnerId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Assets_Users_OwnerId",
|
||||
table: "Assets",
|
||||
column: "OwnerId",
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ namespace Lactose.Migrations
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
@@ -54,21 +55,6 @@ namespace Lactose.Migrations
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.Property<Guid>("SharedAssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("SharedWithId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||
|
||||
b.HasIndex("SharedWithId");
|
||||
|
||||
b.ToTable("AssetUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -91,8 +77,8 @@ namespace Lactose.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UserOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
@@ -100,7 +86,11 @@ namespace Lactose.Migrations
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("UserOwnerId");
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
@@ -133,9 +123,6 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<bool>("IsPubliclyShared")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
@@ -148,9 +135,6 @@ namespace Lactose.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("OwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
@@ -185,6 +169,12 @@ namespace Lactose.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
@@ -192,7 +182,11 @@ namespace Lactose.Migrations
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OwnerId");
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
@@ -328,13 +322,37 @@ namespace Lactose.Migrations
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
@@ -461,21 +479,6 @@ namespace Lactose.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetUser", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedAssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("SharedWithId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
@@ -486,15 +489,9 @@ namespace Lactose.Migrations
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||
.WithMany("OwnedAlbums")
|
||||
.HasForeignKey("UserOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
|
||||
b.Navigation("UserOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
@@ -503,13 +500,13 @@ namespace Lactose.Migrations
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Owner")
|
||||
.WithMany("OwnedAssets")
|
||||
.HasForeignKey("OwnerId");
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Owner");
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
@@ -536,6 +533,25 @@ namespace Lactose.Migrations
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
@@ -564,9 +580,7 @@ namespace Lactose.Migrations
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedAlbums");
|
||||
|
||||
b.Navigation("OwnedAssets");
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
@@ -30,12 +31,6 @@ public class Album {
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user owner identifier.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(UserOwner))]
|
||||
public Guid? UserOwnerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person owner identifier.
|
||||
/// </summary>
|
||||
@@ -48,13 +43,13 @@ public class Album {
|
||||
[ForeignKey(nameof(CoverAsset))]
|
||||
public Guid? CoverAssetId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level of the album.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the user owner of the album. This is the user account who created/owns the album.
|
||||
/// </summary>
|
||||
public User? UserOwner { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person owner of the album. This is the person/model who appears in the album.
|
||||
/// </summary>
|
||||
|
||||
@@ -75,15 +75,15 @@ public class Asset {
|
||||
public required EAssetType Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the owner ID of the asset.
|
||||
/// Gets or sets the ID of the user who uploaded the asset.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Owner))]
|
||||
public Guid? OwnerId { get; set; }
|
||||
[ForeignKey(nameof(UploadedBy))]
|
||||
public Guid? UploadedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the asset is publicly shared.
|
||||
/// Gets or sets the visibility level of the asset.
|
||||
/// </summary>
|
||||
public bool IsPubliclyShared { get; set; }
|
||||
public EVisibility Visibility { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the creation date of the asset.
|
||||
@@ -136,7 +136,7 @@ public class Asset {
|
||||
/// Computed hash of the asset.
|
||||
/// </summary>
|
||||
[Required][Column(TypeName = "bit(64)")]
|
||||
public BitArray Hash { get; set; }
|
||||
public BitArray Hash { get; set; } = new BitArray(64);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or Sets which folder the asset is in.
|
||||
@@ -147,14 +147,9 @@ public class Asset {
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the owner of the asset.
|
||||
/// Gets or sets the user who uploaded the asset.
|
||||
/// </summary>
|
||||
public User? Owner { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of users with whom the asset is shared.
|
||||
/// </summary>
|
||||
public List<User>? SharedWith { get; set; }
|
||||
public User? Uploader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of albums associated with the asset.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
@@ -50,6 +51,11 @@ public class Person {
|
||||
/// </summary>
|
||||
public float? ProfileCropZoom { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility level of the person.
|
||||
/// </summary>
|
||||
public EVisibility Visibility { get; set; }
|
||||
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
@@ -67,5 +73,10 @@ public class Person {
|
||||
/// </summary>
|
||||
public List<Album>? Albums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of users who maintain this cosplayer.
|
||||
/// </summary>
|
||||
public List<User>? Maintainers { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
32
Lactose/Models/PersonMaintainer.cs
Normal file
32
Lactose/Models/PersonMaintainer.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the many-to-many relationship between maintainer users and the cosplayers they maintain.
|
||||
/// </summary>
|
||||
[PrimaryKey(nameof(PersonId), nameof(UserId))]
|
||||
public class PersonMaintainer {
|
||||
/// <summary>
|
||||
/// Gets or sets the person (cosplayer) ID.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(Person))]
|
||||
public Guid PersonId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person (cosplayer).
|
||||
/// </summary>
|
||||
public Person Person { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maintainer user ID.
|
||||
/// </summary>
|
||||
[ForeignKey(nameof(User))]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maintainer user.
|
||||
/// </summary>
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
@@ -78,19 +78,14 @@ public class User {
|
||||
#region Navigation Properties
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets owned by the user.
|
||||
/// Gets or sets the list of assets uploaded by the user.
|
||||
/// </summary>
|
||||
public List<Asset>? OwnedAssets { get; set; }
|
||||
public List<Asset>? UploadedAssets { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of albums owned by the user.
|
||||
/// Gets or sets the list of people (cosplayers) maintained by this user.
|
||||
/// </summary>
|
||||
public List<Album>? OwnedAlbums { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of assets shared with the user.
|
||||
/// </summary>
|
||||
public IEnumerable<Asset>? SharedAssets { get; set; }
|
||||
public List<Person>? MaintainedPersons { get; set; }
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.OpenApi;
|
||||
using Npgsql;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -24,28 +27,81 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false) {
|
||||
IQueryable<Album> albumsQuery = context.Albums
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
||||
.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums;
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
|
||||
|
||||
if (unassigned)
|
||||
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null);
|
||||
|
||||
IOrderedQueryable<Album> ordered = sortBy?.ToLower() switch
|
||||
{
|
||||
"name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title),
|
||||
"updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt),
|
||||
"assets" => sortAsc ? albumsQuery.OrderBy(a => a.Assets.Count) : albumsQuery.OrderByDescending(a => a.Assets.Count),
|
||||
"person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name): albumsQuery.OrderByDescending(a => a.PersonOwner!.Name),
|
||||
_ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt),
|
||||
if (uploadedBy.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
|
||||
|
||||
// Apply visibility filter scoped to access level
|
||||
albumsQuery = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
|
||||
EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == a.PersonOwnerId)),
|
||||
_ => albumsQuery
|
||||
};
|
||||
|
||||
return ordered
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize);
|
||||
IOrderedQueryable<Album> ordered = sortBy?.ToLower() switch
|
||||
{
|
||||
"name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title),
|
||||
"updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt),
|
||||
"assets" => sortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count)
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count),
|
||||
"person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name) : albumsQuery.OrderByDescending(a => a.PersonOwner!.Name),
|
||||
_ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt),
|
||||
};
|
||||
|
||||
var pageOffset = page * pageSize;
|
||||
var albumIds = ordered
|
||||
.Skip(pageOffset)
|
||||
.Take(pageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
if (albumIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var pagedAlbums = context.Albums
|
||||
.Include(a => a.PersonOwner)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
// Compute visible asset count per album at the DB level
|
||||
Dictionary<Guid, int> assetCounts;
|
||||
if (accessLevel >= EAccessLevel.Curator) {
|
||||
assetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, Count = a.Assets!.Count() })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
} else {
|
||||
assetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new {
|
||||
a.Id,
|
||||
Count = a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId != default) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
}
|
||||
|
||||
var dtos = pagedAlbums.Select(a => {
|
||||
var dto = a.ToAlbumPreviewDto(accessLevel);
|
||||
dto.AssetCount = assetCounts.GetValueOrDefault(a.Id, 0);
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -53,13 +109,48 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? Find(Guid id) => context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByOwner(Guid ownerId) => context.Albums.Where(a => a.UserOwnerId == ownerId);
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var album = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
if (album?.Assets == null) return album;
|
||||
|
||||
album.Assets = accessLevel switch {
|
||||
EAccessLevel.Admin => album.Assets,
|
||||
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).ToList(),
|
||||
EAccessLevel.Maintainer when userId.HasValue => FilterAssetsForMaintainer(album, userId.Value),
|
||||
_ => album.Assets.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
)).ToList()
|
||||
};
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
private List<Asset> FilterAssetsForMaintainer(Album album, Guid userId) {
|
||||
if (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == album.PersonOwnerId.Value))
|
||||
return album.Assets!.Where(a => a.DeletedAt == null).ToList();
|
||||
|
||||
return album.Assets!.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Pgvector.EntityFrameworkCore;
|
||||
using System.Collections;
|
||||
using System.Data;
|
||||
@@ -14,6 +13,42 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
/// <inheritdoc />
|
||||
public Asset? Find(Guid id) => context.Assets.Find(id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
IQueryable<Asset> query = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Uploader)
|
||||
.Where(a => a.Id == id);
|
||||
|
||||
return accessLevel switch {
|
||||
>= EAccessLevel.Admin => query.FirstOrDefault(),
|
||||
EAccessLevel.Curator => query.FirstOrDefault(a =>
|
||||
a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer when userId.HasValue => query
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId ||
|
||||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||
context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
|
||||
)),
|
||||
_ => query.FirstOrDefault(a =>
|
||||
a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindWithAlbums(Guid id) => context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
|
||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||
@@ -21,7 +56,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
|
||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize);
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -50,10 +85,13 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindByOwner(Guid ownerId) => context.Assets.Where(a => a.Owner!.Id == ownerId);
|
||||
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.Where(a => a.UploadedBy == uploaderId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets.Where(a => ids.Contains(a.Id));
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.Where(a => ids.Contains(a.Id));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
|
||||
@@ -135,7 +173,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total) {
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var query = context.Assets.AsQueryable();
|
||||
if (type.HasValue)
|
||||
query = query.Where(a => a.Type == type.Value);
|
||||
@@ -144,6 +182,25 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.UpdatedAt <= to.Value);
|
||||
|
||||
// Apply visibility filter
|
||||
query = accessLevel switch {
|
||||
EAccessLevel.Admin => query,
|
||||
EAccessLevel.Curator => query.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer => query.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId ||
|
||||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||
context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == al.PersonOwnerId.Value))
|
||||
)),
|
||||
_ => query.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
))
|
||||
};
|
||||
|
||||
total = query.Count();
|
||||
|
||||
if (orderRandomly && seed.HasValue) {
|
||||
@@ -151,11 +208,12 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
var seedHash = seed.Value.GetHashCode();
|
||||
var shuffledIds = allIds
|
||||
.OrderBy(id => DeterministicHash(id, seedHash))
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
|
||||
var assets = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Where(a => shuffledIds.Contains(a.Id))
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
||||
.ToList();
|
||||
@@ -163,11 +221,13 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
||||
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
||||
}
|
||||
|
||||
IQueryable<Asset> dataQuery = query.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||
IQueryable<Asset> dataQuery = query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
return dataQuery
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Services;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
@@ -37,11 +40,13 @@ public interface IAlbumRepository : IDisposable {
|
||||
public Album? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums by the owner's ID.
|
||||
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
/// <param name="ownerId">The ID of the owner.</param>
|
||||
/// <returns>A collection of albums owned by the specified owner.</returns>
|
||||
public IEnumerable<Album> FindByOwner(Guid ownerId);
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility-scoped asset filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Finds albums by the person's ID.
|
||||
@@ -72,15 +77,18 @@ public interface IAlbumRepository : IDisposable {
|
||||
public void Remove(Album album);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for albums based on a query string with optional sorting and filtering.
|
||||
/// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query string to filter by title.</param>
|
||||
/// <param name="page">The page number for pagination (default is 0).</param>
|
||||
/// <param name="pageSize">The number of results per page (default is 150).</param>
|
||||
/// <param name="pageSize">The number of results per page (default is <see cref="PagedParametersDto.MaxPageSize"/>).</param>
|
||||
/// <param name="sortBy">The field to sort by (e.g. "name", "created", "updated", "assets", "person"). When <c>null</c>, defaults to created descending.</param>
|
||||
/// <param name="sortAsc">Whether to sort ascending. Default is <c>false</c> (descending).</param>
|
||||
/// <param name="unassigned">When <c>true</c>, only return albums with no person assigned.</param>
|
||||
/// <returns>A list of albums that match the search query.</returns>
|
||||
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150, string? sortBy = null, bool sortAsc = false, bool unassigned = false);
|
||||
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
|
||||
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
|
||||
/// <returns>A list of album previews matching the search query.</returns>
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null);
|
||||
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
using System.Collections;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -15,6 +14,23 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||
public Asset? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an asset by its ID, respecting the requesting user's access level.
|
||||
/// Returns null if the asset does not exist or is not visible to the user.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <param name="userId">The requesting user's ID.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>The asset if found and visible; otherwise null.</returns>
|
||||
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an asset by its ID with its album navigation properties loaded.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the asset.</param>
|
||||
/// <returns>Null if not found, otherwise the requested asset with albums.</returns>
|
||||
public Asset? FindWithAlbums(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a set of assets by their IDs.
|
||||
/// </summary>
|
||||
@@ -23,11 +39,11 @@ public interface IAssetRepository : IDisposable {
|
||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets owned by the user.
|
||||
/// Finds all assets uploaded by the user.
|
||||
/// </summary>
|
||||
/// <param name="ownerId">The ID of the owner.</param>
|
||||
/// <returns>A collection of assets owned by the specified user.</returns>
|
||||
public IEnumerable<Asset> FindByOwner(Guid ownerId);
|
||||
/// <param name="uploaderId">The ID of the uploader.</param>
|
||||
/// <returns>A collection of assets uploaded by the specified user.</returns>
|
||||
public IEnumerable<Asset> FindByUploader(Guid uploaderId);
|
||||
|
||||
/// <summary>
|
||||
/// Finds all assets created or updated between the given dates.
|
||||
@@ -161,16 +177,18 @@ public interface IAssetRepository : IDisposable {
|
||||
List<string> GetRandomPathsByFolder(Guid folderId, int count);
|
||||
|
||||
/// <summary>
|
||||
/// Queries assets with optional type filter, date range, and random ordering.
|
||||
/// Queries assets with optional type filter, date range, random ordering, and visibility scoping.
|
||||
/// </summary>
|
||||
/// <param name="type">Optional asset type to filter by.</param>
|
||||
/// <param name="from">Optional start date for the date range filter.</param>
|
||||
/// <param name="to">Optional end date for the date range filter.</param>
|
||||
/// <param name="orderRandomly">If true, orders randomly instead of by date.</param>
|
||||
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
|
||||
/// <param name="pageNumber">The 1-based page number.</param>
|
||||
/// <param name="pageNumber">The zero-based page number.</param>
|
||||
/// <param name="pageSize">The number of items per page.</param>
|
||||
/// <param name="total">The total count of matching assets.</param>
|
||||
/// <param name="total">The total count of matching assets (after visibility filtering).</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level for visibility filtering.</param>
|
||||
/// <returns>A paginated collection of assets matching the filters.</returns>
|
||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total);
|
||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Types;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -11,22 +12,25 @@ public interface IMediaRepository {
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId);
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the thumbnail media data.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId);
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the preview media data.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the media.</param>
|
||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId);
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for person repository operations.
|
||||
@@ -26,6 +25,22 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
Person? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their ID with albums and assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the person.</param>
|
||||
/// <param name="userId">The requesting user's ID for visibility-scoped filtering.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="albumSearch">Optional search term to filter albums by title.</param>
|
||||
/// <param name="albumSortBy">Optional field to sort albums by ("name", "created", "updated", "assets").</param>
|
||||
/// <param name="albumSortAsc">Whether to sort albums ascending.</param>
|
||||
/// <param name="albumPage">Zero-based page number for album pagination.</param>
|
||||
/// <param name="albumPageSize">Number of albums per page (default 150, max 250).</param>
|
||||
/// <returns>The person if found; otherwise, null.</returns>
|
||||
Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Finds a person by their name (case-sensitive).
|
||||
/// </summary>
|
||||
@@ -64,8 +79,8 @@ public interface IPersonRepository : IDisposable {
|
||||
/// <param name="sortAsc">Whether to sort ascending. Default is <c>true</c>.</param>
|
||||
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||
/// <param name="accessLevel">The current user's access level.</param>
|
||||
/// <returns>A paginated list of people matching the query.</returns>
|
||||
IEnumerable<Person> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
/// <returns>A paginated list of person previews matching the query.</returns>
|
||||
IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
|
||||
|
||||
/// <summary>
|
||||
/// Finds multiple people by their IDs.
|
||||
@@ -79,4 +94,20 @@ public interface IPersonRepository : IDisposable {
|
||||
/// </summary>
|
||||
/// <param name="person">The person to remove.</param>
|
||||
void Remove(Person person);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a user is a maintainer of a specific person.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID to check.</param>
|
||||
/// <param name="personId">The person ID to check.</param>
|
||||
/// <returns><see langword="true"/> if the user is a maintainer of the person.</returns>
|
||||
bool IsMaintainerOf(Guid userId, Guid personId);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of user IDs that maintain this person.
|
||||
/// Replaces any existing maintainer assignments.
|
||||
/// </summary>
|
||||
/// <param name="personId">The person ID.</param>
|
||||
/// <param name="userIds">The user IDs to assign as maintainers.</param>
|
||||
void SetMaintainers(Guid personId, IEnumerable<Guid> userIds);
|
||||
}
|
||||
|
||||
@@ -44,9 +44,24 @@ public interface IUserRepository : IDisposable {
|
||||
/// <returns>Returns the user if found </returns>
|
||||
User? FindByUsername(string username);
|
||||
|
||||
/// <summary>
|
||||
/// Get the user with their maintained persons eagerly loaded.
|
||||
/// </summary>
|
||||
/// <param name="id">The user ID.</param>
|
||||
/// <returns>Returns the user with maintained persons if found.</returns>
|
||||
User? FindWithMaintainedPersons(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a user from the database.
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
void Delete(User user);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the list of person (cosplayer) IDs that a user maintains.
|
||||
/// Replaces any existing maintainer assignments.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user ID.</param>
|
||||
/// <param name="personIds">The person IDs to assign.</param>
|
||||
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
|
||||
|
||||
return query
|
||||
.OrderByDescending(j => j.Finished ?? j.Created)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Skip(page * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -1,31 +1,59 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
||||
public class MediaRepository(LactoseDbContext context, IPersonRepository personRepository) : IMediaRepository {
|
||||
/// <summary>
|
||||
/// Trys to get the asset with the given ID, and checks if it is shared with the given user or public.
|
||||
/// Trys to get the asset with the given ID, and checks visibility for the requesting user.
|
||||
/// Public assets are accessible to all; Protected assets require authentication;
|
||||
/// Private assets require the user to be the uploader, a curator+, or a maintainer
|
||||
/// of the cosplayer whose album contains the asset.
|
||||
/// </summary>
|
||||
/// <param name="id">Asset ID</param>
|
||||
/// <param name="userId">UserID we are trying to get this as</param>
|
||||
/// <param name="accessLevel">The requesting user's access level</param>
|
||||
/// <returns></returns>
|
||||
Asset? GetAssetAsUserId(Guid id, Guid? userId) {
|
||||
// Should be equivalent to:
|
||||
// media.IsPubliclyShared && media.SharedWith.Any(user => user.Id == userId)
|
||||
return context.Assets.Include(asset => asset.SharedWith)
|
||||
.FirstOrDefault(asset => asset.Id == id && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.SharedWith!.Any(user => user.Id == userId)
|
||||
));
|
||||
Asset? GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var query = context.Assets.Where(asset => asset.Id == id);
|
||||
|
||||
if (accessLevel >= EAccessLevel.Admin)
|
||||
return query.FirstOrDefault();
|
||||
|
||||
if (accessLevel >= EAccessLevel.Curator)
|
||||
return query.FirstOrDefault(asset =>
|
||||
asset.DeletedAt == null || asset.UploadedBy == userId);
|
||||
|
||||
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||
var asset = query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums)
|
||||
.FirstOrDefault();
|
||||
if (asset == null || asset.DeletedAt != null) return null;
|
||||
if (asset.Visibility == EVisibility.Public) return asset;
|
||||
if (asset.Visibility == EVisibility.Protected) return asset;
|
||||
if (asset.UploadedBy == userId) return asset;
|
||||
if (asset.Albums != null && asset.Albums.Any(a =>
|
||||
a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(userId.Value, a.PersonOwnerId.Value)))
|
||||
return asset;
|
||||
return null;
|
||||
}
|
||||
|
||||
return query.FirstOrDefault(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId) {
|
||||
|
||||
var media = GetAssetAsUserId(id, userId);
|
||||
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
if (media == null) return null;
|
||||
|
||||
@@ -37,12 +65,12 @@ public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
||||
|
||||
//TODO: for Thumbnails the MimeType should be the correct one (depending on which format is used for thumbnails)
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId) {
|
||||
var media = GetAssetAsUserId(id, userId);
|
||||
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
if(media == null) return null;
|
||||
|
||||
if (media.ThumbnailPath == String.Empty) return null;
|
||||
if (string.IsNullOrEmpty(media.ThumbnailPath)) return null;
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = "image/webp",
|
||||
@@ -52,12 +80,12 @@ public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
||||
|
||||
//TODO: for Previews the MimeType should be the correct one (depending on which format is used for previews and if the asset is a video)
|
||||
/// <inheritdoc />
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId) {
|
||||
var media = GetAssetAsUserId(id, userId);
|
||||
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||
|
||||
if(media == null) return null;
|
||||
|
||||
if (media.PreviewPath == String.Empty) return null;
|
||||
if (string.IsNullOrEmpty(media.PreviewPath)) return null;
|
||||
|
||||
return new MediaDto {
|
||||
MimeType = "image/webp",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Butter.Dtos;
|
||||
using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
using Lactose.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
@@ -15,34 +17,122 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? Find(Guid id) => context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.UserOwner)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
|
||||
var person = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
if (person?.Albums == null) return person;
|
||||
|
||||
// Filter albums and assets by visibility
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Admin:
|
||||
case EAccessLevel.Curator:
|
||||
break;
|
||||
|
||||
case EAccessLevel.Maintainer when userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
|
||||
break;
|
||||
|
||||
default:
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Visibility <= EVisibility.Protected || (
|
||||
a.Assets != null && a.Assets.Any(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)
|
||||
)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
foreach (var album in person.Albums) {
|
||||
if (album.Assets != null) {
|
||||
album.Assets = album.Assets
|
||||
.Where(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply album search
|
||||
if (!string.IsNullOrEmpty(albumSearch)) {
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Apply album sorting
|
||||
if (!string.IsNullOrEmpty(albumSortBy)) {
|
||||
person.Albums = albumSortBy.ToLowerInvariant() switch {
|
||||
"name" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Title)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Title)],
|
||||
"created" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.CreatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
|
||||
"updated" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
|
||||
"assets" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
|
||||
_ => person.Albums
|
||||
};
|
||||
}
|
||||
|
||||
// Apply album pagination
|
||||
person.Albums = person.Albums
|
||||
.Skip(albumPage * albumPageSize)
|
||||
.Take(albumPageSize)
|
||||
.ToList();
|
||||
|
||||
return person;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> GetAll() =>
|
||||
context.People.Include(p => p.Albums);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) {
|
||||
if (accessLevel != EAccessLevel.User)
|
||||
return GetAll();
|
||||
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) =>
|
||||
accessLevel switch {
|
||||
// Admin and curator.
|
||||
>= EAccessLevel.Curator => GetAll(),
|
||||
|
||||
var all = context.People
|
||||
.Include(p => p.Albums)!
|
||||
.ThenInclude(a => a.Assets)!
|
||||
.ThenInclude(a => a.SharedWith)
|
||||
.ToList();
|
||||
EAccessLevel.Maintainer => context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id))
|
||||
.ToList(),
|
||||
|
||||
return all.Where(p => p.Albums != null && p.Albums.Any(a =>
|
||||
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
|
||||
))));
|
||||
}
|
||||
EAccessLevel.User => context.People
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => p.Visibility <= EVisibility.Protected)
|
||||
.ToList(),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindByName(string name) =>
|
||||
@@ -60,39 +150,68 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
public void Remove(Person person) => context.People.Remove(person);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Person> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
IQueryable<Person> peopleQuery = context.People
|
||||
.Include(p => p.Albums)!
|
||||
.ThenInclude(a => a.Assets)!
|
||||
.ThenInclude(a => a.SharedWith);
|
||||
public IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||
IQueryable<Person> peopleQuery = context.People;
|
||||
|
||||
// Apply search filter
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%"));
|
||||
|
||||
// Apply sorting
|
||||
IOrderedQueryable<Person> ordered = sortBy?.ToLower() switch
|
||||
{
|
||||
"created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt),
|
||||
"albums" => sortAsc ? peopleQuery.OrderBy(p => p.Albums.Count) : peopleQuery.OrderByDescending(p => p.Albums.Count),
|
||||
_ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name),
|
||||
// Apply visibility filter
|
||||
peopleQuery = accessLevel switch {
|
||||
EAccessLevel.Admin or EAccessLevel.Curator => peopleQuery,
|
||||
|
||||
EAccessLevel.Maintainer => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected
|
||||
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id)),
|
||||
|
||||
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||
};
|
||||
|
||||
var result = ordered
|
||||
.Skip(page * pageSize)
|
||||
// Apply sorting — use subquery counts for "albums"
|
||||
IOrderedQueryable<Person> ordered = sortBy?.ToLower() switch
|
||||
{
|
||||
"created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt),
|
||||
"albums" => sortAsc
|
||||
? peopleQuery.OrderBy(p => p.Albums!.Count)
|
||||
: peopleQuery.OrderByDescending(p => p.Albums!.Count),
|
||||
_ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name),
|
||||
};
|
||||
|
||||
var pageOffset = page * pageSize;
|
||||
var personIds = ordered
|
||||
.Skip(pageOffset)
|
||||
.Take(pageSize)
|
||||
.Select(p => p.Id)
|
||||
.ToList();
|
||||
|
||||
// Filter by visibility client-side (the nested SharedWith.Any() cannot be translated to SQL)
|
||||
if (accessLevel == EAccessLevel.User)
|
||||
result = result.Where(p => p.Albums != null && p.Albums.Any(a =>
|
||||
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
|
||||
asset.IsPubliclyShared ||
|
||||
asset.OwnerId == userId ||
|
||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
|
||||
)))).ToList();
|
||||
if (personIds.Count == 0)
|
||||
return [];
|
||||
|
||||
return result;
|
||||
var pagedPeople = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Albums)
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
|
||||
|
||||
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsMaintainerOf(Guid userId, Guid personId) =>
|
||||
context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == personId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainers(Guid personId, IEnumerable<Guid> userIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.PersonId == personId);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var userId in userIds) {
|
||||
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
using Butter.Settings;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Lactose.Services;
|
||||
using System.Data;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <inheritdoc />
|
||||
public class SettingsRepository(LactoseDbContext context, IServiceProvider serviceProvider) : ISettingsRepository {
|
||||
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
|
||||
/// <inheritdoc />
|
||||
public void Create(Setting setting) {
|
||||
//Check if the settings already exist
|
||||
|
||||
@@ -2,6 +2,7 @@ using Butter.Dtos.Stats;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using System.Collections;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
@@ -51,8 +52,9 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
||||
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
|
||||
|
||||
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
|
||||
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.IsPubliclyShared);
|
||||
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && !a.IsPubliclyShared);
|
||||
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Public);
|
||||
dto.ProtectedAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Protected);
|
||||
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Private);
|
||||
|
||||
dto.AssetsMissingMetadata = context.Assets.Count(a =>
|
||||
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
|
||||
@@ -82,28 +84,33 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
||||
dto.AssetsMissingPhash = context.Assets.Count(a =>
|
||||
a.Hash == emptyHash && a.DeletedAt == null);
|
||||
|
||||
var albumAssetSet = context.Set<Dictionary<string, object>>("AlbumAsset");
|
||||
|
||||
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
|
||||
a.DeletedAt == null && !a.Albums.Any());
|
||||
a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
|
||||
|
||||
dto.AssetsWithNoPerson = context.Assets.Count(a =>
|
||||
a.DeletedAt == null && !a.Albums.Any(al => al.PersonOwnerId != null));
|
||||
a.DeletedAt == null && !albumAssetSet.Any(aa =>
|
||||
EF.Property<Guid?>(aa, "AssetsId") == a.Id
|
||||
&& context.Albums.Any(al => al.Id == EF.Property<Guid?>(aa, "AlbumsId") && al.PersonOwnerId != null)));
|
||||
|
||||
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
|
||||
|
||||
dto.CosplayersMissingCover = 0;
|
||||
dto.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
|
||||
|
||||
dto.TopTags = context.Assets
|
||||
.Where(a => a.DeletedAt == null)
|
||||
.SelectMany(a => a.Tags)
|
||||
.GroupBy(t => new { t.Id, t.Name })
|
||||
.Select(g => new TagStatDto {
|
||||
dto.TopTags = (
|
||||
from at in context.Set<Dictionary<string, object>>("AssetTag")
|
||||
join a in context.Assets.Where(a => a.DeletedAt == null)
|
||||
on EF.Property<Guid?>(at, "AssetsId") equals (Guid?)a.Id
|
||||
join t in context.Tags
|
||||
on EF.Property<Guid?>(at, "TagsId") equals (Guid?)t.Id
|
||||
group t by new { t.Id, t.Name } into g
|
||||
select new TagStatDto {
|
||||
Id = g.Key.Id,
|
||||
Name = g.Key.Name,
|
||||
AssetCount = g.Count()
|
||||
})
|
||||
.OrderByDescending(t => t.AssetCount)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
}
|
||||
).OrderByDescending(t => t.AssetCount).Take(10).ToList();
|
||||
|
||||
var resolutions = context.Assets
|
||||
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
||||
|
||||
@@ -28,4 +28,18 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(User user) => context.Users.Remove(user);
|
||||
|
||||
/// <inheritdoc />
|
||||
public User? FindWithMaintainedPersons(Guid id) =>
|
||||
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
|
||||
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
|
||||
context.PersonMaintainers.RemoveRange(existing);
|
||||
|
||||
foreach (var personId in personIds) {
|
||||
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using Butter;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Models;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Lactose.Services;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Butter.Dtos.Settings;
|
||||
using Butter.Settings;
|
||||
using Butter.Types;
|
||||
using Lactose.Jobs;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Butter.Types;
|
||||
using Lactose.Configuration;
|
||||
using Lactose.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Lactose.Services;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
using Lactose.Configuration;
|
||||
using Lactose.Models;
|
||||
using Lactose.Repositories;
|
||||
using Lactose.Utils;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Content-Type: application/json
|
||||
"username": "{{curatorName}}",
|
||||
"email": "{{curatorName}}@redcode.com",
|
||||
"password": "{{curatorPwd}}",
|
||||
"accessLevel": 1
|
||||
"accessLevel": 2
|
||||
}
|
||||
|
||||
> {%
|
||||
@@ -195,7 +195,7 @@ Content-Type: application/json
|
||||
# =============================================================================
|
||||
|
||||
### 12. Get all users as admin (should succeed)
|
||||
GET {{WepApiTest_HostAddress}}/api/user
|
||||
GET {{WepApiTest_HostAddress}}/api/user?page=0&pageSize=5
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
@@ -214,13 +214,13 @@ Authorization: Bearer {{user_token}}
|
||||
});
|
||||
%}
|
||||
|
||||
### 14. Get all users as curator (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/user
|
||||
### 14. Get all users as curator (should succeed — curators now have access)
|
||||
GET {{WepApiTest_HostAddress}}/api/user?page=0&pageSize=5
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
client.test("Get all users as curator should 403", function () {
|
||||
client.assert(response.status === 403)
|
||||
client.test("Get all users as curator", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -294,6 +294,12 @@ Content-Type: application/json
|
||||
});
|
||||
%}
|
||||
|
||||
> {%
|
||||
client.test("Update own profile as regular user", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
# =============================================================================
|
||||
# PERSON TESTS
|
||||
# =============================================================================
|
||||
@@ -360,17 +366,17 @@ Content-Type: application/json
|
||||
});
|
||||
%}
|
||||
|
||||
### 23. Get all people as anonymous
|
||||
GET {{WepApiTest_HostAddress}}/api/person
|
||||
### 23. Get all people as anonymous (should 401 — controller requires auth)
|
||||
GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5
|
||||
|
||||
> {%
|
||||
client.test("Get all people as anonymous", function () {
|
||||
client.assert(response.status === 200)
|
||||
client.test("Get all people as anonymous should 401", function () {
|
||||
client.assert(response.status === 401)
|
||||
});
|
||||
%}
|
||||
|
||||
### 24. Get all people as regular user
|
||||
GET {{WepApiTest_HostAddress}}/api/person
|
||||
GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
@@ -380,7 +386,7 @@ Authorization: Bearer {{user_token}}
|
||||
%}
|
||||
|
||||
### 25. Get all people as curator
|
||||
GET {{WepApiTest_HostAddress}}/api/person
|
||||
GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
@@ -390,7 +396,7 @@ Authorization: Bearer {{curator_token}}
|
||||
%}
|
||||
|
||||
### 26. Get all people as admin
|
||||
GET {{WepApiTest_HostAddress}}/api/person
|
||||
GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
@@ -661,17 +667,17 @@ Content-Type: application/json
|
||||
});
|
||||
%}
|
||||
|
||||
### 45. Get all albums as anonymous
|
||||
GET {{WepApiTest_HostAddress}}/api/album
|
||||
### 45. Get all albums as anonymous (should 401 — controller requires auth)
|
||||
GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5
|
||||
|
||||
> {%
|
||||
client.test("Get all albums as anonymous", function () {
|
||||
client.assert(response.status === 200)
|
||||
client.test("Get all albums as anonymous should 401", function () {
|
||||
client.assert(response.status === 401)
|
||||
});
|
||||
%}
|
||||
|
||||
### 46. Get all albums as regular user
|
||||
GET {{WepApiTest_HostAddress}}/api/album
|
||||
GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
@@ -681,7 +687,7 @@ Authorization: Bearer {{user_token}}
|
||||
%}
|
||||
|
||||
### 47. Get all albums as curator
|
||||
GET {{WepApiTest_HostAddress}}/api/album
|
||||
GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
@@ -691,7 +697,7 @@ Authorization: Bearer {{curator_token}}
|
||||
%}
|
||||
|
||||
### 48. Get all albums as admin
|
||||
GET {{WepApiTest_HostAddress}}/api/album
|
||||
GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
@@ -880,11 +886,338 @@ Authorization: Bearer {{user_token}}
|
||||
});
|
||||
%}
|
||||
|
||||
# =============================================================================
|
||||
# DATA VISIBILITY TESTS
|
||||
# =============================================================================
|
||||
# These tests verify that each access level only sees the data they are
|
||||
# authorized to see. All test data needed is created inline so the tests
|
||||
# are self-contained regardless of database state.
|
||||
# =============================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup: Create maintainer relationship for visibility tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
### 64. Setup: Assign test user as maintainer of test person
|
||||
POST {{WepApiTest_HostAddress}}/api/person/{{testPersonId}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Bulk Updated Person",
|
||||
"maintainerUserIds": ["{{user_id}}"]
|
||||
}
|
||||
|
||||
> {%
|
||||
client.test("Assign maintainer to test person", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User data visibility (tests 65–68)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
### 65. Regular user: Get own profile — Email visible, maintainedPersonIds visible (self-view)
|
||||
GET {{WepApiTest_HostAddress}}/api/user/{{user_id}}
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User sees own email (self-view)", function () {
|
||||
client.assert(response.status === 200)
|
||||
client.assert(jsonPath(response.body, "$.email") != null, "Self email should be visible")
|
||||
});
|
||||
client.test("User sees own maintainedPersonIds (self-view)", function () {
|
||||
client.assert(jsonPath(response.body, "$.maintainedPersonIds") != null, "Self maintainedPersonIds should be visible")
|
||||
var ids = jsonPath(response.body, "$.maintainedPersonIds");
|
||||
client.assert(ids.length > 0, "maintainedPersonIds should contain at least the test person")
|
||||
});
|
||||
client.test("User does NOT see own deletedAt (admin-only)", function () {
|
||||
client.assert(response.body.deletedAt == null, "Self deletedAt should be hidden")
|
||||
});
|
||||
%}
|
||||
|
||||
### 66. Regular user: Get admin profile — Email, accessLevel, maintainedPersonIds hidden
|
||||
GET {{WepApiTest_HostAddress}}/api/user/{{admin_id}}
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User does NOT see admin email", function () {
|
||||
client.assert(response.status === 200)
|
||||
client.assert(response.body.email === null, "Other user email should be null")
|
||||
});
|
||||
client.test("User does NOT see admin deletedAt", function () {
|
||||
client.assert(response.body.deletedAt === null)
|
||||
});
|
||||
client.test("User does NOT see admin accessLevel (defaults to User=0)", function () {
|
||||
client.assert(response.body.accessLevel === 0, "AccessLevel should default to 0 for other users")
|
||||
});
|
||||
client.test("User does NOT see admin maintainedPersonIds", function () {
|
||||
client.assert(response.body.maintainedPersonIds === null)
|
||||
});
|
||||
%}
|
||||
|
||||
### 67. Curator: Get admin profile — Email and maintainedPersonIds hidden (not self, not admin)
|
||||
GET {{WepApiTest_HostAddress}}/api/user/{{admin_id}}
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
client.test("Curator does NOT see admin email", function () {
|
||||
client.assert(response.status === 200)
|
||||
client.assert(response.body.email === null)
|
||||
});
|
||||
client.test("Curator does NOT see admin maintainedPersonIds", function () {
|
||||
client.assert(response.body.maintainedPersonIds === null)
|
||||
});
|
||||
%}
|
||||
|
||||
### 68. Admin: Get regular user profile — full data visible (email, accessLevel)
|
||||
GET {{WepApiTest_HostAddress}}/api/user/{{user_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Admin sees other user's email", function () {
|
||||
client.assert(response.status === 200)
|
||||
client.assert(jsonPath(response.body, "$.email") != null)
|
||||
});
|
||||
client.test("Admin sees other user's accessLevel", function () {
|
||||
client.assert(jsonPath(response.body, "$.accessLevel") != null)
|
||||
});
|
||||
%}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Person data visibility (tests 69–71)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
### 69. Regular user: Get person detail — maintainerUserIds public, Visibility hidden
|
||||
GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}}
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User gets person detail", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("User sees maintainerUserIds (public info)", function () {
|
||||
client.assert(response.body.maintainerUserIds != null, "maintainerUserIds should be visible to all users")
|
||||
});
|
||||
client.test("User sees maintainerUsernames (public info)", function () {
|
||||
client.assert(response.body.maintainerUsernames != null, "maintainerUsernames should be visible to all users")
|
||||
});
|
||||
client.test("User does NOT see visibility (sensitive)", function () {
|
||||
client.assert(response.body.visibility === null, "visibility should be null for regular user")
|
||||
});
|
||||
%}
|
||||
|
||||
### 70. Curator: Get person detail — maintainerUserIds and visibility visible
|
||||
GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}}
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
client.test("Curator gets person detail", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("Curator sees maintainerUserIds", function () {
|
||||
client.assert(jsonPath(response.body, "$.maintainerUserIds") != null)
|
||||
});
|
||||
client.test("Curator sees visibility", function () {
|
||||
client.assert(jsonPath(response.body, "$.visibility") != null, "visibility should be non-null for curator")
|
||||
});
|
||||
%}
|
||||
|
||||
### 71. Admin: Get person detail — all fields visible
|
||||
GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Admin gets person detail", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("Admin sees maintainerUserIds", function () {
|
||||
client.assert(jsonPath(response.body, "$.maintainerUserIds") != null)
|
||||
});
|
||||
client.test("Admin sees visibility value", function () {
|
||||
client.assert(jsonPath(response.body, "$.visibility") != null)
|
||||
});
|
||||
%}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Asset data visibility (tests 72–77)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
### 72. Admin: Find first asset and verify uploader is loaded
|
||||
GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=1
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
var firstAsset = response.body[0];
|
||||
var assetId = firstAsset != null ? firstAsset.id : null;
|
||||
client.global.set("testAssetId", assetId);
|
||||
client.global.set("hasAssets", assetId != null ? "true" : "false");
|
||||
client.test("Asset search succeeds", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
### 73. Regular user: Get asset detail — FileName hidden, uploader email hidden
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
// Only run assertions if an asset was found — skip gracefully otherwise
|
||||
var hasAssets = client.global.get("hasAssets");
|
||||
if (hasAssets === "true") {
|
||||
client.test("User gets asset detail", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("User does NOT see original fileName", function () {
|
||||
client.assert(response.body.fileName === null, "fileName should be null for regular user but was '" + response.body.fileName + "'")
|
||||
});
|
||||
client.test("User does NOT see uploader email", function () {
|
||||
client.assert(response.body.uploadedBy === null || response.body.uploadedBy.email === null, "uploader email should be null for regular user")
|
||||
});
|
||||
client.test("User does NOT see asset visibility", function () {
|
||||
client.assert(response.body.visibility === null)
|
||||
});
|
||||
} else {
|
||||
client.test("No assets in database — skipping asset visibility tests", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
### 74. Curator: Get asset detail — FileName visible, uploader email hidden (unless self)
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
var hasAssets = client.global.get("hasAssets");
|
||||
if (hasAssets === "true") {
|
||||
client.test("Curator gets asset detail", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("Curator sees original fileName", function () {
|
||||
client.assert(response.body.fileName != null, "fileName should be visible to curator but was null")
|
||||
});
|
||||
client.test("Curator does NOT see uploader email (unless self)", function () {
|
||||
client.assert(response.body.uploadedBy == null || response.body.uploadedBy.email === null, "uploader email should be null for curator viewing another user's asset")
|
||||
});
|
||||
} else {
|
||||
client.test("No assets in database — skipping asset visibility tests", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
### 75. Admin: Get asset detail — FileName and uploader email visible
|
||||
GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
var hasAssets = client.global.get("hasAssets");
|
||||
if (hasAssets === "true") {
|
||||
client.test("Admin gets asset detail", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("Admin sees original fileName", function () {
|
||||
client.assert(response.body.fileName != null)
|
||||
});
|
||||
client.test("Admin sees uploader email (or placeholder if server-scanned)", function () {
|
||||
// uploadedBy is always present (real user or "deleted" placeholder).
|
||||
// If it's a real uploader (non-empty GUID), email must be visible.
|
||||
// If server-scanned (Guid.Empty placeholder), email can be null.
|
||||
var uploader = response.body.uploadedBy;
|
||||
client.assert(uploader != null, "uploadedBy field must be present");
|
||||
if (uploader.id !== "00000000-0000-0000-0000-000000000000") {
|
||||
client.assert(uploader.email != null, "real uploader email should be visible to admin")
|
||||
}
|
||||
});
|
||||
client.test("Admin sees asset visibility", function () {
|
||||
client.assert(response.body.visibility != null)
|
||||
});
|
||||
} else {
|
||||
client.test("No assets in database — skipping asset visibility tests", function () {
|
||||
client.assert(true)
|
||||
});
|
||||
}
|
||||
%}
|
||||
|
||||
### 76. Regular user: Search assets — FileName hidden in previews
|
||||
GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=3
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User searches assets", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("User does NOT see fileName in preview list", function () {
|
||||
var first = response.body[0];
|
||||
client.assert(first === undefined || first === null || first.fileName === null, "fileName should be null in preview for regular user")
|
||||
});
|
||||
%}
|
||||
|
||||
### 77. Curator: Search assets — FileName visible in previews
|
||||
GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=3
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
client.test("Curator searches assets", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
client.test("Curator sees fileName in preview list", function () {
|
||||
var first = response.body[0];
|
||||
client.assert(first != null && first.fileName != null, "fileName should be visible in preview for curator")
|
||||
});
|
||||
%}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization boundary tests (tests 78–81)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
### 78. Regular user: Cannot access stats (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/stats
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User cannot access stats", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
### 79. Regular user: Cannot access settings (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/settings
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User cannot access settings", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
### 80. Regular user: Cannot access user list (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/user
|
||||
Authorization: Bearer {{user_token}}
|
||||
|
||||
> {%
|
||||
client.test("User cannot list all users", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
### 81. Curator: Cannot access settings (should 403)
|
||||
GET {{WepApiTest_HostAddress}}/api/settings
|
||||
Authorization: Bearer {{curator_token}}
|
||||
|
||||
> {%
|
||||
client.test("Curator cannot access settings", function () {
|
||||
client.assert(response.status === 403)
|
||||
});
|
||||
%}
|
||||
|
||||
# =============================================================================
|
||||
# CLEANUP
|
||||
# =============================================================================
|
||||
|
||||
### 64. Delete test person as admin
|
||||
### 82. Delete test person as admin
|
||||
DELETE {{WepApiTest_HostAddress}}/api/person/{{testPersonId}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
@@ -894,7 +1227,7 @@ Authorization: Bearer {{admin_token}}
|
||||
});
|
||||
%}
|
||||
|
||||
### 65. Verify test person is deleted
|
||||
### 83. Verify test person is deleted
|
||||
GET {{WepApiTest_HostAddress}}/api/person/{{testPersonId}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
@@ -904,7 +1237,7 @@ Authorization: Bearer {{admin_token}}
|
||||
});
|
||||
%}
|
||||
|
||||
### 66. Delete curator user as admin
|
||||
### 84. Delete curator user as admin
|
||||
DELETE {{WepApiTest_HostAddress}}/api/user/{{curator_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
@@ -913,3 +1246,13 @@ Authorization: Bearer {{admin_token}}
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
### 85. Delete regular user as admin
|
||||
DELETE {{WepApiTest_HostAddress}}/api/user/{{user_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
> {%
|
||||
client.test("Delete regular user as admin", function () {
|
||||
client.assert(response.status === 200)
|
||||
});
|
||||
%}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@using Butter.Dtos.Folder
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Text.RegularExpressions
|
||||
@inject FoldersService FoldersService
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@using Butter.Dtos.Jobs
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client.Services
|
||||
@inject JobsService JobsService
|
||||
|
||||
@* ── Desktop row ── *@
|
||||
@@ -107,7 +106,7 @@
|
||||
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
|
||||
|
||||
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
|
||||
[EJobStatus.Queued] = "primary",
|
||||
[EJobStatus.Queued] = "secondary",
|
||||
[EJobStatus.Running] = "primary",
|
||||
[EJobStatus.Waiting] = "info",
|
||||
[EJobStatus.Completed] = "success",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
@using MilkStream.Client.Services
|
||||
@using Butter.Types
|
||||
|
||||
@inherits LayoutComponentBase
|
||||
@@ -64,8 +63,8 @@
|
||||
|
||||
@code{
|
||||
bool LoggedIn => loginService.IsLoggedIn;
|
||||
bool Admin => loginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
||||
bool AdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
bool Admin => loginService.IsAdmin;
|
||||
bool AdminOrCurator => loginService.IsAdminOrCurator;
|
||||
|
||||
protected override void OnInitialized() {
|
||||
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
@page "/Users"
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject LoginService LoginService
|
||||
@inject UserService UserService
|
||||
@inject PersonService PersonService
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<PageTitle>Users</PageTitle>
|
||||
@@ -82,7 +85,7 @@
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="user-initials">@(user.Username?.Length > 0 ? user.Username[..1].ToUpper() : "?")</div>
|
||||
<div>
|
||||
<span>@user.Username</span>
|
||||
<a href="/User/@user.Id" class="text-reset text-decoration-none">@user.Username</a>
|
||||
@if (isSelf) { <span class="badge bg-info ms-1">You</span> }
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +126,7 @@
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<div class="user-initials">@(user.Username?.Length > 0 ? user.Username[..1].ToUpper() : "?")</div>
|
||||
<div class="flex-grow-1">
|
||||
<strong>@user.Username</strong>
|
||||
<a href="/User/@user.Id" class="text-reset text-decoration-none fw-bold">@user.Username</a>
|
||||
@if (isSelf) { <span class="badge bg-info ms-1">You</span> }
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => OpenEditModal(user)">
|
||||
@@ -176,9 +179,9 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_createModel.AccessLevel">
|
||||
<option value="@EAccessLevel.User">User</option>
|
||||
<option value="@EAccessLevel.Curator">Curator</option>
|
||||
<option value="@EAccessLevel.Admin">Admin</option>
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</Body>
|
||||
@@ -209,12 +212,71 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_editAccessLevel">
|
||||
<option value="@EAccessLevel.User">User</option>
|
||||
<option value="@EAccessLevel.Curator">Curator</option>
|
||||
<option value="@EAccessLevel.Admin">Admin</option>
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (_editAccessLevel == EAccessLevel.Maintainer)
|
||||
{
|
||||
<hr />
|
||||
<h6>Maintained Cosplayers</h6>
|
||||
<div class="mb-3">
|
||||
<div class="position-relative">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="Search cosplayers..."
|
||||
value="@_personSearchQuery"
|
||||
@oninput="HandlePersonSearchInput" />
|
||||
</div>
|
||||
|
||||
@if (_personSearchResults != null && _personSearchResults.Count > 0)
|
||||
{
|
||||
<div class="dropdown-menu search-dropdown-menu d-block">
|
||||
@foreach (var person in _personSearchResults)
|
||||
{
|
||||
<button type="button" class="dropdown-item"
|
||||
@onclick="() => SelectPerson(person)">
|
||||
@person.Name
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_personSearchQuery))
|
||||
{
|
||||
<div class="dropdown-menu search-dropdown-menu d-block">
|
||||
<div class="dropdown-item disabled">
|
||||
<span class="text-muted small">No cosplayers found</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_assignedPersons.Count > 0)
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-1 mt-2">
|
||||
@foreach (var person in _assignedPersons)
|
||||
{
|
||||
<span class="badge bg-primary d-flex align-items-center gap-1">
|
||||
@person.Name
|
||||
<button type="button" class="p-0 border-0 bg-transparent text-white fw-bold"
|
||||
style="font-size: 0.7rem; line-height: 1;"
|
||||
@onclick="() => RemoveAssignedPerson(person.Id)">×</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_maintainedPersonIds.Count > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
<small class="text-muted">@_maintainedPersonIds.Count cosplayer@(_maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<hr />
|
||||
<h6>Danger Zone</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@@ -318,6 +380,12 @@
|
||||
private bool _showNewPassword;
|
||||
private bool _showConfirmPassword;
|
||||
|
||||
private HashSet<Guid> _maintainedPersonIds = [];
|
||||
private List<PersonPreviewDto> _assignedPersons = [];
|
||||
private string _personSearchQuery = "";
|
||||
private List<PersonPreviewDto>? _personSearchResults;
|
||||
private CancellationTokenSource? _searchCts;
|
||||
|
||||
private IEnumerable<UserInfoDto> _filteredSorted {
|
||||
get {
|
||||
if (_users == null) return [];
|
||||
@@ -351,7 +419,7 @@
|
||||
NavigationManager.NavigateToLogin();
|
||||
return;
|
||||
}
|
||||
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
||||
if (!LoginService.IsAdmin) {
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
@@ -408,12 +476,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenEditModal(UserInfoDto user) {
|
||||
private async Task OpenEditModal(UserInfoDto user) {
|
||||
_editUser = user;
|
||||
_editUsername = user.Username ?? "";
|
||||
_editEmail = user.Email ?? "";
|
||||
_editAccessLevel = user.AccessLevel;
|
||||
_editError = null;
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
|
||||
if (user.AccessLevel == EAccessLevel.Maintainer) {
|
||||
var fullUser = await UserService.GetUserByIdAsync(user.Id);
|
||||
if (fullUser?.MaintainedPersonIds != null) {
|
||||
_maintainedPersonIds = [.. fullUser.MaintainedPersonIds];
|
||||
foreach (var pid in _maintainedPersonIds) {
|
||||
var person = await PersonService.GetByIdAsync(pid);
|
||||
if (person != null) {
|
||||
_assignedPersons.Add(new PersonPreviewDto { Id = person.Id, Name = person.Name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseEditModal() {
|
||||
@@ -435,7 +520,8 @@
|
||||
Id = _editUser.Id,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail,
|
||||
AccessLevel = _editAccessLevel
|
||||
AccessLevel = _editAccessLevel,
|
||||
MaintainedPersonIds = _maintainedPersonIds.ToList()
|
||||
};
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
@@ -500,9 +586,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandlePersonSearchInput(ChangeEventArgs e) {
|
||||
_personSearchQuery = e.Value?.ToString() ?? "";
|
||||
await SearchPersons(_personSearchQuery);
|
||||
}
|
||||
|
||||
private async Task SearchPersons(string query) {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query) || query.Length < 2) {
|
||||
_personSearchResults = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await PersonService.GetAllAsync(search: query, pageSize: 250);
|
||||
if (!token.IsCancellationRequested) {
|
||||
_personSearchResults = results;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectPerson(PersonPreviewDto person) {
|
||||
if (_maintainedPersonIds.Add(person.Id)) {
|
||||
_assignedPersons.Add(person);
|
||||
}
|
||||
_personSearchQuery = "";
|
||||
_personSearchResults = null;
|
||||
}
|
||||
|
||||
private void RemoveAssignedPerson(Guid personId) {
|
||||
_maintainedPersonIds.Remove(personId);
|
||||
_assignedPersons.RemoveAll(p => p.Id == personId);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
}
|
||||
|
||||
private static string AccessLevelBadge(EAccessLevel level) => level switch {
|
||||
EAccessLevel.Admin => "bg-danger",
|
||||
EAccessLevel.Curator => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
EAccessLevel.Admin => "badge-role-admin",
|
||||
EAccessLevel.Curator => "badge-role-curator",
|
||||
EAccessLevel.Maintainer => "badge-role-maintainer",
|
||||
_ => "badge-role-user"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Services
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject AlbumService albumService
|
||||
@@ -31,7 +28,7 @@
|
||||
<div class="album-detail-info">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="album-title-text">@album.Name</span>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEdit(album?.Person)) {
|
||||
<button class="btn btn-sm album-edit-btn" @onclick="OpenEditForm" title="Edit album">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@@ -55,7 +52,7 @@
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEdit(album?.Person)) {
|
||||
@* Desktop: inline buttons *@
|
||||
<div class="d-none d-md-flex gap-1 align-items-center">
|
||||
@if (selectMode) {
|
||||
@@ -187,7 +184,6 @@
|
||||
int selectedIndex = -1;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
string viewMode = "masonry";
|
||||
@@ -225,6 +221,22 @@
|
||||
selectMode = false;
|
||||
selectedImageIds.Clear();
|
||||
album = await albumService.GetAlbumAsync(Id);
|
||||
if (album != null) {
|
||||
var seen = new Dictionary<Guid, int>();
|
||||
var uniqueImages = new List<Guid>(album.Images.Count);
|
||||
var uniquePreviews = new List<AlbumAssetPreviewDto>(album.AssetPreviews.Count);
|
||||
for (var i = 0; i < album.Images.Count; i++) {
|
||||
var id = album.Images[i];
|
||||
if (!seen.ContainsKey(id)) {
|
||||
seen[id] = i;
|
||||
uniqueImages.Add(id);
|
||||
if (i < album.AssetPreviews.Count)
|
||||
uniquePreviews.Add(album.AssetPreviews[i]);
|
||||
}
|
||||
}
|
||||
album.Images = uniqueImages;
|
||||
album.AssetPreviews = uniquePreviews;
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
@page "/albums"
|
||||
@using Butter.Dtos.Album
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Components.Shared
|
||||
@using MilkStream.Client.Services
|
||||
@implements IDisposable
|
||||
|
||||
@inject AlbumService albumService
|
||||
@@ -27,7 +22,7 @@
|
||||
SortOptions="@sortOptions">
|
||||
<ActionButtons>
|
||||
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEditAll) {
|
||||
if (selectMode) {
|
||||
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
|
||||
@@ -51,7 +46,7 @@
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEditAll) {
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenCreateForm">
|
||||
<i class="bi bi-plus-circle"></i> <span class="d-none d-sm-inline">New Album</span>
|
||||
</button>
|
||||
@@ -66,7 +61,6 @@
|
||||
ViewMode="@viewMode"
|
||||
SelectionMode="@selectMode"
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
OnNavigateAlbum="@NavigateToAlbum"
|
||||
OnToggleSelection="@ToggleAlbumSelection"
|
||||
LoadVersion="@loadVersion" />
|
||||
|
||||
@@ -83,8 +77,6 @@
|
||||
string viewMode = "masonry";
|
||||
bool selectMode;
|
||||
HashSet<Guid> selectedAlbumIds = [];
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
string? searchQuery;
|
||||
string? sortBy;
|
||||
bool sortAsc;
|
||||
@@ -125,10 +117,6 @@
|
||||
void SetMasonry() => viewMode = "masonry";
|
||||
void SetGrid() => viewMode = "grid";
|
||||
|
||||
void NavigateToAlbum(Guid id) {
|
||||
navigationManager.NavigateTo($"/albums/{id}");
|
||||
}
|
||||
|
||||
void ToggleAlbumSelection(Guid id) {
|
||||
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
|
||||
else selectedAlbumIds.Add(id);
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Globalization
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject PersonService personService
|
||||
@inject AlbumService albumService
|
||||
@inject LoginService loginService
|
||||
@inject IOptions<ServiceOptions> serviceOptions
|
||||
@inject NavigationManager navigationManager
|
||||
@inject IJSRuntime jsRuntime
|
||||
|
||||
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
|
||||
|
||||
@@ -58,10 +52,28 @@
|
||||
<span><i class="bi bi-collection"></i> @person.TotalAlbums albums</span>
|
||||
<span class="mx-1">·</span>
|
||||
<span><i class="bi bi-image"></i> @person.TotalAssets assets</span>
|
||||
@if (person.MaintainerUserIds?.Count > 0)
|
||||
{
|
||||
<span class="mx-1">·</span>
|
||||
<span>
|
||||
<i class="bi bi-people"></i>
|
||||
Maintained by:
|
||||
@for (var i = 0; i < person.MaintainerUsernames!.Count; i++)
|
||||
{
|
||||
<a href="/User/@person.MaintainerUserIds[i]" class="text-reset text-decoration-none">
|
||||
@person.MaintainerUsernames[i]
|
||||
</a>
|
||||
@if (i < person.MaintainerUsernames.Count - 1)
|
||||
{
|
||||
<text>, </text>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.CanEdit(Id)) {
|
||||
<button class="cosplayer-banner-edit" @onclick="OpenEditForm" title="Edit Cosplayer">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@@ -69,65 +81,66 @@
|
||||
</div>
|
||||
|
||||
@* Albums header *@
|
||||
<div class="d-flex align-items-center justify-content-between mt-4 mb-3 px-2">
|
||||
<div class="d-flex align-items-center mt-4 mb-2 px-2">
|
||||
<h4 class="m-0"><i class="bi bi-collection"></i> Albums</h4>
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
@if (allAlbums.Count > 0) {
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (IsAdminOrCurator) {
|
||||
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
|
||||
<i class="bi bi-plus-circle"></i> Add Albums
|
||||
</button>
|
||||
@if (allAlbums.Count > 0) {
|
||||
if (selectMode) {
|
||||
<button class="btn btn-warning btn-sm" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => { selectMode = false; selectedAlbumIds.Clear(); }">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (allAlbums.Count > 0) {
|
||||
<div class="@("album-grid " + viewMode)">
|
||||
@foreach (var album in allAlbums) {
|
||||
<AlbumCard Album="album"
|
||||
SelectionMode="selectMode"
|
||||
Selected="selectedAlbumIds.Contains(album.Id)"
|
||||
OnNavigate="NavigateToAlbum"
|
||||
OnToggleSelection="ToggleSelectAlbum" />
|
||||
}
|
||||
</div>
|
||||
<SortFilterBar SearchQuery="@albumSearchQuery"
|
||||
SearchQueryChanged="@((string? v) => albumSearchQuery = v)"
|
||||
SortBy="@albumSortBy"
|
||||
SortByChanged="@((string? v) => albumSortBy = v)"
|
||||
SortAsc="@albumSortAsc"
|
||||
SortAscChanged="@((bool v) => albumSortAsc = v)"
|
||||
SortOptions="@albumSortOptions">
|
||||
<ActionButtons>
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
@if (person.TotalAlbums > 0) {
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetMasonry" title="Masonry">
|
||||
<i class="bi bi-grid-3x2-gap"></i>
|
||||
</button>
|
||||
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
|
||||
@onclick="SetGrid" title="Grid">
|
||||
<i class="bi bi-grid-3x3"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (loginService.CanEdit(Id)) {
|
||||
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
|
||||
<i class="bi bi-plus-circle"></i> Add Albums
|
||||
</button>
|
||||
@if (person.TotalAlbums > 0) {
|
||||
if (selectMode) {
|
||||
<button class="btn btn-warning btn-sm" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
|
||||
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => { selectMode = false; selectedAlbumIds.Clear(); }">
|
||||
Done
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
|
||||
<i class="bi bi-check2-square"></i> Select
|
||||
</button>
|
||||
}
|
||||
}
|
||||
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ActionButtons>
|
||||
</SortFilterBar>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
<LoadSpinner/>
|
||||
}
|
||||
|
||||
<div @ref="sentinelRef" class="masonry-sentinel"></div>
|
||||
} else {
|
||||
<EmptyState Variant="info" Title="No albums for this cosplayer" />
|
||||
}
|
||||
<AlbumGrid SearchQuery="@albumSearchQuery"
|
||||
SortBy="@albumSortBy"
|
||||
SortAsc="@albumSortAsc"
|
||||
ViewMode="@viewMode"
|
||||
SelectionMode="@selectMode"
|
||||
SelectedIds="@selectedAlbumIds"
|
||||
OnToggleSelection="@ToggleSelectAlbum"
|
||||
LoadVersion="@albumLoadVersion"
|
||||
FetchAlbums="@FetchPersonAlbums" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -135,6 +148,7 @@
|
||||
<PersonForm Show="true"
|
||||
EditPerson="new PersonPreviewDto { Id = person.Id, Name = person.Name, ProfileAssetId = person.ProfileAssetId, ProfileCropX = person.ProfileCropX, ProfileCropY = person.ProfileCropY, ProfileCropZoom = person.ProfileCropZoom }"
|
||||
AssetIds="allAssetIds"
|
||||
MaintainerUserIds="person.MaintainerUserIds"
|
||||
OnSaved="HandleFormSaved"
|
||||
OnClosed="CloseForm" />
|
||||
}
|
||||
@@ -184,7 +198,6 @@
|
||||
bool selectMode;
|
||||
string token = string.Empty;
|
||||
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
string viewMode = "masonry";
|
||||
bool showAlbumAssigner;
|
||||
List<AlbumPreviewDto>? unassignedAlbums;
|
||||
@@ -192,14 +205,20 @@
|
||||
HashSet<Guid> selectedAlbumIds = [];
|
||||
List<string> bannerCoverUrls = [];
|
||||
|
||||
// Infinite scroll
|
||||
List<AlbumPreviewDto> allAlbums = [];
|
||||
int currentAlbumPage;
|
||||
const int AlbumPageSize = 30;
|
||||
bool hasMoreAlbums = true;
|
||||
bool isLoadingMore;
|
||||
ElementReference sentinelRef;
|
||||
DotNetObjectReference<CosplayerDetail>? dotNetRef;
|
||||
// Search & sort
|
||||
string? albumSearchQuery;
|
||||
string? albumSortBy;
|
||||
bool albumSortAsc = true;
|
||||
|
||||
Dictionary<string, string> albumSortOptions = new()
|
||||
{
|
||||
["Name"] = "name",
|
||||
["Created"] = "created",
|
||||
["Updated"] = "updated",
|
||||
["Asset count"] = "assets"
|
||||
};
|
||||
|
||||
int albumLoadVersion;
|
||||
|
||||
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
|
||||
|
||||
@@ -221,52 +240,21 @@
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender) {
|
||||
if (allAlbums.Count > 0 && dotNetRef == null) {
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
await jsRuntime.InvokeVoidAsync(
|
||||
"albumObserver.observeBottom", sentinelRef, dotNetRef
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async Task LoadPerson() {
|
||||
isLoading = true;
|
||||
allAlbums.Clear();
|
||||
currentAlbumPage = 0;
|
||||
hasMoreAlbums = true;
|
||||
|
||||
person = await personService.GetByIdAsync(Id);
|
||||
if (person?.Albums != null) {
|
||||
allAlbums = person.Albums;
|
||||
hasMoreAlbums = person.Albums.Count >= AlbumPageSize;
|
||||
currentAlbumPage = 1;
|
||||
}
|
||||
|
||||
person = await personService.GetByIdAsync(Id, 0, 30);
|
||||
bannerCoverUrls = person?.Albums?
|
||||
.Where(a => a.CoverAssetId.HasValue)
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(3)
|
||||
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
|
||||
.ToList() ?? [];
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadMoreAlbums() {
|
||||
if (isLoadingMore || !hasMoreAlbums) return;
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var fresh = await personService.GetByIdAsync(Id, currentAlbumPage, AlbumPageSize);
|
||||
if (fresh?.Albums?.Count > 0) {
|
||||
allAlbums.AddRange(fresh.Albums);
|
||||
currentAlbumPage++;
|
||||
}
|
||||
hasMoreAlbums = fresh?.Albums?.Count >= AlbumPageSize;
|
||||
isLoadingMore = false;
|
||||
StateHasChanged();
|
||||
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
|
||||
var p = await personService.GetByIdAsync(Id, page, pageSize, search, sortBy, sortAsc);
|
||||
return p?.Albums;
|
||||
}
|
||||
|
||||
void OpenEditForm() => showForm = true;
|
||||
@@ -309,6 +297,7 @@
|
||||
showAlbumAssigner = false;
|
||||
unassignedAlbums = null;
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
async Task RemoveSelectedAlbums() {
|
||||
@@ -318,6 +307,7 @@
|
||||
selectMode = false;
|
||||
selectedAlbumIds.Clear();
|
||||
await LoadPerson();
|
||||
albumLoadVersion++;
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
@@ -326,16 +316,5 @@
|
||||
navigationManager.NavigateTo("/cosplayers");
|
||||
}
|
||||
|
||||
void NavigateToAlbum(Guid albumId) {
|
||||
navigationManager.NavigateTo($"/albums/{albumId}");
|
||||
}
|
||||
|
||||
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
|
||||
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (dotNetRef != null) {
|
||||
await jsRuntime.InvokeVoidAsync("albumObserver.dispose");
|
||||
dotNetRef.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
@page "/cosplayers"
|
||||
@using Butter.Dtos
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client
|
||||
@using Microsoft.Extensions.Options
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Components.Shared
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Globalization
|
||||
@implements IDisposable
|
||||
|
||||
@inject PersonService personService
|
||||
@@ -29,7 +22,7 @@
|
||||
SortOptions="@sortOptions">
|
||||
<ActionButtons>
|
||||
<div class="d-flex gap-2 flex-shrink-0 flex-wrap align-items-center">
|
||||
@if (IsAdminOrCurator) {
|
||||
@if (loginService.IsAdminOrCurator) {
|
||||
if (cosplayerSelectMode) {
|
||||
<button class="btn btn-danger btn-sm" @onclick="DeleteSelectedCosplayers" disabled="@(selectedCosplayerIds.Count == 0)">
|
||||
<i class="bi bi-trash"></i> Delete @selectedCosplayerIds.Count
|
||||
@@ -54,9 +47,8 @@
|
||||
SortBy="@sortBy"
|
||||
SortAsc="@sortAsc"
|
||||
SelectionMode="@cosplayerSelectMode"
|
||||
SelectedIds="@selectedCosplayerIds"
|
||||
OnCardClicked="@NavigateToPerson"
|
||||
OnToggleSelection="@ToggleCosplayerSelection"
|
||||
SelectedIds="@selectedCosplayerIds"
|
||||
OnToggleSelection="@ToggleCosplayerSelection"
|
||||
LoadVersion="@loadVersion" />
|
||||
|
||||
@if (showCreateForm) {
|
||||
@@ -70,8 +62,6 @@
|
||||
bool showCreateForm;
|
||||
bool cosplayerSelectMode;
|
||||
HashSet<Guid> selectedCosplayerIds = [];
|
||||
bool IsAdminOrCurator => loginService.LoggedUser?.AccessLevel is EAccessLevel.Admin or EAccessLevel.Curator;
|
||||
|
||||
string? searchQuery;
|
||||
string? sortBy;
|
||||
bool sortAsc = true;
|
||||
@@ -105,10 +95,6 @@
|
||||
|
||||
void OnLoggedUserChanged(object? _, UserInfoDto? _2) => StateHasChanged();
|
||||
|
||||
void NavigateToPerson(Guid id) {
|
||||
navigationManager.NavigateTo($"/cosplayer/{id}");
|
||||
}
|
||||
|
||||
void ToggleCosplayerSelection(Guid id) {
|
||||
if (selectedCosplayerIds.Contains(id)) selectedCosplayerIds.Remove(id);
|
||||
else selectedCosplayerIds.Add(id);
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
@using Butter.Dtos.Asset
|
||||
@using Butter.Types
|
||||
@using Microsoft.Extensions.Options
|
||||
@using Microsoft.JSInterop
|
||||
@using MilkStream.Client.Components.Layout
|
||||
@using MilkStream.Client.Services
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@inject AssetService assetService
|
||||
@@ -32,10 +29,8 @@
|
||||
}
|
||||
|
||||
<div class="masonry-grid">
|
||||
@{ var idx = 0; }
|
||||
@foreach (var page in allAssetPages) {
|
||||
@foreach (var asset in page) {
|
||||
var i = idx++;
|
||||
@for (int i = 0; i < flatList.Count; i++) {
|
||||
var asset = flatList[i];
|
||||
<div class="masonry-tile" @key="asset.Id"
|
||||
style="aspect-ratio:@GetAspectRatio(asset)"
|
||||
@onclick="() => OpenPreview(asset, i)">
|
||||
@@ -75,7 +70,6 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isLoadingMore) {
|
||||
@@ -175,13 +169,13 @@
|
||||
async Task LoadFirstPage() {
|
||||
_randomSeed = Guid.NewGuid();
|
||||
isLoading = true;
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 1, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
RebuildFlatList();
|
||||
loadedMinPage = 1;
|
||||
loadedMaxPage = 1;
|
||||
currentPage = 2;
|
||||
loadedMinPage = 0;
|
||||
loadedMaxPage = 0;
|
||||
currentPage = 1;
|
||||
hasMoreUp = false;
|
||||
}
|
||||
hasMore = items?.Count == 30;
|
||||
@@ -214,12 +208,12 @@
|
||||
|
||||
[JSInvokable]
|
||||
public async Task LoadPreviousPage() {
|
||||
if (isLoadingUp || !hasMoreUp || loadedMinPage <= 1) return;
|
||||
if (isLoadingUp || !hasMoreUp || loadedMinPage <= 0) return;
|
||||
isLoadingUp = true;
|
||||
StateHasChanged();
|
||||
|
||||
var pageToLoad = loadedMinPage - 1;
|
||||
if (pageToLoad < 1) {
|
||||
if (pageToLoad < 0) {
|
||||
hasMoreUp = false;
|
||||
isLoadingUp = false;
|
||||
return;
|
||||
@@ -237,12 +231,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
hasMoreUp = loadedMinPage > 1;
|
||||
hasMoreUp = loadedMinPage > 0;
|
||||
isLoadingUp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
void RebuildFlatList() => flatList = allAssetPages.SelectMany(p => p).ToList();
|
||||
void RebuildFlatList() {
|
||||
var seen = new Dictionary<Guid, AssetPreviewDto>();
|
||||
foreach (var page in allAssetPages)
|
||||
foreach (var asset in page)
|
||||
seen.TryAdd(asset.Id, asset);
|
||||
flatList = [.. seen.Values];
|
||||
}
|
||||
|
||||
void OpenPreview(AssetPreviewDto asset, int index) {
|
||||
selectedAsset = asset;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
@implements IDisposable
|
||||
@using Butter.Dtos.Jobs
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client.Services
|
||||
@inject JobsService JobsService
|
||||
@inject LoginService LoginService
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -209,7 +208,7 @@
|
||||
NavigationManager.NavigateToLogin();
|
||||
return;
|
||||
}
|
||||
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
||||
if (!LoginService.IsAdmin) {
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
@page "/login"
|
||||
@using Butter.Dtos
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
@inject NavigationManager navigation
|
||||
@inject LoginService loginService
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/Register"
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
@inject LoginService loginService
|
||||
@inject NavigationManager navigation
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
@using Butter.Settings
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client.Components.SettingBoxes
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Linq
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -114,7 +113,7 @@
|
||||
NavigationManager.NavigateToLogin();
|
||||
return;
|
||||
}
|
||||
if (LoginService.LoggedUser?.AccessLevel != EAccessLevel.Admin) {
|
||||
if (!LoginService.IsAdmin) {
|
||||
NavigationManager.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
@page "/Stats"
|
||||
@using Butter.Dtos.Stats
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client.Services
|
||||
|
||||
@inject LoginService LoginService
|
||||
@inject StatsService StatsService
|
||||
@@ -45,13 +43,14 @@
|
||||
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoPerson.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Person</small></div></div></div>
|
||||
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoAlbum.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Album</small></div></div></div>
|
||||
<div class="col"><div class="card border-danger h-100"><div class="card-body text-center py-3"><h5>@_stats.AlbumsMissingCover.ToString("N0")</h5><small class="text-danger"><i class="bi bi-image"></i> Albums Missing Cover</small></div></div></div>
|
||||
<div class="col"><div class="card border-secondary h-100"><div class="card-body text-center py-3"><h5>@_stats.CosplayersMissingCover.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers Missing Cover</small></div></div></div>
|
||||
<div class="col"><div class="card border-secondary h-100"><div class="card-body text-center py-3"><h5>@_stats.CosplayersMissingProfile.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers Missing Profile</small></div></div></div>
|
||||
</div>
|
||||
|
||||
@* ---- Duplicate / Orphan / Visibility ---- *@
|
||||
<div class="row row-cols-1 row-cols-md-3 g-3 mb-4">
|
||||
<div class="row row-cols-1 row-cols-md-4 g-3 mb-4">
|
||||
<div class="col"><div class="card border-secondary"><div class="card-body"><h6 class="card-title"><i class="bi bi-folder-x"></i> Orphan Assets</h6><h4 class="text-secondary">@_stats.OrphanAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-globe2"></i> Public Assets</h6><h4 class="text-success">@_stats.PublicAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-warning"><div class="card-body"><h6 class="card-title"><i class="bi bi-shield-lock"></i> Protected Assets</h6><h4 class="text-warning">@_stats.ProtectedAssets.ToString("N0")</h4></div></div></div>
|
||||
<div class="col"><div class="card border-danger"><div class="card-body"><h6 class="card-title"><i class="bi bi-lock"></i> Private Assets</h6><h4 class="text-danger">@_stats.PrivateAssets.ToString("N0")</h4></div></div></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,59 +1,89 @@
|
||||
@page "/User/{UserId:guid}"
|
||||
@using Butter.Dtos.Person
|
||||
@using Butter.Dtos.User
|
||||
@using Butter.Types
|
||||
@using MilkStream.Client.Services
|
||||
@implements IDisposable
|
||||
|
||||
@inject LoginService LoginService
|
||||
@inject UserService UserService
|
||||
@inject PersonService PersonService
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="card mb-3" style="max-width: 600px; width: 100%;">
|
||||
<div class="card-header d-flex align-items-center gap-3">
|
||||
<div class="profile-avatar">@_initials</div>
|
||||
<div class="min-width-0">
|
||||
<h5 class="mb-0 text-truncate">@(user?.Username ?? "...")</h5>
|
||||
@if (isSelf || AdminAccess) {
|
||||
<small class="text-muted text-truncate d-block">@user?.Email</small>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @RoleBadge ms-auto">@(user?.AccessLevel.ToString() ?? "")</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 g-2">
|
||||
@if (isSelf || AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Email</dt>
|
||||
<dd class="col-sm-8">@user?.Email</dd>
|
||||
}
|
||||
<dt class="col-sm-4 text-muted small">Role</dt>
|
||||
<dd class="col-sm-8">@user?.AccessLevel.ToString()</dd>
|
||||
<dt class="col-sm-4 text-muted small">Created</dt>
|
||||
<dd class="col-sm-8">@user?.CreatedAt.ToString("D")</dd>
|
||||
<dt class="col-sm-4 text-muted small">Last Login</dt>
|
||||
<dd class="col-sm-8">@(user?.LastLogin?.ToString("D") ?? "Never")</dd>
|
||||
<dt class="col-sm-4 text-muted small">Banned</dt>
|
||||
<dd class="col-sm-8">@(user is {IsBanned: true} ? $"Yes ({user.BannedAt.ToString()})" : "No")</dd>
|
||||
@if (AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Deleted</dt>
|
||||
<dd class="col-sm-8">@(user is not {DeletedAt: null} ? user!.DeletedAt.ToString() : "No")</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@if (canEdit) {
|
||||
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
||||
@if (!string.IsNullOrEmpty(_pageMessage)) {
|
||||
<div class="alert alert-success py-1 mb-0 flex-grow-1">@_pageMessage</div>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenEditModal">
|
||||
<i class="bi bi-pencil"></i> Edit Profile
|
||||
</button>
|
||||
@if (isSelf) {
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _showPasswordModal = true">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_isLoading) {
|
||||
<LoadSpinner />
|
||||
} else if (user == null) {
|
||||
<div class="d-flex justify-content-center">
|
||||
<EmptyState Variant="danger" Title="User not found">
|
||||
<p>The user you are looking for does not exist or you do not have access.</p>
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="card mb-3" style="max-width: 600px; width: 100%;">
|
||||
<div class="card-header d-flex align-items-center gap-3">
|
||||
<div class="profile-avatar">@_initials</div>
|
||||
<div class="min-width-0">
|
||||
<h5 class="mb-0 text-truncate">@user.Username</h5>
|
||||
@if (isSelf || AdminAccess) {
|
||||
<small class="text-muted text-truncate d-block">@user.Email</small>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @RoleBadge ms-auto">@user.AccessLevel.ToString()</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 g-2">
|
||||
@if (isSelf || AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Email</dt>
|
||||
<dd class="col-sm-8">@user.Email</dd>
|
||||
}
|
||||
<dt class="col-sm-4 text-muted small">Role</dt>
|
||||
<dd class="col-sm-8">@user.AccessLevel.ToString()</dd>
|
||||
@if (AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Created</dt>
|
||||
<dd class="col-sm-8">@user.CreatedAt.ToString("D")</dd>
|
||||
}
|
||||
<dt class="col-sm-4 text-muted small">Last Login</dt>
|
||||
<dd class="col-sm-8">@(user.LastLogin?.ToString("D") ?? "Never")</dd>
|
||||
<dt class="col-sm-4 text-muted small">Banned</dt>
|
||||
<dd class="col-sm-8">@(user.IsBanned ? $"Yes ({user.BannedAt.ToString()})" : "No")</dd>
|
||||
@if (AdminAccess) {
|
||||
<dt class="col-sm-4 text-muted small">Deleted</dt>
|
||||
<dd class="col-sm-8">@(user.DeletedAt?.ToString("D") ?? "No")</dd>
|
||||
}
|
||||
@if (user.MaintainedPersonNames is { Count: > 0 } names && user.MaintainedPersonIds is { Count: > 0 } ids)
|
||||
{
|
||||
<dt class="col-sm-4 text-muted small">Maintainer of</dt>
|
||||
<dd class="col-sm-8">
|
||||
@for (var i = 0; i < names.Count; i++)
|
||||
{
|
||||
<a href="/cosplayer/@ids[i]" class="text-reset text-decoration-none">@names[i]</a>
|
||||
@if (i < names.Count - 1)
|
||||
{
|
||||
<text>, </text>
|
||||
}
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
@if (canEdit) {
|
||||
<div class="card-footer d-flex align-items-center gap-2 flex-wrap">
|
||||
@if (!string.IsNullOrEmpty(_pageMessage)) {
|
||||
<div class="alert alert-success py-1 mb-0 flex-grow-1">@_pageMessage</div>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="OpenEditModal">
|
||||
<i class="bi bi-pencil"></i> Edit Profile
|
||||
</button>
|
||||
@if (isSelf) {
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _showPasswordModal = true">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ModalFrame Title="Edit Profile" Show="_showEditModal" OnClose="CloseEditModal">
|
||||
<HeaderActions><i class="bi bi-pencil me-1"></i></HeaderActions>
|
||||
@@ -61,6 +91,7 @@
|
||||
@if (!string.IsNullOrEmpty(_editError)) {
|
||||
<div class="alert alert-danger py-1">@_editError</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" class="form-control" @bind="_editUsername" @bind:event="oninput" />
|
||||
@@ -69,6 +100,97 @@
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" @bind="_editEmail" @bind:event="oninput" />
|
||||
</div>
|
||||
|
||||
@if (AdminAccess)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Level</label>
|
||||
<select class="form-select" @bind="_editAccessLevel">
|
||||
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
|
||||
<option value="@level">@level</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (_editAccessLevel == EAccessLevel.Maintainer)
|
||||
{
|
||||
<hr />
|
||||
<h6>Maintained Cosplayers</h6>
|
||||
<div class="mb-3">
|
||||
<div class="position-relative">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="Search cosplayers..."
|
||||
value="@_personSearchQuery"
|
||||
@oninput="HandlePersonSearchInput" />
|
||||
</div>
|
||||
|
||||
@if (_personSearchResults != null && _personSearchResults.Count > 0)
|
||||
{
|
||||
<div class="dropdown-menu search-dropdown-menu d-block">
|
||||
@foreach (var person in _personSearchResults)
|
||||
{
|
||||
<button type="button" class="dropdown-item"
|
||||
@onclick="() => SelectPerson(person)">
|
||||
@person.Name
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(_personSearchQuery))
|
||||
{
|
||||
<div class="dropdown-menu search-dropdown-menu d-block">
|
||||
<div class="dropdown-item disabled">
|
||||
<span class="text-muted small">No cosplayers found</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (_assignedPersons.Count > 0)
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-1 mt-2">
|
||||
@foreach (var person in _assignedPersons)
|
||||
{
|
||||
<span class="badge bg-primary d-flex align-items-center gap-1">
|
||||
@person.Name
|
||||
<button type="button" class="p-0 border-0 bg-transparent text-white fw-bold"
|
||||
style="font-size: 0.7rem; line-height: 1;"
|
||||
@onclick="() => RemoveAssignedPerson(person.Id)">×</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_maintainedPersonIds.Count > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
<small class="text-muted">@_maintainedPersonIds.Count cosplayer@(_maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<hr />
|
||||
<h6>Danger Zone</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@if (user is { IsBanned: true }) {
|
||||
<button class="btn btn-success btn-sm" @onclick="() => ToggleBan(false)">
|
||||
<i class="bi bi-unlock"></i> Unban
|
||||
</button>
|
||||
} else {
|
||||
<button class="btn btn-warning btn-sm" @onclick="() => ToggleBan(true)">
|
||||
<i class="bi bi-lock"></i> Ban
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => _showPasswordModal = true">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" disabled="@(user?.DeletedAt != null)" @onclick="ConfirmDelete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
|
||||
@@ -85,19 +207,57 @@
|
||||
<div class="alert alert-danger py-1">@_passwordError</div>
|
||||
}
|
||||
|
||||
<PasswordField @ref="_passwordField"
|
||||
ShowOldPassword="true"
|
||||
PasswordLabel="New Password"
|
||||
ConfirmPasswordLabel="Confirm New Password"
|
||||
@bind-OldPassword="_oldPassword"
|
||||
@bind-Password="_newPassword"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
@if (AdminAccess && !isSelf)
|
||||
{
|
||||
<p class="text-muted small">Changing password for <strong>@user?.Username</strong></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showNewPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminNewPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showNewPassword = !_showNewPassword" tabindex="-1">
|
||||
<i class="bi @(_showNewPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm New Password</label>
|
||||
<div class="input-group">
|
||||
<input type="@(_showConfirmPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminConfirmPassword" @bind:event="oninput" />
|
||||
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showConfirmPassword = !_showConfirmPassword" tabindex="-1">
|
||||
<i class="bi @(_showConfirmPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(_adminConfirmPassword) && _adminNewPassword != _adminConfirmPassword) {
|
||||
<small class="text-danger">Passwords do not match</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<PasswordField @ref="_passwordField"
|
||||
ShowOldPassword="true"
|
||||
PasswordLabel="New Password"
|
||||
ConfirmPasswordLabel="Confirm New Password"
|
||||
@bind-OldPassword="_oldPassword"
|
||||
@bind-Password="_newPassword"
|
||||
@bind-ConfirmPassword="_confirmPassword" />
|
||||
}
|
||||
</Body>
|
||||
<Footer>
|
||||
<button class="btn btn-secondary" @onclick="ClosePasswordModal">Cancel</button>
|
||||
<button class="btn btn-primary" disabled="@(!_canSubmit)" @onclick="SubmitPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
@if (AdminAccess && !isSelf)
|
||||
{
|
||||
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(_adminNewPassword) || _adminNewPassword != _adminConfirmPassword)"
|
||||
@onclick="SubmitAdminPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary" disabled="@(!_canSubmit)" @onclick="SubmitPasswordChange">
|
||||
Update Password
|
||||
</button>
|
||||
}
|
||||
</Footer>
|
||||
</ModalFrame>
|
||||
|
||||
@@ -106,17 +266,19 @@
|
||||
[Parameter]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
bool _isLoading;
|
||||
bool isSelf;
|
||||
bool AdminAccess => LoginService.LoggedUser?.AccessLevel == EAccessLevel.Admin;
|
||||
bool AdminAccess => LoginService.IsAdmin;
|
||||
bool canEdit => isSelf || AdminAccess;
|
||||
UserInfoDto? user;
|
||||
|
||||
string _initials => user?.Username is { Length: > 0 } u ? u[..1].ToUpper() : "?";
|
||||
|
||||
string RoleBadge => user?.AccessLevel switch {
|
||||
EAccessLevel.Admin => "bg-danger",
|
||||
EAccessLevel.Curator => "bg-warning text-dark",
|
||||
_ => "bg-secondary"
|
||||
EAccessLevel.Admin => "badge-role-admin",
|
||||
EAccessLevel.Curator => "badge-role-curator",
|
||||
EAccessLevel.Maintainer => "badge-role-maintainer",
|
||||
_ => "badge-role-user"
|
||||
};
|
||||
|
||||
bool _showEditModal;
|
||||
@@ -126,48 +288,82 @@
|
||||
string _editError = "";
|
||||
string _pageMessage = "";
|
||||
|
||||
EAccessLevel _editAccessLevel;
|
||||
|
||||
string _oldPassword = "";
|
||||
string _newPassword = "";
|
||||
string _confirmPassword = "";
|
||||
string _passwordError = "";
|
||||
|
||||
string _adminNewPassword = "";
|
||||
string _adminConfirmPassword = "";
|
||||
bool _showNewPassword;
|
||||
bool _showConfirmPassword;
|
||||
|
||||
PasswordField? _passwordField;
|
||||
|
||||
bool _canSubmit => _passwordField?.IsValid == true
|
||||
&& !string.IsNullOrEmpty(_oldPassword)
|
||||
&& !string.IsNullOrEmpty(_newPassword);
|
||||
|
||||
HashSet<Guid> _maintainedPersonIds = [];
|
||||
List<PersonPreviewDto> _assignedPersons = [];
|
||||
string _personSearchQuery = "";
|
||||
List<PersonPreviewDto>? _personSearchResults;
|
||||
CancellationTokenSource? _searchCts;
|
||||
|
||||
protected async override Task OnInitializedAsync() {
|
||||
await base.OnInitializedAsync();
|
||||
_isLoading = true;
|
||||
if (LoginService.IsLoggedIn) await LoadUser();
|
||||
LoginService.AuthInfoChanged += async (_, _) => await LoadUser();
|
||||
}
|
||||
|
||||
async Task LoadUser() {
|
||||
if (UserId == LoginService.AuthInfo?.UserId) isSelf = true;
|
||||
user = await UserService.GetUserAsync();
|
||||
_isLoading = true;
|
||||
isSelf = UserId == LoginService.AuthInfo?.UserId;
|
||||
user = await UserService.GetUserByIdAsync(UserId);
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
void OpenEditModal() {
|
||||
_editUsername = user?.Username ?? "";
|
||||
_editEmail = user?.Email ?? "";
|
||||
_editAccessLevel = user?.AccessLevel ?? EAccessLevel.User;
|
||||
_editError = "";
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
|
||||
if (user?.MaintainedPersonIds is { Count: > 0 } ids && user.MaintainedPersonNames is { Count: > 0 } names) {
|
||||
_maintainedPersonIds = [.. ids];
|
||||
for (var i = 0; i < ids.Count; i++) {
|
||||
_assignedPersons.Add(new PersonPreviewDto { Id = ids[i], Name = names[i] });
|
||||
}
|
||||
}
|
||||
_showEditModal = true;
|
||||
}
|
||||
|
||||
async Task CloseEditModal() {
|
||||
_showEditModal = false;
|
||||
_editError = "";
|
||||
_maintainedPersonIds = [];
|
||||
_assignedPersons = [];
|
||||
_personSearchResults = null;
|
||||
_personSearchQuery = "";
|
||||
}
|
||||
|
||||
async Task SubmitEditProfile() {
|
||||
_editError = "";
|
||||
|
||||
var dto = new UserUpdateDto {
|
||||
Id = UserId,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail
|
||||
Id = UserId,
|
||||
Username = _editUsername,
|
||||
Email = _editEmail,
|
||||
AccessLevel = AdminAccess ? _editAccessLevel : null,
|
||||
MaintainedPersonIds = AdminAccess ? _maintainedPersonIds.ToList() : null
|
||||
};
|
||||
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
@@ -185,6 +381,8 @@
|
||||
_oldPassword = "";
|
||||
_newPassword = "";
|
||||
_confirmPassword = "";
|
||||
_adminNewPassword = "";
|
||||
_adminConfirmPassword = "";
|
||||
_passwordError = "";
|
||||
}
|
||||
|
||||
@@ -206,4 +404,85 @@
|
||||
}
|
||||
}
|
||||
|
||||
async Task SubmitAdminPasswordChange() {
|
||||
_passwordError = "";
|
||||
|
||||
var dto = new UserUpdateDto {
|
||||
Id = UserId,
|
||||
Password = _adminNewPassword
|
||||
};
|
||||
|
||||
var (result, error) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_pageMessage = "Password changed.";
|
||||
await ClosePasswordModal();
|
||||
} else {
|
||||
_passwordError = error ?? "Failed to change password.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task ToggleBan(bool ban) {
|
||||
var dto = new UserUpdateDto { Id = UserId, IsBanned = ban };
|
||||
var (result, _) = await UserService.UpdateUserAsync(dto);
|
||||
if (result != null) {
|
||||
_pageMessage = ban ? "User has been banned." : "User has been unbanned.";
|
||||
await CloseEditModal();
|
||||
await LoadUser();
|
||||
} else {
|
||||
_editError = $"Failed to {(ban ? "ban" : "unban")} user.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task ConfirmDelete() {
|
||||
if (!await JSRuntime.InvokeAsync<bool>("confirm", $"Delete user '{user?.Username}'? This can be undone.")) return;
|
||||
|
||||
var success = await UserService.DeleteUserAsync(UserId);
|
||||
if (success) {
|
||||
_pageMessage = $"User deleted.";
|
||||
await CloseEditModal();
|
||||
await LoadUser();
|
||||
} else {
|
||||
_editError = "Failed to delete user.";
|
||||
}
|
||||
}
|
||||
|
||||
async Task HandlePersonSearchInput(ChangeEventArgs e) {
|
||||
_personSearchQuery = e.Value?.ToString() ?? "";
|
||||
_searchCts?.Cancel();
|
||||
_searchCts = new CancellationTokenSource();
|
||||
var token = _searchCts.Token;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_personSearchQuery) || _personSearchQuery.Length < 2) {
|
||||
_personSearchResults = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var results = await PersonService.GetAllAsync(search: _personSearchQuery, pageSize: 250);
|
||||
if (!token.IsCancellationRequested) {
|
||||
_personSearchResults = results;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
void SelectPerson(PersonPreviewDto person) {
|
||||
if (_maintainedPersonIds.Add(person.Id)) {
|
||||
_assignedPersons.Add(person);
|
||||
}
|
||||
_personSearchQuery = "";
|
||||
_personSearchResults = null;
|
||||
}
|
||||
|
||||
void RemoveAssignedPerson(Guid personId) {
|
||||
_maintainedPersonIds.Remove(personId);
|
||||
_assignedPersons.RemoveAll(p => p.Id == personId);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose() {
|
||||
_searchCts?.Cancel();
|
||||
_searchCts?.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
@using Butter.Dtos.Settings
|
||||
@using MilkStream.Client.Services
|
||||
@using Microsoft.JSInterop
|
||||
|
||||
@inject SettingsService SettingsService
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@using Butter.Dtos.Settings
|
||||
@using MilkStream.Client.Services
|
||||
@using System.Globalization
|
||||
@inherits SettingBox
|
||||
@inject SettingsService SettingsService
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@using Butter.Dtos.Settings
|
||||
@using MilkStream.Client.Services
|
||||
@inherits SettingBox
|
||||
@inject SettingsService SettingsService
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@using Butter.Dtos.Settings
|
||||
@using MilkStream.Client.Services
|
||||
@inherits SettingBox
|
||||
@inject SettingsService SettingsService
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
@using Butter.Dtos.Settings
|
||||
@using Butter.Settings
|
||||
@using MilkStream.Client.Services
|
||||
@inherits SettingBox
|
||||
@inject SettingsService SettingsService
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user