81 Commits

Author SHA1 Message Date
05b99ab6c5 Merge pull request 'feat: #106 auth/permissions redesign — 5-actor model, #93 visibility levels (backend)' (#125) from feature/106-auth-redesign into develop
Reviewed-on: #125
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-14 20:23:23 +00:00
REDCODE
88a0098314 perf: add AsSplitQuery to repository queries with multiple Include calls 2026-07-14 22:00:33 +02:00
REDCODE
8b53899648 docs: add data size estimates to AGENTS.md 2026-07-14 21:46:19 +02:00
REDCODE
e2b0eaba37 refactor: rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
Clarifies that this property filters albums by assets uploaded by a user,
not albums themselves being uploaded by a user.
2026-07-14 21:30:14 +02:00
REDCODE
e1504dbf72 style: standardize access level comparison to switch expressions
- AlbumRepository.SearchQuery: if/else → switch expression
- PersonRepository.FindVisible: nested if → switch statement with guarded case
- AlbumController Update/BulkDelete/Delete: if/else → switch
2026-07-14 21:29:46 +02:00
REDCODE
b67b2260fe docs(repo): fix stale pageSize default in IAlbumRepository XML doc
Actual default is PagedParametersDto.MaxPageSize (changed in 08c3890),
but documentation still referenced the old hardcoded 150.
2026-07-14 21:27:15 +02:00
REDCODE
06fd26e641 refactor: move all visibility filtering from mappers and controllers to repositories
- AlbumMapper: remove CountVisibleAssets(), drop userId param from ToAlbumPreviewDto()
- PersonMapper: remove IsAssetCountVisible(), drop userId param from ToPersonDetailedDto()
- AssetRepository: add FindVisible(), add userId/accessLevel params to GetAssets()
- AlbumRepository: add FindVisible(), replace .Include(a => a.Assets) with subquery count in SearchQuery()
- PersonRepository: add FindVisible() with visibility filtering + album search/sort/pagination
- AssetController.Get: replace inline switch with FindVisible()
- AssetController.GetAll: pass uid/accessLevel to GetAssets(), remove inline filter
- AlbumController.Get: replace inline asset filter with FindVisible()
- PersonController.Get: replace 50-line inline filter/search/sort/page with FindVisible()
2026-07-14 21:17:02 +02:00
REDCODE
74c74cbbdf fix(mapper): scope asset counts in album/ person previews to visible assets
- Added optional userId parameter to ToAlbumPreviewDto and ToPersonDetailedDto
- AlbumRepository.SearchQuery and PersonMapper now pass userId for visibility-scoped counts
- ToAlbumFullDto unchanged (callers pre-filter assets before mapping)
- Private helper filters out non-visible assets for non-curator users
2026-07-14 20:44:52 +02:00
REDCODE
774ee0d7c7 fix(media): allow Maintainer to access media files for cosplayers they maintain
- Injected IPersonRepository into MediaRepository
- Updated GetVisibleAsset with Maintainer-specific check via album→person→maintainer chain
2026-07-14 20:42:55 +02:00
REDCODE
45b3c56ce2 fix(controller): split Maintainer from User in AssetController.GetAll visibility filter
Maintainers now also see Private assets belonging to cosplayers they maintain,
not just their own uploads.
2026-07-14 20:42:26 +02:00
REDCODE
b0fa104642 fix(mapper): replace null-forgiving operator with null-conditional in AlbumMapper
- ToAlbumFullDto.AssetPreviews and Images now use ?. instead of !.
- Falls back to empty list when Assets is null, matching ToAlbumPreviewDto pattern
2026-07-14 20:41:16 +02:00
REDCODE
d4cd305c28 fix(controller): scope AssetController maintainer permissions via album→person chain
- Added IPersonRepository injection into AssetController
- Added FindWithAlbums to IAssetRepository/AssetRepository for single-entity checks
- Modified FindBulk to include Albums navigation property
- Single Update/Delete: Maintainers can act on assets in albums of maintained persons
- BulkUpdate/BulkDelete: same scope expansion for Maintainers
2026-07-14 20:35:55 +02:00
REDCODE
671783fd16 fix(repo): scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
Maintainers now only see Private albums for persons they maintain,
matching the same pattern already used in PersonRepository.SearchQuery.
2026-07-14 20:33:38 +02:00
REDCODE
674710986f fix(model): add [PrimaryKey] annotation to PersonMaintainer for clarity
Composite key (PersonId, UserId) was only defined via Fluent API;
[PrimaryKey] makes the model class self-documenting.
2026-07-14 20:33:18 +02:00
REDCODE
77a6ee9747 fix(dto): make AssetCreateDto.UploadedBy nullable for scanner-created assets
- Changed UploadedBy from Guid to Guid? to represent system/scanner uploader
- Controller Create method now handles null UploadedBy (skips user validation)
2026-07-14 20:18:53 +02:00
REDCODE
1f2bf02d02 fix(migration): default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
- Changed column default from 0 (Public) to 1 (Protected) for Assets, People, Albums
- Reordered migration to add Visibility column before dropping IsPubliclyShared
- Assets that were IsPubliclyShared = true are restored to Public (0) via SQL
- Private assets and all existing people/albums default to Protected (1)
2026-07-14 20:18:13 +02:00
REDCODE
c5b8c93a39 fix(migration): remap existing EAccessLevel values to prevent data corruption
Old enum values stored in DB:
  - Curator (value 1) → Maintainer after renumbering (incorrect)
  - Admin   (value 2) → Curator after renumbering (incorrect)

Add SQL UPDATE statements to remap:
  1. Old Admin (2) → New Admin (3) — done first to free up value 2
  2. Old Curator (1) → New Curator (2) — done second
2026-07-14 20:17:32 +02:00
b9251c3e19 style(repo): use explicit named enum cases in PersonRepository switch expressions 2026-07-14 19:22:14 +02:00
0d1ce9feb3 style(repo): expand switch expression arms for readability 2026-07-14 19:14:35 +02:00
0673017765 style(repo): convert visibility filters to switch expressions for readability 2026-07-14 19:13:20 +02:00
08c389026a fix(repo): use PagedParametersDto.MaxPageSize const instead of hardcoded 150
Replaced magic number with named constant in both interface and implementation.
2026-07-14 19:03:02 +02:00
bbb2ed8f73 fix(dto): default AssetCreateDto.Visibility to Private instead of Protected 2026-07-14 19:02:34 +02:00
fe33fe2044 fix(stats): add ProtectedAssets count, fix PrivateAssets to count only Private
Previously PrivateAssets counted everything != Public (i.e., Protected+Private combined).
Now PublicAssets=Public, ProtectedAssets=Protected, PrivateAssets=Private.
2026-07-14 19:02:07 +02:00
a449c792c1 fix(media): replace bool parameter with EAccessLevel, add deleted asset check
- GetVisibleAsset: accept EAccessLevel enum instead of bool isCuratorOrAbove
- Admin: sees all assets including deleted
- Curator: sees own deleted + all non-deleted
- Maintainer/User: only non-deleted + visibility filtering
2026-07-14 19:01:39 +02:00
9323111d0d fix(mapper): allow Maintainer to see Visibility field in DTO responses
Changed threshold from >= Curator to >= Maintainer in all three mappers (AssetsMapper, PersonMapper, AlbumMapper). FileName stays Curator+ only.
2026-07-14 19:00:57 +02:00
c2326414b4 fix(controller): add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
- BulkUpdate: Maintainer can update albums of persons they maintain
- Delete: Maintainer can delete albums of persons they maintain
- BulkDelete: same scoped filtering
2026-07-14 19:00:07 +02:00
747ad77f4c fix(controller): scope Maintainer asset access to only maintained cosplayers in Get
Maintainer of the album's person sees all non-deleted assets. Otherwise applies User-level visibility filter.
2026-07-14 18:59:23 +02:00
f77a4574ac fix(controller): scope Maintainer album visibility to only maintained persons
When viewing a person they maintain, show all albums. Otherwise apply User-level visibility filter (Public + Protected only).
2026-07-14 18:58:47 +02:00
c64c1947e6 fix(repo): scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
Previously Maintainer saw all people like Curator+. Now:
- Curator+: sees all people
- Maintainer: sees Public + Protected + persons they maintain
- User: sees Public + Protected only
2026-07-14 18:58:14 +02:00
1b444dab93 feat(auth): complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
- AlbumSearchParametersDto: add UploadedBy field
- IPersonRepository/PersonRepository: add IsMaintainerOf method
- AlbumController: allow Maintainer to update with scope check, pass uploadedBy to SearchQuery
- PersonController: allow Maintainer to update maintained persons
- AssetController: allow Maintainer to update/delete own assets
- TagController: allow Maintainer to CRUD all tags
- Fix Authorize(Roles) to include Maintainer where appropriate
- Update WepApiTest.http: curator accessLevel 1→2
2026-07-14 18:13:15 +02:00
50bf0a2a78 feat(db): add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy 2026-07-14 17:45:22 +02:00
2335e8e23a feat(repos,controllers): update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
- PersonRepository, AlbumRepository, AssetRepository, MediaRepository, StatsRepository: visibility-based filtering
- IAlbumRepository, IAssetRepository, IMediaRepository: updated signatures (FindByUploader, uploadedBy param, accessLevel)
- AlbumController, AssetController, PersonController, MediaController: replace IsPubliclyShared/SharedWith/UserOwnerId/Owner with Visibility/UploadedBy/Uploader
- FileSystemCrawlJob: IsPubliclyShared=false → Visibility=EVisibility.Private
- Asset model: nav property UploadedBy→Uploader to avoid FK/nav name collision
2026-07-14 17:44:46 +02:00
d02a010083 feat(infra): update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility 2026-07-14 17:40:34 +02:00
6676347353 feat(models): update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer 2026-07-14 17:39:48 +02:00
c9391d840b feat(butter): update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields 2026-07-14 17:39:01 +02:00
51323b4a64 feat(butter): add EVisibility enum, Maintainer access level, and SystemUploaderId setting 2026-07-14 17:37:42 +02:00
842ed76345 fix(jobs): use grey for queued segments in layered progress bar 2026-07-14 13:14:44 +02:00
f3ed658dd0 chore: Updated packages, removed unused packages. ImageSharp now requires a license 2026-07-14 13:14:27 +02:00
6c83b8395b fix(album-form): searchable person combobox with modal-safe positioning
Replaces the static <select> with a server-side searchable combobox
in the album edit modal. Fixes issues where the person dropdown was
empty and the name field was not pre-filled on edit.

- Server-side person search (trigram-indexed ILIKE, 20 results)
- 2-character minimum before search triggers
- pre-populates input with current person name when editing
- position: fixed via JS to escape modal-body overflow clipping

Closes #123
2026-07-14 12:43:59 +02:00
e58524ce19 Merge pull request 'refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate' (#121) from refactor/120-albumgrid-cosplayerdetail into develop
Reviewed-on: #121
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-14 10:22:09 +00:00
9dced21554 docs: reconcile AGENTS.md with current codebase
- Remove stale Npgsql legacy timestamp switch note (removed from code)
- Fix EF Core version mismatch description (Tools 8.0.15, not CLI)
- Expand analyzer rules to cover MS001–MS004
2026-07-14 12:09:33 +02:00
3fdf55b53c docs: add PR workflow section to AGENTS.md 2026-07-14 12:07:20 +02:00
f5b28e5a21 docs: add Gitea label levels note to AGENTS.md 2026-07-14 12:04:49 +02:00
7a380d7df5 refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
Replace manual album state management (inline AlbumCard loop, own
infinite scroll, own sentinel/observer, _ = ReloadAlbums() discard
lambdas) with <AlbumGrid> using a new FetchAlbums delegate parameter.

- AlbumGrid.razor: add FetchAlbums Func parameter; use it in
  Reload()/LoadMoreAlbums() when set
- CosplayerDetail.razor: replace manual album rendering + state with
  <AlbumGrid FetchAlbums=@FetchPersonAlbums />; remove all album
  state management fields (allAlbumsDict, currentAlbumPage, etc.),
  ReloadAlbums/LoadMoreAlbums/OnAfterRenderAsync methods, and
  IAsyncDisposable; SortFilterBar now uses clean lambdas
- CosplayerDetail.razor.css: remove unused album-grid overrides

Closes #120
2026-07-14 12:00:38 +02:00
f7d6348ea9 Merge pull request 'fix: make card components middle-clickable by using <a> instead of <div @onclick>' (#119) from fix/102-card-middle-click into develop
Reviewed-on: #119
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-14 09:57:32 +00:00
46871299ca fix: remove underline from album card person link 2026-07-14 11:51:58 +02:00
c281746fdc fix: make card components middle-clickable
Convert <div @onclick> -> NavigationManager.NavigateTo() to real <a>
elements so cards support middle-click, Ctrl+click, and
'Open in new tab'.

- CosplayerGrid.razor: replace <div> with <a href='/cosplayer/{id}'>
- AlbumCard.razor: add full-card <a> overlay, keep inner person <a>
- CosplayerGrid.razor.css + AlbumCard.razor.css: add display:block,
  color:inherit, text-decoration:none for <a> card elements
- Remove OnCardClicked/OnNavigate callbacks and parent page
  navigation methods (nav is now native via <a href>)

Closes #102
2026-07-14 11:46:13 +02:00
be7532dd74 fix: implement CosplayersMissingProfile stat
Replaces the hardcoded zero with a real query counting people
without a profile picture (ProfileAssetId == null).

Renamed from CosplayersMissingCover to CosplayersMissingProfile
to reflect the actual metric.

DTO, repository, and UI label updated accordingly.

fixes #97
2026-07-14 11:40:06 +02:00
108b53a7e2 refactor: use MaxPageSize constant as default pageSize across all frontend services
AlbumService.GetAlbumsAsync, AssetService.GetAssetsAsync, PersonService.GetAllAsync
now default to PagedParametersDto.MaxPageSize (250) instead of hardcoded 30.
Also fixed AssetService.GetAssetsAsync page default from 1 to 0 for consistency
with zero-based pagination.
2026-07-14 11:27:38 +02:00
38793efe6f fix(albums): use MaxPageSize constant for unassigned album fetch
Fixes #118 - hardcoded pageSize=999 exceeded server's MaxPageSize (250),
causing 400 Bad Request and the assign-album modal to hang on loading.
2026-07-14 11:24:34 +02:00
4205188855 docs: add use case catalog for new auth/permissions model
Documents the 5-actor model (Anonymous/User/Maintainer/Curator/Admin),
77 use cases across all roles, permission matrix, visibility integration
with #93, and database schema changes including:
- Album.UserOwnerId removal
- Asset.OwnerId → UploadedBy rename
- PersonMaintainer M:N join table
- EAccessLevel enum update
2026-07-13 18:57:54 +02:00
d2daed27a8 Merge pull request 'Global "Search Anything" typeahead dropdown in navbar' (#91) from feature/global-search-dropdown into develop
Reviewed-on: #91
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-13 15:18:40 +00:00
87b69d5ee8 revert: remove AllowAnonymous from AlbumController.Search and PersonController.GetAll 2026-07-13 17:03:53 +02:00
e0d46aa87b refactor: replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize 2026-07-13 17:02:13 +02:00
dbe55be63f refactor(albums): extract visibility-aware AssetCount into AlbumMapper overload
Replace inline AlbumPreviewDto construction in AlbumRepository.SearchQuery
with a call to the new ToAlbumPreviewDto(album, userId, accessLevel) mapper
overload, making the visibility logic reusable and the repository more compact.
2026-07-12 21:15:00 +02:00
28f9e7faae fix: replace untranslatable Tags navigation in TopTags stats query with direct join table query 2026-07-12 21:02:50 +02:00
820d3b1c8b fix: replace untranslatable Album navigation in stats queries with direct join table queries 2026-07-12 21:02:33 +02:00
278648aae6 Merge branch 'develop' into feature/global-search-dropdown 2026-07-12 21:00:42 +02:00
9d9491253b refactor: clean up imports, simplify checks, and add SearchDropdown component
- Remove unused using directives across C# and Razor files
- Remove unused IServiceProvider from SettingsRepository
- Simplify null/empty string checks in StatsRepository
- Add null-safe navigation for Albums/Tags in stats queries
- Initialize Asset.Hash default to prevent null refs
- Deduplicate AssetIds in AssetPicker
- Add OnStartedWaiting/OnFinishedWaiting/OnProgressChanged to Job
- Add global SearchDropdown component with keyboard nav
- Fix XML doc param mismatches
2026-07-12 20:47:24 +02:00
499c6375f6 Merge branch 'develop' into feature/global-search-dropdown
Resolved merge conflict in CosplayerDetail.razor:
- Kept SortFilterBar with instant search/sort (from feat/cosplayer-detail-search)
- Adapted allAlbums references to allAlbumsDict (from feature/global-search-dropdown)
- Kept updated PersonController.cs and PersonService.cs from both branches
2026-07-12 20:28:59 +02:00
2b150060f4 Merge pull request 'Add searchbar to cosplayer detail page for filtering albums' (#100) from feat/cosplayer-detail-search into develop
Reviewed-on: #100
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-12 18:23:51 +00:00
b93cb73e0a fix(cosplayer-detail): remove search debounce, instant inline reload
Search now fires ReloadAlbums immediately on every keystroke, matching
the same instant behavior as Albums and Cosplayers pages. Since
ReloadAlbums only swaps the album grid content without touching
isLoading, focus is preserved and the page stays intact.
2026-07-12 20:21:27 +02:00
d2d584d509 fix(cosplayer-detail): debounce search, keep page intact on filter changes
- Remove OnFilterChanged from SortFilterBar to prevent per-keystroke reloads
- Debounce search input with 300ms CancellationTokenSource delay
- Sort changes (dropdown/toggle) trigger immediate ReloadAlbums
- ReloadAlbums no longer sets isLoading — updates album grid in-place
  without unmounting the page DOM, preserving focus on the search box
- Dispose searchCts on component disposal
2026-07-12 20:19:30 +02:00
8bcb408a5b fix(cosplayer-detail): prevent banner image shuffle on album search/sort
Add updateBanner parameter to LoadPerson so ReloadAlbums (triggered
by search/sort filter changes) does not regenerate the random banner
cover URLs, keeping the header fixed during filtering.
2026-07-12 20:15:09 +02:00
445b030160 feat(cosplayer-detail): add SortFilterBar for album search and sort
- Backend: accept PagedSearchParametersDto on GET /api/person/{id}
  with in-memory search (case-insensitive title Contains) and sort
  (name/created/updated/assets) before pagination
- Frontend service: add search, sortBy, sortAsc params to GetByIdAsync
- Frontend page: replace inline action buttons with reusable SortFilterBar
  component, wire search/sort state into initial load and infinite scroll
2026-07-12 20:11:06 +02:00
8195808500 docs: update AGENTS.md with missing conventions and project info
- Add Lactose.Analyzers as 5th project (DateTime.UtcNow error MS001)
- Document zero-based pagination standard with validation rules
- Add SharedWith.Any() EF Core translation gotcha
- Document pg_trgm GIN indexes and pgvector extensions
- Fix test count (66+ → 67), add env file reference
2026-07-12 19:41:10 +02:00
393ad136f6 fix: restore PageSize < 1 validation with upper limit of 250 2026-07-12 19:31:23 +02:00
00acfef28c fix: only reject Page < 0, keep original PageSize validation
Per review feedback: only Page being negative is truly invalid.
PageSize validation reverted to original < 0 (allowing 0).
PersonController keeps minimal Page < 0 check since it had none before.
JobsController aligned for consistency.
2026-07-12 19:27:10 +02:00
efeed2b668 test: add page=0&pageSize=5 to paginated list endpoints
Person (tests 23-26), Album (tests 45-48), and User (test 12) list
endpoints now explicitly request the first page instead of the full set.
2026-07-12 19:17:15 +02:00
efc6379bb3 test: add cleanup of regular test user in REST Client suite 2026-07-12 19:11:11 +02:00
20a456591d fix: move SharedWith visibility check to client side for translatable queries
EF Core cannot translate SharedWith.Any() through the many-to-many
AlbumAsset join table inside a SQL WHERE clause. Moved the visibility
filter to client-side after loading the page's records with Include.

PersonRepository.SearchQuery and AlbumRepository.SearchQuery now:
1. Get paginated IDs without visibility filter (translatable SQL)
2. Load those records with Include chains
3. Filter visibility in C# (SharedWith.Any(), etc.)
4. Project to DTOs
2026-07-12 19:07:38 +02:00
1b301ff2c0 fix: standardize pagination to zero-based across all layers
- AssetRepository: all Skip() formulas changed from (page-1)*size to page*size
- JobRecordRepository: same formula change for GetPastRootJobs
- AssetController: removed Page+1 bridge conversion, passes Page directly
- JobsController: GetPast default changed from 1 to 0, added validation
- AlbumController, TagController, PersonController: consistent Page<0/PageSize<1 validation
- IAssetRepository XML doc: 1-based → zero-based
- Home.razor: shifted all internal page state from 1-based to 0-based

Closes #95
2026-07-12 19:03:09 +02:00
50c58e22ff fix: restore ToAlbumPreviewDto mapper method per review feedback #91 2026-07-12 18:50:03 +02:00
ad2f08698d perf: simplify ORDER BY to total count and add trigram GIN indexes
Simplify the 'assets'/'albums' sort to use total count instead of
visibility-filtered count, eliminating the expensive correlated COUNT
subquery from ORDER BY (which evaluated 25k times for every album).

Add pg_trgm extension and GIN trigram indexes on People.Name and
Albums.Title for efficient ILike %%query%% searches.

The SELECT projection still computes visibility-filtered counts for
accuracy, but only for the paginated subset (30 rows).

Migration: 20260712162406_AddTrigramIndexes
2026-07-12 18:24:55 +02:00
e05ab3beaf docs: document REST Client tests in AGENTS.md 2026-07-12 18:05:55 +02:00
48b6d4f149 perf: add partial index on Assets for visibility-aware search queries
Create IX_Assets_VisibleForSearch on (IsPubliclyShared, OwnerId)
WHERE "DeletedAt" IS NULL to accelerate the correlated subqueries
used in both AlbumRepository and PersonRepository for filtering,
sorting by asset/album count, and projecting AssetCount/TotalAlbums.

- Fluent API in LactoseDbContext.OnModelCreating
- Generated migration via dotnet ef migrations add
2026-07-12 18:04:53 +02:00
89edf290ff refactor: project AlbumPreviewDto directly in repository with visibility-aware asset count
Align AlbumRepository.SearchQuery with the PersonRepository pattern:
return AlbumPreviewDto directly with access-level filtering at the
query level, avoiding loading full entity graphs and filtering
in-memory in the controller.

- Add userId/accessLevel params to SearchQuery
- Push visibility filter into the query for regular users
- Compute AssetCount with same visibility logic in the projection
- Sort by 'assets' uses visible count for regular users
- Remove unused AlbumMapper.ToAlbumPreviewDto extension
- Simplify AlbumController.Search to just return repo results
2026-07-12 17:41:24 +02:00
c77096b0f9 refactor: project PersonPreviewDto directly in repository instead of [NotMapped] entity property
Replace the TempData-like TotalVisibleAlbums [NotMapped] property on
Person with a direct DTO projection in PersonRepository.SearchQuery.
The repository now returns IEnumerable<PersonPreviewDto>, computing
the visibility-aware album count in the EF Core subquery and projecting
only the needed columns. This avoids polluting the entity model with a
context-dependent property.
2026-07-12 17:18:17 +02:00
e333d0e9db fix: filter album count by user visibility in person search
- Add Person.TotalVisibleAlbums [NotMapped] populated by repository
- Use visibility-aware album count in PersonMapper
- Restructure PersonRepository.SearchQuery with two-step pagination
  for correct ordering and per-user album visibility filtering
2026-07-12 16:51:29 +02:00
324c1b1a17 fix: prevent duplicate @key errors across all list renderings
Switch accumulating list storage to Dictionary<Guid, T> to guarantee
unique Blazor @key values when paginated data overlaps or races occur
during sort/filter changes.

- CosplayerGrid: Dictionary<Guid, PersonPreviewDto> for people list
- CosplayerDetail: Dictionary<Guid, AlbumPreviewDto> for albums
- AlbumDetail: deduplicate album.Images at load time via dict
- Home: deduplicate flatList rebuild via dict (keeps first occurrence)
- AssetPicker: Distinct() on parameter list
2026-07-12 14:47:12 +02:00
REDCODE
ee3511c09a fix: paginate PersonRepository.SearchQuery by ID before loading albums
Include + Skip/Take on a collection navigation can produce duplicate people when sorted by album count, because pagination operates on joined rows. Fix by selecting paged IDs first, then loading the full entities with Include for those IDs.
2026-07-11 20:47:44 +02:00
115 changed files with 3711 additions and 877 deletions

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ storageImages/
dotnet-tools.json
.opencode/
*.css.map
# SixLabors license file
/Lactose/sixlabors.lic

View File

@@ -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 1250).
- **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`).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
namespace Butter.Dtos.Person;
@@ -39,6 +40,10 @@ 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; }

View File

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

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,21 +9,25 @@ 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; }
}

View File

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

View File

@@ -31,7 +31,9 @@ public enum Settings {
/// <summary>Quality setting (1100) for WebP thumbnail encoding.</summary>
ThumbnailQuality,
/// <summary>Quality setting (1100) 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
};
}

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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,49 +46,13 @@ 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);
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,33 @@ 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";
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
logger.LogTrace($"Updating asset {id}\n{log}");
@@ -215,7 +169,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 +188,16 @@ 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;
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
}
);
@@ -254,18 +211,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 +241,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 +260,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 +283,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 +315,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;
}

View File

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

View File

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

View File

@@ -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,11 +73,19 @@ 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;
@@ -143,7 +128,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 +154,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")]

View File

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

View File

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

View File

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

View File

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

View File

@@ -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); }
}

View File

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

View File

@@ -1,5 +1,4 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;

View File

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

View File

@@ -1,6 +1,4 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;

View File

@@ -1,6 +1,4 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;

View File

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

View File

@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -7,45 +8,46 @@ namespace Lactose.Mapper;
/// provides mapping methods for converting Album objects to DTOs.
/// </summary>
public static class AlbumMapper {
/// <summary>
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
/// </summary>
/// <param name="album">The album to map.</param>
/// <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,
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
};
/// <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) => new AlbumFullDto {
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel) => new AlbumFullDto {
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 {
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 = asset.OriginalFilename,
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
};
}

View File

@@ -9,34 +9,35 @@ 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>
/// <returns>A AssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => 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(),
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,
@@ -55,14 +56,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));
}

View File

@@ -1,5 +1,6 @@
using Butter.Dtos.Album;
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -12,8 +13,9 @@ 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 ?? [];
return new PersonDetailedDto {
@@ -25,7 +27,8 @@ 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()
};
}
@@ -33,24 +36,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
};
}

View 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
}
}
}

View File

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

View 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
}
}
}

View 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", ",,");
}
}
}

View 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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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!;
}

View File

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

View File

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

View File

@@ -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,11 +27,8 @@ 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);
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}%"));
@@ -36,18 +36,72 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
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 />
@@ -55,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);

View File

@@ -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,40 @@ 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) {
var query = context.Assets.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
.AsSplitQuery()
.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 +54,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 +83,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 +171,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 +180,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 +206,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 +219,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();
}

View File

@@ -1,6 +1,5 @@
using Lactose.Context;
using Lactose.Models;
using Lactose.Services;
namespace Lactose.Repositories;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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,12 @@ 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);
}

View File

@@ -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();
}

View File

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

View File

@@ -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,32 +17,120 @@ 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)
.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)
.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(),
return context.People
.Include(p => p.Albums)
.Where(p => p.Albums!.Any(a =>
a.Assets!.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
asset.SharedWith!.Any(u => u.Id == userId)
))
))
.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(),
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) =>
@@ -58,33 +148,60 @@ 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);
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;
if (!string.IsNullOrEmpty(query))
peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%"));
if (accessLevel == EAccessLevel.User)
peopleQuery = peopleQuery.Where(p => p.Albums!.Any(a =>
a.Assets!.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
asset.SharedWith!.Any(u => u.Id == userId)
))
));
// 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)),
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),
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
};
return ordered
.Skip(page * pageSize)
.Take(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();
if (personIds.Count == 0)
return [];
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 Dispose() => context.Dispose();
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Jobs;

View File

@@ -1,7 +1,5 @@
using Butter.Types;
using Lactose.Configuration;
using Lactose.Models;
using Microsoft.Extensions.Options;
using System.Security.Claims;
namespace Lactose.Services;

View File

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

View File

@@ -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}}
> {%
@@ -361,7 +361,7 @@ Content-Type: application/json
%}
### 23. Get all people as anonymous
GET {{WepApiTest_HostAddress}}/api/person
GET {{WepApiTest_HostAddress}}/api/person?page=0&pageSize=5
> {%
client.test("Get all people as anonymous", function () {
@@ -370,7 +370,7 @@ GET {{WepApiTest_HostAddress}}/api/person
%}
### 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 +380,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 +390,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}}
> {%
@@ -662,7 +662,7 @@ Content-Type: application/json
%}
### 45. Get all albums as anonymous
GET {{WepApiTest_HostAddress}}/api/album
GET {{WepApiTest_HostAddress}}/api/album?page=0&pageSize=5
> {%
client.test("Get all albums as anonymous", function () {
@@ -671,7 +671,7 @@ GET {{WepApiTest_HostAddress}}/api/album
%}
### 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 +681,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 +691,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}}
> {%
@@ -913,3 +913,13 @@ Authorization: Bearer {{admin_token}}
client.assert(response.status === 200)
});
%}
### 67. 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)
});
%}

View File

@@ -1,5 +1,4 @@
@using Butter.Dtos.Folder
@using MilkStream.Client.Services
@using System.Text.RegularExpressions
@inject FoldersService FoldersService

View File

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

View File

@@ -1,4 +1,3 @@
@using MilkStream.Client.Services
@using Butter.Types
@inherits LayoutComponentBase

View File

@@ -1,7 +1,6 @@
@page "/Users"
@using Butter.Dtos.User
@using Butter.Types
@using MilkStream.Client.Services
@inject NavigationManager NavigationManager
@inject LoginService LoginService

View File

@@ -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
@@ -225,6 +222,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;
}

View File

@@ -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
@@ -66,7 +61,6 @@
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
OnNavigateAlbum="@NavigateToAlbum"
OnToggleSelection="@ToggleAlbumSelection"
LoadVersion="@loadVersion" />
@@ -125,10 +119,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);

View File

@@ -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>
@@ -69,65 +63,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 (IsAdminOrCurator) {
<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>
}
@@ -192,14 +187,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 +222,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 +279,7 @@
showAlbumAssigner = false;
unassignedAlbums = null;
await LoadPerson();
albumLoadVersion++;
}
async Task RemoveSelectedAlbums() {
@@ -318,6 +289,7 @@
selectMode = false;
selectedAlbumIds.Clear();
await LoadPerson();
albumLoadVersion++;
}
async Task ConfirmDelete() {
@@ -326,16 +298,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();
}
}
}

View File

@@ -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
@@ -54,9 +47,8 @@
SortBy="@sortBy"
SortAsc="@sortAsc"
SelectionMode="@cosplayerSelectMode"
SelectedIds="@selectedCosplayerIds"
OnCardClicked="@NavigateToPerson"
OnToggleSelection="@ToggleCosplayerSelection"
SelectedIds="@selectedCosplayerIds"
OnToggleSelection="@ToggleCosplayerSelection"
LoadVersion="@loadVersion" />
@if (showCreateForm) {
@@ -105,10 +97,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);

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
@page "/login"
@using Butter.Dtos
@using MilkStream.Client.Services
@inject NavigationManager navigation
@inject LoginService loginService

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
@page "/Stats"
@using Butter.Dtos.Stats
@using Butter.Types
@using MilkStream.Client.Services
@inject LoginService LoginService
@inject StatsService StatsService
@@ -45,7 +43,7 @@
<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 ---- *@

View File

@@ -1,7 +1,6 @@
@page "/User/{UserId:guid}"
@using Butter.Dtos.User
@using Butter.Types
@using MilkStream.Client.Services
@inject LoginService LoginService
@inject UserService UserService

View File

@@ -1,6 +1,4 @@
@using Butter.Dtos.Settings
@using MilkStream.Client.Services
@using Microsoft.JSInterop
@inject SettingsService SettingsService
@inject IJSRuntime JSRuntime

View File

@@ -1,5 +1,4 @@
@using Butter.Dtos.Settings
@using MilkStream.Client.Services
@using System.Globalization
@inherits SettingBox
@inject SettingsService SettingsService

View File

@@ -1,5 +1,4 @@
@using Butter.Dtos.Settings
@using MilkStream.Client.Services
@inherits SettingBox
@inject SettingsService SettingsService

View File

@@ -1,5 +1,4 @@
@using Butter.Dtos.Settings
@using MilkStream.Client.Services
@inherits SettingBox
@inject SettingsService SettingsService

View File

@@ -1,6 +1,4 @@
@using Butter.Dtos.Settings
@using Butter.Settings
@using MilkStream.Client.Services
@inherits SettingBox
@inject SettingsService SettingsService

View File

@@ -1,12 +1,12 @@
@using Butter.Dtos.Album
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService
<div class="album-card @(SelectionMode ? "album-card-select" : "")" @key="Album.Id"
@onclick="HandleClick" @onclick:preventDefault="true">
<div class="album-card @(SelectionMode ? "album-card-select" : "")" @key="Album.Id">
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
@if (Album.CoverAssetId.HasValue) {
<img src="@ThumbUrl(Album.CoverAssetId.Value)"
loading="lazy" alt=""
@@ -41,9 +41,6 @@
[Parameter]
public AlbumPreviewDto Album { get; set; } = default!;
[Parameter]
public EventCallback<Guid> OnNavigate { get; set; }
[Parameter]
public bool SelectionMode { get; set; }
@@ -59,10 +56,8 @@
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
async Task HandleClick() {
async Task HandleOverlayClick() {
if (SelectionMode)
await OnToggleSelection.InvokeAsync(Album.Id);
else
await OnNavigate.InvokeAsync(Album.Id);
}
}

View File

@@ -7,6 +7,14 @@
position: relative;
}
.album-card-overlay {
position: absolute;
inset: 0;
z-index: 1;
color: inherit;
text-decoration: none;
}
.album-card:hover {
filter: brightness(1.15);
transform: translateY(-2px);
@@ -64,6 +72,9 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-decoration: none;
position: relative;
z-index: 2;
}
.album-card-count {

View File

@@ -1,7 +1,5 @@
@using Butter.Dtos.Album
@using Butter.Dtos.Person
@using Microsoft.JSInterop
@using MilkStream.Client.Services
@inject AlbumService albumService
@inject PersonService personService
@inject IJSRuntime JSRuntime
@@ -14,14 +12,40 @@
</div>
<div class="mb-3">
<label class="form-label">Person</label>
<select class="form-select" @bind="selectedPersonId">
<option value="">None</option>
@if (people != null) {
@foreach (var person in people) {
<option value="@person.Id">@person.Name</option>
}
<div class="position-relative">
<input type="text" class="form-control"
placeholder="Search cosplayers…"
value="@filterText"
@ref="personInputRef"
@oninput="OnInput"
@onfocus="OnInputFocus"
@onblur="OnBlur"
@onkeydown="OnKeyDown" />
@if (showPersonDropdown) {
<div class="dropdown-menu show person-dropdown-menu"
@ref="personDropdownRef">
<button type="button" class="dropdown-item" @onclick="ClearPerson">
None
</button>
<div class="dropdown-divider"></div>
@if (isSearching) {
<span class="dropdown-item disabled">Searching…</span>
} else if (filterText.Length < 2) {
<span class="dropdown-item disabled">Type at least 2 characters to search</span>
} else if (searchResults is null || searchResults.Count == 0) {
<span class="dropdown-item disabled">No matching cosplayers</span>
} else {
@foreach (var person in searchResults) {
<button type="button"
class="dropdown-item @(selectedPersonId == person.Id ? "active" : "")"
@onclick="() => SelectPerson(person)">
@person.Name
</button>
}
}
</div>
}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Cover Image</label>
@@ -65,26 +89,99 @@
string title = string.Empty;
Guid? selectedPersonId;
Guid? coverAssetId;
List<PersonPreviewDto>? people;
List<Guid>? albumAssetIds;
bool showAssetPicker;
string filterText = string.Empty;
bool showPersonDropdown;
CancellationTokenSource? blurCts;
List<PersonPreviewDto>? searchResults;
bool isSearching;
int searchVersion;
ElementReference personInputRef;
ElementReference personDropdownRef;
bool pendingPosition;
protected override async Task OnParametersSetAsync() {
if (Show && people == null) {
people = await personService.GetAllAsync();
}
if (Show && EditAlbum != null) {
title = EditAlbum.Name;
selectedPersonId = EditAlbum.Person;
filterText = EditAlbum.PersonName ?? string.Empty;
coverAssetId = EditAlbum.CoverAssetId;
} else if (Show && EditAlbum == null) {
title = string.Empty;
selectedPersonId = null;
filterText = string.Empty;
coverAssetId = null;
}
albumAssetIds = AlbumAssetIds;
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (pendingPosition && showPersonDropdown) {
pendingPosition = false;
await JSRuntime.InvokeVoidAsync("masonryObserver.positionDropdown", personInputRef, personDropdownRef);
}
}
void OnInputFocus() {
blurCts?.Cancel();
showPersonDropdown = true;
pendingPosition = true;
if (filterText.Length >= 2) {
_ = Search(filterText);
}
}
async Task OnInput(ChangeEventArgs e) {
filterText = e.Value?.ToString() ?? string.Empty;
showPersonDropdown = true;
pendingPosition = true;
if (filterText.Length >= 2) {
await Search(filterText);
} else {
searchResults = null;
isSearching = false;
}
}
async Task Search(string query) {
var version = ++searchVersion;
isSearching = true;
searchResults = await personService.GetAllAsync(page: 0, pageSize: 20, search: query);
if (version == searchVersion) {
isSearching = false;
}
StateHasChanged();
}
async Task OnBlur() {
blurCts?.Cancel();
blurCts = new CancellationTokenSource();
try {
await Task.Delay(150, blurCts.Token);
showPersonDropdown = false;
} catch (OperationCanceledException) { }
}
void OnKeyDown(KeyboardEventArgs e) {
if (e.Key is "Escape") {
showPersonDropdown = false;
}
}
void SelectPerson(PersonPreviewDto person) {
selectedPersonId = person.Id;
filterText = person.Name;
showPersonDropdown = false;
}
void ClearPerson() {
selectedPersonId = null;
filterText = string.Empty;
showPersonDropdown = false;
searchResults = null;
}
void OpenAssetPicker() {
showAssetPicker = true;
}

View File

@@ -0,0 +1,5 @@
.person-dropdown-menu {
display: block;
max-height: 250px;
overflow-y: auto;
}

View File

@@ -1,8 +1,4 @@
@using Butter.Dtos.Album
@using Butter.Types
@using Microsoft.JSInterop
@using MilkStream.Client.Components.Layout
@using MilkStream.Client.Services
@implements IAsyncDisposable
@inject AlbumService albumService
@@ -23,7 +19,6 @@
<div class="@("album-grid " + ViewMode)">
@foreach (var album in albums) {
<AlbumCard Album="album"
OnNavigate="NavigateToAlbum"
SelectionMode="SelectionMode"
Selected="SelectedIds.Contains(album.Id)"
OnToggleSelection="ToggleAlbumSelection"/>
@@ -44,9 +39,9 @@
[Parameter] public string ViewMode { get; set; } = "masonry";
[Parameter] public bool SelectionMode { get; set; }
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
[Parameter] public EventCallback<Guid> OnNavigateAlbum { get; set; }
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
List<AlbumPreviewDto> albums = [];
int currentPage = 0;
@@ -93,7 +88,9 @@
isLoading = true;
StateHasChanged();
var items = await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
var items = FetchAlbums != null
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums = items;
currentPage = 1;
@@ -108,7 +105,9 @@
isLoadingMore = true;
StateHasChanged();
var items = await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
var items = FetchAlbums != null
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums.AddRange(items);
currentPage++;
@@ -118,8 +117,6 @@
StateHasChanged();
}
async Task NavigateToAlbum(Guid id) => await OnNavigateAlbum.InvokeAsync(id);
async Task ToggleAlbumSelection(Guid id) => await OnToggleSelection.InvokeAsync(id);
async Task DisposeObserver() {

View File

@@ -1,5 +1,6 @@
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@using System.Linq
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@@ -10,7 +11,7 @@
<div class="alert alert-secondary">No assets in this album.</div>
} else {
<div class="picker-grid">
@foreach (var id in AssetIds) {
@foreach (var id in AssetIds.Distinct()) {
<div class="picker-tile @(SelectedAssetId == id ? "selected" : "")"
@key="id"
@onclick="() => SelectAsset(id)">

View File

@@ -1,10 +1,5 @@
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@using Microsoft.JSInterop
@using MilkStream.Client
@using MilkStream.Client.Components.Layout
@using MilkStream.Client.Services
@using System.Globalization
@implements IAsyncDisposable
@@ -15,7 +10,7 @@
@if (isLoading) {
<LoadSpinner/>
} else if (people.Count == 0 && !hasMore) {
} else if (peopleDict.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No media available">
@if (loginService.IsLoggedIn) {
<p>There are no cosplayers yet.</p>
@@ -25,10 +20,10 @@
</EmptyState>
} else {
<div class="cosplayers-grid">
@foreach (var person in people) {
<div class="cosplayer-card @(SelectionMode ? "cosplayer-card-select" : "")" @key="person.Id"
@onclick="() => HandleCardClick(person.Id)"
@onclick:preventDefault="true">
@foreach (var person in peopleDict.Values) {
<a href="/cosplayer/@person.Id" class="cosplayer-card @(SelectionMode ? "cosplayer-card-select" : "")" @key="person.Id"
@onclick="() => HandleCardClick(person.Id)"
@onclick:preventDefault="SelectionMode">
@if (person.ProfileAssetId.HasValue) {
var cx1 = person.ProfileCropX ?? 50;
var cy1 = person.ProfileCropY ?? 50;
@@ -62,7 +57,7 @@
}
</div>
}
</div>
</a>
}
</div>
@@ -79,11 +74,10 @@
[Parameter] public bool SortAsc { get; set; } = true;
[Parameter] public bool SelectionMode { get; set; }
[Parameter] public HashSet<Guid> SelectedIds { get; set; } = [];
[Parameter] public EventCallback<Guid> OnCardClicked { get; set; }
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
List<PersonPreviewDto> people = [];
Dictionary<Guid, PersonPreviewDto> peopleDict = [];
int currentPage = 0;
bool hasMore = true;
bool isLoading = true;
@@ -120,7 +114,7 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (people.Count > 0 && dotNetRef == null) {
if (peopleDict.Count > 0 && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"cosplayerObserver.observeBottom", sentinelRef, dotNetRef
@@ -130,7 +124,7 @@
async Task Reload() {
await DisposeObserver();
people.Clear();
peopleDict.Clear();
currentPage = 0;
hasMore = true;
isLoading = true;
@@ -138,7 +132,8 @@
var items = await personService.GetAllAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
people = items;
foreach (var p in items)
peopleDict[p.Id] = p;
currentPage = 1;
}
hasMore = items?.Count == 30;
@@ -153,7 +148,8 @@
var items = await personService.GetAllAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
people.AddRange(items);
foreach (var p in items)
peopleDict[p.Id] = p;
currentPage++;
}
hasMore = items?.Count == 30;
@@ -164,8 +160,6 @@
void HandleCardClick(Guid id) {
if (SelectionMode) {
OnToggleSelection.InvokeAsync(id);
} else {
OnCardClicked.InvokeAsync(id);
}
}

Some files were not shown because too many files have changed in this diff Show More