334 Commits
Author SHA1 Message Date
REDCODE ee07306a4d Merge branch 'develop'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m29s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m52s
2026-07-22 15:13:01 +02:00
REDCODE 294f7a6cae chore: removed outdated agent.md 2026-07-22 15:11:31 +02:00
REDCODE 595399385b chore: scripts and run configs to automate version injection 2026-07-22 15:09:52 +02:00
REDCODEandopencode 77ee80cd63 feat: embed git version in WASM footer via AssemblyInformationalVersion
Inject git describe output into the assembly's InformationalVersion at build
time via an MSBuild target (local dev) or Docker build arg (CI/Docker).

Local builds use v1.0.2-21-g9e67c59-dirty; Docker builds take
APP_VERSION as a build arg to avoid MSBuild property collision with VERSION.

Co-authored-by: opencode <opencode@anomaly.co>
2026-07-22 15:09:06 +02:00
REDCODE 9e67c597ef refactor: replace IntersectionObserver with input-event-based infinite scroll
- Remove IntersectionObserver, sentinel elements, and scroll reattach
  from albumObserver and cosplayerObserver
- Add wheel, keydown, and touch event listeners that react only to
  explicit user input commands (scroll down/up, arrow keys, swipe)
- Check page position (isAtBottom/isAtTop) before invoking load
- No observer to get stuck, no cascade from auto-scroll/load completion
2026-07-22 14:31:08 +02:00
REDCODE 05ff6e8f2d fix: prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
- Replace per-frame scroll reattach with 300ms debounced scroll-end
  reattach to prevent cascade during fast/momentum scrolling
- Add overflow-anchor: none to grid containers and sentinels to
  prevent browsers from auto-scrolling when content is added below
2026-07-22 14:08:29 +02:00
REDCODE 07e43b5023 feat: add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
- Add PageBuffer<T> utility class (capped at 10 pages, drops oldest) to keep
  DOM and memory bounded during infinite scroll
- Refactor AlbumGrid and CosplayerGrid to use PageBuffer instead of unbounded
  List/Dictionary, add top sentinel for bi-directional scroll-back loading
- Add observeTop and scroll-based sentinel reattach to albumObserver and
  cosplayerObserver in masonryObserver.js — prevents IntersectionObserver
  getting stuck when sentinel stays visible after load by re-evaluating on
  each scroll frame
2026-07-22 13:58:30 +02:00
REDCODE 00e0dd42d1 fix: masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
Root cause: ViewMode="ViewMode" (no @ prefix) was interpreted as string
literal "ViewMode" by Blazor, not property value "masonry". GetCardStyle()
never produced aspect-ratio, so off-screen lazy-loaded images had 0 height
and _doApply stacked them 8px apart in one column.

Fixes:
- AlbumGrid.razor: ViewMode="ViewMode" -> ViewMode="@ViewMode"
- AlbumCard.razor: GetCardStyle() always returns aspect-ratio for masonry
- AlbumGrid.razor.css / AlbumDetail.razor.css: CSS aspect-ratio fallback
- masonryObserver.js: MutationObserver-based setup/teardown replaces
  OnAfterRenderAsync-based apply, eliminating layout-timing races
- AlbumGrid.razor / AlbumDetail.razor: one-time setup with _masonrySetup guard
2026-07-21 18:13:24 +02:00
REDCODE 6f60ade6df fix: eliminate masonry layout race on infinite scroll and banner double-fetch (#146) 2026-07-21 16:55:02 +02:00
REDCODE eaa067134e fix: use status CSS variables for progress bar segment colors
ProgressBar segments now use the same --status-* CSS custom properties as the status dots, ensuring visual consistency. ProgressBar applies CSS variable colors via inline background-color style.
2026-07-21 16:39:22 +02:00
REDCODE 701c4360b4 Merge branch 'hotfix/v1.1-bugfixes'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m42s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 3m6s
2026-07-21 15:59:23 +02:00
REDCODE e132d269eb Merge branch 'hotfix/v1.1-bugfixes' into develop 2026-07-21 15:59:14 +02:00
REDCODE 79252d363d fix: sort album assets by OriginalFilename instead of CreatedAt (#144) 2026-07-21 15:55:58 +02:00
REDCODE 8ff21fa56b fix: use pre-pagination album count (#143)
Add AlbumTotalCount [NotMapped] to Person model, set before pagination in FindVisible, read in PersonMapper instead of paginated Albums.Count. Also apply inline aspect-ratio only in masonry mode so grid mode uses CSS aspect-ratio: 1 for square crops.
2026-07-21 15:47:21 +02:00
REDCODE 2305e44e68 fix: restore masonryLayout.dispose that was lost in reset edit 2026-07-21 15:11:30 +02:00
REDCODE 439df56f2b fix: use per-card aspect-ratio from cover dimensions for stable masonry layout (#146) 2026-07-21 15:05:02 +02:00
REDCODE f70f256c07 fix: add aspect-ratio to album cards for stable masonry height (#146) 2026-07-21 14:39:27 +02:00
REDCODE 3b1f26cdf3 fix: replace overflow-y with JS masonry for stable album layout (#146) 2026-07-21 14:26:31 +02:00
REDCODE 9965a13f40 fix: order album assets by CreatedAt in album detail (#144) 2026-07-21 14:26:29 +02:00
REDCODE fbafa54322 fix: prevent masonry layout shift on infinite scroll
Added overflow-y: scroll to html element to always reserve scrollbar
space. When content grows beyond viewport, the scrollbar appearance
would reduce container width by ~17px, causing CSS column masonry to
recalculate and shift items leftward. Fixes #146.
2026-07-21 14:16:07 +02:00
REDCODE 9607746912 fix: allow manual folder scan when scheduled scan is off
QueueFileSystemCrawl() relied on the 'folders' field which is only
populated when FolderScanEnabled is true. Now falls back to fetching
active folders from the DB when the cached list is empty. Fixes #151.
2026-07-21 14:14:41 +02:00
REDCODE 785d33e289 fix: hide register form when registration is disabled
Adds GET /api/auth/register endpoint to check registration status.
Register page now pre-checks on load and shows EmptyState instead of
the form when registration is disabled. Fixes #149.
2026-07-21 14:14:18 +02:00
REDCODE a3eeb8b6d1 fix: show registration disabled message on 403
LoginService.Register() now returns HttpStatusCode instead of bool,
so the frontend can distinguish between 403 (registration disabled),
409 (duplicate), and other failures. Fixes #149.
2026-07-21 14:13:06 +02:00
REDCODE cae61116c8 fix: jobs past tab zero-based pagination
Frontend was requesting page 1 as the first page, but the backend
uses zero-based pagination (page 0 = first). The past jobs tab never
loaded any results. Fixes #145.
2026-07-21 14:12:25 +02:00
REDCODE ced0f61b87 fix: cropper handle size constant mismatch (14→18 px)
CSS defines handles as 18×18px but JS positioned them as 14px,
causing visible misalignment. Fixes #150.
2026-07-21 14:11:42 +02:00
REDCODE 9751c81574 Merge branch 'master' into develop 2026-07-17 04:47:00 +02:00
REDCODE 550f195cf2 Apply deploy config override fix
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m33s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m54s
2026-07-17 03:47:47 +02:00
REDCODE 06c6cf7648 fix: remove redundant AddJsonFile calls that overrode env vars
Removed the explicit AddJsonFile calls in Program.cs — WebApplication.CreateBuilder
already adds them internally, but in the wrong order (after env vars). This caused
DatabaseAddress__Host env var from docker-compose to be silently overridden by
appsettings.json defaults, resulting in connection to 127.0.0.1:3306 instead of
database:5432.

Also updated docker-compose.yml to set DatabaseAddress__Host and DatabaseAddress__Port
explicitly, and default ASPNETCORE_ENVIRONMENT to Production.
2026-07-17 03:47:11 +02:00
REDCODE a5b6642b94 ci: auto-run on tags only, manual dispatch on branches 2026-07-17 02:19:41 +02:00
REDCODE f0b67b0404 Merge branch 'develop'
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 7m22s
Build and Push Containers / build-and-push (Lactose) (push) Has been cancelled
2026-07-17 02:13:27 +02:00
REDCODE fb2b02e71e Merge branch 'hotfix/ci-actions' into develop
Build and Push Containers / build-and-push (Lactose) (push) Has been cancelled
Build and Push Containers / build-and-push (MilkStream) (push) Has been cancelled
2026-07-17 02:13:16 +02:00
REDCODE 4658ed1a27 fix: remove gha cache config (cache server unreachable from builder)
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m34s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m55s
2026-07-17 02:08:20 +02:00
REDCODE 52c08089b2 fix: match matrix service names to actual directory case (Lactose/MilkStream)
Build and Push Containers / build-and-push (Lactose) (push) Failing after 3m34s
Build and Push Containers / build-and-push (MilkStream) (push) Has been cancelled
2026-07-17 02:04:21 +02:00
REDCODE 111875670b fix: lowercase repository name for Docker compatibility
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m0s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m2s
2026-07-17 02:02:16 +02:00
REDCODE 9c50f84783 fix: sanitize tag names for Docker compatibility (replace / with -)
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m13s
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m11s
2026-07-17 02:00:00 +02:00
REDCODE 593d88d3e3 perf: add BuildKit cache mounts for NuGet packages in Dockerfiles
Build and Push Containers / build-and-push (milkstream) (push) Has been cancelled
Build and Push Containers / build-and-push (lactose) (push) Has been cancelled
2026-07-17 01:58:53 +02:00
REDCODE abef50b010 fix: use explicit domain git.r3d.codes instead of gitea.server_url
Build and Push Containers / build-and-push (milkstream) (push) Has been cancelled
Build and Push Containers / build-and-push (lactose) (push) Has been cancelled
2026-07-17 01:57:29 +02:00
REDCODE ce221428f5 ci: use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m18s
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m18s
2026-07-17 01:53:29 +02:00
REDCODE 213c643263 ci: pass SixLaborsLicenseKey as build arg for lactose
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m15s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m16s
2026-07-17 01:48:56 +02:00
REDCODE ecc6e02c78 ci: also run on develop and hotfix/* branches
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m16s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m17s
2026-07-17 01:46:30 +02:00
REDCODE b78c5772b4 ci: also run on push to master 2026-07-17 01:45:55 +02:00
REDCODE 6463df51d1 CI: added sixlabors license key to the docker file for lactose 2026-07-17 01:43:37 +02:00
REDCODE dd2d703fc0 ci: add workflow_dispatch trigger with version input 2026-07-17 01:39:10 +02:00
REDCODE a388887f56 ci: add Gitea Actions workflow to build and push containers on version tags
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m30s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m32s
2026-07-17 00:42:22 +02:00
REDCODE 7d30ea2394 Version 1.0
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m10s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m11s
2026-07-17 00:24:24 +02:00
REDCODE ab420fe167 CI: added build + push to internal registry action 2026-07-17 00:21:49 +02:00
REDCODE 19e177504f Merge pull request 'feat: add ability to add assets to an album from album detail page' (#142) from feat/add-assets-to-album into develop
Reviewed-on: #142
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-16 22:08:43 +00:00
REDCODE f57e2864c5 refactor: replace individual [FromQuery] params with AssetBrowseOptionsDto 2026-07-17 00:07:47 +02:00
REDCODE d89f6f0a21 fix: address PR #142 review issues
- Fix timer leak: implement IDisposable on AlbumAssetPicker
- Remove unnecessary full-table COUNT in GetFolderGroups
- Remove dead DirectoryEntryDto.AssetCount
- Remove unused ExistingAssetIds parameter
- Remove redundant .Distinct() after .Union()
- Extract hardcoded page size 150 to named constant
- Rename AssetSearchOptionsDto.Unassigned to Unlinked for consistency
- BrowseAssets returns 404 for missing folderId instead of 200 empty
2026-07-17 00:00:37 +02:00
REDCODE ca217ddb59 fix: adjust embedded picker viewport height offset 2026-07-16 23:50:20 +02:00
REDCODE e025866357 fix: simplify maintenance page CSS to flex-fill container without vh calc 2026-07-16 23:40:31 +02:00
REDCODE 1449664678 feat: add visibility and create-album actions, fix embedded viewport sizing 2026-07-16 23:35:24 +02:00
REDCODE cf8bee555a revert: change LoginService back to Singleton (DI error was from docker fast mode) 2026-07-16 23:27:45 +02:00
REDCODE 664ac54a7c fix: change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime 2026-07-16 23:26:50 +02:00
REDCODE df48dd17f4 fix: inline all markup in .razor template to restore CSS isolation 2026-07-16 23:22:43 +02:00
REDCODE f15fe8f31a fix: add scoped CSS for maintenance page to make embedded picker fill viewport 2026-07-16 23:18:02 +02:00
REDCODE 8589a85625 refactor: rename UnlinkedAssetGroupDto to AssetGroupDto 2026-07-16 23:14:14 +02:00
REDCODE 983cd8baef refactor: add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names 2026-07-16 23:13:18 +02:00
REDCODE f52b376fca fix: restrict maintenance page and nav link to admin only 2026-07-16 23:04:55 +02:00
REDCODE 75e57353e7 feat: add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page 2026-07-16 23:03:37 +02:00
REDCODE eae6152738 fix: filter directory names to only paths containing a slash after prefix, excluding filenames 2026-07-16 22:56:17 +02:00
REDCODE c4788e1572 fix: use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error 2026-07-16 22:51:41 +02:00
REDCODE 1dfa9eaf41 perf: replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges 2026-07-16 22:44:20 +02:00
REDCODE 50b5a28841 perf: replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter 2026-07-16 22:15:11 +02:00
REDCODE 580cdee67d feat: add collapsible sidebar toggle to AlbumAssetPicker folder browser 2026-07-16 21:50:23 +02:00
REDCODE 8f0267b0f7 feat: redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX 2026-07-16 21:43:49 +02:00
REDCODE 5d9aa21987 feat: add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath 2026-07-16 21:43:44 +02:00
REDCODE ad1f9e2222 fix: use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init 2026-07-16 21:33:48 +02:00
REDCODE 90c81c2d20 fix: use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging 2026-07-16 21:06:18 +02:00
REDCODE 0cc80a084a fix: handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker 2026-07-16 21:06:14 +02:00
REDCODE ad7c38b20e feat: integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler 2026-07-16 20:55:32 +02:00
REDCODE e0a5bd0ce4 feat: add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select 2026-07-16 20:54:29 +02:00
REDCODE e41e7d774f feat: extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync 2026-07-16 20:53:02 +02:00
REDCODE 34220e93d4 feat: extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params 2026-07-16 20:52:33 +02:00
REDCODE 22706a09cd feat: extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups 2026-07-16 20:51:34 +02:00
REDCODE 664c1de5da feat: add trigram GIN index on Asset.OriginalFilename for efficient ILike search 2026-07-16 20:50:30 +02:00
REDCODE 081295af86 feat: add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters 2026-07-16 20:49:56 +02:00
REDCODE 7dd00c0a5e fix: gate registration behind UserRegistrationEnabled setting
AuthController.Register() never checked the UserRegistrationEnabled
server setting, allowing anyone to register even when disabled.

- Inject ISettingsRepository into AuthController
- Return 403 Forbidden at top of Register() if setting is not 'true'
- Add setup test #3.5 to enable registration for the main test flow

Closes #141
2026-07-16 20:37:50 +02:00
REDCODE 3a195dbc15 chore: Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage 2026-07-16 20:09:47 +02:00
REDCODE cce310ee14 Merge pull request 'feat: frontend multi-select enhancements - merge, unlink cosplayer, visibility refactor' (#140) from feat/128-frontend-merge-multi-select into develop
Reviewed-on: #140
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 17:54:32 +00:00
REDCODE ada113914d refactor: move Unlink cosplayer button from album header to album edit form 2026-07-16 19:48:55 +02:00
REDCODE a2a25673de refactor: replace inline visibility modal with shared VisibilityModal in Cosplayers.razor 2026-07-16 19:33:09 +02:00
REDCODE 963e897fe5 feat: add Unlink cosplayer button to AlbumDetail.razor 2026-07-16 19:31:39 +02:00
REDCODE f9fe91e975 feat: add Merge Albums button to CosplayerDetail.razor 2026-07-16 19:31:12 +02:00
REDCODE 01fc121bc3 feat: add Merge button to Cosplayers.razor with CosplayerGrid preview access 2026-07-16 19:30:22 +02:00
REDCODE da8a5ad539 feat: add Merge button to Albums.razor with AlbumGrid preview access 2026-07-16 19:29:41 +02:00
REDCODE ff2ed4f7cd feat: add MergeModal shared component for merge destination selection 2026-07-16 19:28:45 +02:00
REDCODE 2c292d2d81 feat: add frontend service methods for merge, bulk update, and bulk delete people 2026-07-16 19:27:48 +02:00
REDCODE f064277c31 Merge pull request 'feat: add rate limiting with configurable buckets' (#139) from feature/rate-limit into develop
Reviewed-on: #139
2026-07-16 17:17:53 +00:00
REDCODE c5ea749517 test: added rate limiting stress test 2026-07-16 19:17:37 +02:00
REDCODE ebe83fe691 chore: use record for config classes 2026-07-16 19:06:35 +02:00
REDCODE ab58ac7dd8 Merge branch 'develop' into feature/rate-limit 2026-07-16 16:56:33 +00:00
REDCODE 17d3f0f2a7 Merge pull request 'feat: backend merge endpoints, bulk visibility fix, and tests' (#138) from feat/backend-merge-endpoints into develop
Reviewed-on: #138
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-16 16:46:52 +00:00
REDCODE 1e2bc83b20 fix: guard against null PersonOwnerId in MergePeople albums query
Replace PersonOwnerId ?? Guid.Empty with explicit null check to
avoid matching albums with no owner if sourceIds contained Guid.Empty.
2026-07-16 18:30:23 +02:00
MrFastwind 58014a37bd feat: apply rate-limit buckets to controllers
- AuthController: auth bucket (10 req/60s) — anti-brute-force
- MediaController.GetImage: media_original bucket (50 req/60s)
- MediaController.GetThumb: media_thumb bucket (200 req/60s)
- MediaController.GetPreview: media_preview bucket (200 req/60s)
- JobsController: jobs bucket (30 req/10s) — quick refresh for long-polling
- All other endpoints: global bucket (100 req/60s) via default
2026-07-16 18:26:39 +02:00
MrFastwind c79ed47e41 feat: add rate limiting with configurable buckets
Add fixed-window rate limiting to Lactose with 6 configurable buckets:
- Global (100 req/60s) for all generic calls
- Auth (10 req/60s) strict anti-brute-force
- MediaOriginal (50 req/60s) for full-size media
- MediaThumb (200 req/60s) for thumbnails
- MediaPreview (200 req/60s) for previews
- Jobs (30 req/10s) quick refresh for long-polling

All buckets configurable via appsettings.json RateLimiting section.
Rejected requests return HTTP 429 with JSON body and Retry-After header.
2026-07-16 18:17:58 +02:00
REDCODE e48da562fa test: flatten test numbering to sequential 1–137
Renumber all test blocks flatly with no gaps or sub-letter suffixes.
Split the remaining multi-request block in the bulk visibility section.
2026-07-16 18:08:17 +02:00
REDCODE b943d2e595 fix: split multi-request test blocks into single-request blocks
REST Client only processes the first request per ### block.
Split 99f into 99f/99f1 and 99i2 into 99i2/99i2b so dynamic
variables set by response handlers are available in subsequent tests.
2026-07-16 18:04:40 +02:00
REDCODE 0950106609 test: add merge and bulk visibility tests to WepApiTest.http
Covers: album merge validation and e2e, person merge validation and e2e,
bulk visibility update (people + assets), authorization gating.
2026-07-16 17:58:14 +02:00
REDCODE 7ba1ab1551 feat: add merge endpoints to AlbumController and PersonController
POST /api/album/merge — Curator/Admin, validates DestinationId not in SourceIds
POST /api/person/merge — Admin/Curator, validates DestinationId not in SourceIds
2026-07-16 17:56:52 +02:00
REDCODE 656e6fa9bf feat: add MergePeople to person repository
Reassigns albums (PersonOwnerId), faces (PersonId), and maintainers
from source people to destination, then hard-deletes source people.
2026-07-16 17:56:24 +02:00
REDCODE 5e1306e466 feat: add MergeAlbums to album repository
Loads destination + source albums with assets, moves all assets
from sources to destination (deduplicated), then hard-deletes sources.
2026-07-16 17:55:46 +02:00
REDCODE 22565c4f92 feat: add AlbumMergeDto and PersonMergeDto
New DTOs for merge endpoints. Both require DestinationId and SourceIds,
with DestinationId validated to not appear in SourceIds by the controller.
2026-07-16 17:55:15 +02:00
REDCODE e62a567e4e fix: apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
The bulk update path was ignoring Visibility and MaintainerUserIds
from PersonUpdateDto, unlike the single-update path.
2026-07-16 17:55:00 +02:00
REDCODE e5ffe86077 feat: add visibility-colored borders to cosplayer cards in selection mode
Adds border-warning for non-Public cosplayers and preserves the
cosplayer-card-select accent outline, matching the AlbumCard pattern.
2026-07-16 17:26:26 +02:00
REDCODE d4a53cb2dc fix(UI): make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page 2026-07-16 16:09:34 +02:00
REDCODE 548b0d5bfd Merge pull request 'feat: add reusable ConfirmDialog component for deletion operations' (#135) from issue-57-confirm-dialog into develop
Reviewed-on: #135
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 13:59:26 +00:00
REDCODE 92fb5a0149 fix: add ConfirmDialog to Settings folder deletion flow
FolderBox now delegates deletion to parent via OnDeleteRequested
callback instead of deleting directly. Settings.razor shows a
ConfirmDialog before removing the folder.
2026-07-16 15:11:33 +02:00
REDCODE 6542dcafd6 fix: add ConfirmDialog to Cosplayers bulk delete flow
Adds confirmation before bulk-deleting selected cosplayers.
2026-07-16 15:10:37 +02:00
REDCODE b7690b5828 fix: add ConfirmDialog to Albums bulk delete flow
Adds confirmation before bulk-deleting selected albums.
2026-07-16 15:10:20 +02:00
REDCODE f3f2bdba02 fix: add ConfirmDialog to CosplayerDetail delete flows
Adds confirmation before deleting a cosplayer or bulk-deleting selected
albums. Uses a single ConfirmDialog with dynamic message and stored
callback for either action.
2026-07-16 15:10:03 +02:00
REDCODE 8bf6405c64 fix: add ConfirmDialog to AlbumDetail delete flows
Adds confirmation before deleting an album or bulk-deleting selected
assets. Uses a single ConfirmDialog with dynamic message and stored
callback for either action.
2026-07-16 15:09:36 +02:00
REDCODE c1b1feef4d fix: add ConfirmDialog to User delete user flow
Replaces browser window.confirm() with the reusable ConfirmDialog
component.
2026-07-16 15:09:06 +02:00
REDCODE 0e9820f7c3 fix: add ConfirmDialog to AdminUsers delete user flow
Replaces browser window.confirm() with the reusable ConfirmDialog
component for a consistent, styled confirmation experience.
2026-07-16 15:08:42 +02:00
REDCODE 133c697176 feat: add reusable ConfirmDialog component
A shared confirmation dialog wrapping ModalFrame for delete operations.
Supports plain text Message or custom Body RenderFragment, configurable
confirm button text and styling, and OnConfirm/OnCancel event callbacks.
2026-07-16 15:08:13 +02:00
REDCODE 8f07d7c445 Merge pull request 'feat: bulk cosplayer assignment for albums from multiselection mode' (#134) from issue-122-bulk-assign-cosplayer into develop
Reviewed-on: #134
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 11:25:22 +00:00
REDCODE c7e83ed97b feat: bulk cosplayer assignment for albums from multiselect mode
Adds a shared LinkToCosplayerModal component with person typeahead
search and wires it into the albums list page's multiselect topbar.

The backend already supports bulk person assignment via POST /api/album
with BulkDto<AlbumUpdateDto> { Data.Person = ... }, so no backend
changes are needed.

Also adds API tests for the bulk album person assignment flow.

Part of #128, closes #122
2026-07-16 02:59:15 +02:00
REDCODE 7db1738678 fix: soft-delete users in UserController.Delete instead of hard-delete
UserController.Delete was calling userRepository.Delete(user) which performed
a hard delete (context.Users.Remove), violating the project's soft-delete
convention. Changed to set user.DeletedAt = DateTime.UtcNow, matching the
pattern used in AssetController.Delete.

Also removed the now-unused Delete method from IUserRepository and
UserRepository for consistency with AssetRepository (which also has no
Delete method).

Extended REST tests 103-104 to verify deletedAt is set after deletion.

Closes #130
2026-07-16 02:31:52 +02:00
REDCODE 3e099987a3 fix: album detail back button uses history.back() with fallback to /albums
Replaces the hardcoded <a href="/albums"> back link with a button
that calls history.back() via JS interop, falling back to NavigateTo("/albums")
when there is no browser history. Fixes #133.
2026-07-16 02:17:24 +02:00
REDCODE ca50554108 feat: wire up SystemUploaderId setting — seed, UI, and crawl job backfill
- Seed SystemUploaderId in DefaultSettings.json (empty = no default owner)
- Read setting once per crawl and apply UploadedBy on new assets
- Backfill UploadedBy on existing assets that are missing it
- Add UserSelect display type for user-picker setting
- Create SettingUserSelect component with search dropdown
- Add setting to Scanning group in Settings UI
2026-07-16 01:18:46 +02:00
REDCODE 2bfa370989 Merge pull request 'feat: role-visible colored borders, AlbumDetail bulk actions, and cascading visibility (#78)' (#131) from issue-78-role-visible-borders into develop
Reviewed-on: #131
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-15 22:59:59 +00:00
REDCODE dbd5d15fa1 fix: label Delete button and cropper modal overlay
- CosplayerDetail: label desktop Delete button and mobile dropdown as
  'Delete Cosplayer' (was 'Delete' / 'Delete person')
- ProfileCropper: move instruction text into modal footer to prevent
  overlap with Cancel/Save buttons; darken overlay background to
  rgba(0,0,0,0.85) instead of semi-transparent rgba(0,0,0,0.6)
2026-07-16 00:57:30 +02:00
REDCODE b1f32844a9 fix: homepage preview navigation, grid observer crash, and cascade labels
- fix: @for closure bug in Home.razor causing preview to show only previous arrow
- guard NavigatePreview against flatList rebuilds during infinite scroll
- fix IntersectionObserver crash when sentinel not in DOM during reload (CosplayerGrid, AlbumGrid)
- align cascade checkbox labels with actual cascade logic (skips private/deleted)
2026-07-16 00:02:46 +02:00
REDCODE 3bf98cbfed fix: prevent private assets with null UploadedBy leaking to anonymous users
Adds userId.HasValue guard to the private-visibility condition in both
AssetRepository and MediaRepository, preventing the null==null match
when an anonymous visitor (userId=null) encounters an asset with null
UploadedBy.
2026-07-15 23:33:08 +02:00
REDCODE 80c9c8bd6f fix: Cosplayers page bugs
- Restore New Cosplayer button on desktop
- Fix ToggleSelectAll via CosplayerGrid.GetCurrentPersonIds()
- Fix ApplyVisibility wiping data — fetch person first, include all fields
- Add GetCurrentPersonIds() to CosplayerGrid for Select All support
2026-07-15 23:10:23 +02:00
REDCODE 7086fb127f feat: add multiselect editing controls to Cosplayers page
- Visibility modal with cascade to albums and assets
- Desktop inline + mobile dropdown action buttons
- Select All / Deselect All button
- Uses PersonService single-update for visibility, AlbumService cascade
2026-07-15 23:03:02 +02:00
REDCODE 19ab8c0975 refactor: centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
- Add CascadeVisibilityToAssetsAsync to AlbumService (skips private/deleted)
- Replace duplicated loop+filter in Albums, CosplayerDetail, PersonForm with single call
- Update labels to describe skip-private/deleted behavior
2026-07-15 23:01:41 +02:00
REDCODE d7f8350ae7 fix: add cascade checkbox to CosplayerDetail album visibility modal 2026-07-15 22:58:36 +02:00
REDCODE f0e3cc9c37 feat: cascade visibility to albums and assets with lower-only rule
- VisibilityModal: add optional CascadeLabel + CascadeEnabled for checkbox
- Albums page: checkbox to cascade album visibility to non-deleted assets
  (lower-only: never raise asset visibility)
- PersonForm: cascade checkboxes when editing cosplayer visibility
  - Cascade to albums (lower only)
  - Cascade to assets in every album (only if album cascade enabled)
- Inject AssetService in Albums page and PersonForm
2026-07-15 22:41:13 +02:00
REDCODE 4f56af252b refactor: extract VisibilityModal shared component, add editing controls to Albums page
- Create MilkStream.Client/Components/Shared/VisibilityModal.razor
- Replace duplicated modal markup in AlbumDetail, CosplayerDetail, Albums
- Add Select All/Deselect All to Albums via AlbumGrid ref
- Add Delete, Visibility modal to Albums select mode
- Add mobile dropdown to Albums action buttons
- Add GetCurrentAlbumIds() to AlbumGrid for Select All support
2026-07-15 22:31:41 +02:00
REDCODE 8aeb4df064 fix: wrap CosplayerDetail action buttons into mobile dropdown 2026-07-15 22:24:07 +02:00
REDCODE dc2983ae55 fix: album-card-select and cosplayer-card-select only on selected cards 2026-07-15 22:23:01 +02:00
REDCODE a50dc3aa30 fix: prevent jagged wrapping of toolbar and action buttons
SortFilterBar: wrap search+sort in flex-nowrap row, remove ms-auto
from ActionButtons so wrapped items are left-aligned and clean.
AlbumDetail: add flex-shrink: 0 to action buttons container.
2026-07-15 22:19:42 +02:00
REDCODE 3cdad1cebd feat: add album-level bulk actions to CosplayerDetail page
- Add BulkUpdateAlbumsAsync to AlbumService
- Fix AlbumCard border gate from IsAdminOrCurator to CanEdit()
- CosplayerDetail: Delete, Unlink, Visibility buttons in select mode
- CosplayerDetail: Select All / Deselect All button
- CosplayerDetail: visibility breakdown stats in header
- CosplayerDetail: visibility modal for albums
2026-07-15 22:14:18 +02:00
REDCODE 0903da4490 fix: show borders for maintainers in select mode 2026-07-15 22:07:38 +02:00
REDCODE 2fcd6467e1 feat: add Select All / Deselect All button in album select mode 2026-07-15 22:05:28 +02:00
REDCODE 10337c735c feat: show visibility/deleted breakdown in album header in select mode 2026-07-15 21:59:27 +02:00
REDCODE e3d4cf9673 fix: update border colors and browse-mode visibility in AlbumDetail
- Private assets → red border (instead of orange)
- Protected assets → orange border
- Deleted assets → greyed out (opacity + grayscale)
- When not in select mode: hide Private and Deleted assets from view
2026-07-15 21:55:54 +02:00
REDCODE a203885377 Merge branch 'issue-78-album-detail-bulk-actions' into issue-78-role-visible-borders
Adds bulk delete, visibility modal, and rename Remove→Unlink to
AlbumDetail select mode, plus bugfix for missing Save() in bulk endpoints.
2026-07-15 21:48:18 +02:00
REDCODE fff5ea65df feat: add bulk delete and visibility change to AlbumDetail select mode
- Fix AssetController.BulkUpdate/BulkDelete — add missing Save() call
- Add BulkUpdateAssetsAsync and BulkDeleteAssetsAsync to AssetService
- Rename 'Remove N' → 'Unlink N' (removes from album, original behavior)
- Add 'Delete N' button — soft-deletes selected assets via BulkDelete
- Add 'Visibility' button — opens ModalFrame to pick Public/Protected/Private
- Apply calls BulkUpdateAssetsAsync with chosen visibility level
2026-07-15 21:46:28 +02:00
REDCODE 86f95d7461 feat: add role-visible colored borders on albums and assets based on item state (#78)
Backend:
- Add DeletedAt to AssetPreviewDto, AlbumAssetPreviewDto, AlbumPreviewDto
- Remove redundant DeletedAt from AssetDto (now inherited from base)
- Add viewerId parameter to ToAssetPreviewDto, ToAlbumPreviewDto,
  ToAlbumFullDto, ToPersonDetailedDto mappers
- Conditionally send DeletedAt only when viewer is Admin or asset uploader
- Pass uid/viewerId from all controller/repository call sites

Frontend:
- AlbumCard: show orange (not public) / red (deleted) border in select
  mode for admins/curators
- AlbumDetail: same border logic in GetTileClass for asset tiles
- Borders only appear in select mode per REDCODE's feedback
2026-07-15 20:48:00 +02:00
REDCODE ec287f087c docs: add visibility rules (R1–R7) to AGENTS.md 2026-07-15 20:32:23 +02:00
REDCODE 758696f7c9 fix: add entity-level visibility gating for albums and persons
Adds R3/R4 visibility gates to AlbumRepository.FindVisible and
PersonRepository.FindVisible so that 404 is returned when the
requesting user lacks permission to see the entity itself.

Adds comprehensive HTTP tests verifying:
- R2: anonymous asset detail access
- R3: Public/Protected visible to User, Private returns 404 (Admin sees all)
- R4: Private person returns 404 for User, 200 for Admin/Curator
2026-07-15 20:30:06 +02:00
REDCODE de9cd618b6 docs: fix stale column references and tooling versions in AGENTS.md
- Update dotnet-tools.json location reference
- Fix EF Core Tools version (both now v10.0.9)
- Remove stale SharedWith.Any() EF Core gotcha (per-user sharing removed)
- Fix index example to use Visibility/UploadedBy columns
2026-07-15 18:41:37 +02:00
REDCODE e7fb01be9f Merge pull request 'feat: frontend alignment for #106 auth redesign — Maintainer role, visibility UI, user data' (#127) from feature/106-auth-redesign-frontend into develop
Reviewed-on: #127
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-15 16:23:17 +00:00
REDCODE e4a34a7dad fix: show maintained cosplayers section for all user types in user profile edit modal
- Remove _editAccessLevel == Maintainer guard so the maintainer
  assignment section appears when editing any user as admin
- OpenEditModal already loads maintained persons regardless of access level
2026-07-15 17:42:31 +02:00
REDCODE d799c3eb1f docs: clarify Curators and Admins can be maintainers for credited attribution
- Update Actors table: Curator and Admin entries mention maintainer assignment
- Update Role Design Philosophy: same clarification
- Update Curator section: note about PersonMaintainer as credit/attribution
- Update Admin section: same note
2026-07-15 17:40:49 +02:00
REDCODE c5fabf0e07 fix: show maintained cosplayers section for all user types in admin edit modal
- Remove _editAccessLevel == Maintainer guard from the maintained
  cosplayers markup so it appears for User, Curator, and Admin edits too
- Remove the same guard from OpenEditModal so maintained persons are
  loaded regardless of the user's current access level
2026-07-15 17:39:36 +02:00
REDCODE 3cdf9eb185 refactor: extract person search/assign UI into shared SearchAssignBadges component
- Create generic SearchAssignBadges component (TItem parameterized) with
  search input, dropdown results, and removable badge chips
- Replace duplicate markup in AdminUsers.razor, User.razor, PersonForm.razor
- Update RemoveAssignedPerson/RemoveMaintainer to accept the item DTO
  instead of Guid, aligning with the component's EventCallback<TItem> pattern
2026-07-15 17:33:26 +02:00
REDCODE 6efccae030 fix: address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
- Change ?? EAccessLevel.Admin fallback to ?? EAccessLevel.User in UserController.GetAll
- Add .Include(u => u.MaintainedPersons) to UserRepository.GetAll() so MaintainedPersonIds/MaintainedPersonNames populate in list endpoint
- Fix UsersMapper.cs method body indentation (was flush with class)
- Add Maintainer-role visibility tests (68.5-68.8): promote user, test viewing admin, demote back
2026-07-15 17:31:33 +02:00
REDCODE 93cbbea3c7 fix: allow maintainer assignment for all user roles in UI
Remove _editAccessLevel == Maintainer guard so admins can assign
maintainers to curators/admins/users, not just Maintainer-level users.
2026-07-15 15:12:13 +02:00
REDCODE cace8640e9 fix: replace remaining jsonPath calls in asset visibility tests
Test 75 admin assertion: allow null email for server-scanned assets
(Guid.Empty placeholder has no email). Removes all remaining jsonPath
calls that could NPE on null resolved values.
2026-07-15 15:03:50 +02:00
REDCODE a3ce98a984 fix: replace jsonPath with response.body for null-value checks in visibility tests
jsonPath throws NullPointerException in GraalVM when the resolved JSON
value is null. Use response.body[n].property directly for all null
assertions in search-list and visibility tests.
2026-07-15 14:55:27 +02:00
REDCODE 4f3e3a28cc fix: enforce data visibility per access level with test coverage
- UsersMapper.ToGetUsersDto: filter Email, BannedAt, DeletedAt, AccessLevel,
  MaintainedPersonIds by viewer access (admin/self only)
- AssetsMapper.ToFullAssetsDto: pass viewerId to restrict UploadedBy.Email;
  null-safe Uploader fallback
- AssetsMapper.ToAssetPreviewDto: gate FileName behind Curator+ (was public)
- AlbumMapper: gate FileName behind Curator+; fix AssetCount to count only
  non-deleted assets
- AssetRepository.FindVisible: include Uploader to prevent NRE (500 error)
- AssetController: restrict UploadedBy reassignment to Admin only
- UserController: pass viewer context to all ToGetUsersDto calls
- WepApiTest.http: add 18 data visibility tests (#64-#81) verifying field
  gating per role; fix 3 pre-existing stale assertions; avoid jsonPath NPE
  on null values by using response.body directly
2026-07-15 14:53:39 +02:00
REDCODE 657ffaae11 fix: show email to self-viewing users on user page 2026-07-15 14:24:41 +02:00
REDCODE 700d5a63a7 chore: fix Dockerfiles, add debug compose to solution 2026-07-15 14:21:18 +02:00
REDCODE 9531ac2e23 fix: NRE on user page when user data not yet loaded
Add loading spinner and null-state guard to prevent rendering
before user data arrives. Fix unsafe `user is not {DeletedAt: null}`
pattern that throws NRE on null user.
2026-07-15 14:19:13 +02:00
REDCODE 288da412d9 fix: remove underline from maintainer links, load assigned person names in user edit modal 2026-07-15 14:11:57 +02:00
REDCODE 1b34520a66 feat: add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
- Add docker-compose.debug.yml override for Debug build + Development env
- Add UseWebAssemblyDebugging() middleware in Development
- Production path unchanged (HSTS + HTTPS redirect preserved)
2026-07-15 14:10:25 +02:00
REDCODE 23b285cf49 fix: remove underline from user links, hide email from non-admin viewers 2026-07-15 13:18:32 +02:00
REDCODE 27707b120c feat: make usernames clickable on admin users page, linking to /User/{id} 2026-07-15 13:17:37 +02:00
REDCODE 14978087e3 feat: expand user page edit modal for admins with access level, maintainer assignment, and danger zone 2026-07-15 13:16:32 +02:00
REDCODE 3021788271 fix: null reference in User page maintainer display 2026-07-15 13:14:28 +02:00
REDCODE 358bf0918e feat: maintainer assignment UI for cosplayers, maintainer display on user page
Backend:
- Add MaintainerUserIds to PersonUpdateDto/PersonDetailedDto
- Add MaintainedPersonNames to UserInfoDto
- Add IPersonRepository.SetMaintainers + implementation
- PersonRepository.Find/FindVisible now include Maintainers
- PersonController.Update handles maintainer assignment (admin/curator)
- Fix PersonController.Update to save Visibility field
- Allow curators to list all users via UserController.GetAll

Frontend (PersonForm):
- Add maintainer search+select UI (admin/curator only in edit mode)
- Search users client-side, click to add, badge with X to remove
- Saves maintainer IDs on person update

Frontend (CosplayerDetail):
- Show 'Maintained by: user1, user2' in header with clickable links
- Pass maintainer data to PersonForm

Frontend (User page):
- Show 'Maintainer of: cosplayer1, cosplayer2' with links
- Non-admin view: strip Email, Created, Deleted fields
- Fix LoadUser to use route UserId instead of logged-in user
2026-07-15 13:11:05 +02:00
REDCODE 656784af00 feat: maintainer cosplayer assignment in admin user edit modal
Backend:
- Add MaintainedPersonIds to UserUpdateDto
- Add SetMaintainedPersons to user repository (removes old PersonMaintainer rows, inserts new)
- Wire up in UserController.Update (admin only)

Frontend:
- Add PersonService injection to AdminUsers page
- Searchable cosplayer dropdown in edit modal for Maintainer users
- Click result to add, badge with X to remove
- Zero debounce, saves only on Submit
- Pre-loads currently assigned cosplayer names on modal open
2026-07-15 12:55:03 +02:00
REDCODE c8b71f290b fix: add missing app.css link to index.html 2026-07-15 12:27:38 +02:00
REDCODE 8f109bf92f chore: commit compiled CSS for role badge classes 2026-07-15 12:19:46 +02:00
REDCODE 268b380867 refactor: move CanEdit logic to LoginService, remove local page properties 2026-07-15 12:18:53 +02:00
REDCODE 71889ddfac refactor: extract role checks to LoginService, update all consumers 2026-07-15 12:06:05 +02:00
REDCODE 0a74ff8770 refactor: generate access level dropdown from enum values 2026-07-15 12:04:35 +02:00
REDCODE f158181ccf refactor: extract role badge colors to SCSS theme layer 2026-07-15 12:04:01 +02:00
REDCODE e585abc378 feat: expand UI gating to include Maintainer with scope-aware CanEdit 2026-07-15 11:26:43 +02:00
REDCODE ccf16b6abc feat: add Protected Assets card to stats page 2026-07-15 11:25:05 +02:00
REDCODE 7dcb78db97 feat: add Visibility dropdown to album and person create/edit forms 2026-07-15 11:24:18 +02:00
REDCODE 26984f4452 feat: add Maintainer to admin user dropdowns and role badges 2026-07-15 11:23:15 +02:00
REDCODE f32d0678df feat: add MaintainedPersonIds to UserInfoDto with backend plumbing 2026-07-15 11:22:33 +02:00
REDCODE 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
REDCODE b9251c3e19 style(repo): use explicit named enum cases in PersonRepository switch expressions 2026-07-14 19:22:14 +02:00
REDCODE 0d1ce9feb3 style(repo): expand switch expression arms for readability 2026-07-14 19:14:35 +02:00
REDCODE 0673017765 style(repo): convert visibility filters to switch expressions for readability 2026-07-14 19:13:20 +02:00
REDCODE 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
REDCODE bbb2ed8f73 fix(dto): default AssetCreateDto.Visibility to Private instead of Protected 2026-07-14 19:02:34 +02:00
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE d02a010083 feat(infra): update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility 2026-07-14 17:40:34 +02:00
REDCODE 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
REDCODE c9391d840b feat(butter): update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields 2026-07-14 17:39:01 +02:00
REDCODE 51323b4a64 feat(butter): add EVisibility enum, Maintainer access level, and SystemUploaderId setting 2026-07-14 17:37:42 +02:00
REDCODE 842ed76345 fix(jobs): use grey for queued segments in layered progress bar 2026-07-14 13:14:44 +02:00
REDCODE f3ed658dd0 chore: Updated packages, removed unused packages. ImageSharp now requires a license 2026-07-14 13:14:27 +02:00
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 3fdf55b53c docs: add PR workflow section to AGENTS.md 2026-07-14 12:07:20 +02:00
REDCODE f5b28e5a21 docs: add Gitea label levels note to AGENTS.md 2026-07-14 12:04:49 +02:00
REDCODE 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
REDCODE 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
REDCODE 46871299ca fix: remove underline from album card person link 2026-07-14 11:51:58 +02:00
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 87b69d5ee8 revert: remove AllowAnonymous from AlbumController.Search and PersonController.GetAll 2026-07-13 17:03:53 +02:00
REDCODE e0d46aa87b refactor: replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize 2026-07-13 17:02:13 +02:00
REDCODE 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
REDCODE 28f9e7faae fix: replace untranslatable Tags navigation in TopTags stats query with direct join table query 2026-07-12 21:02:50 +02:00
REDCODE 820d3b1c8b fix: replace untranslatable Album navigation in stats queries with direct join table queries 2026-07-12 21:02:33 +02:00
REDCODE 278648aae6 Merge branch 'develop' into feature/global-search-dropdown 2026-07-12 21:00:42 +02:00
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 393ad136f6 fix: restore PageSize < 1 validation with upper limit of 250 2026-07-12 19:31:23 +02:00
REDCODE 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
REDCODE 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
REDCODE efc6379bb3 test: add cleanup of regular test user in REST Client suite 2026-07-12 19:11:11 +02:00
REDCODE 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
REDCODE 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
REDCODE 50c58e22ff fix: restore ToAlbumPreviewDto mapper method per review feedback #91 2026-07-12 18:50:03 +02:00
REDCODE 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
REDCODE e05ab3beaf docs: document REST Client tests in AGENTS.md 2026-07-12 18:05:55 +02:00
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 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
REDCODE 41c262d157 fix: restore Include(p => p.Albums) in PersonRepository queries
The visibility subquery doesn't load the Albums navigation, so TotalAlbums in the mapper was always 0. Added back a lightweight Include(p => p.Albums) (without Assets/SharedWith chain) to both SearchQuery and GetAllVisible.
2026-07-11 20:39:14 +02:00
REDCODE 837491fa4a fix: guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query 2026-07-11 20:36:33 +02:00
REDCODE 5a2f61d261 perf: push visibility filter into SQL for PersonRepository queries
Replace client-side visibility filtering with EF Core Any() subqueries in SearchQuery and GetAllVisible. This eliminates the cartesian product from the Include chain (Person -> Albums -> Assets -> SharedWith) and lets the database short-circuit the auth check with indexes. Adds null-forgiving operators (!) on navigation access in the query predicates.
2026-07-11 20:35:56 +02:00
REDCODE 851cdfa924 test: restructure WepApiTest.http with three clearly separated users
The test file now creates three distinct users at the start:
  - admin (seeded, admin/admin)
  - curator (created by admin, accessLevel=1)
  - user (registered via /api/auth/register, accessLevel=0)

Each user has its own token variable (admin_token, curator_token,
user_token) and ID variable (admin_id, curator_id, user_id).

Sections are organized: Setup → Auth → User CRUD → Person →
Album → Tag → Asset → Stats → Settings → Cleanup.

All endpoints are tested at each appropriate authorization level:
admin (full access), curator (elevated access), user (own data only),
and anonymous (public data only).
2026-07-11 19:02:58 +02:00
REDCODE 8dd5e7158f test: fix curator user access level from Admin (2) to Curator (1)
EAccessLevel enum: User=0, Curator=1, Admin=2. The test created the
curator user with accessLevel=2 (Admin), making curator_token an
admin token, which broke the 'Create user as curator should 403' test.
2026-07-11 19:00:53 +02:00
REDCODE 9e52cc2351 fix: use zero-based page numbers in AssetController to match API convention
AssetController.GetAll validated page >= 1 (one-based), inconsistent
with PersonController and AlbumController which use zero-based pages.
Adjust validation to page >= 0 and pass page + 1 to the repository
which internally uses one-based.
2026-07-11 18:55:15 +02:00
REDCODE ca8119f356 fix: add [FromQuery] to TagController.GetAll parameter
Complex type PagedSearchParametersDto needs [FromQuery] for GET
requests, otherwise ASP.NET Core tries body binding and returns 415.
2026-07-11 18:54:05 +02:00
REDCODE bef29f5b22 test: add user id to profile update request body
UserUpdateDto.Id is required. The test was sending only username,
causing 400 Bad Request from model binding failure.
2026-07-11 18:52:54 +02:00
REDCODE e27d63cf32 fix: restrict user list to admin only
UserController.GetAll was missing [Authorize(Roles = Admin)],
allowing any authenticated user to list all users.
2026-07-11 18:51:26 +02:00
REDCODE fd46faec21 test: set personId from admin search to ensure it's always populated
Rng user may see no public people, leaving personId unset. Admin
sees all people including test-created ones, so setting personId from
the admin response ensures downstream tests have a valid reference.
2026-07-11 18:50:17 +02:00
REDCODE 648531fcc4 test: handle empty person search results gracefully
The person search may return an empty array when the database has no
publicly visible people. Only set personId when results exist.
2026-07-11 18:48:50 +02:00
REDCODE dda062d959 fix: materialize person search query before client-side visibility filter
The nested Any() through SharedWith navigation cannot be translated
to SQL by EF Core. Apply the same pattern as GetAllVisible and
AlbumController.Search: materialize server-side (search, sort,
pagination), then filter by visibility in memory.
2026-07-11 18:46:10 +02:00
REDCODE 02c62d0dd8 test: add anonymous search tests for persons and albums 2026-07-11 18:42:13 +02:00
REDCODE 2249e0fb2e fix: allow anonymous search for persons and albums
GET /api/person and GET /api/album should not require authentication.
Anonymous users are treated as EAccessLevel.User, so they only see
publicly shared content. PersonController.GetAll uses uid ?? default
instead of uid!.Value to handle null uid safely.
2026-07-11 18:42:10 +02:00
REDCODE f626228c2f fix: return 401 Unauthorized when user data is null in PersonController
PersonController.GetAll used uid!.Value which throws NRE if
authService.GetUserData returns null (e.g. claims present but
user not found). Return 401 Unauthorized instead of crashing.
2026-07-11 18:39:52 +02:00
REDCODE b9fd5e172c test: add endpoint tests for refresh token, albums, curator auth levels
Add HTTP endpoint tests covering:
- Refresh token endpoint (empty body, valid, auth failures)
- Album GET by ID (rng user, admin, non-existent)
- Album search (rng user, admin)
- User get all and profile update (rng user, admin)
- Stats endpoint (admin, curator, rng user 403)
- Tag search and create (admin, rng user 403)
- Asset search (rng user, anonymous)
- Curator-level CRUD permissions (person, album, stats succeed;
  user creation and settings access 403)
- Cleanup of curator test user
2026-07-11 18:33:51 +02:00
REDCODE 3b05061a31 fix: override Bootstrap dropdown-menu display:none
Bootstrap's .dropdown-menu sets display:none. Add display:block to
.search-dropdown-menu to make the search dropdown visible.
2026-07-11 18:15:04 +02:00
REDCODE 7ea72e13ac style: use Bootstrap dropdown-menu and dropdown-item classes
Match styling of the user profile dropdown in the navbar by using
Bootstrap's native .dropdown-menu and .dropdown-item classes instead
of custom classes. Keep only custom positioning, thumbnail sizing,
and the clickable header styles.
2026-07-11 18:09:57 +02:00
REDCODE faa8e93d55 fix: use case-insensitive ILike search for persons and albums
Replace EF Core Contains() with EF.Functions.ILike() which
translates to PostgreSQL ILIKE for case-insensitive matching.
Searching 'alice', 'Alice', or 'ALICE' now returns the same results.
2026-07-11 18:03:17 +02:00
REDCODE c5ba90202d style: add chevron-right indicator to search dropdown headers
Shows a right-pointing arrow on section headers (Cosplayers/Albums)
to visually signal they are clickable and navigate to the list page.
The arrow subtly moves right on hover for extra affordance.
2026-07-11 17:44:13 +02:00
REDCODE 5fba560bec fix: parse search query before await in Albums OnInitializedAsync
Blazor triggers an early render when OnInitializedAsync has a true
await (masonryObserver.unlockBodyScroll). By that point searchQuery
was still null, so the grid briefly loaded unfiltered results and a
stale response could overwrite the correct filtered results.
ParseSearchQuery must run before the await to ensure the first render
passes the correct search query to AlbumGrid.
2026-07-11 17:38:51 +02:00
REDCODE f2059e0dd7 fix: move query param parsing to OnParametersSet
OnInitializedAsync only fires once per component lifetime, so
re-navigating to the same page with a different ?search= value
wouldn't re-parse. OnParametersSet fires on every parameter change,
covering both initial load and subsequent navigations.
2026-07-11 17:34:04 +02:00
REDCODE e02466771a fix: pressing enter with nothing selected does nothing 2026-07-11 17:30:17 +02:00
REDCODE 0b8c435f7c feat: parse ?search= query param on Cosplayers and Albums pages
Headers in the search dropdown navigate to the respective list page
with the search query pre-filled via ?search= parameter. Both pages
now parse this parameter on initialization.
2026-07-11 17:28:51 +02:00
REDCODE fc50aec996 feat: make search dropdown headers selectable with keyboard and mouse
Refactor SearchDropdown to use a flat item list for unified keyboard
navigation. Section headers (Cosplayers/Albums) are now clickable
links that navigate to the respective list page with the search query
as a ?search= parameter.
2026-07-11 17:28:48 +02:00
REDCODE bc72243c3e style: use rounded-square thumbnails for album results
Album covers now show as rounded squares (border-radius: 0.375rem)
while cosplayer profile pics remain circular, improving visual
differentiation between result types.
2026-07-11 17:26:13 +02:00
REDCODE c723a1f265 fix: close outer div and remove invalid @{} blocks in SearchDropdown
- Add missing closing </div> for search-dropdown-container
- Replace @{} variable blocks with IndexOf() calls (invalid inside @if)
- Remove leftover pi++ and ai++ incrementers
2026-07-11 17:22:08 +02:00
REDCODE e772e6d2b9 feat: wire SearchDropdown into NavMenu
Replace static search input markup in NavMenu with the new
SearchDropdown typeahead component.
2026-07-11 17:20:47 +02:00
REDCODE 48ed84b7bd feat: add SearchDropdown typeahead component
New SearchDropdown component that provides a live typeahead dropdown
in the navbar searching both cosplayers (persons) and albums.
- Fires API calls on every keystroke (no debounce) to existing /api/person and /api/album
- Version counter discards stale responses
- Minimum 2 characters before searching
- Keyboard navigation (arrows, enter, escape)
- Blur delay for click-to-navigate
- Thumbnail support with fallback avatars/icons
- Loading and empty states
2026-07-11 17:20:34 +02:00
REDCODE 9cfab29e6c Merge pull request 'feat(cosplayers): add sorting, pagination, and infinite scroll' (#88) from feature/cosplayers-sorting-pagination into develop
Reviewed-on: #88
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-11 15:06:55 +00:00
REDCODE c177afe078 Merge branch 'develop' into feature/cosplayers-sorting-pagination 2026-07-11 15:05:49 +00:00
REDCODE 69cdc30ae9 Merge pull request 'feat(albums): add sorting, filtering, and search' (#87) from feature/albums-sorting into develop
Reviewed-on: #87
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-11 15:04:45 +00:00
REDCODE 0d33be8637 fix(ui): move cosplayer actions into SortFilterBar, fix sortAsc always sending
- Move select/delete/new buttons into SortFilterBar's ActionButtons slot
- Fix PersonService sortAsc: always send param instead of conditional
2026-07-11 16:56:55 +02:00
REDCODE 9f709e5a2e fix(ui): move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation 2026-07-11 16:56:54 +02:00
REDCODE 12cb8aa15b feat(ui): extract CosplayerGrid to isolate grid re-renders from SortFilterBar
- Create CosplayerGrid.razor child component that manages its own data loading,
  pagination, infinite scroll via cosplayerObserver, and empty state
- Cosplayers.razor now only holds header/SortFilterBar/form state
- Filter param changes trigger reload via OnParametersSetAsync
- LoadVersion parameter allows parent to trigger grid reload
- Remove OnFilterChanged no-op handler - grid handles reloads via params
2026-07-11 16:56:54 +02:00
REDCODE c96488b1eb fix(ui): keep header and toolbar visible when cosplayers search returns empty 2026-07-11 16:56:54 +02:00
REDCODE 51bb14862d feat(ui): convert Cosplayers page to infinite scroll with sort/search toolbar
- PersonService.GetAllAsync accepts page, pageSize, search, sortBy, sortAsc
- Add cosplayerObserver JS intersection observer for infinite scroll
- Integrate SortFilterBar component with search, sort field, direction toggle
- Default sort: name ASC (alphabetical)
- Disposes and re-attaches observer on filter changes
2026-07-11 16:56:54 +02:00
REDCODE 4cffc9b424 feat(api): paginate PersonController.GetAll with sort/search params
Accept PersonSearchParametersDto, forward sort/search/pagination to repository.
Returns paginated results instead of loading all people into memory.
2026-07-11 16:56:54 +02:00
REDCODE 4ea0afe9f6 feat(api): add SearchQuery to PersonRepository with sort/search/pagination
SQL-level search by name, sorting (name, created, albums), and pagination.
Access-level filtering for regular users pushed to SQL via subquery.
Replaces the in-memory GetAllVisible pattern.
2026-07-11 16:56:54 +02:00
REDCODE de049d9d33 fix(ui): push ActionButtons to right with ms-auto wrapper 2026-07-11 16:56:51 +02:00
REDCODE 26a59b4ac2 fix(ui): add ActionButtons slot to SortFilterBar, move album actions into bar
- Add ActionButtons RenderFragment parameter to SortFilterBar
- Move view mode toggle, select/delete, and new album buttons into
  SortFilterBar's ActionButtons slot in Albums.razor
- Fix AlbumService sortAsc: always send param instead of conditional
2026-07-11 16:40:07 +02:00
REDCODE 6a3daa397b fix(ui): add margin-bottom to SortFilterBar for spacing from grid 2026-07-11 16:28:18 +02:00
REDCODE 7f0c51edc4 fix(ui): add height:100% to album-card-fallback so icon centers vertically in grid mode 2026-07-11 16:25:44 +02:00
REDCODE 46a68d9dd4 fix(ui): move album grid CSS to AlbumGrid.razor.css for scoped isolation 2026-07-11 16:22:46 +02:00
REDCODE ff76673ee1 feat(ui): extract AlbumGrid to isolate grid re-renders from SortFilterBar
- Create AlbumGrid.razor child component that manages its own data loading,
  pagination, infinite scroll, and empty state independently
- Albums.razor now only holds header/SortFilterBar/form state — grid is a
  child component that re-renders separately when data loads
- Filter parameter changes (search, sort) trigger reload via OnParametersSetAsync
- ViewMode and SelectionMode changes only affect grid rendering, no API call
- LoadVersion parameter allows parent to trigger grid reload (e.g. after delete)
- Remove debounce from SortFilterBar — instant search restored
- Keep backspace prevention on empty search input
- Fix event handler unsubscription with stored delegate reference
2026-07-11 16:15:50 +02:00
REDCODE b0b1856c01 fix(ui): refocus search input after debounce triggers reload
- Add ElementReference _searchInput bound to the input element
- Call FocusAsync() after OnFilterChanged completes via Task.Yield()
  to ensure the render completes before focus is restored
- Catch OperationCanceledException instead of TaskCanceledException
  for wider cancellation compatibility
2026-07-11 16:12:01 +02:00
REDCODE 9e40add0d5 fix(ui): debounce search input and prevent backspace navigation
- Debounce OnFilterChanged on search input (300ms) to prevent focus loss
- Add CancellationTokenSource to cancel stale debounce on new keystroke
- Prevent browser back navigation on Backspace when search input is empty
  (via plain HTML onkeydown attribute, runs at DOM level before Blazor)
- Implement IDisposable to clean up debounce timer
2026-07-11 16:07:12 +02:00
REDCODE 2886c0d629 fix(ui): keep header and toolbar visible when albums search returns empty 2026-07-11 14:52:50 +02:00
REDCODE ed2ce83b58 feat(ui): add SortFilterBar component and update Albums page with search/sort
- Extract reusable SortFilterBar.razor with search input, sort dropdown, direction toggle
- Add search, sort field, and sort direction to Albums page toolbar
- AlbumService.GetAlbumsAsync accepts sortBy, sortAsc, unassigned params
- Albums reset and reload on filter/sort changes
2026-07-11 13:11:16 +02:00
REDCODE 334451238b feat(api): pass sort and filter params through AlbumController.Search
Forward SortBy, SortAsc, and Unassigned from AlbumSearchParametersDto to repository.
Remove in-memory unassigned filter (now handled in SQL).
2026-07-11 13:11:12 +02:00
REDCODE 48ebbebee1 feat(api): add sorting and unassigned filter to AlbumRepository.SearchQuery
Extend SearchQuery with sortBy, sortAsc, and unassigned parameters.
Sorting applied via switch expression: name, created, updated, assets, person.
Unassigned filter pushed to SQL instead of in-memory.
2026-07-11 13:11:07 +02:00
REDCODE bd924f9e23 feat(api): add sort parameters to pagination DTOs
Add SortBy (string?) and SortAsc (bool) to PagedParametersDto base class.
Create PersonSearchParametersDto extending PagedSearchParametersDto.

Closes #84
2026-07-11 13:07:41 +02:00
REDCODE 3633b418ea feat(ui): add eye toggle button to show/hide password on all password fields 2026-07-11 04:08:19 +02:00
REDCODE 46382e2aed fix(ui): prevent infinite loading loop on cosplayers page with no data
The AuthInfoChanged handler was calling StateHasChanged() during
OnInitializedAsync (fired by JwtTokenRefresher when refreshing the
token during LoadPeople). This conflicted with Blazor's initial
render cycle, leaving the component stuck in a loading state when
the result was empty.

Changes:
- Remove StateHasChanged() from AuthInfoChanged handler (token
  update only, consistent with CosplayerDetail pattern)
- Extract named event handler methods for proper unsubscribe
- Implement IDisposable to unsubscribe from singleton events
- Always assign GetAllAsync result (including empty) to people
2026-07-11 00:32:24 +02:00
REDCODE be7dc9bb91 feat(ui): apply metallic gradient to all buttons and surfaces
- Add gradient-button mixin for color-agnostic metallic button style
- Apply to all .btn-* variants via theme-color loop
- Reset hover lift inside .btn-group / .navbar to prevent button breakout
- Add gradient-surface-deep/shallow mixins for cards, modals, dropdowns
- Apply metallic surface gradients to .card, .card-header, .modal-content,
  .dropdown-menu, .dropdown-item, .list-group-item
- Update gradient-accent to brighter stops (lighten 5% / darken 5%)
- Fix danger color contrast (#f85149 -> #d41111)
- Add --text CSS variable
- Clean up Dockerfile (remove unnecessary libc6/libstdc++6)
- Fix LactoseBaseUrl IP in docker-compose.yml
- Minor UI tweaks: badge spacing in AdminUsers, badge style in SettingRange
2026-07-11 00:23:18 +02:00
REDCODE d010b51687 fix(ui): fix remaining float culture issues in CSS style attributes
- CosplayerDetail.razor: add InvariantCulture to @z (missed in prior fix)
- ProgressBar.razor: add InvariantCulture to seg.Width and Value in width% styles
2026-07-10 23:58:42 +02:00
REDCODE 07c0a15369 fix(ui): fix float values in the cosplayer thumbnail and cosplayer card 2026-07-10 23:36:23 +02:00
REDCODE 6e091299e4 Merge pull request 'Frontend restyle: SCSS foundation, ModalFrame, EmptyState, dark navy theme, gradients, polish' (#82) from feat/frontend-restyle into develop
Reviewed-on: #82
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 19:05:49 +00:00
REDCODE 74882904a0 style: soften accent gradient stops (5%/10% instead of 15%/30%) 2026-07-10 21:01:31 +02:00
REDCODE 7639c6909a feat(gradients): strengthen all gradients for more aggressive effect
- gradient-accent: 3 stops (accent -> darken 15% -> darken 30%)
- gradient-surface: opacity doubled (0.03→0.08, 0.08→0.18)
- gradient-overlay: starts earlier (transparent 20%) + darker (0.92)
- gradient-banner-empty: 3 stops with intermediate dark accent
- Body bg: accent opacity doubled (0.06→0.12) + mid stop
- btn-primary hover: 3-stop gradient, stronger lift (-2px)
- btn-primary active: full gradient instead of flat
- Nav-link hover: 3-stop radial glow, stronger opacity
- ImagePreview nav: stronger bg + shadow
- CosplayerDetail banner overlay: darker (0.5→0.85)
2026-07-10 20:57:44 +02:00
REDCODE b58066d563 feat(cropper): circle handles with accent color, black border, drop shadow 2026-07-10 20:53:07 +02:00
REDCODE 14df8f1e8d fix(cropper): add pointer-events:auto on handles, fix cursors
- .cropper-square has pointer-events:none, so handles need
  pointer-events:auto to be interactive
- Restore per-corner cursor variants (nw-resize, ne-resize, etc.)
2026-07-10 20:52:02 +02:00
REDCODE 188e4568da fix(cropper): restore missing cropper CSS styles
- Cropper-viewport, overlay, square, circle, and handle CSS were in the
  old MilkyShot.css which was deleted during SCSS migration
- Restore all cropper styles in _custom.scss
2026-07-10 20:49:47 +02:00
REDCODE 5006d81d00 fix(cropper): revert ProfileCropper to inline modal
- ProfileCropper needs direct DOM access for JS cropper init, but
  ModalFrame's render cycle delays child content rendering
- Revert to inline modal markup with @if (Show && AssetId.HasValue)
- Add global modal styles to _custom.scss for inline modal support
- ModalFrame continues using its own scoped styles in ModalFrame.razor.css
2026-07-10 20:46:26 +02:00
REDCODE 97e5404069 fix(ui): page bg radial gradient, navbar radial glow, favicon cache
- Body: radial gradient (ellipse top center) instead of linear — spreads
  naturally across the viewport instead of pooling at the top
- Navbar: radial-gradient on hover instead of rounded box — blends into
  the navbar background
- Favicon: add ?v=2 cache-busting query param to force re-download
2026-07-10 20:40:23 +02:00
REDCODE aa9aca8f6f fix(favicon): render MilkyShot logo as PNG favicon
- Replace default Blazor favicon.png with rendered logo via rsvg-convert
- White logo on transparent background, 32x32
2026-07-10 20:34:55 +02:00
REDCODE dddc4baa5d feat(ui): replace default favicon with MilkyShot logo SVG 2026-07-10 20:33:46 +02:00
REDCODE 3d2c14ef41 fix(ui): modal icon order, fill all outline buttons
- ModalFrame: move HeaderActions before title so icons appear left of text
- Replace all btn-outline-* with btn-* (secondary/primary/danger/success/
  warning/light/info) across all .razor components for filled buttons
2026-07-10 20:33:08 +02:00
REDCODE 628cfea773 feat(ui): page background gradient, status/strength CSS vars, nav/image polish
- Page body: subtle accent-to-dark gradient (180deg, rgba accent
  4% -> body-bg 30%)
- Status dot colors: extracted to --status-* CSS vars using theme
  colors (accent for running, green for completed, etc.)
- PasswordField: strength colors to --strength-* vars, track to
  --strength-track
- JobRow: inline hex colors replaced with var(--status-*)
- NavMenu: gradient background on nav-link hover + rounded pills
- ImagePreview: gradient nav background + shadow on hover
2026-07-10 20:31:05 +02:00
REDCODE d2d1fc7e50 feat(ui): aggressive gradients on navbar, buttons, modal headers
- btn-primary: gradient-accent background with hover elevation
- Navbar: bg-gradient-surface for subtle shimmer overlay
- Modal header: bg-gradient-surface + accent bottom border
- _gradients.scss preset classes compiled into output
2026-07-10 20:25:18 +02:00
REDCODE 27f89f3820 feat(ui): CosplayerDetail banner empty gradient uses accent theme
- Add --gradient-banner-empty CSS var (accent → dark navy)
- Replace hardcoded #1a1a2e/#16213e/#0f3460 gradient
2026-07-10 20:23:21 +02:00
REDCODE ecac0cdc2e refactor(jobs): use darken() for phash and create-albums hover/active too
All 8 job types now derive hover/active via darken() — consistent behavior
2026-07-10 20:19:09 +02:00
REDCODE 5fd338906c fix(jobs): add hover/active variants for all job types via darken()
- -*-hover and -*-active now derive from base color using
  darken(, 15%) / darken(25%) for consistent button behavior
- Manual values kept for phash and create-albums (they were already correct)
2026-07-10 20:18:20 +02:00
REDCODE 88476e0409 feat(jobs): extract all job-type colors to configurable SCSS variables
- Add -scan, -meta, -thumb, -preview, -persons,
  -integrity to _variables.scss (alongside existing -phash
  and -create-albums)
- Emit all as --job-* CSS custom properties in :root
- Replace Bootstrap btn-info/primary/success/warning/light classes
  with dedicated .btn-job-* classes in Jobs.razor.css
- Update Jobs.razor desktop buttons + dropdown icons to use --job-* vars
- Update JobRow.razor MarkupString icons from Bootstrap text-* classes
  to --job-* CSS vars
- All job colors are now adjustable from _variables.scss in one place
2026-07-10 20:16:32 +02:00
REDCODE 7848923445 feat(ui): CSS variables for .razor.css, job type colors, interactive states, gradients
- Add :root custom properties (--surface-bg, --accent, --shadow-*,
  --gradient-overlay, --selection-outline, --job-*) to _custom.scss
- Replace hardcoded #1a1a1a/var(--bs-dark) fallbacks with var(--surface-bg)
- Replace var(--bs-secondary, #666/555) with var(--text-muted)
- Replace linear-gradient overlays with var(--gradient-overlay)
- Replace rgba selection outlines with var(--accent)
- Extract job-type colors (-phash, -create-albums) to _variables.scss
  and emit as --job-* CSS vars for Jobs.razor.css consumption
- NavMenu: accent hover underline animation, accent avatar bg
- Card hover: translateY(-2px) + box-shadow elevation
- ImagePreview: accent color on nav hover
2026-07-10 20:06:09 +02:00
REDCODE 3b51a35e2c refactor(ui): ModalFrame + EmptyState components, remove duplicate CSS, remove main border
- ModalFrame shared component replaces 8+ inline modal implementations
  (AdminUsers ×3, User ×2, CosplayerDetail, AlbumForm, PersonForm,
   AssetPicker, ProfileCropper) — all now use <ModalFrame> with
   Title/Body/Footer parameters
- ModalFrame.razor.css houses modal styles once — deleted from
  AdminUsers.razor.css, User.razor.css, AssetPicker.razor.css
- ModalFrame handles body scroll lock/unlock via JS interop
- EmptyState component replaces 7 empty-state/not-found patterns
- Removed duplicate .sortable class from AdminUsers.razor.css and Jobs.razor.css
  (now in _custom.scss as shared utility)
- AGENTS.md: fix orphaned SCSS ref, add SCSS docs
- .gitignore: ignore *.css.map
2026-07-10 19:58:03 +02:00
REDCODE 7686f04023 feat(css): SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
- Add AspNetCore.SassCompiler v1.101.0 (MSBuild-only, no hosted service)
- Vendor Bootstrap 5.3.7 SCSS source into Styles/bootstrap/
- Create _variables.scss with adjustable accent, dark navy palette
- Create _gradients.scss with aggressive gradient mixins
- Create _custom.scss (scrollbar, validation, shared sortable, fonts)
- Wire up sasscompiler.json for Debug expanded / Release compressed
- Replace Helvetica with Inter (Google Fonts CDN)
- Delete all precompiled Bootstrap CSS (lib/css/*) - keep JS
- Delete MilkyShot.css, Milky.styles.css, app.css (now SCSS-compiled)
- Update MilkStream.csproj - remove CSS None entries, keep JS entries
- 0 errors, 0 warnings build
2026-07-10 19:38:29 +02:00
332 changed files with 26333 additions and 48180 deletions
+1
View File
@@ -0,0 +1 @@
GIT_VERSION=v1.0.2-21-g9e67c59
+63
View File
@@ -0,0 +1,63 @@
name: Build and Push Containers
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.2.3)'
required: true
default: 'manual'
jobs:
build-and-push:
strategy:
matrix:
service: [Lactose, MilkStream]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.r3d.codes
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine version tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "VERSION=$(echo '${{ github.event.inputs.version }}' | sed 's|/|-|g')" >> $GITHUB_ENV
else
echo "VERSION=$(echo '${{ gitea.ref_name }}' | sed 's|/|-|g')" >> $GITHUB_ENV
fi
- name: Lowercase repository name
run: echo "REPO_LC=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Lowercase service name
run: echo "SERVICE_LC=$(echo '${{ matrix.service }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Prepare build args for ${{ matrix.service }}
run: |
if [ "${{ matrix.service }}" = "Lactose" ]; then
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
else
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
fi
- name: Build and push ${{ matrix.service }}
uses: docker/build-push-action@v5
with:
context: .
file: ./${{ matrix.service }}/Dockerfile
push: true
tags: |
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:${{ env.VERSION }}
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:latest
build-args: ${{ env.BUILD_ARGS }}
-320
View File
@@ -1,320 +0,0 @@
# MilkyShots Agent Guidelines
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
---
## Project Overview
MilkyShots is a .NET 8 media-library application composed of four projects:
| Project | Role |
|---|---|
| **Butter** | Shared class library DTOs, enums, MIME-type tables, settings definitions |
| **Lactose** | ASP.NET Core Web API backend controllers, EF Core models, repositories, services, background jobs |
| **MilkStream** | Blazor WebAssembly host serves static WASM files and provides dynamic configuration |
| **MilkStream.Client** | Blazor WASM client Razor components, frontend services, SCSS styles (runs in browser) |
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
---
## Technology Stack
- **.NET 8 / C# 12** — target framework and language version for all projects.
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
- **JWT Bearer** authentication + refresh-token rotation.
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
- **Docker / docker-compose** for containerised deployment.
---
## Solution Structure
```
MilkyShots/
├── Butter/ # Shared library
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
│ ├── Settings/ # Settings enum + extensions
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
├── Lactose/ # Web API
│ ├── Authorization/ # Claims transformation + authorization handlers
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
│ ├── Controllers/ # API controllers
│ ├── Jobs/ # Background job infrastructure + concrete jobs
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
│ ├── Migrations/ # EF Core migrations (generated do NOT hand-edit)
│ ├── Models/ # EF Core entity classes
│ ├── Repositories/ # Interfaces + implementations for data access
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
│ └── Utils/ # Small helpers / extension methods
├── MilkStream/ # Blazor WASM static host
│ ├── Program.cs # Minimal API serves WASM files + dynamic /appsettings.json endpoint
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
├── Services/ # Frontend services (LoginService, MediaService, …)
├── Styles/ # SCSS source files
└── wwwroot/ # Static assets bundled into WASM
```
---
## Naming Conventions
### C# identifiers
| Category | Pattern | Example |
|---|---|---|
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
| Repository interfaces | `I…Repository` | `IAssetRepository` |
| Repository implementations | `…Repository` | `AssetRepository` |
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
| Controller classes | `…Controller` | `AssetController` |
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
### Files and directories
- One public type per file; file name equals the type name.
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories``Lactose/Repositories/`).
---
## C# Coding Style
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
- Prefer **primary constructors** (C# 12) for constructor injection:
```csharp
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository
) : ControllerBase { … }
```
- Use **`var`** for local variables when the type is obvious from the right-hand side.
- Use **object initialisers** when creating entity or DTO instances.
- Use the **`required`** keyword on properties that must be set during object construction.
- Default string properties to `string.Empty`, not `null`.
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
### Documentation comments
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
- **Mapper classes** must have a `<summary>` on the class and on each method.
- Controllers may omit XML comments on action methods (use inline comments instead).
### Logging
- Inject `ILogger<T>` into every controller and service.
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
- Log-level conventions:
| Level | When to use |
|---|---|
| `LogTrace` | Detailed per-request data (IDs, field values) |
| `LogDebug` | Medium-detail operational info |
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
| `LogError` | Exceptions and failures |
---
## Entity / Model Rules
- Primary keys are **`Guid`** annotated with `[Key]`.
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
---
## Repository Pattern
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
- Standard method set:
| Method | Signature |
|---|---|
| Find by PK | `T? Find(Guid id)` |
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
| Insert | `void Insert(T entity)` |
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
| Save | `void Save()` → `context.SaveChanges()` |
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
- Register all repositories as **`Transient`** in `Program.cs`.
---
## Mapper / DTO Rules
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
- Mapping methods are **extension methods** on the model type:
```csharp
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
```
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
- Never put business logic in mappers — only field assignments.
---
## Controller Rules
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
- HTTP verb → CRUD mapping:
| Verb | Operation |
|---|---|
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
| `[HttpPut]` | Create a new resource |
| `[HttpPost("{id}")]` | Update a specific resource |
| `[HttpPost]` | Bulk update |
| `[HttpDelete("{id}")]` | Delete a specific resource |
| `[HttpDelete]` | Bulk delete |
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
---
## Authentication & Authorisation
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
- Access is controlled by three roles (in ascending privilege order):
| Role | Description |
|---|---|
| `User` | Read-only access to publicly shared / explicitly shared content |
| `Curator` | Can create/update/delete their own content |
| `Admin` | Unrestricted access |
- `[Authorize]` with no roles = any authenticated user.
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
Note the space after the comma in the role list — this is the project's established style.
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
---
## Background Jobs System
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
- Override `TaskJob(CancellationToken token)` to implement job logic.
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
---
## Settings System
- Runtime settings are persisted in the `Settings` database table.
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
- When adding a new setting:
1. Add an entry to `Settings` enum and `AsString()`.
2. Add the default entry to `DefaultSettings.json`.
3. React to changes via `SettingsRepository.SettingChanged` event.
---
## Frontend (MilkStream.Client / Blazor WASM)
- Use **Blazor WebAssembly** the client runs entirely in the browser.
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
### Dynamic Configuration
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
---
## Docker / Deployment
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
- Data-protection keys for MilkStream are persisted via a named Docker volume.
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
---
## Configuration & Secrets
- `appsettings.json` contains placeholder/default values only.
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
---
## Git & Commit Conventions
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
| Prefix | Purpose |
|---|---|
| `feat:` | New feature |
| `fix:` | Bug fix |
| `refactor:` | Code restructuring without behaviour change |
| `perf:` | Performance improvement |
| `doc:` | Documentation changes |
| `style:` | Formatting / style only |
| `test:` | Tests |
| `chore:` / `ci:` | Build system, tooling, CI |
| `revert:` | Revert a previous commit |
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
---
## TODOs & Known Gaps (as of initial analysis)
The following items appear as `TODO` comments in the codebase and should be tracked:
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
- `TagController.Get` (ancestors traversal) — not protected against circular references.
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
+3
View File
@@ -7,3 +7,6 @@ riderModule.iml
storageImages/
dotnet-tools.json
.opencode/
*.css.map
# SixLabors license file
/Lactose/sixlabors.lic
+3 -1
View File
@@ -20,6 +20,8 @@
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
</method>
</configuration>
</component>
+3 -1
View File
@@ -29,6 +29,8 @@
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Unnamed" run_configuration_type="ShConfigurationType" />
</method>
</configuration>
</component>
+115 -8
View File
@@ -6,17 +6,33 @@
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
- **PostgreSQL with pgvector extension**
- **Sass CLI** for SCSS compilation (see SCSS section)
- Run `dotnet tool restore` before first migration — installs `dotnet-ef`
- **SCSS** compiled by `AspNetCore.SassCompiler` MSBuild task (no Node.js needed; compiles during `dotnet build`)
- Run `dotnet tool restore` before first migration — installs `dotnet-ef` (tool manifest at `dotnet-tools.json` in repo root)
## Projects (4 in solution)
## SCSS
SCSS source is in `MilkStream.Client/Styles/`. Compiled via `AspNetCore.SassCompiler` MSBuild target:
```bash
dotnet build # compiles SCSS automatically
```
- Config: `MilkStream.Client/Styles/sasscompiler.json` (expanded in Debug, compressed in Release)
- Source maps disabled
- Bootstrap 5.3.7 SCSS vendored in `Styles/bootstrap/`
- Theme variables adjustable in `Styles/_variables.scss` (`$accent` for primary color)
- Aggressive gradient mixins in `Styles/_gradients.scss`
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
## 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 | `MilkStream.Client/Program.cs` |
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
## Build & run
@@ -32,12 +48,12 @@ 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.
- **`dotnet-ef` CLI vs Microsoft.EntityFrameworkCore.Tools:** The global `dotnet-ef` (from `dotnet-tools.json`, v10.0.9) is the correct CLI to use. The Tools package reference in `Lactose.csproj` (v10.0.9) provides the `dotnet ef` target for `dotnet build`-time validation.
- **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.
- **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
@@ -50,11 +66,54 @@ 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.
## Modularity
## Visibility Rules (R1R7)
Defined in issue [#129](https://git.r3d.codes/MilkyShots/MilkyShots/issues/129). Each entity (Asset, Album, Person) carries **independent** `EVisibility` — no inheritance from container to child.
### R1 — Independent Visibility
No entity's visibility is overridden by its container. An asset in a Public album does NOT become Public; the asset is only visible if the viewer has permission for the asset's own visibility.
### R2 — Asset Visibility (Per-Asset Gate)
| Actor | Public | Protected | Private |
|---|---|---|---|
| Anonymous | ✅ | ❌ | ❌ |
| User | ✅ | ✅ | ❌ |
| Maintainer (not owning / not linked) | ✅ | ✅ | ❌ |
| Maintainer (uploaded by self) | ✅ | ✅ | ✅ |
| Maintainer (album → person they maintain) | ✅ | ✅ | ✅ |
| Curator (non-deleted) | ✅ | ✅ | ✅ |
| Curator (deleted, own upload) | ✅ | ✅ | ✅ |
| Admin (all, including deleted) | ✅ | ✅ | ✅ |
### R3 — Album Access Gating
Album's own visibility determines who can access it. Not visible → 404.
| Actor | Public | Protected | Private |
|---|---|---|---|
| Anonymous / User | ✅ | ✅ | ❌ |
| Maintainer (unassigned) | ✅ | ✅ | ❌ |
| Maintainer (maintains PersonOwner) | ✅ | ✅ | ✅ |
| Curator / Admin | ✅ | ✅ | ✅ |
### R4 — Person Access Gating
Same as R3. Not visible → 404.
### R5 — Media Serving
Uses per-asset rules (R2). Album provenance not checked — too complex, marginal benefit.
### R6 — Edit Mode (Frontend)
API returns Private assets to privileged users (Admin/Curator/Maintainer-on-owned-content). Frontend hides them in browse mode, shows them in edit/curator mode. No backend flag.
### R7 — Deleted Assets
- **Admin**: all deleted assets visible
- **Curator**: own deleted uploads visible
- **Everyone else**: deleted assets hidden (`DeletedAt IS NULL`)
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
When a markup pattern appears in 2+ places, extract it into a shared component.
@@ -75,8 +134,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 v10.0.9 vs Design v10.0.9 mismatch). Example:
```csharp
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
.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`** — 100+ numbered tests covering all API endpoints across 12 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Data Visibility → Visibility Gates → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin) and include comprehensive visibility gate tests (R2R4). 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`).
+16 -6
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,25 +9,33 @@ 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; }
/// <summary>
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
/// </summary>
public DateTime? DeletedAt { get; set; }
}
+10 -8
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;
}
+18
View File
@@ -0,0 +1,18 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents a request to merge multiple albums into a single destination album.
/// </summary>
public class AlbumMergeDto {
/// <summary>
/// Gets or sets the ID of the destination album that will receive all assets.
/// Must not be present in <see cref="SourceIds"/>.
/// </summary>
public required Guid DestinationId { get; set; }
/// <summary>
/// Gets or sets the list of source album IDs whose assets will be moved to the destination.
/// Source albums will be hard-deleted after the merge.
/// </summary>
public required List<Guid> SourceIds { get; set; } = [];
}
+24 -10
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,29 +9,41 @@ 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 cover image width, if a cover exists.
/// </summary>
public int? CoverWidth { get; set; }
/// <summary>
/// Gets or sets the cover image height, if a cover exists.
/// </summary>
public int? CoverHeight { 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; }
/// <summary>
/// Gets or sets the soft-delete timestamp. Always null for albums (not supported).
/// </summary>
public DateTime? DeletedAt { get; set; }
}
@@ -8,4 +8,10 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
/// Gets or sets whether to return only albums with no person assigned.
/// </summary>
public bool Unassigned { get; set; }
/// <summary>
/// Gets or sets the user ID to filter albums by.
/// Only returns albums that contain at least one asset uploaded by the specified user.
/// </summary>
public Guid? AssetUploadedBy { get; set; }
}
+11 -9
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; }
}
@@ -0,0 +1,19 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the parameters for browsing assets within a folder at a specific directory level.
/// </summary>
public class AssetBrowseOptionsDto : PagedSearchParametersDto {
/// <summary>
/// Gets or sets the root folder ID to browse within.
/// </summary>
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets the current path relative to the folder root (empty for root).
/// </summary>
public string Path { get; set; } = "";
/// <summary>
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
/// </summary>
public bool UnlinkedOnly { get; set; } = true;
}
+23
View File
@@ -0,0 +1,23 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the result of browsing unlinked assets at a specific directory level within a folder.
/// </summary>
public class AssetBrowseResultDto {
/// <summary>
/// Gets or sets the list of immediate subdirectories at the current path level.
/// </summary>
public List<DirectoryEntryDto> Directories { get; set; } = [];
/// <summary>
/// Gets or sets the list of assets located directly at the current path level.
/// </summary>
public List<AssetPreviewDto> Assets { get; set; } = [];
/// <summary>
/// Gets or sets the current browsing path relative to the folder root.
/// </summary>
public string CurrentPath { get; set; } = "";
/// <summary>
/// Gets or sets the total count of assets at the current path level (for pagination).
/// </summary>
public int TotalAssetCount { get; set; }
}
+13 -12
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; }
}
+9 -17
View File
@@ -10,41 +10,33 @@ 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; }
/// <summary>
/// Gets or sets the deletion date of the asset.
/// </summary>
public DateTime? DeletedAt { get; set; }
public DateTime? UpdatedAt { 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; }
}
+20
View File
@@ -0,0 +1,20 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents a group of unlinked assets (not assigned to any album),
/// grouped by folder for drill-down browsing in the asset picker.
/// </summary>
public class AssetGroupDto {
/// <summary>
/// Gets or sets the folder ID of the group. Null for assets with no folder or for the maintainer's "My Uploads" group.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the display name of the group (folder base path, or "My Uploads" for maintainers).
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the count of unlinked assets in this group.
/// </summary>
public int AssetCount { get; set; }
}
+21 -11
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
@@ -7,45 +9,53 @@ 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; }
/// <summary>
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
/// </summary>
public DateTime? DeletedAt { get; set; }
}
@@ -26,4 +26,16 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// Gets or sets whether to order results randomly instead of by date.
/// </summary>
public bool Random { get; set; }
/// <summary>
/// Gets or sets whether to filter for assets not assigned to any album (unlinked).
/// </summary>
public bool Unlinked { get; set; }
/// <summary>
/// Gets or sets the folder ID to filter assets by their scan folder.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the uploader user ID to filter assets by their uploader.
/// </summary>
public Guid? UploadedBy { get; set; }
}
+7 -5
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; }
}
+11
View File
@@ -0,0 +1,11 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents a subdirectory entry in the asset file browser.
/// </summary>
public class DirectoryEntryDto {
/// <summary>
/// Gets or sets the directory name (a single path segment, not a full path).
/// </summary>
public string Name { get; set; } = string.Empty;
}
+16
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>
@@ -12,6 +18,16 @@ public class PagedParametersDto {
/// Gets or sets the number of items per page.
/// </summary>
public int PageSize { get; set; } = 150;
/// <summary>
/// Gets or sets the field to sort by (e.g. "name", "created").
/// When <c>null</c>, the default sort order is applied.
/// </summary>
public string? SortBy { get; set; }
/// <summary>
/// Gets or sets the sort direction.
/// <c>false</c> = descending (default), <c>true</c> = ascending.
/// </summary>
public bool SortAsc { get; set; }
}
/// <summary>
+11 -5
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;
}
+13
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
namespace Butter.Dtos.Person;
@@ -39,7 +40,19 @@ public class PersonDetailedDto {
/// </summary>
public int TotalAssets { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the list of album previews associated with this person.
/// </summary>
public List<AlbumPreviewDto>? Albums { get; set; }
/// <summary>
/// Gets or sets the IDs of users who maintain this person.
/// </summary>
public List<Guid>? MaintainerUserIds { get; set; }
/// <summary>
/// Gets or sets the usernames of users who maintain this person.
/// </summary>
public List<string>? MaintainerUsernames { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Represents a request to merge multiple people into a single destination person.
/// </summary>
public class PersonMergeDto {
/// <summary>
/// Gets or sets the ID of the destination person that will receive all associated data.
/// Must not be present in <see cref="SourceIds"/>.
/// </summary>
public required Guid DestinationId { get; set; }
/// <summary>
/// Gets or sets the list of source person IDs whose albums, faces, and maintainers
/// will be reassigned to the destination. Source people will be hard-deleted.
/// </summary>
public required List<Guid> SourceIds { get; set; } = [];
}
+13 -7
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; }
}
@@ -0,0 +1,7 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Search and filter parameters for person (cosplayer) queries.
/// </summary>
public class PersonSearchParametersDto : PagedSearchParametersDto {
}
+15 -5
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,21 +9,29 @@ public class PersonUpdateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID to use as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the visibility level.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
/// </summary>
public List<Guid>? MaintainerUserIds { get; set; }
}
+7 -4
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.
+9
View File
@@ -46,4 +46,13 @@ public class UserInfoDto {
/// Gets or sets the access level of the user.
/// </summary>
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
/// <summary>
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
/// </summary>
public List<Guid>? MaintainedPersonIds { get; set; }
/// <summary>
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
/// </summary>
public List<string>? MaintainedPersonNames { get; set; }
}
+4
View File
@@ -38,4 +38,8 @@ public string? OldPassword { get; set; }
/// Gets or sets the new access level.
/// </summary>
public EAccessLevel? AccessLevel { get; set; }
/// <summary>
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
/// </summary>
public List<Guid>? MaintainedPersonIds { get; set; }
}
+2
View File
@@ -22,6 +22,8 @@ public enum DisplayType {
TimePicker = 7,
/// <summary>Power of 2 selection.</summary>
Po2W = 8,
/// <summary>User picker (searchable dropdown).</summary>
UserSelect = 9,
}
/// <summary>
+4 -1
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
};
}
+5 -3
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
}
+13
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
}
+3 -3
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>
+1 -1
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;
+4 -4
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;
@@ -0,0 +1,61 @@
namespace Lactose.Configuration;
/// <summary>
/// Configuration for rate-limit buckets applied to different API route groups.
/// </summary>
public record RateLimitBucketOptions {
/// <summary>
/// The configuration section name for rate limiting.
/// </summary>
public const string SectionName = "RateLimiting";
/// <summary>
/// Default bucket applied to all routes that are not explicitly assigned.
/// </summary>
public BucketOptions Global { get; set; } = new();
/// <summary>
/// Strict bucket for authentication endpoints (login, register, refresh).
/// </summary>
public BucketOptions Auth { get; set; } = new();
/// <summary>
/// Bucket for serving original (full-size) media files.
/// </summary>
public BucketOptions MediaOriginal { get; set; } = new();
/// <summary>
/// Bucket for serving thumbnail media files.
/// </summary>
public BucketOptions MediaThumb { get; set; } = new();
/// <summary>
/// Bucket for serving preview media files.
/// </summary>
public BucketOptions MediaPreview { get; set; } = new();
/// <summary>
/// Bucket for the long-polling jobs endpoint. Uses a shorter window.
/// </summary>
public BucketOptions Jobs { get; set; } = new();
}
/// <summary>
/// Settings for an individual rate-limit bucket.
/// </summary>
public class BucketOptions {
/// <summary>
/// Maximum number of permits allowed within the window.
/// </summary>
public int PermitLimit { get; set; } = 100;
/// <summary>
/// Length of the fixed window in seconds.
/// </summary>
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// Maximum number of queued requests waiting for a permit. Null disables queueing.
/// </summary>
public int? QueueLimit { get; set; }
}
@@ -0,0 +1,17 @@
using Microsoft.Extensions.Options;
namespace Lactose.Configuration;
/// <summary>
/// Configures <see cref="RateLimitBucketOptions"/> from the application configuration.
/// </summary>
public class RateLimitBucketOptionsProvider(IConfiguration configuration) : IConfigureOptions<RateLimitBucketOptions> {
/// <inheritdoc />
public void Configure(RateLimitBucketOptions options) {
var section = configuration.GetSection(RateLimitBucketOptions.SectionName);
if (!section.Exists())
return;
section.Bind(options);
}
}
+29 -5
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,34 @@ 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");
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// 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 +99,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);
}
+76 -78
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,70 +38,39 @@ 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, uid));
}
/// <summary>
/// Searches albums with pagination, optional search term, and optional unassigned filter.
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
/// </summary>
/// <param name="pagingOptions">Pagination, search, and filter parameters.</param>
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
/// <returns>A list of album previews accessible to the user.</returns>
[HttpGet]
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();
var albums = albumRepository.SearchQuery(pagingOptions.Search ?? string.Empty, pagingOptions.Page, pagingOptions.PageSize).ToList();
if (pagingOptions.Unassigned)
albums = albums.Where(a => a.PersonOwnerId == null).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());
}
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,
uid ?? default,
accessLevel,
pagingOptions.AssetUploadedBy
).ToList();
return Ok(albums);
}
/// <summary>
@@ -109,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;
@@ -119,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;
@@ -151,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;
@@ -162,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
@@ -175,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;
}
@@ -192,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 {
@@ -207,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
};
@@ -218,24 +186,49 @@ public class AlbumController(
return Ok(album.Id);
}
/// <summary>
/// Merges multiple source albums into a destination album.
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
/// </summary>
/// <param name="dto">The merge request with destination and source IDs.</param>
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
[HttpPost("merge")]
[Authorize(Roles = "Curator,Admin")]
public ActionResult Merge([FromBody] AlbumMergeDto dto) {
if (dto.SourceIds.Contains(dto.DestinationId))
return BadRequest("Destination album ID must not appear in source album IDs.");
if (albumRepository.Find(dto.DestinationId) == null)
return NotFound("Destination album not found.");
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
albumRepository.Save();
return Ok();
}
/// <summary>
/// Removes multiple albums (soft delete).
/// </summary>
/// <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();
@@ -249,7 +242,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;
@@ -258,8 +251,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();
+111 -90
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,50 +46,14 @@ public class AssetController(
"""
);
var asset = assetRepository.Find(id);
var asset = assetRepository.FindVisible(id, uid, accessLevel);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
logger.LogWarning($"Request asset {id} not found or not visible!");
return NotFound();
}
switch (accessLevel) {
case EAccessLevel.User: {
//Not publicly shared && Not shared with the user
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
return Unauthorized();
}
//Deleted asset
if (asset.DeletedAt != null) {
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
return NotFound();
}
break;
}
case EAccessLevel.Curator: {
//Deleted asset owned by the user
if (asset.DeletedAt != null)
if (asset.Owner?.Id != uid) {
logger.LogTrace(
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
);
return NotFound();
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
break;
}
case EAccessLevel.Admin: {
//Admins will see this regardless
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
break;
}
}
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
logger.LogTrace(
$"""
@@ -95,11 +61,11 @@ public class AssetController(
dto.Id: {dto.Id}
dto.FileName: {dto.FileName}
dto.AssetType: {dto.AssetType}
dto.IsPublic: {dto.IsPublic}
dto.Visibility: {dto.Visibility}
dto.CreatedAt: {dto.CreatedAt}
dto.UpdatedAt: {dto.UpdatedAt}
dto.DeletedAt: {dto.DeletedAt}
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
"""
);
@@ -107,9 +73,9 @@ public class AssetController(
}
/// <summary>
/// Searches assets with optional date range, type filter, and random ordering with pagination.
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
/// </summary>
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
/// <returns>A list of asset previews.</returns>
[HttpGet]
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
@@ -124,14 +90,16 @@ public class AssetController(
return BadRequest();
}
if (searchOptionsDto.Page < 1 || 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, searchOptionsDto.PageSize, out int total
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
uid, accessLevel,
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
);
logger.LogTrace(
@@ -146,27 +114,56 @@ 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, uid).ToList();
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(dtoList);
}
/// <summary>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
/// "My Uploads" group with their own unlinked upload count.
/// </summary>
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
[HttpGet("unlinked-groups")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
var groups = assetRepository.GetFolderGroups(uid, accessLevel, unlinkedOnly);
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
return Ok(groups);
}
/// <summary>
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
/// and a paginated list of assets at that level.
/// </summary>
/// <param name="options">Browse options including folder ID, path, pagination, search, and unlinked-only filter.</param>
/// <returns>A browse result with subdirectories and assets at the current level.</returns>
[HttpGet("browse")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult<AssetBrowseResultDto> Browse([FromQuery] AssetBrowseOptionsDto options) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
return BadRequest();
logger.LogTrace("Browse: folderId={FolderId}, path={Path}, page={Page}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, accessLevel, options.UnlinkedOnly);
var result = assetRepository.BrowseAssets(options.FolderId, options.Path ?? "", options.Page, options.PageSize, uid, accessLevel, options.Search, options.UnlinkedOnly);
if (result == null) return NotFound();
return Ok(result);
}
/// <summary>
/// Updates an asset's metadata.
/// </summary>
@@ -174,32 +171,35 @@ public class AssetController(
/// <param name="dto">The asset update data.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Curator,Admin")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.Find(id);
var asset = assetRepository.FindWithAlbums(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
return Unauthorized();
}
string log = "";
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
asset.Visibility = dto.Visibility ?? asset.Visibility;
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
if (accesslevel == EAccessLevel.Admin) {
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
}
logger.LogTrace($"Updating asset {id}\n{log}");
@@ -215,7 +215,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,17 +234,24 @@ public class AssetController(
}
//If the user is not an admin, they can only update their own assets
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(
x => {
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
if (accesslevel == EAccessLevel.Admin) {
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
}
}
);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
return Ok();
}
@@ -254,18 +261,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 +291,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,11 +310,15 @@ 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);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
return Ok();
}
@@ -322,23 +334,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 +366,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;
}
+21 -2
View File
@@ -1,5 +1,6 @@
using Butter.Dtos;
using Butter.Dtos.User;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
@@ -7,6 +8,7 @@ using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Lactose.Controllers;
@@ -17,13 +19,16 @@ namespace Lactose.Controllers;
/// <param name="authService">Authentication service.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="passwordHasher">Password hasher.</param>
/// <param name="settingsRepository">Settings repository.</param>
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("auth")]
public class AuthController(
ILogger<AuthController> logger,
LactoseAuthService authService,
IUserRepository userRepository,
IPasswordHasher<User> passwordHasher
IPasswordHasher<User> passwordHasher,
ISettingsRepository settingsRepository
) : ControllerBase {
/// <summary>
@@ -92,14 +97,28 @@ public class AuthController(
};
}
/// <summary>
/// Checks whether user registration is currently enabled.
/// </summary>
/// <returns>200 if enabled, 403 if disabled.</returns>
[HttpGet("register")]
public ActionResult CheckRegistrationEnabled() {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
}
/// <summary>
/// Registers a new user account.
/// </summary>
/// <param name="dto">The registration data.</param>
/// <returns>200 with the new user ID, or 409 if the user already exists.</returns>
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
[HttpPost("register")]
public ActionResult Register([FromBody] UserRegisterDto dto) {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
if (regSetting?.Value != "true")
return StatusCode(403, "Registration is currently disabled on this server");
logger.LogDebug(
$"""
Registering user:
+5 -2
View File
@@ -1,10 +1,11 @@
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;
using Microsoft.AspNetCore.RateLimiting;
namespace Lactose.Controllers;
@@ -17,6 +18,7 @@ namespace Lactose.Controllers;
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
[EnableRateLimiting("jobs")]
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
/// <summary>
@@ -35,7 +37,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);
+8 -3
View File
@@ -4,6 +4,7 @@ using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
@@ -15,6 +16,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]
@@ -34,6 +36,7 @@ public class MediaController(
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
[HttpGet("{id}")]
[HttpGet("original/{id}")]
[EnableRateLimiting("media_original")]
public ActionResult GetImage(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
@@ -43,7 +46,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);
@@ -55,6 +58,7 @@ public class MediaController(
/// <param name="id">The asset ID.</param>
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
[HttpGet("thumb/{id}")]
[EnableRateLimiting("media_thumb")]
public ActionResult GetThumb(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
@@ -65,7 +69,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);
@@ -77,6 +81,7 @@ public class MediaController(
/// <param name="id">The asset ID.</param>
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
[HttpGet("preview/{id}")]
[EnableRateLimiting("media_preview")]
public ActionResult GetPreview(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
@@ -87,7 +92,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);
+62 -39
View File
@@ -27,57 +27,42 @@ 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, uid));
}
/// <summary>
/// Searches people with pagination, filtering out cosplayers with no visible content for non-admin users.
/// Searches people with pagination, optional search term, sort options, filtering out cosplayers with no visible content for non-admin users.
/// </summary>
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <returns>A list of people previews.</returns>
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
/// <returns>A paginated list of person previews.</returns>
[HttpGet]
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var people = personRepository.GetAllVisible(uid!.Value, accessLevel)
.Select(p => p.ToPersonPreviewDto())
.ToList();
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var people = personRepository.SearchQuery(
pagedSearch.Search ?? string.Empty,
pagedSearch.Page,
pagedSearch.PageSize,
pagedSearch.SortBy,
pagedSearch.SortAsc,
uid ?? default,
accessLevel
).ToList();
return Ok(people);
}
@@ -88,16 +73,31 @@ public class PersonController(
/// <param name="dto">The updated person data.</param>
/// <returns>200 on success.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var person = personRepository.Find(id);
if (person == null) return NotFound();
if (accessLevel == EAccessLevel.Maintainer) {
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
return Forbid();
}
person.Name = dto.Name;
person.ProfileAssetId = dto.ProfileAssetId;
person.ProfileCropX = dto.ProfileCropX;
person.ProfileCropY = dto.ProfileCropY;
person.ProfileCropZoom = dto.ProfileCropZoom;
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
person.Visibility = dto.Visibility.Value;
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
person.UpdatedAt = DateTime.UtcNow;
personRepository.Save();
@@ -112,6 +112,7 @@ public class PersonController(
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
foreach (var person in people) {
@@ -120,7 +121,11 @@ public class PersonController(
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
person.UpdatedAt = DateTime.UtcNow;
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
}
personRepository.Save();
@@ -135,7 +140,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")]
@@ -162,7 +166,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")]
@@ -182,6 +185,26 @@ public class PersonController(
return Ok();
}
/// <summary>
/// Merges multiple source people into a destination person.
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
/// </summary>
/// <param name="dto">The merge request with destination and source IDs.</param>
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
[HttpPost("merge")]
[Authorize(Roles = "Admin, Curator")]
public IActionResult Merge([FromBody] PersonMergeDto dto) {
if (dto.SourceIds.Contains(dto.DestinationId))
return BadRequest("Destination person ID must not appear in source person IDs.");
if (personRepository.Find(dto.DestinationId) == null)
return NotFound("Destination person not found.");
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
personRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new person.
/// </summary>
+7 -7
View File
@@ -67,10 +67,10 @@ public class TagController(
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <returns>A list of tags matching the search criteria.</returns>
[HttpGet]
public ActionResult<List<TagDto>> GetAll(PagedSearchParametersDto pagedSearch) {
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) {
+13 -15
View File
@@ -83,34 +83,28 @@ namespace Lactose.Controllers {
return Unauthorized("Unauthorized");
}
//TODO: Lower level of access should fill less information of users
logger.LogDebug($"Sending user with id {id}");
User? user = userRepository.Find(id);
User? user = userRepository.FindWithMaintainedPersons(id);
if (user is null) return NotFound();
return Ok(user.ToGetUsersDto());
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
/// <summary>
/// Gets all users.
/// Gets all users (admin/curator only).
/// </summary>
/// <returns>A list of all users.</returns>
[Authorize(Roles = "Admin,Curator")]
[HttpGet]
public ActionResult<List<UserInfoDto>> GetAll() {
logger.LogDebug("Sending Users list");
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
if (authenticatedUser == null) {
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
return Unauthorized("Unauthorized");
}
logger.LogDebug("Sending Users list");
//TODO: Lower level of access should fill less information of users
//if (false) return Unauthorized();
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
}
@@ -176,10 +170,14 @@ namespace Lactose.Controllers {
}
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
if (dto.MaintainedPersonIds != null) {
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
}
}
userRepository.Save();
return Ok(user.ToGetUsersDto());
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
/// <summary>
@@ -203,7 +201,7 @@ namespace Lactose.Controllers {
if (user == null) return NotFound();
userRepository.Delete(user);
user.DeletedAt = DateTime.UtcNow;
//TODO: eventually remove/hide all of the user content if required
userRepository.Save();
+7
View File
@@ -131,5 +131,12 @@
"Description": "Number of assets to process per sub-job batch.",
"Type": 1,
"DisplayType": 2
},
{
"Name": "System Uploader Id",
"Value": "",
"Description": "User ID to attribute scanner-created assets to. Leave empty for no default owner.",
"Type": 0,
"DisplayType": 9
}
]
+8 -4
View File
@@ -8,16 +8,20 @@ EXPOSE 5162
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
ARG SixLaborsLicenseKey
WORKDIR "/src"
COPY ["Lactose/Lactose.csproj", "Lactose/"]
RUN dotnet restore "Lactose/Lactose.csproj"
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore "Lactose/Lactose.csproj"
COPY . .
WORKDIR "/src/Lactose"
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
+2 -2
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;
+2 -2
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;
+22 -3
View File
@@ -1,4 +1,5 @@
using Butter;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
@@ -17,6 +18,7 @@ public sealed class FileSystemCrawlJob : Job {
/// <inheritdoc />
public override JobStatus JobStatus { get; }
IAssetRepository assetRepository;
ISettingsRepository settingsRepository;
string workingPath;
Guid folderId;
ILogger<FileSystemCrawlJob> logger;
@@ -31,17 +33,20 @@ public sealed class FileSystemCrawlJob : Job {
/// <param name="workingPath">The base path to crawl.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="assetRepository">The asset repository.</param>
/// <param name="settingsRepository">The settings repository.</param>
/// <param name="jobManager">The job manager.</param>
public FileSystemCrawlJob(
Guid folderId,
string workingPath,
ILogger<FileSystemCrawlJob> logger,
IAssetRepository assetRepository,
ISettingsRepository settingsRepository,
JobManager jobManager
) {
JobStatus = new(this);
this.logger = logger;
this.assetRepository = assetRepository;
this.settingsRepository = settingsRepository;
this.workingPath = workingPath;
this.folderId = folderId;
this.jobManager = jobManager;
@@ -64,6 +69,12 @@ public sealed class FileSystemCrawlJob : Job {
int steps,
totalSteps;
// Resolve system uploader from settings once
Guid? uploaderId = null;
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
if (Guid.TryParse(uploaderSetting, out var parsed))
uploaderId = parsed;
var files = dir.GetFiles();
logger.LogDebug($"Found {files.Length} files.");
@@ -115,14 +126,21 @@ public sealed class FileSystemCrawlJob : Job {
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
existing.FileSize = file.Length;
existing.UpdatedAt = file.LastWriteTimeUtc;
if (existing.UploadedBy == null && uploaderId.HasValue)
existing.UploadedBy = uploaderId;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
existing.UploadedBy = uploaderId;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
}
continue;
}
var asset = AssetFromPath(file.FullName);
var asset = AssetFromPath(file.FullName, uploaderId);
if (asset == null) continue;
try {
@@ -153,7 +171,7 @@ public sealed class FileSystemCrawlJob : Job {
JobStatus.Complete("Crawl completed successfully.");
}
Asset? AssetFromPath(string filePath) {
Asset? AssetFromPath(string filePath, Guid? uploaderId) {
logger.LogTrace("Loading asset from file path {filePath}", filePath);
// Get the file info
var finfo = new FileInfo(filePath);
@@ -176,7 +194,8 @@ public sealed class FileSystemCrawlJob : Job {
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
IsPubliclyShared = false,
Visibility = EVisibility.Private,
UploadedBy = uploaderId,
CreatedAt = finfo.CreationTimeUtc,
UpdatedAt = finfo.LastWriteTimeUtc,
MimeType = type.Value switch {
+18 -1
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); }
}
-1
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;
-1
View File
@@ -1,5 +1,4 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
+2 -3
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;
-2
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;
-2
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;
+3 -5
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>
+36 -27
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -8,44 +9,52 @@ namespace Lactose.Mapper;
/// </summary>
public static class AlbumMapper {
/// <summary>
/// Maps an Album object to an AlbumFullDto object.
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
/// </summary>
/// <param name="album">The album to map.</param>
/// <returns>An AlbumFullDto object.</returns>
public static AlbumFullDto ToAlbumFullDto(this Album album) => new AlbumFullDto {
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>An AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel, Guid? viewerId = null) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count ?? 0,
Images = album.Assets!.Select(asset => asset.Id).ToList(),
AssetPreviews = album.Assets!.Select(asset => new AlbumAssetPreviewDto {
CoverWidth = album.CoverAsset?.ResolutionWidth,
CoverHeight = album.CoverAsset?.ResolutionHeight,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
DeletedAt = 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>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>An AlbumFullDto object.</returns>
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
Id = album.Id,
Name = album.Title,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
DeletedAt = null,
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
Id = asset.Id,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = asset.OriginalFilename
}).ToList()
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : 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
};
}
+25 -16
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.Asset;
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
@@ -9,41 +10,45 @@ namespace Lactose.Mapper;
/// </summary>
public static class AssetsMapper {
/// <summary>
/// Maps an Asset object to a GetFullAssetDto object.
/// Maps an Asset object to a AssetDto object.
/// </summary>
/// <param name="asset">The asset to map.</param>
/// <param name="accessLevel">The access level of the user.</param>
/// <returns>A GetFullAssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A AssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
Id = asset.Id,
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
AssetType = asset.Type,
IsPublic = asset.IsPubliclyShared,
CreatedAt = asset.CreatedAt,
UpdatedAt = asset.UpdatedAt,
DeletedAt = asset.DeletedAt,
Owner = asset.Owner!.ToGetUsersDto(),
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
MimeType = asset.MimeType,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
FileSize = asset.FileSize,
Duration = asset.Duration ?? 0,
FrameRate = asset.FrameRate ?? 0
FrameRate = asset.FrameRate ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
};
/// <summary>
/// Maps an Asset object to a GetAssetPreviewDto object.
/// Maps an Asset object to a AssetPreviewDto object.
/// </summary>
/// <param name="asset">The asset to map.</param>
/// <returns>A GetAssetPreviewDto object.</returns>
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
/// <param name="accessLevel">The access level of the user.</param>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A AssetPreviewDto object.</returns>
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto {
Id = asset.Id,
MimeType = asset.MimeType,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = asset.OriginalFilename,
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
CosplayerNames = asset.Albums?
@@ -55,14 +60,18 @@ public static class AssetsMapper {
.Where(a => a.PersonOwner != null)
.Select(a => a.PersonOwner!.Id)
.Distinct()
.ToList()
.ToList(),
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : 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>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A collection of AssetPreviewDto objects.</returns>
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel, Guid? viewerId)
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel, viewerId));
}
+14 -15
View File
@@ -1,5 +1,6 @@
using Butter.Dtos.Album;
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -12,9 +13,12 @@ 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>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A PersonDetailedDto.</returns>
public static PersonDetailedDto ToPersonDetailedDto(this Person person) {
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel, Guid? viewerId = null) {
var albums = person.Albums ?? [];
var maintainers = person.Maintainers ?? [];
return new PersonDetailedDto {
Id = person.Id,
@@ -23,9 +27,12 @@ public static class PersonMapper {
ProfileCropX = person.ProfileCropX,
ProfileCropY = person.ProfileCropY,
ProfileCropZoom = person.ProfileCropZoom,
TotalAlbums = albums.Count,
TotalAlbums = person.AlbumTotalCount > 0 ? person.AlbumTotalCount : 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, viewerId)).ToList(),
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
};
}
@@ -33,24 +40,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
};
}
+24 -12
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -8,22 +9,33 @@ namespace Lactose.Mapper;
/// </summary>
public static class UsersMapper {
/// <summary>
/// Maps a User object to a UserInfoDto object.
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
/// when the viewer is an admin or viewing their own profile.
/// </summary>
/// <param name="user">The User object to map.</param>
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
/// <returns>A UserInfoDto object containing the mapped data.</returns>
public static UserInfoDto ToGetUsersDto(this User user) {
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
bool isSelf = viewerId == user.Id;
bool showSensitive = isAdmin || isSelf;
return new UserInfoDto {
Id = user.Id,
Email = user.Email,
Username = user.Username,
CreatedAt = user.CreatedAt,
IsBanned = user.BannedAt != null,
BannedAt = user.BannedAt,
LastLogin = user.LastLogin,
UpdatedAt = user.UpdatedAt,
DeletedAt = user.DeletedAt,
AccessLevel = user.AccessLevel,
Id = user.Id,
Username = user.Username,
Email = showSensitive ? user.Email : null,
CreatedAt = user.CreatedAt,
UpdatedAt = showSensitive ? user.UpdatedAt : null,
LastLogin = showSensitive ? user.LastLogin : null,
BannedAt = showSensitive ? user.BannedAt : null,
DeletedAt = isAdmin ? user.DeletedAt : null,
IsBanned = showSensitive && user.BannedAt != null,
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
};
}
}
@@ -0,0 +1,581 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260712160403_AddAssetsSearchVisibilityIndex")]
partial class AddAssetsSearchVisibilityIndex
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("IsPubliclyShared", "OwnerId")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetsSearchVisibilityIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "IsPubliclyShared", "OwnerId" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
}
}
}
@@ -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
}
}
}
@@ -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", ",,");
}
}
}
@@ -0,0 +1,591 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260714154509_AuthRedesign_VisibilityAccessLevel")]
partial class AuthRedesign_VisibilityAccessLevel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,229 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AuthRedesign_VisibilityAccessLevel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Albums_Users_UserOwnerId",
table: "Albums");
migrationBuilder.DropForeignKey(
name: "FK_Assets_Users_OwnerId",
table: "Assets");
// Remap old EAccessLevel values: old Admin (2) → new Admin (3) first, then old Curator (1) → new Curator (2)
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 3 WHERE \"AccessLevel\" = 2");
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 2 WHERE \"AccessLevel\" = 1");
migrationBuilder.DropTable(
name: "AssetUser");
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Albums_UserOwnerId",
table: "Albums");
// Add Visibility columns with default Protected (1) before dropping IsPubliclyShared,
// so we can map old values via SQL
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "People",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "Assets",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "Albums",
type: "integer",
nullable: false,
defaultValue: 1);
// Map old IsPubliclyShared = true assets back to Public (0)
// Assets that were private default to Protected (1) from the column default
migrationBuilder.Sql("UPDATE \"Assets\" SET \"Visibility\" = 0 WHERE \"IsPubliclyShared\" = true");
migrationBuilder.DropColumn(
name: "IsPubliclyShared",
table: "Assets");
migrationBuilder.DropColumn(
name: "UserOwnerId",
table: "Albums");
migrationBuilder.RenameColumn(
name: "OwnerId",
table: "Assets",
newName: "UploadedBy");
migrationBuilder.RenameIndex(
name: "IX_Assets_OwnerId",
table: "Assets",
newName: "IX_Assets_UploadedBy");
migrationBuilder.CreateTable(
name: "PersonMaintainers",
columns: table => new
{
PersonId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PersonMaintainers", x => new { x.PersonId, x.UserId });
table.ForeignKey(
name: "FK_PersonMaintainers_People_PersonId",
column: x => x.PersonId,
principalTable: "People",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PersonMaintainers_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "Visibility", "UploadedBy" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_PersonMaintainers_UserId",
table: "PersonMaintainers",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Assets_Users_UploadedBy",
table: "Assets",
column: "UploadedBy",
principalTable: "Users",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Assets_Users_UploadedBy",
table: "Assets");
migrationBuilder.DropTable(
name: "PersonMaintainers");
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
migrationBuilder.DropColumn(
name: "Visibility",
table: "People");
migrationBuilder.DropColumn(
name: "Visibility",
table: "Assets");
migrationBuilder.DropColumn(
name: "Visibility",
table: "Albums");
migrationBuilder.RenameColumn(
name: "UploadedBy",
table: "Assets",
newName: "OwnerId");
migrationBuilder.RenameIndex(
name: "IX_Assets_UploadedBy",
table: "Assets",
newName: "IX_Assets_OwnerId");
migrationBuilder.AddColumn<bool>(
name: "IsPubliclyShared",
table: "Assets",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "UserOwnerId",
table: "Albums",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "AssetUser",
columns: table => new
{
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
table.ForeignKey(
name: "FK_AssetUser_Assets_SharedAssetsId",
column: x => x.SharedAssetsId,
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AssetUser_Users_SharedWithId",
column: x => x.SharedWithId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "IsPubliclyShared", "OwnerId" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Albums_UserOwnerId",
table: "Albums",
column: "UserOwnerId");
migrationBuilder.CreateIndex(
name: "IX_AssetUser_SharedWithId",
table: "AssetUser",
column: "SharedWithId");
migrationBuilder.AddForeignKey(
name: "FK_Albums_Users_UserOwnerId",
table: "Albums",
column: "UserOwnerId",
principalTable: "Users",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Assets_Users_OwnerId",
table: "Assets",
column: "OwnerId",
principalTable: "Users",
principalColumn: "Id");
}
}
}
@@ -0,0 +1,597 @@
// <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("20260716185022_AddUnlinkedAssetFilters")]
partial class AddUnlinkedAssetFilters
{
/// <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("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddUnlinkedAssetFilters : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_OriginalFilename_Trgm",
table: "Assets",
column: "OriginalFilename")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_OriginalFilename_Trgm",
table: "Assets");
}
}
}
@@ -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,14 +169,30 @@ 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");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
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 +328,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 +485,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 +495,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 +506,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 +539,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 +586,7 @@ namespace Lactose.Migrations
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
+6 -11
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>
+8 -13
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.
+18
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,17 @@ 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
/// <summary>
/// Gets or sets the total number of albums (before pagination).
/// Populated by <c>FindVisible</c>; ignored by EF Core.
/// </summary>
[NotMapped]
public int AlbumTotalCount { get; set; }
}
+32
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!;
}
+4 -9
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
}
+66 -5
View File
@@ -1,3 +1,5 @@
using System.Net;
using System.Threading.RateLimiting;
using Lactose.Authorization;
using Lactose.Configuration;
using Lactose.Context;
@@ -9,17 +11,13 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Npgsql;
using Pgvector.EntityFrameworkCore;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
//Adds configurations to the builder
builder.Configuration.AddJsonFile("appsettings.json", true, true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true);
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
// CORS: allow the Blazor WASM frontend to call this API from the browser.
@@ -49,6 +47,68 @@ builder.Services.AddCors(options => {
});
builder.Services.ConfigureOptions<SignKeyProvider>();
builder.Services.ConfigureOptions<RateLimitBucketOptionsProvider>();
builder.Services.AddRateLimiter(options => {
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
options.OnRejected = (context, cancellationToken) => {
context.HttpContext.Response.ContentType = "application/json";
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfterValue)
? (int)retryAfterValue.TotalSeconds
: 60;
context.HttpContext.Response.Headers.RetryAfter = retryAfter.ToString();
return new ValueTask(context.HttpContext.Response.WriteAsJsonAsync(new {
error = "rate limit exceeded",
retryAfter
}, cancellationToken));
};
var bucketConfig = builder.Configuration
.GetSection(RateLimitBucketOptions.SectionName)
.Get<RateLimitBucketOptions>() ?? new RateLimitBucketOptions();
options.AddFixedWindowLimiter("global", opt => {
opt.PermitLimit = bucketConfig.Global.PermitLimit;
opt.Window = TimeSpan.FromSeconds(bucketConfig.Global.WindowSeconds);
opt.QueueLimit = bucketConfig.Global.QueueLimit ?? 0;
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.AddFixedWindowLimiter("auth", opt => {
opt.PermitLimit = bucketConfig.Auth.PermitLimit;
opt.Window = TimeSpan.FromSeconds(bucketConfig.Auth.WindowSeconds);
opt.QueueLimit = bucketConfig.Auth.QueueLimit ?? 0;
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.AddFixedWindowLimiter("media_original", opt => {
opt.PermitLimit = bucketConfig.MediaOriginal.PermitLimit;
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaOriginal.WindowSeconds);
opt.QueueLimit = bucketConfig.MediaOriginal.QueueLimit ?? 0;
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.AddFixedWindowLimiter("media_thumb", opt => {
opt.PermitLimit = bucketConfig.MediaThumb.PermitLimit;
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaThumb.WindowSeconds);
opt.QueueLimit = bucketConfig.MediaThumb.QueueLimit ?? 0;
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.AddFixedWindowLimiter("media_preview", opt => {
opt.PermitLimit = bucketConfig.MediaPreview.PermitLimit;
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaPreview.WindowSeconds);
opt.QueueLimit = bucketConfig.MediaPreview.QueueLimit ?? 0;
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
options.AddFixedWindowLimiter("jobs", opt => {
opt.PermitLimit = bucketConfig.Jobs.PermitLimit;
opt.Window = TimeSpan.FromSeconds(bucketConfig.Jobs.WindowSeconds);
opt.QueueLimit = bucketConfig.Jobs.QueueLimit ?? 0;
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
});
string? user = builder.Configuration["DatabaseCredentials:UserID"];
string? password = builder.Configuration["DatabaseCredentials:Password"];
@@ -179,6 +239,7 @@ if (app.Environment.IsDevelopment()) {
}
app.UseCors("MilkStreamPolicy");
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
+157 -10
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,14 +27,82 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
}
/// <inheritdoc />
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150) {
return context.Albums
.Include(a => a.CoverAsset)
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
IQueryable<Album> albumsQuery = context.Albums;
if (!string.IsNullOrEmpty(query))
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
if (unassigned)
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null);
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
};
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)
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
.Where(x => x.Title.Contains(query))
.Skip(page * pageSize)
.Take(pageSize);
.Include(a => a.CoverAsset)
.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, userId);
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 />
@@ -39,13 +110,62 @@ 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 == null) return null;
// R3: Gate album visibility
bool canSeeAlbum = accessLevel switch {
< EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
EAccessLevel.Maintainer when userId.HasValue => album.Visibility <= EVisibility.Protected
|| (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)),
EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
_ => true
};
if (!canSeeAlbum) return null;
if (album.Assets == null) return album;
album.Assets = accessLevel switch {
EAccessLevel.Admin => [.. album.Assets.OrderBy(a => a.OriginalFilename)],
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).OrderBy(a => a.OriginalFilename).ToList(),
EAccessLevel.Maintainer when userId.HasValue => [.. FilterAssetsForMaintainer(album, userId.Value).OrderBy(a => a.OriginalFilename)],
_ => album.Assets.Where(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
)).OrderBy(a => a.OriginalFilename).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);
@@ -57,6 +177,33 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
/// <inheritdoc />
public void MergeAlbums(Guid destId, List<Guid> sourceIds) {
var dest = context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.FirstOrDefault(a => a.Id == destId);
if (dest == null) return;
var sources = context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.Where(a => sourceIds.Contains(a.Id))
.ToList();
var existingAssetIds = dest.Assets?.Select(a => a.Id).ToHashSet() ?? [];
foreach (var source in sources) {
if (source.Assets == null) continue;
foreach (var asset in source.Assets) {
if (existingAssetIds.Add(asset.Id))
dest.Assets?.Add(asset);
}
}
context.Albums.RemoveRange(sources);
}
/// <inheritdoc />
public void Remove(Album album) => context.Albums.Remove(album);
+225 -8
View File
@@ -1,19 +1,58 @@
using Butter.Types;
using Lactose.Context;
using Lactose.Mapper;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Pgvector.EntityFrameworkCore;
using System.Collections;
using System.Data;
using System.Text;
namespace Lactose.Repositories;
/// <inheritdoc />
public class AssetRepository(LactoseDbContext context) : IAssetRepository {
public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository> logger) : IAssetRepository {
/// <inheritdoc />
public Asset? Find(Guid id) => context.Assets.Find(id);
/// <inheritdoc />
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
IQueryable<Asset> query = context.Assets
.AsSplitQuery()
.Include(a => a.Uploader)
.Where(a => a.Id == id);
return accessLevel switch {
>= EAccessLevel.Admin => query.FirstOrDefault(),
EAccessLevel.Curator => query.FirstOrDefault(a =>
a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer when userId.HasValue => query
.Include(a => a.Albums)
.FirstOrDefault(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId ||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
context.PersonMaintainers.Any(pm =>
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
)),
_ => query.FirstOrDefault(a =>
a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Private && userId.HasValue && 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 +60,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 +89,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 +177,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, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null) {
var query = context.Assets.AsQueryable();
if (type.HasValue)
query = query.Where(a => a.Type == type.Value);
@@ -143,6 +185,33 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
query = query.Where(a => a.CreatedAt >= from.Value);
if (to.HasValue)
query = query.Where(a => a.UpdatedAt <= to.Value);
if (unlinked)
query = query.Where(a => a.Albums!.Count == 0);
if (folderId.HasValue)
query = query.Where(a => a.FolderId == folderId.Value);
if (uploadedBy.HasValue)
query = query.Where(a => a.UploadedBy == uploadedBy.Value);
if (!string.IsNullOrEmpty(search))
query = query.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
// 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 && userId.HasValue && a.UploadedBy == userId)
))
};
total = query.Count();
@@ -151,11 +220,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 +233,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();
}
@@ -193,4 +265,149 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
.Take(count)
.Select(a => a.OriginalPath)
.ToList();
/// <inheritdoc />
public List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true) {
logger.LogTrace("GetFolderGroups: userId={UserId}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", userId, accessLevel, unlinkedOnly);
var query = context.Assets
.Where(a => a.DeletedAt == null);
if (unlinkedOnly)
query = query.Where(a => a.Albums!.Count == 0);
if (accessLevel < EAccessLevel.Curator) {
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
var count = query.Count(a => a.UploadedBy == userId);
return [new Butter.Dtos.Asset.AssetGroupDto {
FolderId = null,
Name = "My Uploads",
AssetCount = count
}];
}
return [];
}
var groups = query
.GroupBy(a => a.FolderId)
.Select(g => new {
FolderId = g.Key,
AssetCount = g.Count()
})
.ToList();
var folderNames = context.Folders
.Where(f => groups.Select(g => g.FolderId).Contains(f.Id))
.ToDictionary(f => f.Id, f => f.BasePath);
var result = groups
.Where(g => g.AssetCount > 0)
.Select(g => new Butter.Dtos.Asset.AssetGroupDto {
FolderId = g.FolderId,
Name = g.FolderId.HasValue && folderNames.TryGetValue(g.FolderId.Value, out var name) ? name : "No Folder",
AssetCount = g.AssetCount
})
.OrderByDescending(g => g.AssetCount)
.ToList();
return result;
}
/// <inheritdoc />
public Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true) {
var folder = context.Folders.Find(folderId);
if (folder == null)
return null;
var basePath = folder.BasePath.TrimEnd('/') + "/";
var prefix = string.IsNullOrEmpty(currentPath) ? basePath : basePath + currentPath.TrimEnd('/') + "/";
var likePattern = prefix + "%";
var baseQuery = context.Assets
.Where(a => a.FolderId == folderId)
.Where(a => a.DeletedAt == null);
if (unlinkedOnly)
baseQuery = baseQuery.Where(a => a.Albums!.Count == 0);
baseQuery = accessLevel switch {
EAccessLevel.Admin => baseQuery,
EAccessLevel.Curator => baseQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer => baseQuery.Where(a => a.UploadedBy == userId),
_ => baseQuery.Where(a => false)
};
if (!string.IsNullOrEmpty(search))
baseQuery = baseQuery.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
var dirs = GetDirectoryNames(prefix, prefix.Length, folderId, userId, accessLevel, search, unlinkedOnly);
var levelQuery = baseQuery
.Where(a => EF.Functions.Like(a.OriginalPath, likePattern))
.Where(a => !EF.Functions.Like(a.OriginalPath, prefix + "%/%"));
var totalAtLevel = levelQuery.Count();
var assetEntities = levelQuery
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
.OrderBy(a => a.OriginalFilename)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
var assetDtos = assetEntities.Select(a => a.ToAssetPreviewDto(accessLevel, userId)).ToList();
return new Butter.Dtos.Asset.AssetBrowseResultDto {
Directories = dirs,
Assets = assetDtos,
CurrentPath = currentPath,
TotalAssetCount = totalAtLevel
};
}
private List<Butter.Dtos.Asset.DirectoryEntryDto> GetDirectoryNames(string prefix, int prefixLen, Guid folderId, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly) {
var sql = new System.Text.StringBuilder();
sql.Append("SELECT DISTINCT SPLIT_PART(SUBSTRING(a.\"OriginalPath\", LENGTH({0}) + 1), '/', 1) AS \"Name\" ");
sql.Append("FROM \"Assets\" a ");
if (unlinkedOnly)
sql.Append("LEFT JOIN \"AlbumAsset\" aa ON a.\"Id\" = aa.\"AssetsId\" ");
sql.Append("WHERE a.\"FolderId\" = {1} ");
sql.Append("AND a.\"DeletedAt\" IS NULL ");
if (unlinkedOnly)
sql.Append("AND aa.\"AssetsId\" IS NULL ");
sql.Append("AND a.\"OriginalPath\" LIKE {2} ");
sql.Append("AND a.\"OriginalPath\" LIKE {3} ");
sql.Append("AND LENGTH(a.\"OriginalPath\") > LENGTH({0}) ");
var parms = new List<object> { prefix, folderId, prefix + "%", prefix + "%/%" };
int p = 4;
switch (accessLevel) {
case EAccessLevel.Curator:
sql.Append($"AND (a.\"DeletedAt\" IS NULL OR a.\"UploadedBy\" = {{{p}}}) ");
parms.Add(userId ?? Guid.Empty);
p++;
break;
case EAccessLevel.Maintainer:
sql.Append($"AND a.\"UploadedBy\" = {{{p}}} ");
parms.Add(userId ?? Guid.Empty);
p++;
break;
case < EAccessLevel.Curator:
sql.Append("AND FALSE ");
break;
}
if (!string.IsNullOrEmpty(search)) {
sql.Append($"AND a.\"OriginalFilename\" ILIKE '%' || {{{p}}} || '%' ");
parms.Add(search!);
}
sql.Append("ORDER BY \"Name\"");
return context.Database
.SqlQueryRaw<Butter.Dtos.Asset.DirectoryEntryDto>(sql.ToString(), parms.ToArray())
.ToList();
}
}
-1
View File
@@ -1,6 +1,5 @@
using Lactose.Context;
using Lactose.Models;
using Lactose.Services;
namespace Lactose.Repositories;
+28 -9
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.
@@ -65,6 +70,14 @@ public interface IAlbumRepository : IDisposable {
/// <returns>A collection of albums created or updated within the specified date range.</returns>
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
/// <summary>
/// Merges multiple source albums into a destination album.
/// All assets from source albums are moved to the destination, then source albums are hard-deleted.
/// </summary>
/// <param name="destId">The ID of the destination album.</param>
/// <param name="sourceIds">The IDs of the source albums to merge.</param>
public void MergeAlbums(Guid destId, List<Guid> sourceIds);
/// <summary>
/// Removes an album from the database.
/// </summary>
@@ -72,12 +85,18 @@ public interface IAlbumRepository : IDisposable {
public void Remove(Album album);
/// <summary>
/// Searches for albums based on a query string.
/// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping.
/// </summary>
/// <param name="query">The search query string.</param>
/// <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>
/// <returns>A list of album IDs that match the search query.</returns>
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150);
/// <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>
/// <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);
}
+57 -9
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,48 @@ 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>
/// <param name="unlinked">If true, filters for assets not assigned to any album.</param>
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</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, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null);
/// <summary>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
/// "My Uploads" group with their own unlinked upload count.
/// </summary>
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="unlinkedOnly">If true (default), only counts assets not assigned to any album.</param>
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true);
/// <summary>
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
/// and the assets located directly at that level with pagination.
/// </summary>
/// <param name="folderId">The root folder ID to browse within.</param>
/// <param name="currentPath">The current path relative to the folder root (empty string for root).</param>
/// <param name="page">The zero-based page number for asset pagination.</param>
/// <param name="pageSize">The number of assets per page.</param>
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
/// <param name="unlinkedOnly">If true (default), only includes assets not assigned to any album.</param>
/// <returns>A browse result containing subdirectories and a page of assets at the current level.</returns>
Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true);
}
+7 -3
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);
}
+57 -4
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>
@@ -53,6 +68,20 @@ public interface IPersonRepository : IDisposable {
/// <param name="accessLevel">The current user's access level.</param>
IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel);
/// <summary>
/// Searches for people based on a query string with optional sorting and pagination.
/// Respects access-level filtering for regular users.
/// </summary>
/// <param name="query">The search query string to filter by name.</param>
/// <param name="page">The page number for pagination (default is 0).</param>
/// <param name="pageSize">The number of results per page (default is 30).</param>
/// <param name="sortBy">The field to sort by (e.g. "name", "created", "albums"). When <c>null</c>, defaults to name ascending.</param>
/// <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 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.
/// </summary>
@@ -60,9 +89,33 @@ public interface IPersonRepository : IDisposable {
/// <returns>A collection of matching people.</returns>
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
/// <summary>
/// Merges multiple source people into a destination person.
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
/// </summary>
/// <param name="destId">The ID of the destination person.</param>
/// <param name="sourceIds">The IDs of the source people to merge.</param>
void MergePeople(Guid destId, List<Guid> sourceIds);
/// <summary>
/// Removes a person from the database.
/// </summary>
/// <param name="person">The person to remove.</param>
void Remove(Person person);
/// <summary>
/// Checks whether a user is a maintainer of a specific person.
/// </summary>
/// <param name="userId">The user ID to check.</param>
/// <param name="personId">The person ID to check.</param>
/// <returns><see langword="true"/> if the user is a maintainer of the person.</returns>
bool IsMaintainerOf(Guid userId, Guid personId);
/// <summary>
/// Sets the list of user IDs that maintain this person.
/// Replaces any existing maintainer assignments.
/// </summary>
/// <param name="personId">The person ID.</param>
/// <param name="userIds">The user IDs to assign as maintainers.</param>
void SetMaintainers(Guid personId, IEnumerable<Guid> userIds);
}
+12 -3
View File
@@ -45,8 +45,17 @@ public interface IUserRepository : IDisposable {
User? FindByUsername(string username);
/// <summary>
/// Delete a user from the database.
/// Get the user with their maintained persons eagerly loaded.
/// </summary>
/// <param name="user"></param>
void Delete(User user);
/// <param name="id">The user ID.</param>
/// <returns>Returns the user with maintained persons if found.</returns>
User? FindWithMaintainedPersons(Guid id);
/// <summary>
/// Sets the list of person (cosplayer) IDs that a user maintains.
/// Replaces any existing maintainer assignments.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="personIds">The person IDs to assign.</param>
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
}
+1 -1
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();
}
+47 -19
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 && userId.HasValue && 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",
+220 -18
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,34 +17,136 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
/// <inheritdoc />
public Person? Find(Guid id) => context.People
.AsSplitQuery()
.Include(p => p.ProfileAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)!.ThenInclude(a => a.SharedWith)
.Include(p => p.Albums)!.ThenInclude(a => a.UserOwner)
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == id);
/// <inheritdoc />
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
var person = context.People
.AsSplitQuery()
.Include(p => p.ProfileAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == id);
if (person == null) return null;
// R4: Gate person visibility
bool canSeePerson = accessLevel switch {
< EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
EAccessLevel.Maintainer when userId.HasValue => person.Visibility <= EVisibility.Protected
|| context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id),
EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
_ => true
};
if (!canSeePerson) return null;
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.AlbumTotalCount = person.Albums.Count;
person.Albums = person.Albums
.Skip(albumPage * albumPageSize)
.Take(albumPageSize)
.ToList();
return person;
}
/// <inheritdoc />
public IEnumerable<Person> GetAll() =>
context.People.Include(p => p.Albums);
/// <inheritdoc />
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) {
if (accessLevel != EAccessLevel.User)
return GetAll();
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) =>
accessLevel switch {
// Admin and curator.
>= EAccessLevel.Curator => GetAll(),
var all = context.People
.Include(p => p.Albums)!
.ThenInclude(a => a.Assets)!
.ThenInclude(a => a.SharedWith)
.ToList();
EAccessLevel.Maintainer => context.People
.Include(p => p.Albums)
.Where(p => p.Visibility <= EVisibility.Protected
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id))
.ToList(),
return all.Where(p => p.Albums != null && p.Albums.Any(a =>
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
asset.IsPubliclyShared ||
asset.OwnerId == userId ||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
))));
}
EAccessLevel.User => context.People
.Include(p => p.Albums)
.Where(p => p.Visibility <= EVisibility.Protected)
.ToList(),
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
};
/// <inheritdoc />
public Person? FindByName(string name) =>
@@ -59,6 +163,104 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
/// <inheritdoc />
public void Remove(Person person) => context.People.Remove(person);
/// <inheritdoc />
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}%"));
// Apply visibility filter
peopleQuery = accessLevel switch {
EAccessLevel.Admin or EAccessLevel.Curator => peopleQuery,
EAccessLevel.Maintainer => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id)),
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
};
// 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 SetMaintainers(Guid personId, IEnumerable<Guid> userIds) {
var existing = context.PersonMaintainers.Where(pm => pm.PersonId == personId);
context.PersonMaintainers.RemoveRange(existing);
foreach (var userId in userIds) {
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
}
}
/// <inheritdoc />
public void MergePeople(Guid destId, List<Guid> sourceIds) {
var dest = context.People
.AsSplitQuery()
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == destId);
if (dest == null) return;
// Reassign albums
var albums = context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToList();
foreach (var album in albums)
album.PersonOwnerId = destId;
// Reassign faces
var faces = context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToList();
foreach (var face in faces)
face.PersonId = destId;
// Reassign maintainers
var existingMaintainerIds = dest.Maintainers?.Select(m => m.Id).ToHashSet() ?? [];
var sourceMaintainers = context.PersonMaintainers
.Where(pm => sourceIds.Contains(pm.PersonId))
.ToList();
foreach (var pm in sourceMaintainers) {
if (!existingMaintainerIds.Contains(pm.UserId))
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = destId, UserId = pm.UserId });
}
// Hard-delete source people
var people = context.People.Where(p => sourceIds.Contains(p.Id)).ToList();
context.People.RemoveRange(people);
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
}
+1 -3
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
+21 -14
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)
+13 -2
View File
@@ -9,7 +9,7 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
public void Dispose() => context.Dispose();
/// <inheritdoc />
public IEnumerable<User> GetAll() => context.Users.AsEnumerable();
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
/// <inheritdoc />
public void Insert(User user) => context.Users.Add(user);
@@ -27,5 +27,16 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
/// <inheritdoc />
public void Delete(User user) => context.Users.Remove(user);
public User? FindWithMaintainedPersons(Guid id) =>
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
/// <inheritdoc />
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
context.PersonMaintainers.RemoveRange(existing);
foreach (var personId in personIds) {
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
}
}
}
-3
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;
+12 -3
View File
@@ -1,4 +1,3 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Jobs;
@@ -159,9 +158,19 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
return;
}
// if the scheduled scan list isn't populated, fetch active folders directly from DB
var targetFolders = folders;
if (targetFolders == null || targetFolders.Count == 0) {
using var scope = serviceProvider.CreateScope();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
}
// queue a crawl job for each active folder
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
logger?.LogInformation("Queued file system crawl jobs.");
targetFolders.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
if (targetFolders.Count > 0)
logger?.LogInformation("Queued {Count} file system crawl job(s).", targetFolders.Count);
else
logger?.LogWarning("No active folders to scan.");
}
/// <summary>
-2
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;
-3
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;
+2166 -478
View File
File diff suppressed because it is too large Load Diff

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