745 Commits

Author SHA1 Message Date
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
74882904a0 style: soften accent gradient stops (5%/10% instead of 15%/30%) 2026-07-10 21:01:31 +02:00
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
b58066d563 feat(cropper): circle handles with accent color, black border, drop shadow 2026-07-10 20:53:07 +02:00
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
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
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
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
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
dddc4baa5d feat(ui): replace default favicon with MilkyShot logo SVG 2026-07-10 20:33:46 +02:00
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
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
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
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
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
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
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
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
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
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
31d917c56e fix: remove soft-delete from Person model
Person had a DeletedAt property and all queries filtered by it, but the
controller was hard-deleting via context.People.Remove(). This removes
the DeletedAt property, drops the column via migration, and cleans up
all stale DeletedAt filters in queries.
2026-07-10 18:49:59 +02:00
bc71887726 Merge pull request 'fix: batch-processing subjob status reporting + redesign jobs page UI (#76)' (#77) from fix/preview-subjob-status-reporting into develop
Reviewed-on: #77
Reviewed-by: Fastwind <fastwind@noreply.localhost>
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 15:48:54 +00:00
89c91b2291 refactor: replace tuple + custom header with ChildrenResponse DTO
- Add ChildrenResponse DTO (Children + Since) in Butter.Dtos.Jobs
- JobManager.GetChildren returns List<JobStatusDto> only (no tuple),
  active children filtered via LINQ over activeJobs.Values
- JobsController generates Since timestamp, returns ChildrenResponse
  instead of setting x-delta-timestamp header
- JobsService deserializes ChildrenResponse instead of parsing headers
2026-07-10 17:40:28 +02:00
4a6bc2f8aa Merge branch 'develop' into fix/preview-subjob-status-reporting 2026-07-10 17:37:34 +02:00
ac5b75c0d6 fix: suppress nullable warnings on Include chain in GetAllVisible 2026-07-10 17:36:52 +02:00
7e1a2a7ed6 Merge branch 'develop' into fix/preview-subjob-status-reporting 2026-07-10 15:36:11 +00:00
e98aa6167e fix: translate GetAllVisible to client-side eval for SharedWith navigation
EF Core cannot translate three-level nested Any through many-to-many
navigation (Person→Albums→Assets→SharedWith). Load data with Include
chain and filter visible persons in-memory instead.
2026-07-10 17:28:13 +02:00
17ddd1c332 feat: hide cosplayers with no visible content from regular users
- IPersonRepository.GetAllVisible filters persons by asset visibility
- For User level: only returns persons that have at least one album
  with a non-deleted asset that is publicly shared, owned by the user,
  or explicitly shared with the user
- For Admin/Curator: returns all persons (unchanged behavior)
- PersonController.GetAll extracts user data and uses GetAllVisible
2026-07-10 17:21:47 +02:00
b0b65e808d refactor: extract password strength UI into shared PasswordField component
Create PasswordField.razor in Components/Shared with:
- Two-way binding for Password, ConfirmPassword, OldPassword
- Strength bar with color-coded segments
- Requirements checklist (min length, upper/lower, number/special)
- Passwords-do-not-match validation
- Public IsValid property for parent submit-button checks
- ShowOldPassword toggle for change-password vs register flows

Register.razor and User.razor now use the shared component,
eliminating duplicated password validation logic.
2026-07-10 15:26:26 +02:00
0a99178416 feat: add password strength validation to registration page
Copy password validation UI from User.razor — strength bar,
requirements checklist, passsword match check, submit disabled
until all requirements are met.
2026-07-10 15:23:02 +02:00
91cb420743 fix: update albums logged-out message to match home/cosplayers 2026-07-10 15:21:01 +02:00
f4a62eafc2 fix: update home logged-out message to match cosplayers wording 2026-07-10 15:19:39 +02:00
dc5ac98c68 feat: return-url login redirects + keepalive + cosplayers fix
- NavigationExtensions: centralized NavigateToLogin/NavigateToLoginForce
  helpers that include returnUrl query parameter
- LoginService: SemaphoreSlim guard on Reauthenticate to prevent
  concurrent refresh races (timer + API handler)
- MainLayout: expiry-driven keepalive loop — parses JWT, schedules
  reauthentication 2 min before token expiry; starts on login,
  stops on logout/force-logout
- Login page: reads returnUrl from query via SupplyParameterFromQuery,
  navigates there (or /) on success
- All NavigateTo(/login) call sites updated to use returnUrl:
  App, MainLayout, NavMenu, Jobs, Settings, AdminUsers
- Cosplayers: fix eternal spinner when logged out (set isLoading=false),
  update message to 'No media available' with hint about public content
2026-07-10 15:16:59 +02:00
b741f331e9 revert: restore docker-compose.yml LactoseBaseUrl to 192.168.50.100 2026-07-10 13:23:00 +02:00
04a528ce55 fix: eager-fetch children for all roots including past jobs
Remove status filter in EagerFetchMissing so past (completed) roots
also get their child status icons on load.
2026-07-10 13:17:17 +02:00
ac7833a069 fix: re-render after eager child fetch so icons appear immediately 2026-07-10 13:15:02 +02:00
27eec196a0 fix: eagerly fetch children on load and hide sub-jobs placeholder
- EagerFetchMissing fetches children for active roots on parameter set
  so the summary never shows the placeholder
- ChildSummary returns null instead of "sub-jobs..." when not fetched
2026-07-10 13:12:37 +02:00
c6d8d2c746 feat: poll children for all active roots and fix segment progress bar colors
- JobTree polls children for all Level 0 active roots (not just expanded),
  so segment progress bars update on collapsed rows too
- Add PollChildren helper to deduplicate fetch logic
- OnExpanded only stops refresh when no active roots remain
- Store UtcNow as fallback when server x-delta-timestamp is missing
- Fix segment colors: share ToCssClass between single and stacked bars,
  use text-bg-* classes for proper Bootstrap 5.3 progress-bar override
- Replace status words with Bootstrap icons in child summary
  (e.g. "15 Running" → "15 🔃") for compact display
2026-07-10 13:09:56 +02:00
71ac41086c fix: pass null since on first child fetch instead of DateTime.MinValue
GetValueOrDefault on missing key returns DateTime.MinValue (year 0001),
causing every poll to send since=0001-01-01 instead of omitting it.
2026-07-10 12:56:00 +02:00
da3a2a0a4b feat: add delta merge logic to job tree components
- JobsService.GetChildren returns (Children, Since) tuple
  with x-delta-timestamp parsed from response headers
- JobTree stores per-parent _sinceTimestamps
- ChildRefreshLoop fetches deltas with since param
- MergeChildren replaces/inserts entries by ID
- Jobs.razor FetchChildren signature updated for delta
2026-07-10 12:49:47 +02:00
0203644b3e feat: implement delta-based children endpoint with ?since parameter
- JobManager.GetChildren now accepts optional 'since' parameter
- Returns only children with LastChange/ModifiedAt > since
- Includes seenIds dedup between active and past children
- Returns response timestamp for next poll
- Controller sets x-delta-timestamp header
2026-07-10 12:49:43 +02:00
dd43d069d5 feat: add ModifiedAt to JobRecord with delta query support
- Add ModifiedAt (DateTime?) to JobRecord
- Auto-set on Insert/Update in repository
- Add GetChildrenModifiedSince to interface + implementation
- EF Core migration AddModifiedAtToJobRecord
2026-07-10 12:49:40 +02:00
7dc5eff14b feat: add LastChange tracking to JobStatus
Set LastChange = UtcNow on every status/progress transition
for delta-based children sync.
2026-07-10 12:49:36 +02:00
314ac766e4 fix: metadata icon visible in job list rows 2026-07-10 12:30:36 +02:00
7456850dec fix: metadata icon in dropdown uses text-light for visibility 2026-07-10 12:29:53 +02:00
30de7d0784 style: mobile launch button cyan for better contrast 2026-07-10 12:29:36 +02:00
3a892f6f7a style: custom job color palette based on purpose, avoid red
Scan: cyan (discovery)
Metadata: gray (technical data)
Thumbnails: blue (core functionality)
Previews: green (final visual output)
PHash: purple (identity/fingerprinting)
Integrity: white (clean health check)
CreatePersons: amber (warm, people)
CreateAlbums: teal (collection, organization)

Launch buttons use filled variants (btn-*) instead of outline.
Custom btn-job-purple and btn-job-teal CSS classes added to
Jobs.razor.css with proper hover/active states.
2026-07-10 12:26:51 +02:00
b8be4f7314 style: replace badge backgrounds with colored icons + matching launch buttons
- Removed job type badge spans from desktop and mobile JobRow
- Colored job-type icons instead (text-info/secondary/primary/etc)
- Desktop launch buttons use matching btn-outline-* colors per job type
- Mobile launch dropdown changed from dropup to dropdown to avoid
  clipping into navbar
- Dropdown item icons colored per job type
2026-07-10 12:22:45 +02:00
5514bf89d9 fix: remove double-count of failed assets in master Done handler
The Interlocked.Increment(ref failedAssets) for Failed subjobs
caused double-counting: a subjob with N/12 failed assets would
add 1 (failed subjob) + 12 (individual assets) = 13, exceeding
the total asset count. Removed the Increment since subjob's
failedAssets is already the accurate per-asset count.
2026-07-10 12:17:50 +02:00
326966b774 feat(ui): redesign jobs page with tabs, search, filters, and mobile cards
- Unified Active/Past tabbed view replacing separate sections
- Search by name/message, filter by job type and status
- Status summary strip with colored counts
- Job type badges with distinct colors per EJobType
- Status labels next to status dots
- Compact mobile card layout (d-md-none)
- Tighter tree spacing with lighter indentation
- Scoped Jobs.razor.css extracted from inline styles

Refs #76
2026-07-10 12:15:55 +02:00
8ad378b649 fix: batch-processing subjobs report correct status on asset failures
Subjobs (SlaveJob) now check failedAssets after processing:
- All failed -> JobStatus.Fail()
- Some failed -> JobStatus.CompleteWithErrors()
- None failed -> JobStatus.Complete()

Also fixed race condition in master Done handler:
- Replaced non-atomic failedAssets += sub.failedAssets
- with Interlocked.Add(ref failedAssets, sub.failedAssets)

Affects: PreviewJob, ThumbnailJob, MetadataJob, PHashJob

Refs #76
2026-07-10 12:05:45 +02:00
41f977d262 Merge pull request 'feat: add album select/edit mode with bulk deletion' (#75) from feature/albums-edit-mode into develop
Reviewed-on: #75
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 09:56:56 +00:00
5b04f39bfe feat(ui): add album select/edit mode with bulk deletion 2026-07-10 11:50:39 +02:00
71dba46565 feat(api): add BulkDeleteAlbumsAsync for bulk album deletion 2026-07-10 11:50:36 +02:00
c16749bdf6 Merge pull request 'Album detail page — multi-select/edit mode with cosplayer link (#63)' (#74) from feature/album-detail-edit-mode into develop
Reviewed-on: #74
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 09:43:29 +00:00
1d107d2de6 chore: revert docker-compose.yml to match develop 2026-07-10 11:42:50 +02:00
5b445cbe89 fix(mobile): keep back button inline with title, collapse admin actions into dropdown 2026-07-10 11:40:18 +02:00
3482f5adde style: cosplayer link dims on hover instead of blue/underline 2026-07-10 11:33:46 +02:00
3dd73d3a63 style: borderless edit pencil with hover reveal on header 2026-07-10 11:31:41 +02:00
3b4a06709a refactor: center-align header, album name on top, cosplayer smaller below 2026-07-10 11:29:37 +02:00
5ffb114532 fix: use bold (700) instead of semi-bold for reliable font rendering 2026-07-10 11:22:33 +02:00
590aef2ece style: consistent inline typography for album title and cosplayer name with dash separator 2026-07-10 11:22:08 +02:00
e3a2a78b21 style: match cosplayer link font size to h3, remove icon, keep regular weight 2026-07-10 11:19:23 +02:00
d0bf3b7ec7 refactor: move cosplayer link to same line as album name 2026-07-10 11:15:46 +02:00
2af3995ae7 style: style cosplayer link with inherited color and person icon 2026-07-10 11:15:24 +02:00
02bdba3891 feat(ui): album detail multi-select edit mode, cosplayer link, move edit button (#63)
- Replace non-functional cosplayer badge with clickable link to cosplayer page
- Move Edit button next to album title in info section
- Add select mode: select/deselect image tiles with check overlays
- Add bulk removal of selected assets (unlink from album)
- Add Select / Done / Remove N action buttons replacing Edit in actions area
- Add scoped CSS for cosplayer link style and tile selection overlays
2026-07-10 11:13:15 +02:00
8ba1c9b6b2 Merge pull request 'User profile & admin users page redesign (#72)' (#73) from feature/user-pages-redesign into develop
Reviewed-on: #73
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 09:03:47 +00:00
e188cc35e3 feat(ui): show avatar initial in navbar instead of generic icon 2026-07-10 10:53:54 +02:00
75cbe2717f fix: default sort users table by role descending (admin on top) 2026-07-10 10:52:56 +02:00
4c63e80b6c feat(ui): rewrite admin users page with sortable table, search, and modal-based editing 2026-07-10 10:50:34 +02:00
354198c413 feat(ui): redesign user profile page with avatar initials and edit profile modal 2026-07-10 10:50:30 +02:00
56c3f9c8e1 Merge pull request 'Add password change field on user's own page (#46)' (#71) from feature/password-change-self-service into develop
Reviewed-on: #71
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 08:42:59 +00:00
b59c3b68ec fix: wire profile icon in navbar to user profile page 2026-07-10 10:37:35 +02:00
47eef8aad0 fix: center profile card on page 2026-07-10 10:33:55 +02:00
d4e0af2c29 refactor(ui): complete user profile page redesign with card layout 2026-07-10 10:32:18 +02:00
9e4b0ecb27 fix: auto-close modal on password change success, add page-level success message 2026-07-10 10:29:07 +02:00
64220d0818 fix: propagate server-side error messages from UpdateUserAsync to UI 2026-07-10 10:23:27 +02:00
55af7dccec refactor(ui): replace inline password form with modal 2026-07-10 10:22:39 +02:00
3bddd4385d feat: add server-side password policy validation (admin bypass) 2026-07-10 10:22:36 +02:00
d8927d90c2 docs: add granular commit convention to AGENTS.md 2026-07-10 10:15:25 +02:00
afad073d0c feat: add password change UI with strength bar and validation on user profile page 2026-07-10 10:15:05 +02:00
15b3fcedf1 feat: enforce old-password verification on own password change, allow admin override 2026-07-10 10:15:03 +02:00
3f56f9afdc feat: add OldPassword field to UserUpdateDto for self-service password change 2026-07-10 10:15:01 +02:00
c541c45e9b docs: add Gitea repo owner note to AGENTS.md 2026-07-10 10:14:58 +02:00
459079d708 Merge pull request 'refactor(auth): remove redundant SendWithRefreshAsync, enforce JwtTokenRefresher as sole handler' (#69) from feature/remove-sendwithrefreshasync into develop
Reviewed-on: #69
Reviewed-by: Fastwind <fastwind@noreply.localhost>
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 23:47:08 +00:00
REDCODE
220581579b refactor(auth): remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
- Remove SendWithRefreshAsync method from AuthServiceBase (now dead code)
- Replace all 15 SendWithRefreshAsync call sites in 7 services with
  direct Client.XxxAsync calls
- Remove unused using System.Net from AuthServiceBase
- Update AGENTS.md to document JwtTokenRefresher as the sole token
  refresh mechanism
2026-07-10 01:12:31 +02:00
REDCODE
254e98b37b refactor(auth): extract AuthServiceBase, replace retry with re-auth on 401
- Move AuthServiceBase from ServiceBase.cs into its own file
- Replace FetchLoggedUserAsync retry loop with re-authentication on
  failure, then single recursive retry
- Fix override keyword order in App.razor OnInitializedAsync
- Clean up MainLayout.razor inject (unqualified type name)
2026-07-10 01:01:17 +02:00
REDCODE
652143d738 feat: allow users to update own albums and curators to update any album
- Users: changed from blanket Forbid to ownership check (can update own albums)
- Curators: changed from ownership check to full access (can update any album)
2026-07-09 23:46:02 +02:00
5a74edd29f Merge pull request 'CSS/UI fixes for cosplayers page and image previewer' (#67) from feature/CSS-UI-Fixes into develop
Reviewed-on: #67
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 21:31:52 +00:00
REDCODE
7f447077b0 feat: make album name in image previewer an anchor element for native navigation 2026-07-09 23:27:18 +02:00
REDCODE
b813c32756 fix(#62): lock body scroll when modal forms open to prevent position:fixed breakage on mobile
- PersonForm and AlbumForm now call lockBodyScroll/unlockBodyScroll via JS
  when Show state changes, preventing the body scroll context from
  interfering with position:fixed on mobile browsers
- added minimal mobile CSS: align-items:flex-start, padding-top, and
  reduced max-height:85vh on modal-content for small viewports
- safe disposal: unlocks body scroll on DisposeAsync if modal was open
2026-07-09 22:54:57 +02:00
REDCODE
cdd2e9a1ef fix(#62): use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile 2026-07-09 22:49:24 +02:00
REDCODE
64bee6c92a revert: restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident 2026-07-09 22:45:21 +02:00
REDCODE
870ebb06f8 fix(#62): move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
fix(AlbumCard): add null check for Album.Person before rendering link

The ::deep combinator in CosplayerDetail.razor.css cannot target
.modal-overlay rendered by the PersonForm child component because
Blazor's CSS isolation applies the child's scope attribute to its
root, and ::deep only strips the parent's scope — it doesn't match
elements bearing only the child's scope.
2026-07-09 22:43:27 +02:00
REDCODE
2f7cd3d7da fix: resolve UI issues #61, #62, #66
- #61: wrap standalone fallback div in cosplayer-card-img-wrap on
  Cosplayers page so placeholder icon gets proper sizing/positioning
- #62: add mobile breakpoint rule for modal-overlay so edit form
  appears centered on small screens instead of at page bottom
- #66: make cosplayer name in ImagePreview, AlbumDetail, AlbumCard,
  and Home asset tiles a clickable link to /cosplayer/{id}
- add CosplayerIds to AssetPreviewDto and populate via mapper for
  linked navigation from the home page image previewer
2026-07-09 22:36:07 +02:00
18ddcf681f Merge pull request 'feat: migrate to UTC timestamps — remove legacy Npgsql behavior' (#65) from feature/utctimezone into develop
Reviewed-on: #65
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 19:57:43 +00:00
MrFastwind
ede759b50b feat: migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
- Remove EnableLegacyTimestampBehavior switch from Program.cs
- Add ConfigureConventions to DbContext pinning DateTime → timestamptz
- Add migration to convert all 17 DateTime columns across 5 tables
  (Albums, Assets, Users, People, JobRecords) with safe AT TIME ZONE 'UTC'
- Add Roslyn analyzer (MS001-MS004) enforcing UTC-only DateTime at compile time
  with code fix provider for auto-replacement
- Fix pre-existing DateTime.Now in AuthController.cs:119
- Add AllowMissingPrunePackageData to work around .NET 10 SDK issue
2026-07-09 21:43:46 +02:00
9f74bc91a9 chore: Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts 2026-07-09 18:46:16 +02:00
8922dbcc57 Merge branch 'fix/mobile-ui-responsive-fixes' into develop
Closes #49

Integrates responsive mobile UI fixes on top of the cosplayers pages
(#55) without conflicts. All 19 files from PR #49 merged cleanly; the
two overlapping files (NavMenu.razor, Albums.razor) were auto-merged
correctly with the cosplayers features preserved.
2026-07-09 18:34:02 +02:00
095a2bdce0 Merge pull request 'feat(ui): cosplayers pages — browse and discover cosplayers' (#55) from feature/cosplayers-pages into develop
Reviewed-on: #55
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 16:00:02 +00:00
b807e4b636 feat: infinite scroll for albums on cosplayer detail page 2026-07-09 17:53:42 +02:00
9c17843dec fix: cache random banner covers in field, not computed on every render 2026-07-09 17:48:12 +02:00
c1aafc4223 refactor: replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint 2026-07-09 17:42:37 +02:00
a8d06959b5 feat: person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select 2026-07-09 17:07:43 +02:00
dbbb25da32 fix: create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param 2026-07-09 16:46:18 +02:00
9f180742cd fix(test): update name assertion to match renamed person 2026-07-09 16:40:32 +02:00
e8c1a80264 test: all tests create their own data — create album returns ID, all mutations use self-created resources 2026-07-09 16:38:43 +02:00
7e16c77cb6 test: use real album from search for update test, not zero-GUID fallback 2026-07-09 16:36:01 +02:00
8f6a4396db test: add album update test without removePerson field 2026-07-09 16:34:47 +02:00
34fbbc91a0 chore(test): remove Swagger test — disabled in container environments 2026-07-09 16:32:47 +02:00
9be49ac9c6 fix(test): use response.body directly instead of JSON.parse (already parsed object) 2026-07-09 16:32:00 +02:00
c96b22fe62 fix(tests): fix pre-existing auth route tests and handle empty unassigned albums gracefully 2026-07-09 16:30:53 +02:00
7f50d3c3e2 test(api): add comprehensive http tests for all person and album endpoints 2026-07-09 16:26:11 +02:00
191cba4946 fix(pr): implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery] 2026-07-09 16:22:51 +02:00
a38f627ca4 feat(dto): add RemovePerson flag to AlbumUpdateDto for explicit person unlinking 2026-07-09 15:56:20 +02:00
f31b753182 refactor(dto): move Unassigned filter into PagedSearchParametersDto 2026-07-09 15:54:02 +02:00
1377d02520 fix: prevent album title from being overwritten when only setting Person in update DTO 2026-07-09 15:19:03 +02:00
bac1ad0afc fix(build): remove stray semicolon in onclick lambda 2026-07-09 15:16:01 +02:00
fa434b180d fix(api): allow unlinking person from album by directly assigning null PersonOwnerId 2026-07-09 15:12:34 +02:00
4d169e6303 docs: add API reuse convention to AGENTS.md — prefer optional query params over new endpoints 2026-07-09 15:10:05 +02:00
e1d7822b7c refactor(api): fold unassigned album filter into search endpoint via query param 2026-07-09 15:09:09 +02:00
cc611828b9 feat(ui): unassigned album selector in PersonForm, Add Albums button in CosplayerDetail 2026-07-09 15:07:19 +02:00
9aead51481 feat(ui): add New Cosplayer button on list page with create endpoint and form 2026-07-09 15:00:52 +02:00
67d00efb10 fix(js): resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images 2026-07-09 14:56:47 +02:00
b71fef147a fix(js): skip resize on first mousemove if mouse hasn't moved from mousedown position 2026-07-09 14:50:58 +02:00
8004dc2b43 fix(js): use correct opposite corners for TL/TR/BL resize handles 2026-07-09 14:44:35 +02:00
c9f08ea48e fix(ui): use pixel-exact transform positioning for avatar display instead of background-size formula 2026-07-09 14:43:52 +02:00
52b88a3ba4 fix(js): store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save 2026-07-09 14:32:09 +02:00
e39d753a85 fix(js): adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle 2026-07-09 14:15:17 +02:00
83025065b3 fix(ui): correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position 2026-07-09 14:08:59 +02:00
0c65e9de1b fix(css): show full image in cropper viewport (contain instead of cover) 2026-07-09 14:02:18 +02:00
1559bf081e fix(js): corner-drag resize with fixed opposite corner, proper visual rate matching 2026-07-09 13:58:07 +02:00
79305646dd fix(js): handle positions relative to square, not viewport 2026-07-09 13:53:48 +02:00
b9ca9f7b45 fix(ui): 4 corner handles on square boundary for resizing instead of single circular handle 2026-07-09 13:29:05 +02:00
5aed999671 fix(ui): random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static 2026-07-09 13:25:37 +02:00
56b80038a3 fix(ui): always render ProfileCropper in tree, use OnParametersSet to trigger JS init 2026-07-09 13:20:54 +02:00
3d3c59d58b chore: remove unused banner-cell-empty CSS class 2026-07-09 13:17:32 +02:00
76bbdbd2f4 fix(ui): remove empty filler cells from banner mosaic, let overlay cover gaps naturally 2026-07-09 13:17:22 +02:00
22b15c387b feat(ui): redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support 2026-07-09 13:15:19 +02:00
bcfb838b5e fix(js): set onload before src to handle cached images 2026-07-09 13:07:23 +02:00
63a16392d7 feat(ui): add circular profile picture cropper with drag-to-pan 2026-07-09 13:03:08 +02:00
d3ef5ff4c1 feat(backend): add ProfileCropX/Y to Person model for profile picture cropping 2026-07-09 13:01:01 +02:00
f45ef7c8b2 fix(ui): cosplayer cards now match album card style — image fills square, text overlay at bottom 2026-07-09 12:54:57 +02:00
8228cc2b1a refactor(css): remove SCSS, switch to plain CSS with Bootstrap custom properties 2026-07-09 12:51:58 +02:00
6b9234dfac refactor(css): move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS 2026-07-09 12:49:06 +02:00
f150f6d863 fix(ui): add scoped modal CSS to PersonForm for proper overlay rendering 2026-07-09 12:47:44 +02:00
7521f15b63 fix(auth): force logout on any refresh failure, redirect to login 2026-07-09 12:45:37 +02:00
88eb22d454 fix(ui): larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle 2026-07-09 12:42:25 +02:00
c1c6a4cead feat(css): add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles 2026-07-09 12:31:28 +02:00
845751fca1 fix(css): move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors 2026-07-09 12:20:28 +02:00
2dc0153b10 fix(backend): add [FromQuery] to person list endpoint to fix 415 error 2026-07-09 12:16:24 +02:00
7143af14f0 feat(client): add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs 2026-07-09 12:13:12 +02:00
51a3d0d19b feat(client): add PersonService client methods and PersonForm component 2026-07-09 12:11:43 +02:00
53b1d0fbf3 feat(client): extract AlbumCard reusable component, update Albums.razor 2026-07-09 12:10:44 +02:00
20fd6a930b test(api): add person endpoint tests to WepApiTest.http 2026-07-09 12:09:40 +02:00
8f0af8d544 feat(backend): implement person detail, update, and delete endpoints 2026-07-09 12:03:33 +02:00
16161ebfb8 feat(backend): add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto 2026-07-09 12:02:36 +02:00
ca224feafb feat(backend): eager-load ProfileAsset and Albums in PersonRepository 2026-07-09 12:01:46 +02:00
0909842c37 feat(dtos): update Person DTOs with ProfileAssetId and album previews 2026-07-09 12:01:22 +02:00
9c785423a9 feat(backend): add ProfileAssetId to Person model + migration 2026-07-09 12:00:56 +02:00
REDCODE
d1f545bb59 fix(ui): restore progress bar visibility on job rows
d-none d-sm-flex hides the progress bar but also changes its
display from inline to flex on desktop, breaking visibility.
Remove the hide-class — the progress bar is always shown.
2026-07-09 03:31:49 +02:00
REDCODE
584e24d751 fix(ui): replace inline save indicator with Bootstrap toast notification
SettingBox base now uses JS interop to call showToast() which
renders a Bootstrap Toast at the bottom-right corner. Removed
all SaveIndicator RenderFragments and IsSaving/IsSaved state
from the four child components. Toast auto-hides after 2s.
2026-07-09 02:58:01 +02:00
REDCODE
ea25eca9fa fix(ui): right-align number inputs, let them grow to fill card width
Replace w-auto with text-end+flex-grow-1 so the number input
right-aligns its value text and adapts its width on narrow
setting cards.
2026-07-09 02:55:03 +02:00
REDCODE
b8f0eea848 fix(ui): stack setting card body vertically — description above, control + indicator below
Remove d-flex justify-content-between from all card bodies.
Description always sits above the input/control row, and the save
indicator sits inline on the same row as the control. Removed
mobile-specific stacking workaround and save-indicator CSS class
since layout shift is impossible with this structure.
2026-07-09 02:44:10 +02:00
REDCODE
b417f37780 fix(ui): prevent save indicator from shifting adjacent controls
Always render the save indicator container with a fixed 5rem min-width,
only toggle the inner text/color. Previously the conditional Rendering
caused the element to appear-disappear-resize, shifting nearby inputs.
2026-07-09 02:39:39 +02:00
REDCODE
f52b684722 fix(ui): prevent layout shift from save indicator width change
Add .save-indicator CSS class with fixed min-width (4rem) and
text-align:right. Apply to all four setting box types so
'Saving...'→'Saved' text change doesn't shift adjacent
controls.
2026-07-09 02:27:00 +02:00
REDCODE
5c15f89f59 fix(ui): stack setting card description and control vertically on mobile
On ≤576px viewports, switch card body from side-by-side to
stacked layout: description text on top, input/slider
control below. Prevents text clipping and control overflow.
2026-07-09 02:26:24 +02:00
REDCODE
f2e2997988 fix(ui): reduce password input width on AdminUsers to prevent cell overflow
Tighten password inline-edit input from 80-120px to 60-100px
so it fits alongside confirm/cancel buttons in narrow
mobile table cells.
2026-07-09 02:26:04 +02:00
REDCODE
1589c7fe29 fix(ui): change AdminUsers mobile actions dropdown from dropup to dropdown
dropup opens upward and clips off-screen for the first table rows.
Use standard dropdown (downward) which works correctly for
all row positions.
2026-07-09 02:25:42 +02:00
REDCODE
5e23180c7c fix(ui): prevent JobRow horizontal overflow on mobile portrait
Hide non-essential columns (child summary, dates/progress bar,
elapsed time) on mobile using d-none d-sm-inline/d-sm-flex.
Add flex-wrap to main row for overflow safety on very
narrow viewports.
2026-07-09 02:25:24 +02:00
MrFastwind
f8eafed093 fix(milkystream): Fix sorted masonry for albums 2026-07-09 02:08:35 +02:00
MrFastwind
debd0dd4af Revert "refactor: replace custom masonry JS with Bootstrap grid + Masonry.js approach"
This reverts commit 04d425b88d.
2026-07-09 02:07:16 +02:00
REDCODE
04d425b88d refactor: replace custom masonry JS with Bootstrap grid + Masonry.js approach
Use Bootstrap's recommended pattern: .row.g-2 container with col-* grid
classes for item widths, Masonry.js with percentPosition:true for
vertical positioning. Extracted infinite scroll observers into separate
infiniteScroll.js. Removed custom column-width JS, imagesLoaded CDN,
and the old masonryObserver.js.
2026-07-09 01:51:38 +02:00
REDCODE
ca21dc3dd9 fix(frontend): removed faulty d-flex tag on main object 2026-07-09 00:57:58 +02:00
f4d4b6a75a Merge branch 'develop' into fix/mobile-ui-responsive-fixes 2026-07-08 21:33:43 +00:00
REDCODE
4ff3b24df8 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-08 23:33:05 +02:00
REDCODE
1da7236d5d fix(frontend): sorts setting by alphabetical 2026-07-08 23:32:46 +02:00
MrFastwind
df14b8b227 fix (lactose): remove check from authentication endpoint
ref: #36
2026-07-08 23:04:51 +02:00
MrFastwind
15508013a3 Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	Lactose/Controllers/UserController.cs
2026-07-08 22:39:20 +02:00
MrFastwind
cb47336230 fix(Lactose): Change to use UtcNow, even if alredy working without timeline 2026-07-08 22:38:05 +02:00
76cabe058b fix(ui): collapse AdminUsers table columns and actions on mobile (#48)
- Hide Email and Created columns below md breakpoint (d-none d-md-table-cell)
- Hide Last Login below lg breakpoint (d-none d-lg-table-cell)
- Replace action btn-group with dropdown menu on mobile (d-sm-none)
- Reduces table from 7 visible columns to 4 on mobile
2026-07-08 19:51:48 +02:00
33175ffdcb fix(ui): responsive layout fixes for mobile portrait mode (closes #48)
- Settings: stack sidebar vertically on mobile, fix grid minmax 380->280px, use dvh
- AlbumDetail: add mobile breakpoints for grid, restructure header actions
- ImagePreview: switch info bar to column layout on mobile
- User profile: fix col-2/col-2 to responsive col-5/col-7 breakpoints
- AdminUsers: wrap inline editing controls with flex-wrap on mobile
- Jobs: collapse 8 action buttons into dropdown on mobile
- Albums: add flex-wrap to header, hide 'New Album' text on mobile
- NavMenu: responsive authenticated area (column on mobile, btn-group-sm)
- Empty-state messages: m-5 -> m-2 m-sm-5
- Footer: hide on mobile (d-none d-sm-flex)
- Login: remove orphaned 'Remember me' checkbox
2026-07-08 19:49:11 +02:00
54cf5eff2b feat(stats): add percentage and stale counters to completeness cards
- Show percentage of total assets on missing metadata/thumbnail/preview/phash cards
- Add stale counts for thumbnails and previews (generated at wrong resolution vs current settings)
- Add h-100 to all completeness cards for consistent row heights
2026-07-08 19:45:09 +02:00
94bba45f45 Merge branch 'feature/admin-users-page' into develop
Admin user management page (#45)
2026-07-08 19:18:29 +02:00
23ef7e3ded fix(auth): detect banned/deleted user on page load via InitializeAsync
- LoginService.InitializeAsync: after fetching LoggedUser, check IsBanned
  and DeletedAt. If banned/disabled, set ForceLogoutReason, call Logout
  to clear state, and return false
- App.razor: after InitializeAsync completes, check ForceLogoutReason
  and navigate to /login if set. This handles the timing issue where
  MainLayout isnt created yet when the event fires on page load

Refs #45
2026-07-08 19:15:49 +02:00
a84d93882d fix(auth): show ban/disabled message on login and force-logout on refresh
- AuthController: fix HTTP 100 Continue → 403 Forbidden for banned/disabled
  users in both Login and RefreshToken endpoints
- LoginService.Login: return error message from AuthResultDto on failure
  instead of a generic message
- LoginService.Reauthenticate: parse AuthResultDto on refresh failure;
  if "User is banned" or "User is disabled", trigger force logout via
  new ForceLogout event, setting ForceLogoutReason property
- MainLayout: subscribe to ForceLogout event, redirect to /login
- Login.razor: display specific error messages (ban reason, force-logout
  reason) instead of generic "Login failed"

Refs #45
2026-07-08 19:10:53 +02:00
0ec6888809 fix(admin): show inline errors instead of replacing entire page
- Moved error/success alerts inside the main content block (else branch)
  so validation errors (empty email, username, etc.) do not hide the table
- Added dismissible error alert with ClearError method
- Added clearError method to code-behind
- Fixed broken @if(_showCreateForm) guard for create form

Refs #45
2026-07-08 19:00:08 +02:00
977d2325fa feat(admin): add inline editing for username, email, and password
- Username and email columns now have pencil-to-edit with confirm/cancel
- Actions column gains a key button to change password inline
- Validation: empty username/email/password rejected with error message
- Unified CancelFieldEdit clears all edit modes at once

Refs #45
2026-07-08 18:56:52 +02:00
20975a2872 fix(ui): remove broken onfocusout handler from role edit dropdown
- onfocusout on the wrapper span was firing when focus moved to child buttons,
  cancelling the edit before the button onclick handlers ran
- Confirm/cancel now works via explicit button clicks only
- Starting a new edit on a different row implicitly cancels the previous one

Refs #45
2026-07-08 18:54:49 +02:00
61cf886174 fix(user): hash password in Create endpoint instead of storing raw
- UserController.Create was assigning userCreateDto.Password directly without
  hashing via IPasswordHasher, unlike the Update method and AuthController
- Moved duplicate email/username checks before User object instantiation

Refs #45
2026-07-08 18:53:46 +02:00
bf638ca995 fix(admin): add confirm/cancel for role change and prevent admin self-demotion
- Role dropdown now uses explicit confirm (checkmark) / cancel (X) buttons
  instead of auto-committing on selection
- Clicking outside the dropdown cancels the edit (onfocusout)
- Selecting the same role as current is a no-op
- Admins cannot demote themselves below Admin level

Refs #45
2026-07-08 18:52:27 +02:00
a599648d87 feat(admin): add user management page for admins
- Add AdminUsers.razor page at /Users with user listing, ban/unban toggle,
  access level change, delete, and create user functionality
- Extend UserService with GetAllUsersAsync, GetUserByIdAsync,
  UpdateUserAsync, DeleteUserAsync, CreateUserAsync methods
- Add Users link to NavMenu dropdown for admin users
- Fix ban/unban bug in UserController where BannedAt was only updated
  if the user had been previously banned

Closes #45
2026-07-08 18:47:01 +02:00
e5fc1a017e chore: remove debug auth logging middleware from Program.cs 2026-07-08 18:08:48 +02:00
44394daebf fix(AlbumController): exclude deleted assets from search visibility for #11
Album search for User role used asset.IsPubliclyShared and
SharedWith checks without filtering soft-deleted assets. A deleted
public asset would cause its album to appear in search results.

Added asset.DeletedAt == null checks to both filter paths.

Refs: #11
2026-07-08 17:49:08 +02:00
ac76aab168 fix(AssetController): correct search visibility filter for #11
The User-role asset search filter had two bugs:
1. DeletedAt != null was negated — shared non-deleted assets were
   excluded, while shared deleted assets passed the filter.
2. DeletedAt check was applied only to the shared leg — public
   deleted assets leaked through the filter.

Corrected to: not-deleted AND (public OR shared), matching the
logic used by GET /api/asset/{id}.

Refs: #11
2026-07-08 17:47:43 +02:00
4f56acb320 fix(controllers): tighten asset visibility and media access checks
- AlbumController GET {id}: exclude soft-deleted assets for User role
  (public/owned and shared paths) and Curator role (viewed all assets)
- AssetController GET: add explicit parentheses to DeletedAt operator
  grouping in search filter to match intent
- MediaController: deny Curators direct file access to deleted assets;
  extract CanAccessAssetDirectly helper method

Refs: #11
2026-07-08 17:44:03 +02:00
70fe586fc6 fix: force square aspect-ratio in grid mode over inline style
- Inline style aspect-ratio:W/H overrides CSS aspect-ratio:1
- !important needed to force square crop in grid tiles
2026-07-08 16:44:40 +02:00
58bd4ee766 fix: override masonry inline styles with !important for grid mode
- JS masonry sets position/width/left/top/height as inline styles on tiles
- Grid CSS uses !important to override inline styles for proper layout
- Force StateHasChanged on mode switch to ensure re-render
2026-07-08 16:36:46 +02:00
3d789d68e7 refactor: remove list view, add grid/masonry toggle to album detail
- Albums page: drop list mode, keep masonry and grid
- AlbumDetail page: add masonry/grid toggle in header
- Masonry mode uses JS masonryLayout, grid mode uses CSS grid with square tiles
2026-07-08 16:32:30 +02:00
9327a739f8 feat: view mode selector for albums page (masonry/grid/list)
- Button group in header: masonry (columns), grid (square tiles), list (rows)
- CSS-driven mode switching via album-grid.masonry/.grid/.list classes
- List mode: horizontal card with thumbnail left, info right
2026-07-08 16:19:47 +02:00
de731095cc fix: use CSS columns for albums page, keep JS masonry only for album detail
- Albums page: remove masonryLayout JS call, use column-count CSS (reliable, no ordering needed)
- AlbumDetail: keeps JS masonryLayout for left-to-right alphabetical ordering with varying heights
2026-07-08 16:18:03 +02:00
696ea613de revert: switch album detail back to masonry layout 2026-07-08 14:52:38 +02:00
9b17b6b231 fix: faulty migration that could cause lactose crash on migration apply 2026-07-08 14:47:54 +02:00
b1d5356220 feat: justified gallery layout for album detail page
- New justifiedLayout.apply JS function: uniform row heights, automatic widths
- Reads aspect-ratio from tile inline style, fills each row to container width
- AlbumDetail calls justifiedLayout in OnAfterRenderAsync
- Albums page stays on masonryLayout
2026-07-08 14:19:19 +02:00
37dad0d1d7 fix: JS-based masonry for left-to-right item flow with varying heights
- Add masonryLayout.apply JS function using shortest-column-next algorithm
- Both Albums and AlbumDetail call masonryLayout on each render
- Items flow left-to-right in alphabetical order, each keeping its own aspect-ratio height
- Remove CSS column-count in favor of absolute positioning by JS
2026-07-08 14:10:51 +02:00
c5e41f2c05 fix: unlock body scroll on navigation, reorder masonry for left-to-right flow
- Home.DisposeAsync now unlocks body scroll so navigation to other pages works
- Albums and AlbumDetail unlock body scroll on init as defense
- Reorder items with column-major index mapping so CSS columns display left-to-right
- Switch both pages back to column-count CSS for reliable masonry
- Add pageLayout.columnCount JS helper matching media query breakpoints
2026-07-08 14:05:31 +02:00
d45a915ba9 fix: use flexbox with align-items:flex-start for album detail grid
- CSS Grid forces uniform row heights, cropped landscape images next to portraits
- Flexbox with align-items:flex-start preserves each tile's own aspect-ratio height
- align-content:flex-start ensures container height grows for scrolling
2026-07-08 13:58:37 +02:00
b7d1912fc6 fix: switch album detail grid from flexbox to CSS Grid
- Flexbox was collapsing with aspect-ratio tiles, preventing scroll
- CSS Grid with repeat(N, 1fr) fills left-to-right with proper row heights
2026-07-08 13:54:20 +02:00
d51efe8b3c fix: remove content-visibility and contain-intrinsic-size from album detail tiles
- These properties overrode aspect-ratio in flexbox layout, causing all tiles to render at 200px regardless of actual dimensions
2026-07-08 13:52:56 +02:00
48a97beb74 fix: remove preventDefault from album links so they actually navigate
- @onclick:preventDefault was canceling the browser's link navigation
- @onclick:stopPropagation alone suffices to prevent tile click
2026-07-08 13:51:33 +02:00
e94406932b fix: switch album detail page to row-based flexbox masonry
- Replace column-count CSS masonry with flex-wrap layout
- Tiles flow left-to-right first, then wrap to next row
- Responsive column counts: 2/3/4/5 per row
2026-07-08 13:48:36 +02:00
4fe1fb0da2 feat: make album names clickable links in home tile hover and preview
- Add AlbumIds parallel list to AssetPreviewDto
- Populate AlbumIds in AssetsMapper
- Home tile hover: album names link to /albums/{id} with onclick:stopPropagation
- ImagePreview ChildContent: album names link to /albums/{id}
- CSS: tile-info-album and preview-info-album support <a> tags with hover underline
2026-07-08 13:48:17 +02:00
dcc8eba931 fix: scoped CSS deep selectors for preview info text and filename binding
- Use ::deep in ImagePreview.razor.css so preview-info-cosplayer/album styles pierce CSS isolation boundary into ChildContent
- Add missing @ prefix to Filename parameters so they evaluate as C# expressions instead of literal strings
2026-07-08 13:36:55 +02:00
4c90e553af feat: auto-assign album cover from first alphabetically-sorted asset
- Add one-time migration SQL to set CoverAssetId for existing albums
- AlbumController.Create auto-sets cover from first asset if not specified
- CreateAlbumsJob assigns cover images after asset assignment phase
- Stats: add AlbumsMissingCover and CosplayersMissingCover counters
2026-07-08 13:20:11 +02:00
b8f75f9529 refactor: extract ImagePreview shared component from Home and AlbumDetail
- Create ImagePreview.razor component with self-contained url generation, keyboard handling, and download
- Add Filename to AssetPreviewDto and AlbumAssetPreviewDto DTOs
- Update AssetsMapper and AlbumMapper to populate FileName
- Remove duplicate preview overlay HTML/CSS/code from Home.razor and AlbumDetail.razor
- Fix AssetDto.FileName hiding inherited member from AssetPreviewDto
2026-07-08 13:18:23 +02:00
d10e062373 feat: add albums list page and album detail page with inline preview
- Albums.razor: masonry card grid with infinite scroll, create button for Admin/Curator
- AlbumDetail.razor: asset masonry grid with inline preview overlay (prev/next, download)
- AlbumForm.razor: create/edit modal with title, person dropdown, cover image picker
- AssetPicker.razor: modal showing album assets for cover selection
- Fix NavMenu Albums link to /albums
- Add albumObserver JS for independent infinite scroll
2026-07-08 13:11:40 +02:00
3c1d2ef14b feat: add AlbumService and PersonService to WASM client
- AlbumService: CRUD operations for albums via API
- PersonService: GetAllAsync for person dropdown
- Register both as Scoped in DI
2026-07-08 13:11:31 +02:00
b58c8ebce8 fix: album controller Save calls, person list endpoint, media empty path guard
- Add missing Save() calls to AlbumController Update and Delete
- Wire CoverAssetId in AlbumController Create/Update/BulkUpdate
- Sort album assets by OriginalFilename in Get endpoint
- Implement PersonController.GetAll returning non-deleted people
- Guard MediaController GetThumb/GetPreview against empty paths (return 404)
2026-07-08 13:11:27 +02:00
d602d2d34b feat: add CoverAssetId to albums with resolution data in full DTO
- Add CoverAssetId FK and CoverAsset navigation to Album model
- Add AlbumAssetPreviewDto with resolution/thumbnail info
- Extend AlbumPreviewDto/FullDto with PersonName, CoverAssetId, AssetCount
- Add CoverAssetId to AlbumCreateDto and AlbumUpdateDto
- Update AlbumMapper to populate new fields from eager-loaded relations
- Add EF migration for CoverAssetId column
2026-07-08 13:11:20 +02:00
de938e6d1e docs: update AGENTS.md with verified infrastructure gotchas 2026-07-08 12:28:39 +02:00
949dec9bdc feat: add data completeness indicators to statistics page 2026-07-08 12:21:25 +02:00
89c4bfe13f feat: implement seed-based deterministic random ordering for home page
Replace the fixed GUID-based ordering (OrderBy(a => a.Id)) with a
client-generated seed that produces a truly different random order on
each page refresh. The seed is passed to the server and used to
deterministically shuffle all asset IDs via a hash function, ensuring
consistent pagination with no duplicates across pages within a session.

Also fix LoadPreviousPage showing a spinner when already at page 1 by
adding loadedMinPage <= 1 to the guard clause.
2026-07-08 12:10:37 +02:00
7a0b72584c fix: propagate cancellation tokens through linked child job CTS
Replace manual childJobs.ForEach(j => j.Cancel()) with linked
CancellationTokenSources. Master jobs now call LinkParentToken(token)
on each child, so when the master's CTS fires, all children (running
and queued) receive cancellation automatically via their linked token.

Cancelling a master job now:
- Immediately signals all Running children via their linked CTS
- Ensures queued children, when started by JobManager, receive an
  already-cancelled token and bail without processing
- Eliminates the collection-modification-during-ForEach race condition

Affects: PHashJob, ThumbnailJob, PreviewJob, MetadataJob,
FileSystemCrawlJob.
2026-07-08 12:00:27 +02:00
ebfab19df4 Add autorestart for services 2026-07-08 09:56:45 +02:00
77630f4e83 fix: remove scroll position correction from LoadPreviousPage for smoother scroll-up
The snapshotFirstTileTop/restoreScrollAfterPrepend dance caused a double
visual correction (content shifts down then scroll snaps back) making
scroll-up feel janky compared to scroll-down. Removing it lets new content
naturally push existing tiles down when prepended, matching the expected
scroll-up behavior where content above fills in seamlessly.
2026-07-08 03:40:45 +02:00
16619f9631 feat: add bidirectional infinite scroll (up and down)
- Replace single bottom observer with observeBottom/observeTop dual observers
- Track loadedMinPage/loadedMaxPage to know which pages are in memory
- hasMoreUp flag controls whether scrolling up should load pages
- LoadPreviousPage prepends pages at index 0 with scroll position preservation
  using snapshotFirstTileTop/restoreScrollAfterPrepend JS helpers
- Eviction balanced: scroll down evicts from top, scroll up evicts from bottom
- Both observers registered on first render, methods return early when idle
2026-07-08 03:33:51 +02:00
9829e02a38 fix: download button now fetches blob and triggers actual file download
Previously the download link just navigated cross-origin to the media URL.
Now uses JS interop to fetch the file as a blob, create an object URL,
and trigger a browser download via a hidden anchor element.
2026-07-08 03:27:18 +02:00
632ca0d3e4 feat: add full-size view and download buttons in preview overlay
- Add 'View full size' (opens original in new tab) and 'Download' buttons
- Positioned opposite the cosplayer/album names in the preview info bar
- Info bar always visible so buttons are accessible even without metadata
- Uses original media endpoint with JWT token for authenticated access
2026-07-08 03:24:37 +02:00
4d436620a5 fix: move preview info text below image instead of overlaying on bottom border 2026-07-08 03:16:57 +02:00
78da2b7207 feat: add cosplayer and album name overlays on home page
- Add hover overlay on masonry tiles showing cosplayer and album names
- Add info bar in full-screen preview overlay with same metadata
- Hide overlays entirely when no data is available (jobs not yet run)
- CSS: gradient overlay at tile bottom with smooth fade-in on hover
- CSS: matching info bar at preview bottom
2026-07-08 03:07:06 +02:00
d4a8b272f2 feat: extend AssetPreviewDto with album and cosplayer names
- Add AlbumNames and CosplayerNames fields to AssetPreviewDto
- Include Albums and PersonOwner in AssetRepository.GetAssets() query
- Populate new fields in AssetsMapper.ToAssetPreviewDto()
2026-07-08 03:06:03 +02:00
bb01f6526e fix: remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats 2026-07-08 02:58:00 +02:00
866d01c4e2 fix: rewrite TopTags query to avoid EF Core collection navigation translation error 2026-07-08 02:36:38 +02:00
2159eaec08 fix: populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
UsersMapper.ToGetUsersDto() was not copying LastLogin, BannedAt, or
UpdatedAt from the User entity to the UserInfoDto, causing the client
to always receive null for these fields. Added the missing mappings.

Also added null fallback ('Never') for LastLogin display on the User
page to prevent NullReferenceException when the value has not been set.
2026-07-08 02:32:12 +02:00
bf3e5e619b fix: add JobBatchSize to DefaultSettings.json to prevent reset on restart 2026-07-08 02:25:18 +02:00
REDCODE
458cf70081 feat: add thousands separators to job status numbers 2026-07-08 02:03:40 +02:00
REDCODE
3d81b7a97e fix: skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run 2026-07-08 01:50:39 +02:00
REDCODE
03f52cfb5b feat: add statistics page with aggregate asset/user/system stats via fast DB queries 2026-07-08 01:49:50 +02:00
REDCODE
4b0a6df5dd fix: per-batch progress messages and counting bug in album/person jobs 2026-07-08 01:30:33 +02:00
REDCODE
744bc9e4d0 feat: improve logging and progress reporting for album/person jobs 2026-07-08 01:23:25 +02:00
REDCODE
76ba4fd74a configuration changes 2026-07-08 01:08:00 +02:00
REDCODE
132e3d2930 fix: make job cancellation work for all job states and types
Job.Cancel() now handles queued jobs (cts null) by setting Canceled status and firing events directly. ContinueWith checks t.IsCanceled to catch ThrowIfCancellationRequested patterns. cts is now disposed after job completion. CreatePersonsJob, CreateAlbumsJob, and IntegrityCheckJob now catch OperationCanceledException and set proper Canceled state. IntegrityCheckJob also gained cancellation checks between each step and within iteration loops.
2026-07-08 01:07:25 +02:00
REDCODE
7554dbac9c fix: cancel past refresh loop when poll rate is set to Off 2026-07-08 01:06:38 +02:00
REDCODE
defd97e7f0 feat: add stacked progress bar for parent jobs with sub-job breakdown
Parent jobs with fetched sub-jobs now show a multi-color stacked
progress bar instead of the single parent-progress bar. Segments
represent sub-job status distribution: completed (success),
running (primary), waiting (info), failed (danger), with-errors
(warning), and queued (primary for visibility in dark theme).

ProgressBar gets an optional Segments parameter for stacked mode.
JobRow accepts Children and computes status-weighted segments.
JobTree passes cached children to JobRow.
2026-07-08 00:20:46 +02:00
REDCODE
6c9a608ce6 fix: propagate cancellation to sub-jobs in batch processing jobs
MetadataJob, ThumbnailJob, PreviewJob, and PHashJob previously only
canceled the master job's own token, leaving already-enqueued sub-jobs
running indefinitely. Added child-job tracking with proper cancellation
propagation at both master cancellation exit points, matching the
existing pattern in FileSystemCrawlJob.
2026-07-08 00:20:40 +02:00
MrFastwind
dc1e44f2be chore(dev): fix ovveride PM Compose deploy 2026-07-07 23:27:57 +02:00
923aaa6865 feat: add CreatePersons and CreateAlbums jobs 2026-07-07 20:33:30 +02:00
53fe2cc3d5 removed unused run configs 2026-07-07 20:05:45 +02:00
970f99db54 fix: wire MaxConcurrentJobs to database setting on startup and runtime changes 2026-07-07 19:56:41 +02:00
96f431fb41 feat: add PreviewJob and upgrade thumbnails to WebP
- Add PreviewJob (WebP) — same master/sub-job pattern as ThumbnailJob
- Convert ThumbnailJob from JPEG to WebP with configurable quality
- Add ThumbnailQuality/PreviewQuality settings (configurable in settings UI)
- Add PreviewSize, ThumbnailFormat, PreviewFormat fields to Asset model
- Add format-aware repo queries for auto-regeneration of stale JPEGs
- Fix MIME types in MediaRepository/MediaController to return image/webp
- Wire PreviewJob into JobScheduler, JobMapper, JobsController
- Expose quality settings on Settings page (Jobs group, range sliders)
- Add 'Make Previews' button on Jobs page, reorder to scan/metadata/thumb/preview/phash/integrity
2026-07-07 19:41:51 +02:00
604f0e33a0 refactor: make LoginService singleton, centralize auth state and persistence
- Register LoginService as singleton in WASM (was scoped)
- Add InitializeAsync() with retry logic for user profile fetch on boot
- LoginService now owns localStorage persistence (not components)
- Login() auto-persists auth and fetches LoggedUser atomically
- Logout() clears localStorage internally
- Reauthenticate() re-fetches LoggedUser if null after token refresh
- Simplify App.razor, Login.razor, NavMenu.razor by removing direct localStorage calls
2026-07-07 19:18:50 +02:00
6d75843dcd feat: make navbar sticky during infinite scroll 2026-07-07 18:43:54 +02:00
0cb5d097d6 update gitignore 2026-07-07 18:38:48 +02:00
0549be6d42 Merge branch 'feature/masonryLayoutInHome' into develop 2026-07-07 18:37:45 +02:00
5cd16a7416 build: upgrade to .NET 10 — packages, Docker images, and Swagger fix
- Bump all NuGet packages to 10.0.x (ASP.NET, EF Core, Npgsql, Swashbuckle)
- Bump Pgvector.EntityFrameworkCore to 0.3.0, Microsoft.IdentityModel.JsonWebTokens to 8.19.1
- Pin Microsoft.OpenApi to 2.7.5, strip security scheme from SwaggerGen (API removed in v2)
- Docker images already at 10.0
2026-07-07 18:25:18 +02:00
5573be1502 Merge remote-tracking branch 'origin/feature/masonryLayoutInHome' into feature/masonryLayoutInHome 2026-07-07 18:09:04 +02:00
99ef2db822 chore: add dotnet-tools.json for ef tool 2026-07-07 18:08:08 +02:00
0502cbbd76 chore: upgrade target framework to net10.0
Update all projects from net8.0 to net10.0 and global.json SDK to 10.0.0.
Gitignore dotnet-tools.json.
2026-07-07 18:03:42 +02:00
6d29f053f4 fix: proper bidirectional SharedWith many-to-many migration
Replace broken one-to-many User.AssetId shadow FK with proper
AssetUser join table using bidirectional navigation:
  HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets)

Join table columns: SharedAssetsId (FK to Assets) + SharedWithId (FK to Users).
Adds SharedAssets navigation to User model.

Generated via dotnet ef migrations add.
2026-07-07 18:03:22 +02:00
MrFastwind
75c667d1c2 Merge remote-tracking branch 'origin/feature/masonryLayoutInHome' into feature/masonryLayoutInHome 2026-07-07 18:02:51 +02:00
MrFastwind
740941790b fix(Lactose): Admin and Curators media visibility 2026-07-07 18:02:29 +02:00
5511bc56f2 fix: remove AspNetCore.SassCompiler to fix Docker startup crash
The native Dart binary at runtimes/linux-x64/src/dart is not
published into the runtime container, causing a crash when the
SassCompilerHostedService tries to start at runtime.

SCSS compilation now must be done manually with the  CLI
or by temporarily re-adding the package. Instructions added
to AGENTS.md.
2026-07-07 17:25:13 +02:00
d90a9421e9 dotnet tools 2026-07-07 17:16:04 +02:00
ba1071ba6c fix: add AssetUser many-to-many join table for SharedWith
Replace broken one-to-many User.AssetId shadow FK with proper
AssetUser join table (AssetsId + SharedWithId composite PK).
Configure explicit UsingEntity in OnModelCreating to match
database column names and prevent convention mismatches.

Migration: 20260707160000_FixSharedWithManyToMany
2026-07-07 17:13:50 +02:00
29b021bee5 feat: pass JWT token in media URLs for authenticated thumbnail/preview requests
- MediaController: accept ?token=<jwt> query param as alt auth for browser img requests,
  validating against signing key via JwtSecurityTokenHandler
- Home.razor: append current JWT token to all thumbnail/preview URLs,
  update on AuthInfoChanged, empty when not logged in
2026-07-07 15:58:56 +02:00
07fcd05031 feat: masonry gallery on homepage with infinite scroll
- Enrich AssetPreviewDto with ResolutionWidth/Height, HasThumbnail, HasPreview
- Extend GET /api/asset with ?type=&random=true for gallery queries
- Add general GetAssets repository method (type, dates, random, pagination)
- Fix LactoseAuthService.GetUserData to handle unmapped JWT claim names
- Add request-level auth debug logging middleware
- New AssetService (MilkStream.Client) for gallery API calls
- Rewrite Home.razor: CSS column-count masonry, infinite scroll via
  IntersectionObserver JS interop, inline preview overlay with
  progressive loading (preview -> thumbnail fallback), arrow/Esc nav
- Home.razor.css: responsive 2-5 column masonry, overlay styles,
  content-visibility:auto for memory optimization
- Sliding window of 10 pages (300 tiles) with loading=lazy images
- Broken thumbnail fallback to Bootstrap bi-image icon
2026-07-07 15:44:03 +02:00
64a0fdf81d feat(folders): add regex pattern field with named groups, syntax highlighting, and sample path preview 2026-07-07 14:19:35 +02:00
9e8723b79f feat(settings): add runtime-configurable Po2W slider limits + split thumbnail/preview ranges
- SettingRange.razor: add MinPowerValue/MaxPowerValue params; component computes
  log2 exponents internally when set, falls back to MinValue/MaxValue for
  non-Po2W sliders (TimePicker)
- Settings.razor: Po2W case passes pixel values via MinPowerValue/MaxPowerValue
  instead of exponents; move Thumbnail Size and Preview Size to Jobs tab
- DefaultSettings.json: Thumbnail 64-1024 px, Preview 256-4096 px (split ranges)
- Migration: ConvertPo2WOptionsToPixelValues — converts existing Po2W Options
  from exponents to actual pixel values via POWER(2,n), guarded by < 32 check
  to skip already-migrated rows
2026-07-07 13:07:11 +02:00
1ecd6d167a fix: add DeletedAt filter to asset batch queries to exclude soft-deleted assets 2026-07-07 12:13:39 +02:00
78ecec2e12 fix(jobs): add delay to JobManager main loop to prevent CPU spin 2026-07-07 12:13:01 +02:00
6ac692f137 fix(jobs): fix IntegrityCheckJob conditional + use soft-delete 2026-07-07 12:12:15 +02:00
a6d26067b3 refactor(jobs): flatten MetadataJob to batch-based sub-jobs 2026-07-07 12:11:25 +02:00
2b90a4893e refactor(jobs): flatten PHashJob to batch-based sub-jobs 2026-07-07 12:10:55 +02:00
001be4160b refactor(jobs): flatten ThumbnailJob to batch-based sub-jobs 2026-07-07 12:10:03 +02:00
9f8ec47a15 feat(settings): add JobBatchSize setting for batch job sizing 2026-07-07 12:09:05 +02:00
1aad7e1d76 feat(settings): add JobBatchSize setting for batch job sizing 2026-07-07 12:05:00 +02:00
05d0ef1a9a fix(ui): submit login form on Enter key press 2026-07-07 11:55:58 +02:00
REDCODE
c7d555d90b fix(ui): apply poll interval to child jobs, add @key for DOM reuse, guard admin pages
- Pass PollInterval from Jobs page to JobTree so children refresh at the
  same rate as parent jobs (instead of hardcoded 1s)
- Add @key on JobRow (by job Id) and group divs so Blazor reuses DOM
  nodes across polls instead of tearing down and rebuilding
- Redirect non-admin users to / on Jobs and Settings pages
- Fix date formatting in User page
2026-07-07 01:05:31 +02:00
REDCODE
0d12b45563 feat(jobs): apply batching to PHashJob and MetadataJob
Same batch processing pattern as ThumbnailJob:
- Paginated DB queries (200 per batch) via Count + Skip/Take methods
- Atomic counters with Interlocked.Increment
- Thread.Sleep(100) polling instead of busy-wait
- Added failedAssets tracking to MetadataJob (was missing)
2026-07-07 00:19:08 +02:00
REDCODE
b20b7d20fd fix(jobs): prevent silent crash on large thumbnail jobs and auto-refresh child jobs
Backend (Lactose):
- Dispose DI scopes in JobManager.CreateJob to fix memory leak
- Check t.IsFaulted in Job.ContinueWith so unhandled exceptions surface as
  failed jobs instead of being silently treated as completed
- Batch asset processing in ThumbnailJob (200 per batch) with paginated DB
  queries instead of loading all 1.3M+ assets into memory at once
- Replace busy-wait with Thread.Sleep(100) to avoid CPU spinning

Frontend (MilkStream.Client):
- Auto-refresh child jobs every 1s in JobTree when a parent is expanded
  and still active (Queued/Running/Waiting), stopping on collapse or
  terminal status
2026-07-06 23:58:07 +02:00
25ac326d7e fix(phash): display child jobs grouped by status
- Refactor PHashJob to match ThumbnailJob's master/sub-job pattern:
  - return after JobStatus.Fail() so failures stay Failed
  - track failedAssets counter, use CompleteWithErrors
  - descriptive sub-job names (pHash for {path})
  - lock-based sub-jobs list for thread safety
- Fix JobTree.razor: re-fetch children when parent still active
  - OnExpanded invalidates _childrenCache if job is Queued/Running/Waiting
- Fix scope leaks in IntegrityCheckJob and JobScheduler.FetchSettings
- Increase JobRecord.Name to VARCHAR(2048) to prevent truncation
- Add debug logging for sub-job completion tracking
2026-07-06 21:33:53 +02:00
095c80071f Merge branch 'feature/thumbnailInvalidation' into develop 2026-07-06 20:32:06 +02:00
9dd93494c1 feat(ui): show CompletedWithErrors as orange and group sub-jobs by status 2026-07-06 20:28:42 +02:00
a7b71153e9 feat(jobs): add CompletedWithErrors status for parents with failed sub-jobs 2026-07-06 20:28:39 +02:00
cbe374a010 fix(ui): restore auth in App.razor before rendering to prevent race redirect to /login 2026-07-06 20:22:15 +02:00
ffffb7ff2b fix(docker): create thumbnails/previews dirs as app user so subdirs can be created at runtime 2026-07-06 20:13:15 +02:00
167cbb8ad1 Revert "fix(db): always override ThumbnailPath/PreviewPath values with local paths on init"
This reverts commit 9e8d31f457.
2026-07-06 20:10:47 +02:00
9e8d31f457 fix(db): always override ThumbnailPath/PreviewPath values with local paths on init 2026-07-06 20:09:23 +02:00
82250739e3 fix(ui): render child jobs in JobTree by skipping root filter at deeper levels 2026-07-06 20:04:32 +02:00
699ab85614 fix: redirect unauthenticated users from jobs page to /login 2026-07-06 19:59:03 +02:00
4cffb152b7 feat(db): add migration for ThumbnailSize column on Assets 2026-07-06 19:57:44 +02:00
4372206d9c feat(thumbs): read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen 2026-07-06 19:56:56 +02:00
7e6e0ed3ec feat(repository): implement GetAssetsMissingOrWrongThumbnail query 2026-07-06 19:56:20 +02:00
7a580a950c feat(repository): add GetAssetsMissingOrWrongThumbnail to interface 2026-07-06 19:56:10 +02:00
3b007d577b feat(assets): add ThumbnailSize column to track generated thumbnail dimensions 2026-07-06 19:56:04 +02:00
7dbd79315f fix: redirect unauthenticated users from settings page to /login 2026-07-06 19:48:56 +02:00
d93036f754 fix: handle 401 transparently at AuthServiceBase level for all services
- JwtTokenRefresher: remove ForceLogout that was clearing auth on refresh
  failure and redirecting to login. Instead return a synthetic 401 so the
  service-layer handling can retry.
- AuthServiceBase: add LoginService property and SendWithRefreshAsync
  helper — wraps any HTTP call, catches 401, refreshes token via
  Reauthenticate(), updates DefaultRequestHeaders, retries once.
- JobsService: remove local SendWithRefreshAsync, use base class version.
- SettingsService, FoldersService: wrap GET calls with
  SendWithRefreshAsync.
- LoginService: fix Logout to also clear LoggedUser.
2026-07-06 19:45:42 +02:00
9fde1d263a Merge branch 'feature/settingsRework' into develop 2026-07-06 19:39:08 +02:00
dcda9a8feb fix: add minutes unit to Folder Scan Interval description in default settings 2026-07-06 19:37:26 +02:00
d6d167f40c fix: use oninput for live slider value update and widen range layout 2026-07-06 19:31:38 +02:00
e33d87bb30 feat: unify settings pages into sidebar + detail panel with auto-save
Replace two separate settings pages with a single unified page featuring:
- Sidebar navigation with 6 categories (General, Scanning, Upload, Paths, Jobs, Folders)
- Auto-save with 500ms debounce and visual feedback per setting
- Inline folder management as a dedicated tab
- Removed duplicate BeginSave event subscription bug in SettingBox components

Breaking: SettingsService.BeginSave event and OnBeginSave() removed
2026-07-06 19:30:07 +02:00
e297a81ebb feat(docker): add named volumes for thumbnails and previews 2026-07-06 19:18:34 +02:00
ff3fbc1ff1 Merge branch 'feature/metadataJob' into develop 2026-07-06 19:17:41 +02:00
4cf29ab4f2 fix: prevent master PHashJob from spinning indefinitely on sub-job completion
- Use Interlocked.Increment with Volatile.Read to fix cross-thread
  visibility of the processed count
- Add await Task.Delay(100) in the wait loop to yield the thread
- Fix inverted cancellation check
2026-07-06 19:08:29 +02:00
08dba4b257 fix: remove completed sub-jobs from list so master job can finish
Sub-jobs were never removed from the subJobs list in the Done handler,
causing the wait loop (subJobs.Count > 0) to spin forever.
2026-07-06 19:08:12 +02:00
e88a5c4ea7 fix: add missing Butter.Types import in AssetRepository for EAssetType 2026-07-06 18:55:32 +02:00
09883c797c feat: implement MetadataJob for image resolution extraction
- Create MetadataJob with master/sub-job pattern (mirrors PHashJob)
- Register MetadataJob in JobMapper.ToJobStatusDto()
- Implement QueueMetadataExtraction in JobScheduler with duplicate guard
- Remove NotImplementedException catch in JobsController
2026-07-06 18:55:03 +02:00
b73b0b245c feat: add GetAssetsMissingMetadata repository method for metadata job 2026-07-06 18:54:35 +02:00
d5962501b2 docs: update AGENTS.md with XML doc enforcement and CORS guidance 2026-07-06 18:47:50 +02:00
eb9f0d14d3 docs: add XML documentation to all public APIs across all projects
Achieves 100% XML doc coverage on non-trivial types with CS1591
enforcement via Directory.Build.props.

Coverage by project:
- Butter: from 6.5% to 100% (DTOs, enums, MIME types)
- Lactose: from ~28% to 100% (controllers, services, repos, jobs, models)
- MilkStream.Client: from ~29% to 100% (all frontend services)

Uses <inheritdoc /> on repository implementations (interfaces
already documented) and full <summary>/<param>/<returns> tags
elsewhere. Enums include member-level docs.
2026-07-06 18:45:21 +02:00
c8a862b29f Merge branch 'feature/JobsEndpoint' into develop 2026-07-06 18:30:25 +02:00
5b12c3e697 Merge remote-tracking branch 'origin/develop' into develop 2026-07-06 18:27:57 +02:00
7ce738df44 Merge remote-tracking branch 'Gitea/develop' into develop 2026-07-06 18:26:35 +02:00
db8c576c29 Config files adjustment for testing on docker 2026-07-06 18:18:26 +02:00
0a9c3ba207 feat: optimize jobs API with pagination, DB persistence, and cleanup
- Split /api/jobs into three endpoints: active roots, paginated past,
  children fetched on expand — reduces response from ~1MB to ~5KB
- Persist completed jobs to PostgreSQL via JobRecord entity + migration
- Add configurable job retention cleanup (hourly timer, default 30 days)
- Fix PHashJob master not persisting (missing Complete() call)
- Add PastJobsResponse DTO to fix case-sensitive JsonElement deserialization
- Refactor JobTree/JobRow for lazy-loaded children via FetchChildren callback
- Handle 401 responses in JwtTokenRefresher with token refresh + retry
- Remove JobChildrenSummary component (replaced by inline parent summary)
2026-07-06 18:17:49 +02:00
b9a0e2f1b6 fix: handle 401 in JobsService directly with refresh+retry
The JwtTokenRefresher DelegatingHandler was not reliably intercepting
401 responses in Blazor WASM. Add a SendWithRefreshAsync helper to
JobsService that catches 401, calls Reauthenticate() to get a fresh
token, updates DefaultRequestHeaders, and retries once. This ensures
the Jobs page transparently recovers from token expiry without
showing an error.
2026-07-06 17:42:38 +02:00
f9dea23757 chore: remove redundant XML doc comments from ServiceBase 2026-07-06 17:34:18 +02:00
4128ea1b58 fix: redirect to login when token refresh fails in JwtTokenRefresher
- Inject NavigationManager and ILocalStorageService into JwtTokenRefresher
- Extract TryRefresh() helper to avoid repeating refresh logic
- Add ForceLogout() to LoginService that clears both AuthInfo and LoggedUser,
  firing AuthInfoChanged and LoggedUserChanged events
- When an auth refresh fails (both pre-emptive and reactive paths), call
  ForceLogout(), clear persisted auth from localStorage, and redirect to /
  with a full reload so the user sees the login page
2026-07-06 17:34:08 +02:00
4876bb3e74 fix: handle 401 responses in JwtTokenRefresher by refreshing token and retrying
The handler only performed a pre-emptive check (token expiry before
sending), but never reacted to a 401 response from the server. Now
after sending, if the response is 401, the handler disposes it,
refreshes the token via Reauthenticate(), updates the request header,
and retries once. This covers clock skew, server-side invalidation,
and any other case where the server rejects a token the client
considered valid.
2026-07-06 16:55:08 +02:00
3a5729045e chore: remove temporary debug panel from Jobs page 2026-07-06 15:50:08 +02:00
902c4418ae fix: change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel 2026-07-06 15:36:26 +02:00
9863126262 feat: redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard 2026-07-06 15:19:03 +02:00
e0508c2a22 feat: add JobRow, JobChildrenSummary, JobSection, JobTree components 2026-07-06 15:17:54 +02:00
0f505575aa feat: add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector 2026-07-06 15:16:18 +02:00
0177a99449 feat: map ParentJobId in JobMapper 2026-07-06 15:15:14 +02:00
8a395da790 feat: set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob 2026-07-06 15:15:08 +02:00
af0545fdd1 feat: add ParentJobId to Job base class 2026-07-06 15:14:52 +02:00
1e13e5694e feat: add ParentJobId to JobStatusDto for job hierarchy tracking 2026-07-06 15:14:43 +02:00
0a48663d35 fix: notify auth info changed after token refresh and handle polling errors
- Fire AuthInfoChanged event after successful Reauthenticate() so all
  AuthServiceBase instances update DefaultRequestHeaders with the new
  token, preventing cascading failure on the next poll cycle
- Wrap RefreshJobs() poll body in try-catch so transient errors don't
  crash the component
2026-07-06 15:04:54 +02:00
Samuele Lorefice
0b47a8d2b2 Merge pull request #7 from SamueleLorefice/copilot/fix-hanging-jobs-and-folderid-issue 2026-07-06 15:03:18 +02:00
copilot-swe-agent[bot]
836a5472b8 fix: catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation 2026-07-06 13:01:04 +00:00
491003aeac Makes the configuration work during debug on rider 2026-07-06 14:41:39 +02:00
8c5b4895ac fix: add DB healthcheck and service_healthy condition to prevent lactose startup race condition 2026-07-06 01:51:49 +02:00
e2041dd2c9 WIP 2026-07-02 13:29:33 +02:00
b16ad2d096 Merge branch 'develop' into copilot/fix-hanging-jobs-and-folderid-issue 2026-07-02 12:27:01 +02:00
55995eca18 Updated gitignore 2026-07-02 11:29:56 +02:00
copilot-swe-agent[bot]
ab5e9e7de4 Fix hanging jobs and missing FolderId in FileSystemCrawlJob
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/92e2d123-7cee-43cd-ae8f-fe89aa3954ec

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-05-06 03:38:39 +00:00
copilot-swe-agent[bot]
7b81887729 Initial plan 2026-05-06 03:33:58 +00:00
Samuele Lorefice
3b274ff38c Merge pull request #5 from SamueleLorefice/copilot/analyze-cors-risk
feat(cors): support semicolon-separated CorsAllowedOrigins env var
2026-04-25 19:15:30 +02:00
REDCODE
01e2d236a1 Small cleanup 2026-04-25 19:13:39 +02:00
copilot-swe-agent[bot]
d1ef731a9e refactor: improve readability of CorsAllowedOrigins parsing block
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/6de09298-b302-4816-bb1b-54ead6ced089

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 03:08:21 +00:00
copilot-swe-agent[bot]
7a1a4fd519 docs(readme): add wildcard CORS warning and MilkStream CORS setup guide
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/ff435695-5dfd-4eb0-8f11-36ba41026ccf

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 03:01:15 +00:00
Samuele Lorefice
c3a352eb9c Update Lactose/Program.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 04:55:59 +02:00
copilot-swe-agent[bot]
b57314a44a docs: add CORS configuration section to README
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/45efe1a3-cd3d-4e90-b6ea-10de28631f2a

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 02:42:11 +00:00
copilot-swe-agent[bot]
12a1e7624d feat(cors): support semicolon-separated CorsAllowedOrigins env var
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/d6c35095-3c5e-4b44-bffd-13cec81852dd

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 02:39:17 +00:00
copilot-swe-agent[bot]
35cb672403 fix: replace CORS wildcard with explicit safe origins per environment
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/735f53a1-08d3-4eae-854f-d730ca9c7798

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 02:32:09 +00:00
REDCODE
230d0a7d1b Merge remote-tracking branch 'GitHub/develop' into feature/JobsEndpoint 2026-04-23 04:07:04 +02:00
Samuele Lorefice
2cff9525bb Merge pull request #4 from SamueleLorefice/copilot/update-agents-md-wasm-architecture
doc: update agents.md for Blazor WebAssembly architecture
2026-04-23 04:06:44 +02:00
Samuele Lorefice
3ff8b3de8f Merge pull request #3 from SamueleLorefice/copilot/update-branch-to-be-compatible
Merge WASM frontend migration (PR #2) into feature branch
2026-04-23 04:06:07 +02:00
copilot-swe-agent[bot]
3e9f5c1164 Fix CORS errors on unimplemented job types: catch NotImplementedException in controller, add error feedback in Jobs.razor
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/3ff2b38e-490d-474f-adf2-e980d2154099

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-23 01:54:21 +00:00
Samuele Lorefice
e0cddb8aaa Merge pull request #2 from SamueleLorefice/copilot/convert-frontend-to-wasm
Convert from SSR to WASM client
2026-04-23 03:37:49 +02:00
copilot-swe-agent[bot]
4c158c87d3 doc: update agents.md for Blazor WASM architecture
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/836b581c-7b5b-4e36-af0f-1093860aa969

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 23:18:35 +00:00
copilot-swe-agent[bot]
8df80f8e2c Initial plan 2026-04-22 23:16:56 +00:00
copilot-swe-agent[bot]
76e6a08923 Fix build errors after merge: lowercase injection in Home.razor, null-guard in SettingNumber.razor
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/51f22dbb-c20e-4f84-8edc-e2f45d2dea1f

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 21:19:15 +00:00
copilot-swe-agent[bot]
a1d5e23817 Merge PR#2 (convert-frontend-to-wasm) into update-branch-to-be-compatible
Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 21:17:46 +00:00
REDCODE
e2f08607e2 made so settings save buttons to only show if any setting is available 2026-04-22 23:02:12 +02:00
REDCODE
374bd48adb Added * as CORS origins for the WASM client to be able to connect 2026-04-22 22:58:19 +02:00
copilot-swe-agent[bot]
cdb7103809 fix: support wildcard CORS origin for self-hosted Docker deployments
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/b76b1dfa-e722-4617-b86d-02bfca57a717

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 20:47:54 +00:00
copilot-swe-agent[bot]
de3445318e fix: serve appsettings.json dynamically from MilkStream to fix WASM BaseUrl in Docker
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/9e431129-9b03-450e-8547-401878c3428b

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 20:30:05 +00:00
copilot-swe-agent[bot]
4e1d608143 fix: handle stale ProtectedLocalStorage value crashing NavMenu deserialization
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/04bc37b1-a1c3-4c1a-936e-e2234d7f9bf2

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 20:16:03 +00:00
copilot-swe-agent[bot]
27259c7241 fix: address code review findings (blocking .Result, async void handlers, email logging)
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/2a560c45-14b6-47a3-b348-22e4142217be

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 19:43:56 +00:00
copilot-swe-agent[bot]
7df482f9e9 feat: migrate MilkStream frontend from Blazor SSR to Blazor WASM
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/2a560c45-14b6-47a3-b348-22e4142217be

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 19:39:07 +00:00
Samuele Lorefice
9ee59e50c9 Merge pull request #1 from SamueleLorefice/copilot/create-agents-md
Created Agents.md
2026-04-22 21:15:46 +02:00
copilot-swe-agent[bot]
960e2c5f7c Add preliminary agents.md with project conventions and standards
Agent-Logs-Url: https://github.com/SamueleLorefice/MilkyShots/sessions/88f66be5-2a7f-47a7-afb3-f3d51cafb582

Co-authored-by: SamueleLorefice <6562494+SamueleLorefice@users.noreply.github.com>
2026-04-22 17:19:56 +00:00
Samuele Lorefice
84c1548221 Settings WIP 2025-09-09 22:01:55 +02:00
Samuele Lorefice
b183521af8 General Cleanup (and async/awaiting rules enforcing) 2025-09-09 19:32:19 +02:00
Samuele Lorefice
16f53cb931 Added User Page 2025-09-09 18:49:20 +02:00
Samuele Lorefice
b052a025a7 Small Refactor 2025-09-09 17:30:33 +02:00
Samuele Lorefice
f31369bea9 Added missing settings and power slider. Broken state 2025-09-05 04:56:53 +02:00
REDCODE
b2cfdc5af4 Fixed Race condition on Thumbnail job signaling.
Moved compose to use proper docker configs instead of normal development configs.
2025-09-05 00:48:18 +02:00
Samuele Lorefice
3dacf84a85 Some more failsafes 2025-09-04 20:40:22 +02:00
Samuele Lorefice
bd18d8f636 Tasks now fail when they fail. 2025-09-04 20:34:03 +02:00
Samuele Lorefice
19b57901f1 I am slowly losing sanity 2025-09-04 20:20:48 +02:00
Samuele Lorefice
3bee6c7939 Strictly enforces calling the correct methods to change the job status, ensuring events are fired correctly 2025-09-04 20:10:05 +02:00
Samuele Lorefice
dde98a9740 Refactoring for readability in thumb job 2025-09-04 19:53:21 +02:00
Samuele Lorefice
2b84d66e8b WIP debug for Thumbnailjob 2025-09-04 19:50:52 +02:00
Samuele Lorefice
7c2029c7e4 Wasn't done with the time yet. 2025-09-04 19:25:23 +02:00
Samuele Lorefice
ccbc75a31c fix(frontend): Fixes all of the special HttpClients to not have been specialized due to a small typo in the program init.
fix(frontend): Fixes time being interpreted as local time instead of UTC time for job cards.
feat(frontend): Adds more job types buttons on the job page
2025-09-04 19:13:59 +02:00
Samuele Lorefice
a170025520 Moved to a compatible pgvector db variant, implemented Thumbnails job 2025-09-04 04:59:15 +02:00
Samuele Lorefice
627239f0a3 Enabled vector extensions 2025-09-04 02:40:29 +02:00
REDCODE
28e53eb31b PHash Job implementation 2025-09-03 23:37:56 +02:00
Samuele Lorefice
e070031ec0 Added libraries for image processing and hashing 2025-09-03 20:56:29 +02:00
Samuele Lorefice
a4747ca417 Front end work on Jobs page 2025-09-03 20:55:57 +02:00
REDCODE
74f3584dd0 FrontEnd work (partial) 2025-09-03 03:45:03 +02:00
REDCODE
1e3a0eae53 Added IntegrityCheck job 2025-09-03 01:02:20 +02:00
REDCODE
9d6da2af06 Leveled logging settings 2025-09-02 22:35:27 +02:00
Samuele Lorefice
4beddeaa2e Added scheduler as singleton to allow JobsController to use it 2025-09-02 21:09:56 +02:00
Samuele Lorefice
b18f294db4 Scaffolded Jobs controller basics 2025-09-02 20:40:16 +02:00
Samuele Lorefice
6719cb6546 Merge branch 'feature/JobSystem' into develop 2025-09-02 19:34:48 +02:00
Samuele Lorefice
8778cef71d Merge branch 'feature/JobSystem' into develop 2025-09-02 19:34:48 +02:00
Samuele Lorefice
21d865e654 Fixed crashes on UpdateProgress, reorganized logic for scanner service, fixed scheduler not being able to get hold of the JobManager, JobManager now uses ConcurrentDictionary for thread safe access. 2025-09-02 19:19:44 +02:00
Samuele Lorefice
8e5ba0ec31 Fixed crashes on UpdateProgress, reorganized logic for scanner service, fixed scheduler not being able to get hold of the JobManager, JobManager now uses ConcurrentDictionary for thread safe access. 2025-09-02 19:19:44 +02:00
Samuele Lorefice
d5148c1722 feat(backend): Added JobScheduler
feat(backend): Added logging to JobManager
feat(API): FolderRepository now exposes FolderAdded / FolderRemoved static events.
2025-09-02 17:35:20 +02:00
Samuele Lorefice
67a7eec155 feat(backend): Added JobScheduler
feat(backend): Added logging to JobManager
feat(API): FolderRepository now exposes FolderAdded / FolderRemoved static events.
2025-09-02 17:35:20 +02:00
Samuele Lorefice
5689098e00 Refined Job system, added FileSystemCrawl job full implementation. 2025-09-01 18:20:39 +02:00
Samuele Lorefice
49b53b0886 Refined Job system, added FileSystemCrawl job full implementation. 2025-09-01 18:20:39 +02:00
Samuele Lorefice
ed3ca19d66 Initial job implementation 2025-09-01 15:13:45 +02:00
Samuele Lorefice
c6295546ad Initial job implementation 2025-09-01 15:13:45 +02:00
Samuele Lorefice
ff8ae77ebe Merge branch 'feature/frontend' into develop 2025-09-01 11:19:18 +02:00
Samuele Lorefice
01a9fd654d Merge branch 'feature/frontend' into develop 2025-09-01 11:19:18 +02:00
Samuele Lorefice
516c4dba61 misc. 2025-08-31 23:46:31 +02:00
Samuele Lorefice
e296b142a3 misc. 2025-08-31 23:46:31 +02:00
Samuele Lorefice
a4457a824a fix(API): solved filesystem scanner not scanning 2025-08-31 23:43:03 +02:00
Samuele Lorefice
4cab2988e1 fix(API): solved filesystem scanner not scanning 2025-08-31 23:43:03 +02:00
Samuele Lorefice
3bd30dfde9 filesystem scanner troubleshooting 2025-08-31 22:16:17 +02:00
Samuele Lorefice
0a57919271 filesystem scanner troubleshooting 2025-08-31 22:16:17 +02:00
Samuele Lorefice
8d4186c137 feat(frontend): added folder removal in settings 2025-08-31 20:19:28 +02:00
Samuele Lorefice
1fd6ebc570 feat(frontend): added folder removal in settings 2025-08-31 20:19:28 +02:00
Samuele Lorefice
2aeaf9b5fe Attempts to speedup user login retrieval from localstorage 2025-08-31 20:08:16 +02:00
Samuele Lorefice
5827f88956 Attempts to speedup user login retrieval from localstorage 2025-08-31 20:08:16 +02:00
Samuele Lorefice
bd00119fe9 feat(frontend): editing of settings and addition/editing of folder paths 2025-08-31 19:35:15 +02:00
Samuele Lorefice
ca290bcba7 feat(frontend): editing of settings and addition/editing of folder paths 2025-08-31 19:35:15 +02:00
Samuele Lorefice
4e5a7ce3f2 fix(API): Added SaveChanges to Settings repository, reflecting changes to he database 2025-08-31 17:18:57 +02:00
Samuele Lorefice
fe544ebf24 fix(API): Added SaveChanges to Settings repository, reflecting changes to he database 2025-08-31 17:18:57 +02:00
Samuele Lorefice
94ab51220a Added save logic for Settings (folders are still not saved) 2025-08-31 16:33:57 +02:00
Samuele Lorefice
21d99ef14f Added save logic for Settings (folders are still not saved) 2025-08-31 16:33:57 +02:00
Samuele Lorefice
39378c604a Moved setting cards to components. Only saving left. 2025-08-31 15:14:09 +02:00
Samuele Lorefice
cec2d805ca Moved setting cards to components. Only saving left. 2025-08-31 15:14:09 +02:00
Samuele Lorefice
c40bdb1600 Bunch of fucking stuff, mainly docker fixes and settings fixes 2025-08-30 22:43:57 +02:00
Samuele Lorefice
0b9acbf7b3 Bunch of fucking stuff, mainly docker fixes and settings fixes 2025-08-30 22:43:57 +02:00
Samuele Lorefice
ab28e549e0 More docker shenanighans 2025-08-30 20:54:14 +02:00
Samuele Lorefice
0a814ec6c0 More docker shenanighans 2025-08-30 20:54:14 +02:00
Samuele Lorefice
67cd13e012 Fixes docker deployments 2025-08-30 19:58:03 +02:00
Samuele Lorefice
597533d70c Fixes docker deployments 2025-08-30 19:58:03 +02:00
REDCODE
d46ec912bb WIP 2025-08-30 18:56:39 +02:00
REDCODE
8ead1d0ee1 WIP 2025-08-30 18:56:39 +02:00
Samuele Lorefice
a279025647 Fixes settings page layout 2025-08-29 20:14:20 +02:00
Samuele Lorefice
1db0ea89cf Fixes settings page layout 2025-08-29 20:14:20 +02:00
Samuele Lorefice
a267ecb5bb Versioned bootstrap 2025-08-21 18:16:03 +02:00
Samuele Lorefice
c6a61879d9 Versioned bootstrap 2025-08-21 18:16:03 +02:00
Samuele Lorefice
9342bcf895 Fixed JSON Default settings not being serialized, explicitly defined enum 2025-08-21 17:58:01 +02:00
Samuele Lorefice
2e882e7c13 Fixed JSON Default settings not being serialized, explicitly defined enum 2025-08-21 17:58:01 +02:00
MrFastwind
53143291d2 feat(API): Edits SettingsRepository to manage FileWatcher Service 2025-07-30 20:44:59 +02:00
MrFastwind
6a47f84fcc feat(API): Edits SettingsRepository to manage FileWatcher Service 2025-07-30 20:44:59 +02:00
MrFastwind
3de1cd6b14 feat(API): Adds method to modify the FileWatcherService: folders, time 2025-07-30 20:39:56 +02:00
MrFastwind
b0e84119ac feat(API): Adds method to modify the FileWatcherService: folders, time 2025-07-30 20:39:56 +02:00
MrFastwind
bd9cef0352 Fix(API): Refresh token being changed the last 10 minutes instead of every moment 2025-07-22 17:04:33 +02:00
MrFastwind
597b20483c Fix(API): Refresh token being changed the last 10 minutes instead of every moment 2025-07-22 17:04:33 +02:00
Samuele Lorefice
d28b53e793 feat(API): Settings rework
feat(db): extended amount of settings informations stored on the database
feat(frontend): added support for updating the refreshToken after a refresh request.
feat(backend): added a defaultSettings.json file that defines how the settings are initialized.
2025-07-18 20:40:42 +02:00
Samuele Lorefice
e411c0ce27 feat(API): Settings rework
feat(db): extended amount of settings informations stored on the database
feat(frontend): added support for updating the refreshToken after a refresh request.
feat(backend): added a defaultSettings.json file that defines how the settings are initialized.
2025-07-18 20:40:42 +02:00
MrFastwind
96ce0a777a WIP Generate new Refresh token 2025-07-18 19:32:11 +02:00
MrFastwind
8f18978ad9 WIP Generate new Refresh token 2025-07-18 19:32:11 +02:00
REDCODE
c3a749c960 WIP 2025-07-18 02:21:52 +02:00
REDCODE
d99cc61056 WIP 2025-07-18 02:21:52 +02:00
Samuele Lorefice
b479981c32 Initial Settings page implementation 2025-07-17 21:18:45 +02:00
Samuele Lorefice
0ae0f4ec35 Initial Settings page implementation 2025-07-17 21:18:45 +02:00
Samuele Lorefice
c0c4c2ea67 feat(frontend): nav menu restyle 2025-07-17 20:49:09 +02:00
Samuele Lorefice
5a05822a9f feat(frontend): nav menu restyle 2025-07-17 20:49:09 +02:00
Samuele Lorefice
fc33bcd5d9 feat(frontend): added folders service 2025-07-17 20:48:39 +02:00
Samuele Lorefice
445ae26360 feat(frontend): added folders service 2025-07-17 20:48:39 +02:00
Samuele Lorefice
06708c53bd fix(frontend): adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object. 2025-07-17 19:35:32 +02:00
Samuele Lorefice
adad2dcfd9 fix(frontend): adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object. 2025-07-17 19:35:32 +02:00
Samuele Lorefice
d23f813e25 feat(db): limited strings length in Settings Table
feat(API): removed SettingsDto in favor of List<SettingDto> instead.
2025-07-17 19:34:52 +02:00
Samuele Lorefice
7c58a270b3 feat(db): limited strings length in Settings Table
feat(API): removed SettingsDto in favor of List<SettingDto> instead.
2025-07-17 19:34:52 +02:00
Samuele Lorefice
848b91e399 Syncronized logged user account into LoginService, added state sync across navbar and home (and laid groundwork for statesync across all of the application) 2025-07-17 15:27:15 +02:00
Samuele Lorefice
83ecd83f83 Syncronized logged user account into LoginService, added state sync across navbar and home (and laid groundwork for statesync across all of the application) 2025-07-17 15:27:15 +02:00
Samuele Lorefice
0ee27a4260 feat(frontend): Login and logout now properly work 2025-07-17 15:08:50 +02:00
Samuele Lorefice
c07332657b feat(frontend): Login and logout now properly work 2025-07-17 15:08:50 +02:00
Samuele Lorefice
f67af97c3f WIP 2025-07-17 12:53:04 +02:00
Samuele Lorefice
860ed07429 WIP 2025-07-17 12:53:04 +02:00
Samuele Lorefice
738e2d5105 JWT Token refresher improved performance in logging strings 2025-07-17 12:52:41 +02:00
Samuele Lorefice
1d7bb08b79 JWT Token refresher improved performance in logging strings 2025-07-17 12:52:41 +02:00
Samuele Lorefice
8eb5644096 Made all services derive from AuthServiceBase/ServiceBase 2025-07-17 12:52:12 +02:00
Samuele Lorefice
bcd85b8f61 Made all services derive from AuthServiceBase/ServiceBase 2025-07-17 12:52:12 +02:00
Samuele Lorefice
ece0a58b58 feat(frontend): adds ServiceBase and AuthServiceBase abstract classes 2025-07-17 10:42:20 +02:00
Samuele Lorefice
d52f4da3bb feat(frontend): adds ServiceBase and AuthServiceBase abstract classes 2025-07-17 10:42:20 +02:00
Samuele Lorefice
e2949f9e55 Added Event for Auth info change in loginService 2025-07-17 10:21:33 +02:00
Samuele Lorefice
82c6394c82 Added Event for Auth info change in loginService 2025-07-17 10:21:33 +02:00
Samuele Lorefice
968798f697 Added dropdown to user icon 2025-07-17 10:16:59 +02:00
Samuele Lorefice
330071a864 Added dropdown to user icon 2025-07-17 10:16:59 +02:00
Samuele Lorefice
8f6e725409 Join searchbox and button 2025-07-17 10:16:11 +02:00
Samuele Lorefice
040e590802 Join searchbox and button 2025-07-17 10:16:11 +02:00
Samuele Lorefice
269f82a0da Generic cleanup 2025-07-16 22:10:24 +02:00
Samuele Lorefice
c062b4073c Generic cleanup 2025-07-16 22:10:24 +02:00
MrFastwind
f1f3abb05a feat(API): Added Access level to the data returned from authentication actions
Adds featrure from forntend branch
2025-07-16 21:40:10 +02:00
Samuele Lorefice
2d726e77dc More logging 2025-07-16 19:44:27 +02:00
Samuele Lorefice
3ade5c8c64 More logging 2025-07-16 19:44:27 +02:00
Samuele Lorefice
a6e1159158 JWT Token refresher rewrite 2025-07-16 19:43:52 +02:00
Samuele Lorefice
7b472f337f JWT Token refresher rewrite 2025-07-16 19:43:52 +02:00
Samuele Lorefice
8e423ca1ab Lazy loading for NavMenu 2025-07-16 19:43:06 +02:00
Samuele Lorefice
3630291ad9 Lazy loading for NavMenu 2025-07-16 19:43:06 +02:00
Samuele Lorefice
e63f04efcf increased logging for hotreload configurations 2025-07-16 19:42:51 +02:00
Samuele Lorefice
8265c7c051 increased logging for hotreload configurations 2025-07-16 19:42:51 +02:00
Samuele Lorefice
7ca023dde1 removed get-all method from settings controller 2025-07-16 19:42:40 +02:00
Samuele Lorefice
f4773ec096 removed get-all method from settings controller 2025-07-16 19:42:40 +02:00
Samuele Lorefice
6d327ce8a2 Merge branch 'develop' into feature/frontend 2025-07-16 18:04:50 +02:00
Samuele Lorefice
c00f9f505e Merge branch 'develop' into feature/frontend 2025-07-16 18:04:50 +02:00
Samuele Lorefice
5780aaec73 feat: added disposability to some repositories, changed the FileSystemScanner service to be a singleton. 2025-07-16 18:03:15 +02:00
Samuele Lorefice
07741f4129 Start implementing settings page 2025-07-16 17:10:32 +02:00
Samuele Lorefice
781c594f37 Start implementing settings page 2025-07-16 17:10:32 +02:00
Samuele Lorefice
c8f0bfe85b Minor styling and usability improvements 2025-07-16 16:17:54 +02:00
Samuele Lorefice
c31d698bdf Minor styling and usability improvements 2025-07-16 16:17:54 +02:00
Samuele Lorefice
c04522a992 Login service rework 2025-07-16 16:17:25 +02:00
Samuele Lorefice
c3c2a5810f Login service rework 2025-07-16 16:17:25 +02:00
Samuele Lorefice
3a2d0341b0 styling 2025-07-16 15:18:08 +02:00
Samuele Lorefice
6c1bd7d45f styling 2025-07-16 15:18:08 +02:00
Samuele Lorefice
1b3a545809 fix(frontend): added correct headers and response format to LoginService 2025-07-16 15:15:57 +02:00
Samuele Lorefice
e95619c969 fix(frontend): added correct headers and response format to LoginService 2025-07-16 15:15:57 +02:00
Samuele Lorefice
cd60649cc0 Added Iconfont 2025-07-16 15:06:58 +02:00
Samuele Lorefice
c52ddb9820 Added Iconfont 2025-07-16 15:06:58 +02:00
REDCODE
0c8ebd743d feat(frontend): added login and JWT token auto-refresh 2025-07-16 02:48:46 +02:00
REDCODE
6994dbab7a feat(frontend): added login and JWT token auto-refresh 2025-07-16 02:48:46 +02:00
REDCODE
e786c68612 feat(API): Added Access level to the data returned from authentication actions 2025-07-16 01:47:14 +02:00
REDCODE
21299543ec feat(API): Added Access level to the data returned from authentication actions 2025-07-16 01:47:14 +02:00
REDCODE
65207456db Re-enabled page pre-render, fixed login form (do not use form) 2025-07-16 00:32:24 +02:00
REDCODE
b80f6eb0a3 Re-enabled page pre-render, fixed login form (do not use form) 2025-07-16 00:32:24 +02:00
Samuele Lorefice
10d44f079c Axed 3rd party storage solutions for built in storage 2025-07-15 20:23:02 +02:00
Samuele Lorefice
2033877fe9 Axed 3rd party storage solutions for built in storage 2025-07-15 20:23:02 +02:00
Samuele Lorefice
42384b3b8d More layout work 2025-07-15 16:52:08 +02:00
Samuele Lorefice
2018fed2c9 More layout work 2025-07-15 16:52:08 +02:00
REDCODE
0277afe995 Register page done aswell, removed useless css override files 2025-07-15 04:34:46 +02:00
REDCODE
fc086e63de Register page done aswell, removed useless css override files 2025-07-15 04:34:46 +02:00
REDCODE
9dedb9622a Login form refactored 2025-07-15 04:20:14 +02:00
REDCODE
278eaed931 Login form refactored 2025-07-15 04:20:14 +02:00
REDCODE
ee52d9560f Added navbar logic for login and register, extra cleanup 2025-07-15 03:50:49 +02:00
REDCODE
020d8618aa Added navbar logic for login and register, extra cleanup 2025-07-15 03:50:49 +02:00
REDCODE
709004d3c1 More styling passes 2025-07-15 03:33:40 +02:00
REDCODE
89c0933f45 More styling passes 2025-07-15 03:33:40 +02:00
REDCODE
3cb30a49be main layout setup 2025-07-15 03:12:51 +02:00
REDCODE
b0318c4d8b main layout setup 2025-07-15 03:12:51 +02:00
REDCODE
d763f640e1 fix(frontend): added missing js file include for proper bootstrap functionality
feat(frontend): redone navbar
2025-07-15 03:12:41 +02:00
REDCODE
66fcba5d26 fix(frontend): added missing js file include for proper bootstrap functionality
feat(frontend): redone navbar
2025-07-15 03:12:41 +02:00
REDCODE
5282dea339 WIP full frontend graphics rewrite 2025-07-15 00:29:05 +02:00
REDCODE
494531be62 WIP full frontend graphics rewrite 2025-07-15 00:29:05 +02:00
Samuele Lorefice
91d14ef8fa chore: refactored wwwroot folders
feat(frontend): added full bootstrap library, js included
feat(frontend): added SCSS compiler service w/ hotreload config
2025-07-14 20:39:20 +02:00
Samuele Lorefice
d0ec91aa2a chore: refactored wwwroot folders
feat(frontend): added full bootstrap library, js included
feat(frontend): added SCSS compiler service w/ hotreload config
2025-07-14 20:39:20 +02:00
Samuele Lorefice
15c19a0c19 remove unused using 2025-07-14 18:11:05 +02:00
Samuele Lorefice
aebd61a885 remove unused using 2025-07-14 18:11:05 +02:00
Samuele Lorefice
208547f1f5 feat(frontend): added global css style file
fix(frontend): solved compilation errors
2025-07-14 17:58:35 +02:00
Samuele Lorefice
cf972e3d6e feat(frontend): added global css style file
fix(frontend): solved compilation errors
2025-07-14 17:58:35 +02:00
Samuele Lorefice
1c51a36510 fix(frontend): adds the new services to the app startup configuration 2025-07-14 17:41:02 +02:00
Samuele Lorefice
65e344f2c4 fix(frontend): adds the new services to the app startup configuration 2025-07-14 17:41:02 +02:00
Samuele Lorefice
1f462cb075 chore: updated run profiles 2025-07-14 17:35:44 +02:00
Samuele Lorefice
6a734c7b01 chore: updated run profiles 2025-07-14 17:35:44 +02:00
Samuele Lorefice
966ae30787 chore(frontend): switched to a more recent session storage implementation which doesn't have a security vulnerability. 2025-07-14 17:35:13 +02:00
Samuele Lorefice
b54d20cb05 chore(frontend): switched to a more recent session storage implementation which doesn't have a security vulnerability. 2025-07-14 17:35:13 +02:00
REDCODE
022b7b8ff0 feat(frontend): Added settings service 2025-07-14 01:13:10 +02:00
REDCODE
3e7031b74b feat(frontend): Added settings service 2025-07-14 01:13:10 +02:00
REDCODE
e7c985ccb0 feat(API): added get-all endpoint for settings controller. 2025-07-14 01:12:04 +02:00
REDCODE
698876fca6 feat(API): added get-all endpoint for settings controller. 2025-07-14 01:12:04 +02:00
REDCODE
78c2f95e5e chore: moved milkstream services under a services namespace 2025-07-14 01:04:09 +02:00
REDCODE
7b4d743b0e chore: moved milkstream services under a services namespace 2025-07-14 01:04:09 +02:00
Samuele Lorefice
99e49898fd Added test images 2025-07-13 22:25:26 +02:00
Samuele Lorefice
c5deb2af73 WIP 2025-07-13 22:24:52 +02:00
Samuele Lorefice
54faa08bd3 WIP 2025-07-13 22:24:52 +02:00
Samuele Lorefice
732a6da1e4 Added failed login message and redirection to home on successful login 2025-07-13 20:22:27 +02:00
Samuele Lorefice
7c400ce3c0 Added failed login message and redirection to home on successful login 2025-07-13 20:22:27 +02:00
Samuele Lorefice
99ddb5e87d feat(frontend): upgraded login to new API changes 2025-07-10 19:57:07 +02:00
Samuele Lorefice
4be744b224 Merge branch 'develop' into feature/frontend 2025-07-10 19:23:17 +02:00
MrFastwind
c1c399c03a feat(db): Adds Migration for Settings in LactoseDbContext 2025-07-10 19:22:43 +02:00
Samuele Lorefice
81d02ec1ff Merge branch 'develop' into feature/frontend 2025-07-10 18:51:11 +02:00
Samuele Lorefice
8bb0741069 fix(Services): Added FileSystem scan service to the startup of the webserver. 2025-07-10 18:50:30 +02:00
Samuele Lorefice
6742947422 Merge branch 'develop' into feature/frontend 2025-07-10 18:42:33 +02:00
Samuele Lorefice
409a32e1d2 fix(FS Service): Added error handling on asset addition
feat(FS Service): Added setting string retrieval from newly added Setting enum
feat(FS Service): Added ability to deactivate the service via settings.
2025-07-10 18:18:44 +02:00
Samuele Lorefice
70adbee4d2 feat: Added a settings enum + it's own extension class to retrieve interpretation type and string name in DB 2025-07-10 18:16:46 +02:00
Samuele Lorefice
4c0dce5cdd chore: split TokenGeneratorProvider file in their respective classes 2025-07-10 18:15:48 +02:00
Samuele Lorefice
3b71e052fd fix(DB): added check for already existing assets on DB entry. 2025-07-10 18:15:09 +02:00
Samuele Lorefice
264daecb3e feat(DB): Changed Asset table to have OriginalPath as Unique Index 2025-07-10 18:14:40 +02:00
Samuele Lorefice
c739c17266 Reworks Settings to be a key/value pair 2025-07-08 20:15:20 +02:00
Samuele Lorefice
54d0cd0343 Added FolderScanInterval to settings 2025-07-08 17:53:04 +02:00
Samuele Lorefice
ce7dba34c8 Added implementation to FileSystemScanService 2025-07-08 17:30:26 +02:00
Samuele Lorefice
abe0868c3b Merge branch 'develop' into feature/frontend 2025-07-06 17:20:48 +02:00
Samuele Lorefice
08f105faf3 Filesystem services basics 2025-07-06 17:19:36 +02:00
MrFastwind
607fef4cb0 Merge branch 'feature/webapi/endpoint-impl' into develop 2025-07-02 17:54:54 +02:00
MrFastwind
308e9fe21b Merge branch 'feature/webapi/refresh-token' into develop 2025-07-02 17:53:18 +02:00
MrFastwind
dbdce9d731 feat(Lactose): Adds RefreshToken Endpoint 2025-07-02 17:51:22 +02:00
MrFastwind
87432ad663 feat(Lactose): Adds DB Context Snapshot 2025-07-02 17:50:25 +02:00
MrFastwind
1c0ac12e76 feat(Lactose): Adds RefreshToken 2025-07-02 17:49:16 +02:00
MrFastwind
7ce3c548db fix(API): Fixes the Db Initializer on Production Environment 2025-06-19 17:32:32 +02:00
Samuele Lorefice
610cc1452b Merge branch 'feature/webapi/endpoint-impl' into feature/frontend 2025-06-19 16:13:07 +02:00
MrFastwind
b320d34bda feat(API): Adds Authentication Result as DTO for authentication 2025-06-04 18:27:47 +02:00
MrFastwind
0817bd148a fix(API): correct reference to the return type of result 2025-06-04 18:26:39 +02:00
MrFastwind
176f33a8a0 chore(API): Removes Redundant Check 2025-06-04 18:05:51 +02:00
MrFastwind
6171ef6f8c fix(): Program not loading SignKey configuration form file 2025-05-10 22:06:38 +02:00
Samuele Lorefice
43f9b8cacb Added session storage and fixed login, authkey is now used properly 2025-05-10 19:06:05 +02:00
Samuele Lorefice
cff19120a3 Fixed login service to the correct routes 2025-05-10 18:40:43 +02:00
Samuele Lorefice
916dc8f384 Merge branch 'feature/DtoRefactor' into develop 2025-05-10 16:15:17 +02:00
Samuele Lorefice
e3d49b2f48 "Formatting" 2025-05-10 04:20:24 +02:00
Samuele Lorefice
c05dce0b87 Added login and register page logic and navigation 2025-05-10 04:17:34 +02:00
Samuele Lorefice
962a83e708 Added loginService 2025-05-10 04:17:18 +02:00
Samuele Lorefice
0dd7efac84 Make all pages render dynamically by default 2025-05-10 04:16:40 +02:00
Samuele Lorefice
647c5be859 Moved DTOs to external library, referenced the library back into Lactose 2025-05-10 01:57:00 +02:00
Samuele Lorefice
1bb5730908 More styling on navbar 2025-05-10 01:38:10 +02:00
Samuele Lorefice
67df71942c More styling on all pages 2025-05-10 01:25:12 +02:00
Samuele Lorefice
9979e4183f Initial addition of a front end 2025-05-10 01:10:11 +02:00
Samuele Lorefice
617edf6d32 Merge branch 'feature/webapi/endpoint-impl' into feature/frontend 2025-05-10 01:09:20 +02:00
MrFastwind
450f62ba1f feat(DbContext): Adds Initialization for Development environment 2025-04-11 17:23:02 +02:00
REDCODE
a3cd7b128e fix(db): added postgres switch for Legacy Timestamp behaviour 2025-04-11 00:04:05 +02:00
REDCODE
ddaea8744b fix(docker)!: postgres volume data was not named properly 2025-04-11 00:03:36 +02:00
REDCODE
e95713a721 Merge remote-tracking branch 'origin/feature/webapi/endpoint-impl' into feature/webapi/endpoint-impl 2025-04-10 23:42:28 +02:00
MrFastwind
a3e619e472 fix(Docker): Add internal db port in appsettings.Development.json 2025-04-10 23:41:53 +02:00
REDCODE
d8d5814d9b chore: refined db connector params 2025-04-10 23:41:47 +02:00
MrFastwind
3e629e4c7d fix(Docker): Removes Env var from where is not needed and add it to teh compose 2025-04-10 23:40:06 +02:00
REDCODE
c76ce88b24 fix(docker): applied "Docker" as environment when building the application in a container thus avoiding not getting the correct configuration overloads at run time. 2025-04-10 21:06:46 +02:00
REDCODE
0201db730b feat:Switched to on the fly connection string generation 2025-04-10 21:05:41 +02:00
REDCODE
10eb0d20aa Updated run configs 2025-04-10 21:04:40 +02:00
REDCODE
a7cc3f5b61 chore: removed MySql package from required nuget packages 2025-04-10 19:41:13 +02:00
REDCODE
b5731f7cfd fix(docker): solved mismatch with base folders in both dockerfile and compose file 2025-04-10 19:40:44 +02:00
REDCODE
c4d1deafb0 fix(docker): Dockerfiles updated to use Net 8 as base images 2025-04-10 19:40:10 +02:00
REDCODE
68da5ceff7 chore: updated nuget packages to latest releases supporting Net 8.0, removed ImageMagick package. 2025-04-10 19:11:10 +02:00
REDCODE
7e05c1a37f Downgraded project to Net 8.0 2025-03-13 23:20:47 +01:00
REDCODE
fafc97f3de Updated workspace 2025-01-23 16:11:45 +01:00
REDCODE
32261eb92a feat(DB)!: Regenerated migrations for PostgreSQL switch 2024-12-17 22:36:34 +01:00
MrFastwind
de7e5696c4 feat(DB)!:Change to use database pgvecto-rs
Break actual DBs and migration model
2024-12-17 21:55:51 +01:00
REDCODE
3f2e048092 feat(DB): switched to PostgreSQL connector 2024-12-17 19:07:02 +01:00
MrFastwind
c52fb203ea Fix(API): Disables Asset Create 2024-11-19 17:37:50 +01:00
MrFastwind
fdfa6a277d fix(API): Allow Anonymous to use MediaEndpoint 2024-11-19 17:36:58 +01:00
REDCODE
a67a6cd81d refactor(API): asset controller reformatted params 2024-11-19 04:04:42 +01:00
REDCODE
0adabf49d2 Initial implementation of FileSystemScanService 2024-11-19 04:04:14 +01:00
REDCODE
53b5d1105c fix(API): added FindByPath in AssetRepository 2024-11-19 02:45:49 +01:00
REDCODE
61febac376 fix(API)!: Marked required properties in Asset as required 2024-11-19 02:45:08 +01:00
REDCODE
ec100eb364 fix(API): added GetAll method to Folder Repository and controller 2024-11-19 02:44:27 +01:00
REDCODE
c27f0d4f4f refactor: reformatted AssetController and AssetCreateDto files 2024-11-19 02:41:31 +01:00
REDCODE
920374aeea chore(NuGet)!: Added dependency to Magick.NET-Q16-HDRI-OpenMP-x64 2024-11-19 02:40:01 +01:00
REDCODE
d6e48a9b62 chore(docker): updated dockerfile to build using dotnet 9.0 2024-11-19 01:06:56 +01:00
REDCODE
ebecb976ae chore(docker): Added NoCache build option for lactose 2024-11-19 00:56:06 +01:00
REDCODE
6a406fedf5 feat(API): Implemented Media endpoint and relative Repository and DTO 2024-11-19 00:41:50 +01:00
REDCODE
33ba29c4a0 feat(API): Implemented Settings Endpoint
Closes #23
2024-11-18 23:26:10 +01:00
REDCODE
874a05fc92 chore: removed more unused using directives 2024-11-18 22:09:47 +01:00
REDCODE
94300d326b chore: commented out the logger in tagController and added a TODO regarding logging 2024-11-18 22:09:07 +01:00
REDCODE
938ab6a415 chore: removed useless using declarations in TagMapper 2024-11-18 22:08:28 +01:00
REDCODE
81433c7964 chore: removed redundant type cast in tag search 2024-11-18 22:07:52 +01:00
REDCODE
8d64cbc08e fix(API): added null checking to folderRepository get operations in FolderController 2024-11-18 22:06:11 +01:00
REDCODE
8c69e91dd7 chore: commented out logger in FolderController and added a TODO regarding logging 2024-11-18 22:05:40 +01:00
REDCODE
9b783ecdbf feat!: Upgraded project to .Net 9.0 2024-11-18 21:58:05 +01:00
REDCODE
2e6f854c0c refactor(API): AlbumController explicitly marked all of the x in lambdas as they were accidentally hiding previous stage, also improved null checking 2024-11-18 21:53:16 +01:00
REDCODE
1318ed334c chore: marked navigation property in folder as nullable 2024-11-18 21:52:02 +01:00
REDCODE
a3099916bf chore: commented out unused logger in Album controller and added a TODO: about adding logging capabilities 2024-11-18 21:51:40 +01:00
REDCODE
84de282c13 chore: swapped empty array initializer with empty collection initializer in Asset hash 2024-11-18 21:50:53 +01:00
REDCODE
bf8405d790 chore: removed unused using in Program.cs 2024-11-18 21:49:54 +01:00
REDCODE
5046d83dde fix(API): Marked as required all the various required fields in person related DTOs 2024-11-18 21:34:12 +01:00
REDCODE
a1e154b23c fix(API): Marked as required the tag name in TagCreateDto 2024-11-18 21:33:32 +01:00
REDCODE
d38238e805 fix(API): Marked as required both fields of credentials DTOs 2024-11-18 21:33:23 +01:00
REDCODE
ee3c6ec9ac fix(API): Marked as required both fields of BulkDto 2024-11-18 21:33:14 +01:00
REDCODE
bb1dd95482 fix(API): Added missing Email field in UserInfoDto 2024-11-18 21:32:58 +01:00
REDCODE
71f4b677d9 fix(API): Added required instead of null initialized strings where it didn't make sense to have a null string in Users DTOs 2024-11-18 21:32:47 +01:00
REDCODE
4b2f117e3c fix(API): standardized DTO namespaces 2024-11-18 21:31:45 +01:00
REDCODE
067af4ab37 fix(API): wrong logger in album controller 2024-11-18 19:21:55 +01:00
REDCODE
196b755f08 Registered folderRepository in program startup 2024-11-18 19:21:55 +01:00
REDCODE
e4f2ca48e3 feat(API): Implemented FolderController and their respective Mappers/DTO/Repository interface and it's own implementation 2024-11-18 19:21:55 +01:00
REDCODE
395ebe985f Adds scaffolding for the FolderController 2024-11-18 18:17:43 +01:00
REDCODE
a31e2fc89e Updated workspace 2024-11-18 17:49:07 +01:00
REDCODE
d097502a18 chore(DB): Generated migration for Folder, Settings and asset hash integration.
chore(DB): added relationships between folder and asset
2024-11-11 22:25:39 +01:00
REDCODE
5bcf011c6d feat(API): adds Folder and Asset entities in the DB 2024-11-11 22:14:43 +01:00
REDCODE
1ef42c69bf feat(API): Adds computed file hash field in Asset 2024-11-11 22:12:48 +01:00
REDCODE
d557cadc81 feat(API): integrated new metadata in asset on their respective DTOs
chore(DB): generated database migration for the updated asset schema
2024-11-11 19:18:21 +01:00
REDCODE
537e642cb6 feat(API): added Resolution, mimetype and extra media data on asset table 2024-11-11 19:11:42 +01:00
REDCODE
e6e4ed2183 chore(config): separates connection string defaults for docker and local webserver run 2024-11-11 18:09:36 +01:00
REDCODE
86b36facc3 chore(cliff): generated cliff config file
chore(docker): explicitly marked all the services to build and run in the compose file
2024-11-11 16:48:24 +01:00
REDCODE
aa6b86b0f6 fix(API): implements GetAll tags in tag Repository 2024-11-11 16:48:24 +01:00
REDCODE
26618d4c25 fix(API): added deleted at field in UserInfo mappers 2024-11-11 16:48:24 +01:00
REDCODE
6896695db5 fix(API): added support for pagination on Asset GetAll Query 2024-11-11 16:48:24 +01:00
REDCODE
a6ec9dc928 test: More unit testing on HTTP client 2024-11-11 16:48:24 +01:00
REDCODE
26495d71fd chore: Added datagrip files 2024-11-11 16:48:23 +01:00
REDCODE
acd159fda0 fix(docker): Fixed Dockerfile stuck on old project name and build configs 2024-11-11 16:48:23 +01:00
REDCODE
a9dca7da69 fix(API): Switches tagSearch method from uint to int 2024-11-11 16:48:23 +01:00
REDCODE
847e93eda4 Implemented Search function on Album endpoint. Closes #5 2024-11-11 16:48:23 +01:00
REDCODE
645c6e02a2 Fixes Album controller ambiguous routing 2024-11-11 16:48:23 +01:00
REDCODE
2f2290deb0 Implemented Album Delete and BulkDelete 2024-11-11 16:48:23 +01:00
REDCODE
3cabbc7a04 Implemented Album Create 2024-11-11 16:48:23 +01:00
REDCODE
741a1453da Implemented Album BulkUpdate 2024-11-11 16:48:23 +01:00
REDCODE
6702f4e4ee Implemented Album update endpoint 2024-11-11 16:48:23 +01:00
REDCODE
cf77a0943f Initial implementation of AlbumController Get action 2024-11-11 16:48:23 +01:00
MrFastwind
9bd6632264 refactor(API): Rename Asset Dtos 2024-11-11 16:48:23 +01:00
REDCODE
1b179abc50 XML Documentation Pass 2024-11-11 16:48:23 +01:00
REDCODE
58204f23dd Initial Album repository implementation 2024-11-11 16:48:23 +01:00
REDCODE
7d8b585731 Updates AssetRepository docs 2024-11-11 16:48:23 +01:00
REDCODE
c813fef4c3 feat(API): Implemented Asset Endpoint
Merges asset-endpoint into endpoint-impl branch
2024-11-11 16:48:23 +01:00
REDCODE
906cbc354f Refactoring of DTO files into separated folders for easier organization. Fixed discrepancy with one of user Dto naming. 2024-11-11 16:38:32 +01:00
REDCODE
0eeb84bfb3 Integrate refactoring from endpoint-impl branch 2024-11-11 16:38:32 +01:00
REDCODE
d09d078dd8 Fixes typo in UsersCreateDto 2024-11-11 16:38:32 +01:00
REDCODE
6c1299062c Cleanup of all asset related files 2024-11-11 16:38:32 +01:00
MrFastwind
56a27720bd refactor(API): Move classes and Standardlize
Moves Dtos to their files
Adds HTTPMethod specification
Adds Bulks where missing
2024-11-11 16:38:32 +01:00
REDCODE
b3c3cbffe8 Merge branch 'feature/webapi/endpoint-impl' into feature/webapi/asset-endpoint 2024-11-11 16:38:32 +01:00
MrFastwind
7773966525 refactor(API): Switch to use a class for paged search 2024-11-11 16:38:32 +01:00
REDCODE
e2d358c5db Merge branch 'feature/webapi/endpoint-impl' into feature/webapi/asset-endpoint 2024-11-11 16:38:32 +01:00
MrFastwind
374745a3fb feat(API): Adds person stub endpoint 2024-11-11 16:38:32 +01:00
REDCODE
b72acbd42c AssetController implementations 2024-11-11 16:38:32 +01:00
MrFastwind
700161a3f2 refactor(API): Extract BulkDtoinseparate file 2024-11-11 16:38:32 +01:00
REDCODE
cdfe768dfe Asset repository implementation 2024-11-11 16:38:32 +01:00
MrFastwind
138bdeac5a Merge branch 'feature/webapi/tag-endpoint' into feature/webapi/endpoint-impl 2024-11-11 16:38:32 +01:00
REDCODE
3355de1cd1 Moved Asset DTOs to their own subfolder 2024-11-11 16:38:32 +01:00
MrFastwind
52fd1528d2 fix(API): User endpoints to be in line with new structure 2024-11-11 16:38:32 +01:00
MrFastwind
894ac99a34 feat(API): Implements TagController With Bulk Requests 2024-11-11 16:38:32 +01:00
REDCODE
54c546aa10 Implemented single get method on AssetController 2024-11-11 16:38:32 +01:00
MrFastwind
5dd11eec1e feat(API): Adds Album endpoint Stubs 2024-11-11 16:38:32 +01:00
MrFastwind
ebd17f55ae feat(API): adds Repository implementation (not tested yet) 2024-11-11 16:38:32 +01:00
REDCODE
f25c322b1e Fixed typo on Assets field 2024-11-11 16:38:32 +01:00
MrFastwind
8c42051461 fix(API): Remove login permission to Banned or Disabled Users 2024-11-11 16:38:32 +01:00
MrFastwind
0b7011e4f3 feat(API):Adds Tag endpoint implementation 2024-11-11 16:38:32 +01:00
REDCODE
88e18636ab Added AssetsMapper and AssetRepository has been moved to it's own file 2024-11-11 16:38:32 +01:00
REDCODE
8f5cd758c5 Refactor 2024-11-11 16:38:32 +01:00
MrFastwind
b1bb076906 feat(API): Adds TagController class 2024-11-11 16:38:32 +01:00
REDCODE
f1cadb4a63 Removed empty lines from UserController 2024-11-11 16:38:32 +01:00
MrFastwind
b81dd0f7a4 feat(API): Implements user delete endpoint 2024-11-11 16:38:32 +01:00
REDCODE
eb25e3f7dc Added extra data on GetFullAssetDto 2024-11-11 16:38:32 +01:00
MrFastwind
83a54c4c2f refactor(API): Adds Bulk capabilities 2024-11-11 16:38:32 +01:00
REDCODE
ceaa6e8637 Merge branch 'feature/webapi/endpoint-impl' into feature/webapi/asset-endpoint 2024-11-11 16:38:32 +01:00
MrFastwind
85e743110c feat(API): Adds stubs for AssetController.cs
Adds method endpoint, dtos (detailed and previews)
2024-11-11 16:38:32 +01:00
MrFastwind
a3a47be4cd chore(API): Changes the category name of the logger 2024-11-11 16:38:32 +01:00
REDCODE
4ca6fa7c95 Merge branch 'feature/webapi/endpoint-impl' into feature/webapi/asset-endpoint 2024-11-11 16:38:32 +01:00
REDCODE
9595919d26 Merged changes from endpoint-impl 2024-11-11 16:38:32 +01:00
REDCODE
48e42fdfff Initial scaffolding for asset controller 2024-11-11 16:38:32 +01:00
REDCODE
46514c052f Added HTTP Requests unit testing, first interaction 2024-11-11 16:38:31 +01:00
REDCODE
17dd6bda0c Reverts UserRepository to use first or default 2024-11-11 16:38:31 +01:00
b52ecee6a0 Merge pull request 'feature/webapi/auth Adds working authentication' (#10) from feature/webapi/auth into develop
Reviewed-on: #10
Reviewed-by: REDCODE <aironenerowork@gmail.com>
2024-11-11 16:38:31 +01:00
MrFastwind
b1ce2fc0bb refactor(API): Cleans the code 2024-11-11 16:38:31 +01:00
MrFastwind
d2d05fdcfa fix(API): Adds last login update on login 2024-11-11 16:38:31 +01:00
MrFastwind
145f12dc8a fix(API): Fix Password hasher dependency resolver 2024-11-11 16:38:31 +01:00
MrFastwind
a75b26ef63 feat(API): Adds Password hashing 2024-11-11 16:38:31 +01:00
MrFastwind
4b08ae7b5a fix(API): Fix exception for UserRepository.cs 2024-11-11 16:38:31 +01:00
MrFastwind
cd0d04a024 fix(API): Fixes null exception against anonymous users requests 2024-11-11 16:38:31 +01:00
MrFastwind
295a0ce41d chore(docker): Removes auto generated docker-compose file for deployment 2024-11-11 16:38:31 +01:00
REDCODE
259bbe3135 Added HTTP Requests unit testing, first interaction 2024-10-07 21:59:10 +02:00
REDCODE
e11622716e Reverts UserRepository to use first or default 2024-10-07 21:58:42 +02:00
7a0e732363 Merge pull request 'feature/webapi/auth Adds working authentication' (#10) from feature/webapi/auth into develop
Reviewed-on: #10
Reviewed-by: REDCODE <aironenerowork@gmail.com>
2024-10-07 17:50:09 +00:00
MrFastwind
2f04f36659 refactor(API): Cleans the code 2024-10-07 19:12:34 +02:00
MrFastwind
9969dddb11 fix(API): Adds last login update on login 2024-10-07 16:49:17 +02:00
MrFastwind
076c1115f7 fix(API): Fix Password hasher dependency resolver 2024-10-07 15:54:55 +02:00
MrFastwind
37dbac2611 feat(API): Adds Password hashing 2024-10-07 15:16:57 +02:00
MrFastwind
79d47257f3 fix(API): Fix exception for UserRepository.cs 2024-10-07 15:14:23 +02:00
MrFastwind
bd0aabd75c fix(API): Fixes null exception against anonymous users requests 2024-10-07 10:53:09 +02:00
MrFastwind
2acd38d75b chore: Removes auto generated docker-compose file for deployment 2024-10-07 10:38:52 +02:00
MrFastwind
5df852c0c7 feature(API): Adds UserRepository.cs 2024-10-07 10:05:11 +02:00
REDCODE
4a05e5c5ce Merge branch 'develop' into feature/webapi/endpoint-impl 2024-10-06 22:43:09 +02:00
REDCODE
4875972fa4 Fixed compose pipeline. 2024-10-06 22:06:28 +02:00
REDCODE
b2038321b2 Unfucked the database creation. 2024-10-06 21:53:37 +02:00
REDCODE
932572b7c6 Added Docker run configs 2024-10-05 19:51:15 +02:00
MrFastwind
b6fc0be25c Merge remote-tracking branch 'origin/develop' into feature/webapi/endpoint-impl 2024-10-03 17:04:00 +02:00
MrFastwind
e6c27553a7 Merge branch 'feature/webapi/endpoint-impl' into feature/webapi/auth
# Conflicts:
#	Lactose/Controllers/AuthController.cs
#	Lactose/Controllers/UserController.cs
#	Lactose/Dtos/CredentialsDto.cs
#	Lactose/Repositories/IUserRepository.cs
2024-10-03 16:53:00 +02:00
MrFastwind
6a3ceb9ffc fix(API): fix retrieve of user authentication data 2024-10-02 18:22:50 +02:00
MrFastwind
dbef20e6b5 feat(API): Adds Authentication system
Swagger is not compatible with JWT token Authentication
2024-10-01 22:14:50 +02:00
REDCODE
4ed4ef82ad Removed unused .env file, added new DB migration in relationship to 8ccef5c2f6 2024-09-29 23:50:32 +02:00
MrFastwind
19b95084a6 feat(API): Adds Authentication System (Needs Testing) 2024-09-29 23:43:44 +02:00
MrFastwind
c72fcd922e refactor(API): Switch to use UserRepository AuthController.cs 2024-09-29 23:43:43 +02:00
MrFastwind
786b6128e1 refactor(API): Switch to repository pattern 2024-09-29 23:43:42 +02:00
MrFastwind
833af15e9d fix(API): Split authentication from user endpoint 2024-09-29 23:43:14 +02:00
REDCODE
51095a28e3 Removed useless usings. 2024-09-29 23:00:05 +02:00
REDCODE
8ccef5c2f6 Adds a Person who owns a specific album as a column. 2024-09-29 22:52:22 +02:00
REDCODE
b583e4d045 Modified the classname for the database context from "testDbContext" to "LactoseDbContext" 2024-09-29 22:39:41 +02:00
MrFastwind
9325ed73b8 refactor(API): Switch to use UserRepository AuthController.cs 2024-09-28 18:11:40 +02:00
MrFastwind
506ec5f54a refactor(API): Switch to repository pattern 2024-09-27 21:02:39 +02:00
MrFastwind
c95ebc7842 fix(API): Split authentication from user endpoint 2024-09-27 21:02:38 +02:00
REDCODE
22f8767c42 Removed mixed color logo, added white and black version of the logo 2024-09-24 23:54:59 +02:00
REDCODE
7a3d935df9 Added README.md 2024-09-24 23:49:29 +02:00
2131 changed files with 71763 additions and 1730 deletions

View File

@@ -23,3 +23,4 @@
**/values.dev.yaml **/values.dev.yaml
LICENSE LICENSE
README.md README.md
/storageImages

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
.jpg filter=lfs diff=lfs merge=lfs -text

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

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

5
.gitignore vendored
View File

@@ -3,3 +3,8 @@ obj/
/packages/ /packages/
riderModule.iml riderModule.iml
/_ReSharper.Caches/ /_ReSharper.Caches/
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
storageImages/
dotnet-tools.json
.opencode/
*.css.map

1
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1 @@
/dataSources/*

View File

@@ -1,384 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AnalysisUIOptions">
<option name="SCOPE_TYPE" value="3" />
</component>
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="IIS Express">BlazorTest/BlazorTest.csproj</projectFile>
<projectFile profileName="http">BlazorTest/BlazorTest.csproj</projectFile>
<projectFile profileName="https">BlazorTest/BlazorTest.csproj</projectFile>
<projectFile profileName="IIS Express">WepApiTest/WepApiTest.csproj</projectFile>
<projectFile profileName="http">WepApiTest/WepApiTest.csproj</projectFile>
<projectFile profileName="https">WepApiTest/WepApiTest.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="DatabaseLocalColorSettings">
<colors>
<entry key="eab007eb-4f50-4961-9bce-5c4940a7cdff" value="Blue" />
</colors>
</component>
<component name="DpaMonitoringSettings">
<option name="autoShow" value="false" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="HTTP Public Environment File" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" root0="FORCE_HIGHLIGHTING" />
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1013dc174d8e4a313727d545767084e3df6affa8d4723b41a5f7881e96b1d/ControllerContext.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f/Monitor.CoreCLR.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/3bd4df5aff92cabbc4d630be64227073db1b8539b3a1e47786b4b189d7cdb7/DbContext.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/449b441523c469ed34ff5a5e14f0bafcd8f097aa463655303dc19048fa44ac3/EntityFrameworkServiceCollectionExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/7b3b6985aad11c5d633d75b545f5f5f4fc7e50918ad1cb9f6b615682f5bfb76d/SingleQueryingEnumerable.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d/ControllerBase.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/9e6ecf3790a24c43487ae724bf9ea8185bdedaf6c28da376d6ea862e66453/WebApplicationBuilder.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a42ba4db448ed38dcb9fb39194c637a317a8281856b88e139e8a905ee6d12b/Guid.Windows.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/bd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182/ExceptionDispatchInfo.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/WebApiTest/GlobalUsings.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/WebApiTest/Program.cs" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="HttpClientSelectedEnvironments">
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" environment="test" />
<file url="file://$USER_HOME$/AppData/Local/Temp/whats-new-dir/whats-new.http" environment="test" />
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" environment="test" />
</component>
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
&quot;isMigrated&quot;: true
}</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProblemsViewState">
<option name="selectedTabId" value="CurrentFile" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 4
}</component>
<component name="ProjectId" id="2mIMBmlQ4hFJsyahkv7dCj0zoOT" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.BlazorTest: http.executor&quot;: &quot;Debug&quot;,
&quot;.NET Launch Settings Profile.BlazorTest: https.executor&quot;: &quot;Debug&quot;,
&quot;.NET Launch Settings Profile.WepApiTest: http.executor&quot;: &quot;Debug&quot;,
&quot;Docker.MariaDbDev.executor&quot;: &quot;Run&quot;,
&quot;Docker.WebApiDocker.executor&quot;: &quot;Run&quot;,
&quot;Docker.WebApiTest/Dockerfile.executor&quot;: &quot;Debug&quot;,
&quot;HTTP Request.WepApiTest | #1 (1).executor&quot;: &quot;Run&quot;,
&quot;HTTP Request.WepApiTest | #1.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;mariadb&quot;
]
}
}</component>
<component name="RunManager" selected=".NET Launch Settings Profile.WepApiTest: http">
<configuration name="WepApiTest | #1 (1)" type="HttpClient.HttpRequestRunConfigurationType" factoryName="HTTP Request" temporary="true" nameIsGenerated="true" path="$PROJECT_DIR$/WebApiTest/WepApiTest.http" requestIdentifier="#1" runType="Run single request">
<method v="2" />
</configuration>
<configuration name="BlazorTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="BlazorTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="BlazorTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="WepApiTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="WepApiTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="WepApiTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="BlazorTest/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="blazortest" />
<option name="containerName" value="blazortest" />
<option name="contextFolderPath" value="." />
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="BlazorTest/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration name="MariaDbDev" type="docker-deploy" factoryName="docker-image" temporary="true" server-name="Docker">
<deployment type="docker-image">
<settings>
<option name="imageTag" value="mariadb:latest" />
<option name="containerName" value="MariaDbDev" />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="MYSQL_ROOT_PASSWORD" />
<option name="value" value="testOnlyDb" />
</DockerEnvVarImpl>
</list>
</option>
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="3306" />
<option name="hostPort" value="3306" />
</DockerPortBindingImpl>
</list>
</option>
</settings>
</deployment>
<method v="2" />
</configuration>
<configuration name="WebApiDocker" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="wepapitest" />
<option name="containerName" value="wepapitest" />
<option name="contextFolderPath" value="." />
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
<DockerPortBindingImpl>
<option name="containerPort" value="5162" />
<option name="hostPort" value="5162" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="WebApiTest/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
<deployment type="docker-compose.yml">
<settings />
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="docker-image" temporary="true">
<deployment type="docker-image">
<settings />
</deployment>
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile">
<settings />
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="envFilePath" value="" />
<option name="sourceFilePath" value="docker-compose.yml" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<list>
<item itemvalue=".NET Launch Settings Profile.BlazorTest: http" />
<item itemvalue=".NET Launch Settings Profile.BlazorTest: https" />
<item itemvalue=".NET Launch Settings Profile.BlazorTest: IIS Express" />
<item itemvalue=".NET Launch Settings Profile.WepApiTest: http" />
<item itemvalue=".NET Launch Settings Profile.WepApiTest: https" />
<item itemvalue=".NET Launch Settings Profile.WepApiTest: IIS Express" />
<item itemvalue="Docker.BlazorTest/Dockerfile" />
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
<item itemvalue="Docker.WebApiDocker" />
<item itemvalue="Docker.MariaDbDev" />
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
</list>
<recent_temporary>
<list>
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
<item itemvalue="Docker.MariaDbDev" />
</list>
</recent_temporary>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
<created>1726759505353</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1726759505353</updated>
<workItem from="1726759506537" duration="8762000" />
<workItem from="1726775138143" duration="20326000" />
<workItem from="1726877247262" duration="2104000" />
<workItem from="1726879467881" duration="2586000" />
<workItem from="1726882140090" duration="5969000" />
<workItem from="1726888243937" duration="249000" />
<workItem from="1726888519041" duration="1373000" />
<workItem from="1727188917179" duration="1894000" />
</task>
<task id="LOCAL-00001" summary="BaseFiles">
<option name="closed" value="true" />
<created>1726786753746</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1726786753746</updated>
</task>
<task id="LOCAL-00002" summary="BaseFiles">
<option name="closed" value="true" />
<created>1726786763975</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1726786763975</updated>
</task>
<option name="localTasksCounter" value="3" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="BaseFiles" />
<option name="LAST_COMMIT_MESSAGE" value="BaseFiles" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true"> <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="TestDb@192.168.50.110" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff"> <data-source source="LOCAL" name="MariaDb" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
<driver-ref>mariadb</driver-ref> <driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize> <synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver> <jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://localhost:3306</jdbc-url> <jdbc-url>jdbc:mariadb://localhost:3306/</jdbc-url>
<jdbc-additional-properties> <jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" /> <property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" /> <property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
@@ -15,5 +15,29 @@
<working-dir>$ProjectFileDir$</working-dir> <working-dir>$ProjectFileDir$</working-dir>
<time-zone>Europe/Rome</time-zone> <time-zone>Europe/Rome</time-zone>
</data-source> </data-source>
<data-source source="LOCAL" name="TestDb@localhost" uuid="80498c22-67da-4c4a-8106-c4789470ef83">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:3306/TestDb</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="TestDb@pm" uuid="a0f3e480-c856-41e0-8fb4-5cac87b9faf6">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component> </component>
</project> </project>

View File

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

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

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

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<file url="PROJECT" libraries="{bootstrap-icons}" />
</component>
</project>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SwaggerSettings">
<remoteSpecificationUrls>
<UrlInfo>
<option name="urlString" value="http://192.168.51.101:5162/swagger/v1/swagger.json" />
</UrlInfo>
</remoteSpecificationUrls>
</component>
</project>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions">
<TaskOptions isEnabled="false">
<option name="arguments" value="$FileName$:$FileNameWithoutExtension$.css" />
<option name="checkSyntaxErrors" value="true" />
<option name="description" />
<option name="exitCodeBehavior" value="ERROR" />
<option name="fileExtension" value="scss" />
<option name="immediateSync" value="true" />
<option name="name" value="SCSS" />
<option name="output" value="$FileNameWithoutExtension$.css:$FileNameWithoutExtension$.css.map" />
<option name="outputFilters">
<array />
</option>
<option name="outputFromStdout" value="false" />
<option name="program" value="sass" />
<option name="runOnExternalChanges" value="true" />
<option name="scopeName" value="All Places" />
<option name="trackOnlyRoot" value="true" />
<option name="workingDir" value="$FileDir$" />
<envs />
</TaskOptions>
</component>
</project>

8
.idea/MilkyShots.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="DBE_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

37
.idea/dataSources.local.xml generated Normal file
View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="dataSourceStorageLocal" created-in="DB-243.22562.115">
<data-source name="MilkyShots DB" uuid="5c3a7e69-b472-4b75-a109-a12ad8f595c4">
<database-info product="MariaDB" version="11.5.2-MariaDB-ubu2404" jdbc-version="4.2" driver-name="MariaDB Connector/J" driver-version="3.3.3" dbms="MARIADB" exact-version="11.5.2" exact-driver-version="3.3">
<extra-name-characters>#@</extra-name-characters>
<identifier-quote-string>`</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="exact" quoted-identifiers="exact" />
<secret-storage>master_key</secret-storage>
<user-name>root</user-name>
<schema-mapping>
<introspection-scope>
<node kind="schema">
<name qname="TestDb" />
<name qname="TestDB" />
</node>
</introspection-scope>
</schema-mapping>
</data-source>
<data-source name="TestDb@localhost" uuid="dd4c02e2-288e-48c7-9edb-c171a228f8e7">
<database-info product="PostgreSQL" version="17.1 (Debian 17.1-1.pgdg120+1)" jdbc-version="4.2" driver-name="PostgreSQL JDBC Driver" driver-version="42.7.3" dbms="POSTGRES" exact-version="17.1" exact-driver-version="42.7">
<identifier-quote-string>&quot;</identifier-quote-string>
</database-info>
<case-sensitivity plain-identifiers="lower" quoted-identifiers="exact" />
<secret-storage>master_key</secret-storage>
<user-name>root</user-name>
<schema-mapping>
<introspection-scope>
<node kind="database" qname="@">
<node kind="schema" qname="@" />
</node>
</introspection-scope>
</schema-mapping>
</data-source>
</component>
</project>

19
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="MilkyShots DB" uuid="5c3a7e69-b472-4b75-a109-a12ad8f595c4">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://localhost:3306</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="TestDb@localhost" uuid="dd4c02e2-288e-48c7-9edb-c171a228f8e7">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:3306/TestDb</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/MilkyShots.iml" filepath="$PROJECT_DIR$/.idea/MilkyShots.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

45
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="a41b55c3-f6d1-4218-8aa4-78c5f70285ef" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 6
}</component>
<component name="ProjectId" id="2ohtQjPWvrPgJ9BoCV3cyJMXtHw" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;RunOnceActivity.OpenDatabaseViewOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;database.data.extractors.current.export.id&quot;: &quot;Pretty-Groovy.txt.groovy&quot;,
&quot;database.data.extractors.current.id&quot;: &quot;Pretty-Groovy.txt.groovy&quot;,
&quot;git-widget-placeholder&quot;: &quot;feature/webapi/endpoint-impl&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;C:/Users/airon&quot;
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;mariadb&quot;,
&quot;postgresql&quot;
]
}
}</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
</project>

View File

@@ -0,0 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="All in WepApiTest" type="HttpClient.HttpRequestRunConfigurationType" factoryName="HTTP Request" path="$PROJECT_DIR$/Lactose/WepApiTest.http">
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compose Deployment (Local)" type="docker-deploy" factoryName="docker-compose.yml" activateToolWindowBeforeRun="false" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="containerName" value="" />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
<option name="value" value="Docker" />
</DockerEnvVarImpl>
</list>
</option>
<option name="removeImagesOnComposeDown" value="LOCAL" />
<option name="removeOrphansOnComposeDown" value="false" />
<option name="commandLineOptions" value="--build" />
<option name="sourceFilePath" value="docker-compose.yml" />
<option name="upForceRecreate" value="true" />
<option name="upRemoveOrphans" value="true" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@@ -0,0 +1,34 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compose Deployment (PM)" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
<deployment type="docker-compose.yml">
<settings>
<option name="containerName" value="" />
<option name="envFilePath" value="" />
<option name="envFilePaths">
<list>
<option value="" />
</list>
</option>
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
<option name="value" value="Docker" />
</DockerEnvVarImpl>
</list>
</option>
<option name="removeOrphansOnComposeDown" value="false" />
<option name="commandLineOptions" value="--build" />
<option name="secondarySourceFiles">
<list>
<option value="docker-compose.pm.yml" />
</list>
</option>
<option name="sourceFilePath" value="docker-compose.yml" />
<option name="upRemoveOrphans" value="true" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,17 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Lactose: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

View File

@@ -0,0 +1,18 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MilkStream HotReload" type="RunDotNetWatch" factoryName="RunDotNetWatch">
<option name="envs">
<map>
<entry key="ASPNETCORE_ENVIRONMENT" value="Development" />
<entry key="DOTNET_ENVIRONMENT" value="Hotreload" />
</map>
</option>
<option name="exePath" value="$PROJECT_DIR$/MilkStream/bin/Debug/net8.0/MilkStream.exe" />
<option name="programParameters" value="" />
<option name="projectFilePath" value="$PROJECT_DIR$/MilkStream/MilkStream.csproj" />
<option name="projectTfm" value="net8.0" />
<option name="suppressBrowserLaunch" value="true" />
<option name="watchParameters" value="" />
<option name="workingDirectory" value="$PROJECT_DIR$/MilkStream" />
<method v="2" />
</configuration>
</component>

13
.run/MilkStream.run.xml Normal file
View File

@@ -0,0 +1,13 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MilkStream" type="docker-deploy" factoryName="dockerfile" server-name="Docker-PM">
<deployment type="dockerfile">
<settings>
<option name="containerName" value="milkstream" />
<option name="contextFolderPath" value="." />
<option name="sourceFilePath" value="MilkStream/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

97
AGENTS.md Normal file
View File

@@ -0,0 +1,97 @@
# MilkyShots Agent Guide
**Gitea repo owner:** MilkyShots
## Prerequisites
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
- **PostgreSQL with pgvector extension**
- **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`
## 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 (4 in solution)
| Project | Role | Entrypoint |
|---|---|---|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
## Build & run
```bash
dotnet build MilkyShots.sln # .NET 10, C# 12
dotnet run --project Lactose # API on :5162 (host) / :8080 (container)
dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (container)
```
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
## 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.
- **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.
## XML docs enforced
`Directory.Build.props` at solution root enables `GenerateDocumentationFile` and treats **CS1591** (missing XML comment) as error. All public APIs must have XML docs. Repo implementations use `<inheritdoc />` — keep interface docs in sync.
## Conventions
- **`HttpPut` = create, `HttpPost("{id}")` = update** — not REST-idiomatic; do not "fix" without team buy-in
- **Soft delete only** — set `DeletedAt`, never hard-delete (models: Asset, User)
- **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`)
- **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
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
When a markup pattern appears in 2+ places, extract it into a shared component.
See issue #52 for the tracking list of candidate components.
## CORS gotchas
- `CorsAllowedOrigins` env var **replaces** the JSON array entirely (semicolon-separated)
- MilkStream browser origin must be listed in Lactose's CORS
- `LactoseBaseUrl` on MilkStream = URL the browser uses to reach Lactose
## EF Core / Migrations
Migrations in `Lactose/Migrations/` — generated, do not hand-edit.
```bash
dotnet ef migrations add <Name> --project Lactose
dotnet ef database update --project Lactose
```
## Tests
No test project found in solution.

9
Butter/Butter.csproj Normal file
View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,31 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Minimal asset info needed for displaying album assets.
/// </summary>
public class AlbumAssetPreviewDto {
/// <summary>
/// Gets or sets the asset ID.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the resolution width.
/// </summary>
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height.
/// </summary>
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets whether the asset has a thumbnail.
/// </summary>
public bool HasThumbnail { get; set; }
/// <summary>
/// Gets or sets whether the asset has a preview.
/// </summary>
public bool HasPreview { get; set; }
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents the data needed to create a new album.
/// </summary>
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; }
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets the cover asset ID for the album.
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid>? Assets { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents the full details of an album, including its associated assets.
/// </summary>
public class AlbumFullDto : AlbumPreviewDto {
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid> Images { get; set; } = [];
/// <summary>
/// Gets or sets the asset preview information for rendering.
/// </summary>
public List<AlbumAssetPreviewDto> AssetPreviews { get; set; } = [];
}

View File

@@ -0,0 +1,35 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents a summary view of an album.
/// </summary>
public class AlbumPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the album.
/// </summary>
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; }
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets the person name associated with the album.
/// </summary>
public string? PersonName { get; set; }
/// <summary>
/// Gets or sets the ID of the cover asset for this album.
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the number of assets in the album.
/// </summary>
public int AssetCount { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Search and filter parameters for album queries.
/// </summary>
public class AlbumSearchParametersDto : PagedSearchParametersDto {
/// <summary>
/// Gets or sets whether to return only albums with no person assigned.
/// </summary>
public bool Unassigned { get; set; }
}

View File

@@ -0,0 +1,31 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents the data needed to update an existing album.
/// </summary>
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; }
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets whether to remove the person assignment from the album.
/// </summary>
public bool RemovePerson { get; set; }
/// <summary>
/// Gets or sets the cover asset ID for the album.
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid>? Assets { get; set; }
}

View File

@@ -0,0 +1,49 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the data needed to create a new asset.
/// </summary>
public class AssetCreateDto {
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the owner ID of the asset.
/// </summary>
public Guid Owner { get; set; }
/// <summary>
/// Gets or sets the type of the asset.
/// </summary>
public EAssetType AssetType { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// </summary>
public bool IsPublic { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the resolution width in pixels.
/// </summary>
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
public long FileSize { get; set; }
/// <summary>
/// Gets or sets the duration in seconds (for video/audio).
/// </summary>
public int? Duration { get; set; }
/// <summary>
/// Gets or sets the frame rate (for video).
/// </summary>
public int? FrameRate { get; set; }
}

View File

@@ -0,0 +1,50 @@
using Butter.Dtos.User;
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the full information needed to display an asset on a detail page.
/// </summary>
public class AssetDto : AssetPreviewDto {
/// <summary>
/// Gets or sets the type of the asset (e.g., Image, Video).
/// </summary>
public EAssetType AssetType { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// </summary>
public bool IsPublic { get; set; }
/// <summary>
/// Gets or sets the owner of the asset.
/// </summary>
public required UserInfoDto Owner { get; set; }
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
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; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
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; }
/// <summary>
/// Gets or sets the duration of the asset in seconds (for video/audio).
/// </summary>
public float Duration { get; set; }
/// <summary>
/// Gets or sets the frame rate of the asset (for video).
/// </summary>
public float FrameRate { get; set; }
}

View File

@@ -0,0 +1,51 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the minimum information needed to display an asset in a list.
/// </summary>
public class AssetPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the asset.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
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; }
/// <summary>
/// Gets or sets the resolution height of the asset in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets whether the asset has a generated thumbnail.
/// </summary>
public bool HasThumbnail { get; set; }
/// <summary>
/// Gets or sets whether the asset has a generated preview.
/// </summary>
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; }
/// <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; }
/// <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; }
/// <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; }
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
}

View File

@@ -0,0 +1,29 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the search options for a detailed search of assets.
/// </summary>
public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// <summary>
/// Gets or sets the start date for filtering assets by date range.
/// </summary>
public string? StartDate { get; set; }
/// <summary>
/// Gets or sets the end date for filtering assets by date range.
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// Gets or sets the optional asset type filter.
/// </summary>
public EAssetType? Type { get; set; }
/// <summary>
/// Gets or sets the seed for deterministic random ordering.
/// </summary>
public Guid? Seed { get; set; }
/// <summary>
/// Gets or sets whether to order results randomly instead of by date.
/// </summary>
public bool Random { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the data needed to update an existing asset.
/// </summary>
public class AssetUpdateDto {
/// <summary>
/// Gets or sets the new owner ID for the asset.
/// </summary>
public Guid? Owner { get; set; }
/// <summary>
/// Gets or sets the deletion date to set on the asset.
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// </summary>
public bool? IsPublic { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace Butter.Dtos;
/// <summary>
/// Represents the result of an authentication operation.
/// </summary>
public class AuthResultDto {
/// <summary>
/// Gets or sets the user ID if authentication succeeded.
/// </summary>
public Guid? UserId { get; set; }
/// <summary>
/// Gets or sets the JWT access token.
/// </summary>
public string? Token { get; set; }
/// <summary>
/// Gets or sets the refresh token.
/// </summary>
public string? RefreshToken { get; set; }
/// <summary>
/// Gets or sets an error message if authentication failed.
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Gets or sets whether the authentication was successful.
/// </summary>
public bool Success { get; set; }
}

16
Butter/Dtos/BulkDto.cs Normal file
View File

@@ -0,0 +1,16 @@
namespace Butter.Dtos;
/// <summary>
/// Represents a bulk operation targeting multiple entities with shared update data.
/// </summary>
/// <typeparam name="TDto">The type of the update data to apply to each entity.</typeparam>
public class BulkDto<TDto> {
/// <summary>
/// Gets or sets the list of entity IDs to update.
/// </summary>
public required List<Guid> Ids { get; set; } = [];
/// <summary>
/// Gets or sets the update data to apply to each entity.
/// </summary>
public required TDto Data { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Butter.Dtos;
/// <summary>
/// Represents user login credentials.
/// </summary>
public class CredentialsDto
{
/// <summary>
/// Gets or sets the user identifier (username or email).
/// </summary>
public required string Identifier { get; set; }
/// <summary>
/// Gets or sets the user password.
/// </summary>
public required string Password { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Butter.Dtos.Folder;
/// <summary>
/// Represents the data needed to create a new folder.
/// </summary>
public class FolderCreateDto {
/// <summary>
/// Gets or sets the base path of the folder on disk.
/// </summary>
public string BasePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the regex pattern used to extract person and group names from asset paths.
/// Must contain named groups <c>person</c> and <c>group</c>.
/// </summary>
public string? RegexPattern { get; set; }
}

View File

@@ -0,0 +1,24 @@
namespace Butter.Dtos.Folder;
/// <summary>
/// Represents the full details of a folder.
/// </summary>
public class FolderFullDto {
/// <summary>
/// Gets or sets the unique identifier of the folder.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the base path of the folder on disk.
/// </summary>
public string BasePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether the folder is active for scanning.
/// </summary>
public bool Active { get; set; }
/// <summary>
/// Gets or sets the regex pattern used to extract person and group names from asset paths.
/// Must contain named groups <c>person</c> and <c>group</c>.
/// </summary>
public string? RegexPattern { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Folder;
/// <summary>
/// Contains a sample of asset paths from a folder for regex pattern testing.
/// </summary>
public class FolderSamplePathsDto {
/// <summary>
/// Gets or sets the folder ID these paths belong to.
/// </summary>
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets a random selection of asset paths from the folder.
/// </summary>
public List<string> Paths { get; set; } = new();
}

View File

@@ -0,0 +1,20 @@
namespace Butter.Dtos.Folder;
/// <summary>
/// Represents the data needed to update an existing folder.
/// </summary>
public class FolderUpdateDto {
/// <summary>
/// Gets or sets the base path of the folder.
/// </summary>
public string? BasePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether the folder is active for scanning.
/// </summary>
public bool? Active { get; set; }
/// <summary>
/// Gets or sets the regex pattern used to extract person and group names from asset paths.
/// Must contain named groups <c>person</c> and <c>group</c>.
/// </summary>
public string? RegexPattern { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Jobs;
/// <summary>
/// Represents a response containing child jobs and a timestamp for delta-based polling.
/// </summary>
public class ChildrenResponse {
/// <summary>
/// Gets the list of child job statuses.
/// </summary>
public List<JobStatusDto> Children { get; init; } = [];
/// <summary>
/// Gets the server timestamp to use as <c>since</c> for the next delta poll.
/// </summary>
public DateTime Since { get; init; }
}

View File

@@ -0,0 +1,85 @@
using Butter.Types;
namespace Butter.Dtos.Jobs;
/// <summary>
/// Represents the status of a job in the system.
/// </summary>
public class JobStatusDto {
/// <summary>
/// Gets the unique identifier of the job.
/// </summary>
public Guid Id { get; init; }
/// <summary>
/// Gets or sets the ID of the parent job, if this is a sub-job.
/// </summary>
public Guid? ParentJobId { get; set; }
/// <summary>
/// Gets the name of the job.
/// </summary>
public string Name { get; init; } = null!;
/// <summary>
/// Gets the type of the job.
/// </summary>
public EJobType JobType { get; init; }
/// <summary>
/// Gets the current status of the job.
/// </summary>
public EJobStatus Status { get; init; }
/// <summary>
/// Gets the creation date of the job.
/// </summary>
public DateTime Created { get; init; }
/// <summary>
/// Gets the start date of the job, if it has been started.
/// </summary>
public DateTime? Started { get; init; }
/// <summary>
/// Gets the completion date of the job, if it has finished.
/// </summary>
public DateTime? Finished { get; init; }
/// <summary>
/// Gets an optional message providing additional information about the job status.
/// </summary>
public string? Message { get; init; }
/// <summary>
/// Gets the progress of the job as a value between 0 and 1.
/// </summary>
public float Progress { get; init; }
}
/// <summary>
/// Defines the types of jobs that can be executed by the system.
/// </summary>
public enum EJobType {
/// <summary>Scans the file system for new assets.</summary>
FileSystemScan,
/// <summary>Generates JPEG thumbnails for assets.</summary>
ThumbnailGeneration,
/// <summary>Generates preview images for assets.</summary>
PreviewGeneration,
/// <summary>Extracts metadata from assets.</summary>
MetadataExtraction,
/// <summary>Performs an integrity check on the system.</summary>
IntegrityCheck,
/// <summary>Computes perceptual hashes for assets.</summary>
PHashGeneration,
/// <summary>Creates missing Person entities extracted from asset paths via folder regex patterns.</summary>
CreatePersons,
/// <summary>Creates missing Person and Album entities extracted from asset paths via folder regex patterns.</summary>
CreateAlbums
}
/// <summary>
/// Represents a request to enqueue a new job.
/// </summary>
public class JobRequestDto {
/// <summary>
/// Gets the type of job to enqueue.
/// </summary>
public EJobType JobType { get; init; }
/// <summary>
/// Gets the parameters for the job.
/// </summary>
public List<string> Parameters { get; init; } = new();
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Jobs;
/// <summary>
/// Represents a paginated response of past jobs.
/// </summary>
public class PastJobsResponse {
/// <summary>
/// Gets the list of job statuses.
/// </summary>
public List<JobStatusDto> Jobs { get; init; } = [];
/// <summary>
/// Gets the total number of jobs matching the query.
/// </summary>
public int Total { get; init; }
}

15
Butter/Dtos/MediaDto.cs Normal file
View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos;
/// <summary>
/// Represents media file data with its MIME type and file path.
/// </summary>
public class MediaDto {
/// <summary>
/// Gets the MIME type of the media file.
/// </summary>
public required string MimeType { get; init; }
/// <summary>
/// Gets the file path of the media file.
/// </summary>
public required string Path { get; init; }
}

View File

@@ -0,0 +1,25 @@
namespace Butter.Dtos;
/// <summary>
/// Represents pagination parameters for a query.
/// </summary>
public class PagedParametersDto {
/// <summary>
/// Gets or sets the page number (zero-based).
/// </summary>
public int Page { get; set; } = 0;
/// <summary>
/// Gets or sets the number of items per page.
/// </summary>
public int PageSize { get; set; } = 150;
}
/// <summary>
/// Represents pagination parameters with an optional search term.
/// </summary>
public class PagedSearchParametersDto: PagedParametersDto {
/// <summary>
/// Gets or sets the search term to filter results.
/// </summary>
public string? Search { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Represents the data needed to create a new person.
/// </summary>
public class PersonCreateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
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; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
}

View File

@@ -0,0 +1,45 @@
using Butter.Dtos.Album;
namespace Butter.Dtos.Person;
/// <summary>
/// Represents the full details of a person, including associated albums.
/// </summary>
public class PersonDetailedDto {
/// <summary>
/// Gets or sets the unique identifier of the person.
/// </summary>
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID used as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture (0100).
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture (0100).
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the total number of non-deleted albums.
/// </summary>
public int TotalAlbums { get; set; }
/// <summary>
/// Gets or sets the total number of non-deleted assets across all albums.
/// </summary>
public int TotalAssets { get; set; }
/// <summary>
/// Gets or sets the list of album previews associated with this person.
/// </summary>
public List<AlbumPreviewDto>? Albums { get; set; }
}

View File

@@ -0,0 +1,35 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Represents a summary view of a person.
/// </summary>
public class PersonPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the person.
/// </summary>
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID used as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the number of albums associated with this person.
/// </summary>
public int TotalAlbums { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Represents the data needed to update an existing person.
/// </summary>
public class PersonUpdateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
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; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
}

15
Butter/Dtos/RefreshDto.cs Normal file
View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos;
/// <summary>
/// Represents a request to refresh an authentication token.
/// </summary>
public class RefreshDto {
/// <summary>
/// Gets the user ID requesting the token refresh.
/// </summary>
public required Guid UserId { get; init; }
/// <summary>
/// Gets the refresh token to validate.
/// </summary>
public required string RefreshToken { get; init; }
}

View File

@@ -0,0 +1,33 @@
using Butter.Settings;
namespace Butter.Dtos.Settings;
/// <summary>
/// Represents an application setting with its value, type, and display configuration.
/// </summary>
public class SettingDto {
/// <summary>
/// Gets or sets the name of the setting.
/// </summary>
public required string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the current value of the setting.
/// </summary>
public string? Value { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the description of the setting.
/// </summary>
public required string? Description { get; set; }
/// <summary>
/// Gets or sets the data type of the setting value.
/// </summary>
public required EType Type { get; set; }
/// <summary>
/// Gets or sets how the setting should be displayed in the UI.
/// </summary>
public required DisplayType DisplayType { get; set; }
/// <summary>
/// Gets or sets the available options for the setting (for range types: min, max, step).
/// </summary>
public string[]? Options { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// A MIME type and its associated asset count for statistics.
/// </summary>
public class MimeTypeStatDto {
/// <summary>
/// Gets or sets the MIME type string.
/// </summary>
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of assets with this MIME type.
/// </summary>
public int Count { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// Monthly growth statistics for assets and users.
/// </summary>
public class MonthlyStatDto {
/// <summary>
/// Gets or sets the year.
/// </summary>
public int Year { get; set; }
/// <summary>
/// Gets or sets the month (1-12).
/// </summary>
public int Month { get; set; }
/// <summary>
/// Gets or sets the number of new assets added in this month.
/// </summary>
public int NewAssets { get; set; }
/// <summary>
/// Gets or sets the number of new users registered in this month.
/// </summary>
public int NewUsers { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// A resolution bucket with a label and the count of assets that fall into it.
/// </summary>
public class ResolutionBucketDto {
/// <summary>
/// Gets or sets the display label for the resolution bucket.
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of assets in this resolution bucket.
/// </summary>
public int Count { get; set; }
}

View File

@@ -0,0 +1,143 @@
using Butter.Types;
namespace Butter.Dtos.Stats;
/// <summary>
/// Comprehensive statistics about the MilkyShots instance, gatherable from fast aggregate queries.
/// </summary>
public class StatsDto {
/// <summary>
/// Gets or sets the total number of assets (excluding soft-deleted).
/// </summary>
public int TotalAssets { get; set; }
/// <summary>
/// Gets or sets the total number of registered users (excluding soft-deleted).
/// </summary>
public int TotalUsers { get; set; }
/// <summary>
/// Gets or sets the total number of albums.
/// </summary>
public int TotalAlbums { get; set; }
/// <summary>
/// Gets or sets the total number of tags.
/// </summary>
public int TotalTags { get; set; }
/// <summary>
/// Gets or sets the total number of people.
/// </summary>
public int TotalPeople { get; set; }
/// <summary>
/// Gets or sets the total number of detected faces.
/// </summary>
public int TotalFaces { get; set; }
/// <summary>
/// Gets or sets the total number of watched folders.
/// </summary>
public int TotalFolders { get; set; }
/// <summary>
/// Gets or sets the asset count broken down by type (Image, Video, Animation, PhotoSphere).
/// </summary>
public Dictionary<EAssetType, int> AssetsByType { get; set; } = [];
/// <summary>
/// Gets or sets the total storage used by all assets in bytes.
/// </summary>
public long TotalStorageBytes { get; set; }
/// <summary>
/// Gets or sets the storage used broken down by asset type.
/// </summary>
public Dictionary<EAssetType, long> StorageByType { get; set; } = [];
/// <summary>
/// Gets or sets the user count broken down by access level.
/// </summary>
public Dictionary<EAccessLevel, int> UsersByAccessLevel { get; set; } = [];
/// <summary>
/// Gets or sets the number of assets added in the last 7 days.
/// </summary>
public int AssetsAddedLast7Days { get; set; }
/// <summary>
/// Gets or sets the number of assets added in the last 30 days.
/// </summary>
public int AssetsAddedLast30Days { get; set; }
/// <summary>
/// Gets or sets the number of users registered in the last 30 days.
/// </summary>
public int UsersRegisteredLast30Days { get; set; }
/// <summary>
/// Gets or sets the number of assets with no associated folder.
/// </summary>
public int OrphanAssets { get; set; }
/// <summary>
/// Gets or sets the number of publicly shared assets.
/// </summary>
public int PublicAssets { get; set; }
/// <summary>
/// Gets or sets the number of non-publicly-shared assets.
/// </summary>
public int PrivateAssets { get; set; }
/// <summary>
/// Gets or sets the number of image assets missing resolution metadata.
/// </summary>
public int AssetsMissingMetadata { get; set; }
/// <summary>
/// Gets or sets the number of assets missing a generated thumbnail.
/// </summary>
public int AssetsMissingThumbnail { get; set; }
/// <summary>
/// Gets or sets the number of assets whose thumbnail was generated at a different size than the current setting.
/// </summary>
public int AssetsMissingThumbnailStale { get; set; }
/// <summary>
/// Gets or sets the number of assets missing a generated preview.
/// </summary>
public int AssetsMissingPreviews { get; set; }
/// <summary>
/// Gets or sets the number of assets whose preview was generated at a different size than the current setting.
/// </summary>
public int AssetsMissingPreviewsStale { get; set; }
/// <summary>
/// Gets or sets the number of assets missing a perceptual hash.
/// </summary>
public int AssetsMissingPhash { get; set; }
/// <summary>
/// Gets or sets the number of assets with no person assigned (no album with a PersonOwner).
/// </summary>
public int AssetsWithNoPerson { get; set; }
/// <summary>
/// Gets or sets the number of assets with no album assigned.
/// </summary>
public int AssetsWithNoAlbum { get; set; }
/// <summary>
/// Gets or sets the number of albums without a cover image.
/// </summary>
public int AlbumsMissingCover { get; set; }
/// <summary>
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
/// </summary>
/// <remarks>Not wired yet — reserved for future use.</remarks>
public int CosplayersMissingCover { get; set; }
/// <summary>
/// Gets or sets the top tags by asset count.
/// </summary>
public List<TagStatDto> TopTags { get; set; } = [];
/// <summary>
/// Gets or sets the resolution distribution of assets.
/// </summary>
public List<ResolutionBucketDto> ResolutionDistribution { get; set; } = [];
/// <summary>
/// Gets or sets the file format breakdown by MIME type.
/// </summary>
public List<MimeTypeStatDto> FileFormatBreakdown { get; set; } = [];
/// <summary>
/// Gets or sets the monthly new-asset and new-user counts for the last 12 months.
/// </summary>
public List<MonthlyStatDto> MonthlyGrowth { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// A tag and its associated asset count for statistics.
/// </summary>
public class TagStatDto {
/// <summary>
/// Gets or sets the tag identifier.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of assets associated with the tag.
/// </summary>
public int AssetCount { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Tag;
/// <summary>
/// Represents the data needed to create a new tag.
/// </summary>
public class TagCreateDto {
/// <summary>
/// Gets or sets the name of the tag.
/// </summary>
public required string Name { get; set; }
/// <summary>
/// Gets or sets the parent tag ID, if this tag should be a child.
/// </summary>
public Guid? Parent { get; set; }
}

23
Butter/Dtos/Tag/TagDto.cs Normal file
View File

@@ -0,0 +1,23 @@
namespace Butter.Dtos.Tag;
/// <summary>
/// Represents a tag with its hierarchical relationships.
/// </summary>
public class TagDto {
/// <summary>
/// Gets or sets the unique identifier of the tag.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the tag.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the parent tag.
/// </summary>
public TagDto? Parent { get; set; }
/// <summary>
/// Gets or sets the child tags.
/// </summary>
public List<TagDto>? Children { get; set; } = [];
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Tag;
/// <summary>
/// Represents the data needed to update an existing tag.
/// </summary>
public class TagUpdateDto {
/// <summary>
/// Gets or sets the name of the tag.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the parent tag ID.
/// </summary>
public Guid? Parent { get; set; }
}

View File

@@ -0,0 +1,25 @@
using Butter.Types;
namespace Butter.Dtos.User;
/// <summary>
/// Represents the data needed to create a new user by an administrator.
/// </summary>
public class UserCreateDto {
/// <summary>
/// Gets or sets the username.
/// </summary>
public required string Username { get; set; }
/// <summary>
/// Gets or sets the email address.
/// </summary>
public required string Email { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public required string Password { get; set; }
/// <summary>
/// Gets or sets the initial access level.
/// </summary>
public EAccessLevel? AccessLevel { get; set; }
}

View File

@@ -0,0 +1,49 @@
using Butter.Types;
namespace Butter.Dtos.User;
/// <summary>
/// Represents the public information of a user.
/// </summary>
public class UserInfoDto {
/// <summary>
/// Gets or sets the unique identifier of the user.
/// </summary>
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the username.
/// </summary>
public required string Username { get; set; }
/// <summary>
/// Gets or sets the email address.
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Gets or sets the account creation date.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the last update date.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the last login date.
/// </summary>
public DateTime? LastLogin { get; set; }
/// <summary>
/// Gets or sets the date the user was banned, if applicable.
/// </summary>
public DateTime? BannedAt { get; set; }
/// <summary>
/// Gets or sets the date the user was deleted (soft delete), if applicable.
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets whether the user is currently banned.
/// </summary>
public bool IsBanned { get; set; }
/// <summary>
/// Gets or sets the access level of the user.
/// </summary>
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
}

View File

@@ -0,0 +1,19 @@
namespace Butter.Dtos.User;
/// <summary>
/// Represents the data needed to register a new user.
/// </summary>
public class UserRegisterDto {
/// <summary>
/// Gets or sets the desired username.
/// </summary>
public required string Username { get; set; }
/// <summary>
/// Gets or sets the email address.
/// </summary>
public required string Email { get; set; }
/// <summary>
/// Gets or sets the password.
/// </summary>
public required string Password { get; set; }
}

View File

@@ -0,0 +1,41 @@
using Butter.Types;
namespace Butter.Dtos.User;
/// <summary>
/// Represents the data needed to update an existing user.
/// </summary>
public class UserUpdateDto {
/// <summary>
/// Gets or sets the ID of the user to update.
/// </summary>
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the new username.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// Gets or sets the new email address.
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Gets or sets the new password.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the current password (required for self-service password changes).
/// </summary>
public string? OldPassword { get; set; }
/// <summary>
/// Gets or sets whether the user is banned.
/// </summary>
public bool? IsBanned { get; set; }
/// <summary>
/// Gets or sets whether the user is deleted (soft delete).
/// </summary>
public bool? IsDeleted { get; set; }
/// <summary>
/// Gets or sets the new access level.
/// </summary>
public EAccessLevel? AccessLevel { get; set; }
}

121
Butter/MimeTypes.cs Normal file
View File

@@ -0,0 +1,121 @@
namespace Butter;
/// <summary>
/// Represents a mapping between a MIME type and its associated file extensions.
/// </summary>
public struct MimeTypeMap(string mimeType, string[] extensions) {
/// <summary>
/// Gets or sets the MIME type string (e.g., "image/jpeg").
/// </summary>
public string MimeType { get; set; } = mimeType;
/// <summary>
/// Gets or sets the file extensions associated with this MIME type.
/// </summary>
public string[] Extensions { get; set; } = extensions;
}
/// <summary>
/// Provides predefined mappings of MIME types to file extensions for images and videos.
/// </summary>
public static class MimeTypes{
/// <summary>
/// Gets the MIME type mappings for image formats.
/// </summary>
public static readonly MimeTypeMap[] Image = [
new("image/avif", [".avif", ".avifs"]),
new("image/bmp}", [".bmp"]),
new("image/cgm", [".cgm"]),
new("image/g3fax", [".g3"]),
new("image/gif", [".gif"]),
new("image/heic", [".heif", ".heic"]),
new("image/ief", [".ief"]),
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/pjpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfi", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/png", [".png"]),
new("image/prs.btif", [".btif"]),
new("image/svg+xml", [".svg", ".svgz"]),
new("image/tiff", [".tif", ".tiff"]),
new("image/vnd.adobe.photoshop", [".psd"]),
new("image/vnd.djvu", [".djv", ".djvu"]),
new("image/vnd.dwg", [".dwg"]),
new("image/vnd.dxf", [".dxf"]),
new("image/vnd.fastbidsheet", [".fbs"]),
new("image/vnd.fpx", [".fpx"]),
new("image/vnd.fst", [".fst"]),
new("image/vnd.fujixerox.edmics-mmr", [".mmr"]),
new("image/vnd.fujixerox.edmics-rlc", [".rlc"]),
new("image/vnd.ms-modi", [".mdi"]),
new("image/vnd.net-fpx", [".npx"]),
new("image/vnd.wap.wbmp", [".wbmp"]),
new("image/vnd.xiff", [".xif"]),
new("image/webp", [".webp"]),
new("image/x-adobe-dng", [".dng"]),
new("image/x-canon-cr2", [".cr2"]),
new("image/x-canon-crw", [".crw"]),
new("image/x-cmu-raster", [".ras"]),
new("image/x-cmx", [".cmx"]),
new("image/x-epson-erf", [".erf"]),
new("image/x-freehand", [".fh", ".fh4", ".fh5", ".fh7", ".fhc"]),
new("image/x-fuji-raf", [".raf"]),
new("image/x-icns", [".icns"]),
new("image/x-icon", [".ico"]),
new("image/x-kodak-dcr", [".dcr"]),
new("image/x-kodak-k25", [".k25"]),
new("image/x-kodak-kdc", [".kdc"]),
new("image/x-minolta-mrw", [".mrw"]),
new("image/x-nikon-nef", [".nef"]),
new("image/x-olympus-orf", [".orf"]),
new("image/x-panasonic-raw", [".raw", ".rw2", ".rwl"]),
new("image/x-pcx", [".pcx"]),
new("image/x-pentax-pef", [".pef", ".ptx"]),
new("image/x-pict", [".pct", ".pic"]),
new("image/x-portable-anymap", [".pnm"]),
new("image/x-portable-bitmap", [".pbm"]),
new("image/x-portable-graymap", [".pgm"]),
new("image/x-portable-pixmap", [".ppm"]),
new("image/x-rgb", [".rgb"]),
new("image/x-sigma-x3f", [".x3f"]),
new("image/x-sony-arw", [".arw"]),
new("image/x-sony-sr2", [".sr2"]),
new("image/x-sony-srf", [".srf"]),
new("image/x-xbitmap", [".xbm"]),
new("image/x-xpixmap", [".xpm"]),
new("image/x-xwindowdump", [".xwd"])
];
/// <summary>
/// Gets the MIME type mappings for video formats.
/// </summary>
public static readonly MimeTypeMap[] Video = [
new("video/3gpp", [".3gp"]),
new("video/3gpp2", [".3g2"]),
new("video/h261", [".h261"]),
new("video/h263", [".h263"]),
new("video/h264", [".h264"]),
new("video/jpeg", [".jpgv"]),
new("video/jpm", [".jpgm, .jpm"]),
new("video/mj2", [".mj2, .mjp2"]),
new("video/mp2t", [".ts"]),
new("video/mp4", [".mp4, .mp4v, .mpg4"]),
new("video/mpeg", [".m1v, .m2v, .mpa, .mpe, .mpeg, .mpg"]),
new("video/ogg", [".ogv"]),
new("video/quicktime", [".mov", ".qt"]),
new("video/vnd.fvt", [".fvt"]),
new("video/vnd.mpegurl", [".m4u", ".mxu"]),
new("video/vnd.ms-playready.media.pyv", [".pyv"]),
new("video/vnd.vivo", [".viv"]),
new("video/webm", [".webm"]),
new("video/x-f4v", [".f4v"]),
new("video/x-fli", [".fli"]),
new("video/x-flv", [".flv"]),
new("video/x-m4v", [".m4v"]),
new("video/x-matroska", [".mkv"]),
new("video/x-ms-asf", [".asf", ".asx"]),
new("video/x-ms-wm", [".wm"]),
new("video/x-ms-wmv", [".wmv"]),
new("video/x-ms-wmx", [".wmx"]),
new("video/x-ms-wvx", [".wvx"]),
new("video/x-msvideo", [".avi"]),
new("video/x-sgi-movie", [".movie"])
];
}

View File

@@ -0,0 +1,42 @@
namespace Butter.Settings;
/// <summary>
/// Defines the display types available for application settings.
/// </summary>
public enum DisplayType {
/// <summary>Text input field.</summary>
Text = 0,
/// <summary>Password input field.</summary>
Password = 1,
/// <summary>Integer number input.</summary>
IntNumber = 2,
/// <summary>Floating-point number input.</summary>
FloatNumber = 3,
/// <summary>Checkbox toggle.</summary>
Checkbox = 4,
/// <summary>Switch toggle.</summary>
Switch = 5,
/// <summary>Date and time picker.</summary>
DateTimePicker = 6,
/// <summary>Time picker.</summary>
TimePicker = 7,
/// <summary>Power of 2 selection.</summary>
Po2W = 8,
}
/// <summary>
/// Provides extension methods for the <see cref="DisplayType"/> enum.
/// </summary>
public static class DisplayTypeExtension {
/// <summary>
/// Converts a string value to its corresponding <see cref="DisplayType"/>.
/// </summary>
/// <param name="type">The string to convert.</param>
/// <returns>The parsed <see cref="DisplayType"/> value, or null if the string is not a valid display type.</returns>
public static DisplayType? ToDisplayType(this string type) {
var success = Enum.TryParse(type, out DisplayType result);
if (success) return result;
return null;
}
}

34
Butter/Settings/EType.cs Normal file
View File

@@ -0,0 +1,34 @@
namespace Butter.Settings;
/// <summary>
/// Defines the data types available for application setting values.
/// </summary>
public enum EType {
/// <summary>String/text value.</summary>
String = 0,
/// <summary>Integer numeric value.</summary>
Integer = 1,
/// <summary>Boolean true/false value.</summary>
Boolean = 2,
/// <summary>Floating-point numeric value.</summary>
Float = 3,
/// <summary>Date and time value.</summary>
DateTime = 4
};
/// <summary>
/// Provides extension methods for the <see cref="EType"/> enum.
/// </summary>
public static class ETypeExtension {
/// <summary>
/// Converts a string value to its corresponding <see cref="EType"/>.
/// </summary>
/// <param name="type">The string to convert.</param>
/// <returns>The parsed <see cref="EType"/> value, or null if the string is not a valid type.</returns>
public static EType? ToType(this string type) {
var success = Enum.TryParse(type, out EType result);
if (success) return result;
return null;
}
}

View File

@@ -0,0 +1,65 @@
namespace Butter.Settings;
/// <summary>
/// Defines the available application settings keys.
/// </summary>
public enum Settings {
/// <summary>Enable or disable self user registration.</summary>
UserRegistrationEnabled,
/// <summary>Enable or disable folder scanning.</summary>
FolderScanEnabled,
/// <summary>Interval in minutes for folder scanning.</summary>
FolderScanInterval,
/// <summary>Enable or disable file upload.</summary>
FileUploadEnabled,
/// <summary>Maximum file size for uploads in bytes.</summary>
FileUploadMaxSize,
/// <summary>Path to store thumbnails.</summary>
ThumbnailPath,
/// <summary>Path to store previews.</summary>
PreviewPath,
/// <summary>Longest side size for thumbnails.</summary>
ThumbnailSize,
/// <summary>Longest side size for previews.</summary>
PreviewSize,
/// <summary>Maximum number of concurrent jobs.</summary>
MaxConcurrentJobs,
/// <summary>Number of days to keep completed/failed/canceled jobs.</summary>
JobRetentionDays,
/// <summary>Number of assets to process per sub-job batch.</summary>
JobBatchSize,
/// <summary>Quality setting (1100) for WebP thumbnail encoding.</summary>
ThumbnailQuality,
/// <summary>Quality setting (1100) for WebP preview encoding.</summary>
PreviewQuality
}
/// <summary>
/// Provides extension methods for the <see cref="Settings"/> enum.
/// </summary>
public static class SettingsExtensions {
/// <summary>
/// Converts a <see cref="Settings"/> value to its human-readable display string.
/// </summary>
/// <param name="setting">The setting to convert.</param>
/// <returns>A human-readable string representation of the setting.</returns>
public static string AsString(this Settings setting) {
return setting switch {
Settings.UserRegistrationEnabled => "User Registration Enabled",
Settings.FolderScanEnabled => "Folder Scan Enabled",
Settings.FolderScanInterval => "Folder Scan Interval",
Settings.FileUploadEnabled => "File Upload Enabled",
Settings.FileUploadMaxSize => "File Upload MaxSize",
Settings.ThumbnailPath => "Thumbnail Path",
Settings.PreviewPath => "Preview Path",
Settings.ThumbnailSize => "Thumbnail Size",
Settings.PreviewSize => "Preview Size",
Settings.MaxConcurrentJobs => "Max Concurrent Jobs",
Settings.JobRetentionDays => "Job Retention Days",
Settings.JobBatchSize => "Job Batch Size",
Settings.ThumbnailQuality => "Thumbnail Quality",
Settings.PreviewQuality => "Preview Quality",
_ => setting.ToString() // Fallback to the enum name
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Butter.Types;
/// <summary>
/// Defines the access levels for users in the system.
/// </summary>
public enum EAccessLevel {
/// <summary>Standard user with basic permissions.</summary>
User,
/// <summary>Curator with elevated permissions for managing assets.</summary>
Curator,
/// <summary>Administrator with full system access.</summary>
Admin
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Types;
/// <summary>
/// Defines the types of assets supported by the system.
/// </summary>
public enum EAssetType {
/// <summary>Standard image files (JPEG, PNG, etc.).</summary>
Image,
/// <summary>Video files (MP4, MOV, etc.).</summary>
Video,
/// <summary>Animated images (GIF, etc.).</summary>
Animation,
/// <summary>360-degree photo spheres.</summary>
PhotoSphere
}

View File

@@ -0,0 +1,21 @@
namespace Butter.Types;
/// <summary>
/// Defines the possible states of a job in the system.
/// </summary>
public enum EJobStatus {
/// <summary>Not yet started.</summary>
Queued,
/// <summary>Actively executing.</summary>
Running,
/// <summary>Waiting for sub-jobs or paused.</summary>
Waiting,
/// <summary>Successfully finished.</summary>
Completed,
/// <summary>Finished, but some sub-jobs or steps had errors.</summary>
CompletedWithErrors,
/// <summary>Finished with an error.</summary>
Failed,
/// <summary>Canceled by user or system.</summary>
Canceled
}

6
Directory.Build.props Normal file
View File

@@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<WarningsAsErrors>CS1591</WarningsAsErrors>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<NoWarn>$(NoWarn);RS1038;RS2008</NoWarn>
</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" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,132 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace Lactose.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OnlyUtcDateTimeAnalyzer : DiagnosticAnalyzer
{
const string Category = "Correctness";
static readonly DiagnosticDescriptor DateTimeNowRule = new(
id: "MS001",
title: "Use DateTime.UtcNow instead of DateTime.Now",
messageFormat: "Use DateTime.UtcNow instead of DateTime.Now to ensure UTC semantics",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
static readonly DiagnosticDescriptor DateTimeTodayRule = new(
id: "MS002",
title: "Use DateTime.UtcNow.Date instead of DateTime.Today",
messageFormat: "Use DateTime.UtcNow.Date instead of DateTime.Today to ensure UTC semantics",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
static readonly DiagnosticDescriptor DateTimeDateRule = new(
id: "MS003",
title: "DateTime.Date may return non-UTC value",
messageFormat: "DateTime.Date produces a DateTime with Kind=Local; ensure this is intentional or use DateTime.UtcNow.Date",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
static readonly DiagnosticDescriptor SpecifyKindRule = new(
id: "MS004",
title: "DateTime.SpecifyKind must use DateTimeKind.Utc",
messageFormat: "DateTime.SpecifyKind with {0} is not allowed; use DateTimeKind.Utc",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(DateTimeNowRule, DateTimeTodayRule, DateTimeDateRule, SpecifyKindRule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}
void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
{
var memberAccess = (MemberAccessExpressionSyntax)context.Node;
if (memberAccess.Name is not IdentifierNameSyntax memberName)
return;
switch (memberName.Identifier.Text)
{
case "Now":
if (IsSystemDateTime(context, memberAccess.Expression))
context.ReportDiagnostic(Diagnostic.Create(DateTimeNowRule, memberAccess.GetLocation()));
break;
case "Today":
if (IsSystemDateTime(context, memberAccess.Expression))
context.ReportDiagnostic(Diagnostic.Create(DateTimeTodayRule, memberAccess.GetLocation()));
break;
case "Date":
{
var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression, context.CancellationToken);
if (typeInfo.Type is INamedTypeSymbol namedType &&
namedType.SpecialType == SpecialType.System_DateTime)
{
context.ReportDiagnostic(Diagnostic.Create(DateTimeDateRule, memberAccess.GetLocation()));
}
break;
}
}
}
void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return;
if (memberAccess.Name is not IdentifierNameSyntax { Identifier.Text: "SpecifyKind" })
return;
if (!IsSystemDateTime(context, memberAccess.Expression))
return;
if (invocation.ArgumentList.Arguments.Count < 2)
return;
var kindArg = invocation.ArgumentList.Arguments[1].Expression;
var kindValue = context.SemanticModel.GetConstantValue(kindArg, context.CancellationToken);
if (!kindValue.HasValue || kindValue.Value is not int intKind)
return;
var kindName = intKind switch
{
0 => "DateTimeKind.Unspecified",
1 => "DateTimeKind.Utc",
2 => "DateTimeKind.Local",
_ => $"DateTimeKind value {intKind}"
};
if (intKind != 1)
{
context.ReportDiagnostic(Diagnostic.Create(
SpecifyKindRule,
kindArg.GetLocation(),
kindName));
}
}
static bool IsSystemDateTime(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
{
var typeInfo = context.SemanticModel.GetTypeInfo(expression, context.CancellationToken);
return typeInfo.Type is INamedTypeSymbol named &&
named.SpecialType == SpecialType.System_DateTime;
}
}

View File

@@ -0,0 +1,109 @@
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;
namespace Lactose.Analyzers;
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(OnlyUtcDateTimeCodeFix))]
[Shared]
public class OnlyUtcDateTimeCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create("MS001", "MS002", "MS004");
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
return;
var diagnostic = context.Diagnostics[0];
var diagnosticSpan = diagnostic.Location.SourceSpan;
var node = root.FindNode(diagnosticSpan);
switch (diagnostic.Id)
{
case "MS001":
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with DateTime.UtcNow",
createChangedDocument: ct => ReplaceNowWithUtcNow(context.Document, node, ct),
equivalenceKey: "MS001"),
diagnostic);
break;
case "MS002":
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with DateTime.UtcNow.Date",
createChangedDocument: ct => ReplaceTodayWithUtcNowDate(context.Document, node, ct),
equivalenceKey: "MS002"),
diagnostic);
break;
case "MS004":
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with DateTimeKind.Utc",
createChangedDocument: ct => ReplaceWithUtcKind(context.Document, node, ct),
equivalenceKey: "MS004"),
diagnostic);
break;
}
}
static async Task<Document> ReplaceNowWithUtcNow(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
if (node is MemberAccessExpressionSyntax memberAccess)
{
var utcNow = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("DateTime"),
SyntaxFactory.IdentifierName("UtcNow"));
editor.ReplaceNode(memberAccess, utcNow.WithTriviaFrom(memberAccess));
}
return editor.GetChangedDocument();
}
static async Task<Document> ReplaceTodayWithUtcNowDate(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
if (node is MemberAccessExpressionSyntax memberAccess)
{
var utcNow = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("DateTime"),
SyntaxFactory.IdentifierName("UtcNow"));
var utcNowDotDate = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
utcNow,
SyntaxFactory.IdentifierName("Date"));
editor.ReplaceNode(memberAccess, utcNowDotDate.WithTriviaFrom(memberAccess));
}
return editor.GetChangedDocument();
}
static async Task<Document> ReplaceWithUtcKind(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
if (node is ArgumentSyntax argument)
{
var utcKind = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("DateTimeKind"),
SyntaxFactory.IdentifierName("Utc"));
editor.ReplaceNode(argument.Expression, utcKind.WithTriviaFrom(argument.Expression));
}
return editor.GetChangedDocument();
}
}

View File

@@ -1,4 +0,0 @@
ServerName="MariaDbDev"
DbUser="root"
DbPassword="testOnlyDb"
DatabaseName="WebApiTest"

View File

@@ -0,0 +1,79 @@
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
namespace Lactose.Authorization;
/*
* Technical note:
*
* The RefreshTokenTransformation is used to add a claim to the user's claims principal that indicates if
* the user's login is still valid. while UnexpiredLoginRequirement is used to check the claim.
*
* More Claim Transformation and Requirements can be added with IClaimsTransformation IAuthorizationRequirement and
* added to Program.cs
*/
/// <summary>
/// Authorization requirement that checks whether the user's login (refresh token) is still valid.
/// </summary>
public class UnexpiredLoginRequirement : IAuthorizationRequirement {
/// <summary>
/// Handles the <see cref="UnexpiredLoginRequirement"/> by checking the login validity claim.
/// </summary>
public class RefreshTokenValidityHandler : AuthorizationHandler<UnexpiredLoginRequirement> {
/// <inheritdoc />
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
UnexpiredLoginRequirement requirement
) {
Claim? firstOrDefault
= context.User.Claims.FirstOrDefault(claim => claim.Type == RefreshTokenTransformation.ClaimType);
if (firstOrDefault is not { Value: "true" }) {
context.Fail();
return Task.CompletedTask;
}
context.Succeed(requirement);
return Task.CompletedTask;
}
}
}
/// <summary>
/// Claims transformation that adds a login validity claim based on the user's refresh token status.
/// </summary>
public class RefreshTokenTransformation(
IUserRepository userRepository,
LactoseAuthService authService
) : IClaimsTransformation
{
/// <summary>
/// The claim type used to indicate login validity.
/// </summary>
public const string ClaimType = "LoginValid";
/// <inheritdoc />
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
foreach (ClaimsIdentity identity in principal.Identities) {
if (identity.HasClaim(claim => claim.Type == ClaimType)) {
identity.RemoveClaim(identity.FindFirst(ClaimType)!);
}
}
}
var userData = authService.GetUserData(principal);
if (userData == null) return Task.FromResult(principal);
var user = userRepository.Find(userData.Id);
if (user == null) return Task.FromResult(principal);
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
principal.AddIdentity(claimsIdentity);
return Task.FromResult(principal);
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace Lactose.Configuration;
/// <summary>
/// Configuration for the JWT signing key.
/// </summary>
public class SignKeyConfiguration {
/// <summary>
/// The configuration section name for the signing key.
/// </summary>
public const string SectionName = "SignKey";
/// <summary>
/// Gets or sets the signing key string. Must be at least 256 bits (32 characters).
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Creates a <see cref="SecurityKey"/> from the configured key string.
/// </summary>
/// <returns>A symmetric security key.</returns>
public SecurityKey GetSecurityKey() => new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this.Key));
}

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.Options;
namespace Lactose.Configuration;
/// <summary>
/// Configures <see cref="SignKeyConfiguration"/> from the application configuration.
/// </summary>
public class SignKeyProvider(IConfiguration configuration) : IConfigureOptions<SignKeyConfiguration> {
const string SectionName = SignKeyConfiguration.SectionName+":Key";
/// <inheritdoc />
public void Configure(SignKeyConfiguration options) {
if (configuration[SectionName]!.Length <= 32 ) {
throw new Exception("SignKey:Key is not 256 bits long");
}
options.Key = configuration[SectionName] ?? throw new Exception("SignKey:Key is not set");
}
/// <summary>
/// Gets a configured <see cref="SignKeyConfiguration"/> instance.
/// </summary>
/// <returns>The configured signing key configuration.</returns>
public SignKeyConfiguration Get() {
var options = new SignKeyConfiguration();
this.Configure(options);
return options;
}
}

View File

@@ -0,0 +1,91 @@
using Lactose.Models;
namespace Lactose.Context;
/// <summary>
/// Entity Framework Core database context for the Lactose application.
/// </summary>
public class LactoseDbContext : DbContext {
/// <summary>
/// Gets or sets the assets DbSet.
/// </summary>
public DbSet<Asset> Assets { get; set; }
/// <summary>
/// Gets or sets the albums DbSet.
/// </summary>
public DbSet<Album> Albums { get; set; }
/// <summary>
/// Gets or sets the faces DbSet.
/// </summary>
public DbSet<Face> Faces { get; set; }
/// <summary>
/// Gets or sets the people DbSet.
/// </summary>
public DbSet<Person> People { get; set; }
/// <summary>
/// Gets or sets the users DbSet.
/// </summary>
public DbSet<User> Users { get; set; }
/// <summary>
/// Gets or sets the tags DbSet.
/// </summary>
public DbSet<Tag> Tags { get; set; }
/// <summary>
/// Gets or sets the folders DbSet.
/// </summary>
public DbSet<Folder> Folders { get; set; }
/// <summary>
/// Gets or sets the settings DbSet.
/// </summary>
public DbSet<Setting> Settings { get; set; }
/// <summary>
/// Gets or sets the job records DbSet.
/// </summary>
public DbSet<JobRecord> JobRecords { get; set; }
/// <summary>
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
/// </summary>
/// <param name="options">The database context options.</param>
public LactoseDbContext(DbContextOptions options) : base(options) {}
/// <inheritdoc />
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
configurationBuilder.Properties<DateTime>()
.HaveColumnType("timestamp with time zone");
configurationBuilder.Properties<DateTime?>()
.HaveColumnType("timestamp with time zone");
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.HasPostgresExtension("vector");
//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>().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);
//Face Relationships
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
//Person Relationships
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);
//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);
//Folder Relationships
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
}
}

View File

@@ -1,39 +0,0 @@
using Lactose.Models;
namespace Lactose.Context;
public class TestDbContext : DbContext {
public DbSet<Asset> Assets { get; set; }
public DbSet<Album> Albums { get; set; }
public DbSet<Face> Faces { get; set; }
public DbSet<Person> People { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<Tag> Tags { get; set; }
public TestDbContext(DbContextOptions options) : base(options) {}
protected override void OnModelCreating(ModelBuilder modelBuilder) {
//Album Relationships
modelBuilder.Entity<Album>().HasOne(e => e.Owner).WithMany(e => e.OwnedAlbums);
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);
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);
//Face Relationships
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
//Person Relationships
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
//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.Owner);
//base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,269 @@
using Butter.Dtos;
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Manages albums — collections of assets.
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="albumRepository">Album repository.</param>
/// <param name="assetRepository">Asset repository.</param>
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class AlbumController(
//TODO: Add logging
//ILogger<AlbumController> logger,
LactoseAuthService authService,
IAlbumRepository albumRepository,
IAssetRepository assetRepository
) : ControllerBase {
/// <summary>
/// Gets an album by its ID with filtered assets based on access level.
/// </summary>
/// <param name="id">The album ID.</param>
/// <returns>The full album details with accessible assets, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<AlbumFullDto> Get([FromRoute] Guid id) {
Guid? uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
// 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());
}
/// <summary>
/// Searches albums with pagination, optional search term, and optional unassigned filter.
/// </summary>
/// <param name="pagingOptions">Pagination, search, 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());
}
}
/// <summary>
/// Updates an existing album.
/// </summary>
/// <param name="id">The album ID.</param>
/// <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")]
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
Guid? uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
// If the album does not exist, return a 404
if (album == null) return NotFound();
switch (accessLevel) {
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;
}
// 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.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
// Save changes
albumRepository.Update(album);
albumRepository.Save();
return Ok();
}
/// <summary>
/// Updates multiple albums in a bulk operation.
/// </summary>
/// <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")]
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var albums = albumRepository.FindBulk(bulkDto.Ids).ToList();
switch (accessLevel) {
case EAccessLevel.User:
// Users can't update albums
return Forbid();
case EAccessLevel.Curator:
// Curators can only update albums they own
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
break;
case EAccessLevel.Admin:
// Admins can update any album
break;
}
foreach (var album in albums) {
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.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
}
albumRepository.UpdateBulk(albums);
albumRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new album.
/// </summary>
/// <param name="albumDto">The album creation data.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin, Curator")]
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 {
Id = Guid.NewGuid(),
Title = albumDto.Name,
CreatedAt = DateTime.UtcNow,
PersonOwnerId = albumDto.Person ?? null,
UserOwnerId = albumDto.Owner,
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
Assets = assets
};
albumRepository.Insert(album);
albumRepository.Save();
return Ok(album.Id);
}
/// <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")]
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(); }
// If no albums are left after filtering, return a 403
if (albumIds.Count == 0) return Forbid();
albums.ForEach(albumRepository.Remove);
albumRepository.Save();
return Ok();
}
/// <summary>
/// Removes an album by its ID (soft delete).
/// </summary>
/// <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")]
public ActionResult Delete(Guid id) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
// 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();
albumRepository.Remove(album);
albumRepository.Save();
return Ok();
}
}

View File

@@ -0,0 +1,358 @@
using Butter.Dtos;
using Butter.Dtos.Asset;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Lactose.Controllers;
/// <summary>
/// Manages assets — images, videos, and other media files.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="authService">Authentication service.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="userRepository">User repository.</param>
[ApiController]
[Route("api/[controller]")]
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository,
IUserRepository userRepository
) : ControllerBase {
/// <summary>
/// Gets an asset by its ID with full details, respecting access level.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
[HttpGet("{id}")]
public ActionResult<AssetDto> Get(Guid id) {
var uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
logger.LogTrace(
$"""
Requested asset: {id}
by user: {uid}
access level: {accessLevel}
"""
);
var asset = assetRepository.Find(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
switch (accessLevel) {
case EAccessLevel.User: {
//Not publicly shared && Not shared with the user
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
return Unauthorized();
}
//Deleted asset
if (asset.DeletedAt != null) {
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
return NotFound();
}
break;
}
case EAccessLevel.Curator: {
//Deleted asset owned by the user
if (asset.DeletedAt != null)
if (asset.Owner?.Id != uid) {
logger.LogTrace(
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
);
return NotFound();
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
break;
}
case EAccessLevel.Admin: {
//Admins will see this regardless
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
break;
}
}
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
logger.LogTrace(
$"""
Outbound DTO:
dto.Id: {dto.Id}
dto.FileName: {dto.FileName}
dto.AssetType: {dto.AssetType}
dto.IsPublic: {dto.IsPublic}
dto.CreatedAt: {dto.CreatedAt}
dto.UpdatedAt: {dto.UpdatedAt}
dto.DeletedAt: {dto.DeletedAt}
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
"""
);
return Ok(dto);
}
/// <summary>
/// Searches assets with optional date range, type filter, and random ordering with pagination.
/// </summary>
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
/// <returns>A list of asset previews.</returns>
[HttpGet]
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
DateTime? from = string.IsNullOrEmpty(searchOptionsDto.StartDate) ? null : DateTime.Parse(searchOptionsDto.StartDate);
DateTime? to = string.IsNullOrEmpty(searchOptionsDto.EndDate) ? null : DateTime.Parse(searchOptionsDto.EndDate);
if (from.HasValue && to.HasValue && from > to) {
logger.LogWarning("Invalid date range provided");
return BadRequest();
}
if (searchOptionsDto.Page < 1 || searchOptionsDto.PageSize < 1) {
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
);
logger.LogTrace(
$"""
Requested assets
by user: {uid}
access level: {accessLevel}
type: {searchOptionsDto.Type}
random: {searchOptionsDto.Random}
date range: {from} - {to}
page: {searchOptionsDto.Page}
"""
);
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();
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(dtoList);
}
/// <summary>
/// Updates an asset's metadata.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <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")]
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);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
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.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";
logger.LogTrace($"Updating asset {id}\n{log}");
assetRepository.Update(asset);
assetRepository.Save();
return Ok();
}
/// <summary>
/// Updates multiple assets in a bulk operation.
/// </summary>
/// <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")]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(bulkDto.Ids);
var enumerable = assets as List<Asset> ?? assets.ToList();
if (enumerable.Count() != bulkDto.Ids.Count) {
logger.LogWarning(
$"Some assets were not found in the database" +
$"\nrequested assets: {bulkDto.Ids.Count}, found assets: {enumerable.Count()}"
);
var missingIds = bulkDto.Ids.Except(enumerable.Select(a => a.Id)).ToList();
logger.LogTrace($"Missing IDs: {string.Join(", ", missingIds)}");
}
//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();
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;
}
);
assetRepository.UpdateBulk(enumerable);
return Ok();
}
/// <summary>
/// Soft-deletes an asset by its ID.
/// </summary>
/// <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")]
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.Find(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
return Unauthorized();
}
asset.DeletedAt = DateTime.UtcNow;
assetRepository.Update(asset);
assetRepository.Save();
return Ok();
}
/// <summary>
/// Soft-deletes multiple assets in a bulk operation.
/// </summary>
/// <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")]
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(ids);
var enumerable = assets as List<Asset> ?? assets.ToList();
if (enumerable.Count() != ids.Count) {
logger.LogWarning(
$"Some assets were not found in the database" +
$"\nrequested assets: {ids.Count}, found assets: {enumerable.Count()}"
);
var missingIds = ids.Except(enumerable.Select(a => a.Id)).ToList();
logger.LogTrace($"Missing IDs: {string.Join(", ", missingIds)}");
}
//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();
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
return Ok();
}
/// <summary>
/// Creates a new asset record.
/// </summary>
/// <param name="dto">The asset creation data.</param>
/// <returns>200 with the new asset ID, or an error response.</returns>
[NonAction]
[HttpPut]
[Authorize(Roles = "Curator,Admin")]
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
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 (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
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,
CreatedAt = dto.CreatedAt,
MimeType = dto.MimeType,
ResolutionWidth = dto.ResolutionWidth,
ResolutionHeight = dto.ResolutionHeight,
FileSize = dto.FileSize,
Duration = dto.Duration ?? 0,
FrameRate = dto.FrameRate ?? 0,
OriginalPath = String.Empty,
OriginalFilename = String.Empty
};
assetRepository.Insert(asset);
assetRepository.Save();
return Ok(asset.Id);
}
}

View File

@@ -0,0 +1,214 @@
using Butter.Dtos;
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Handles user authentication — login, registration, token refresh, and logout.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="authService">Authentication service.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="passwordHasher">Password hasher.</param>
[ApiController]
[Route("api/[controller]")]
public class AuthController(
ILogger<AuthController> logger,
LactoseAuthService authService,
IUserRepository userRepository,
IPasswordHasher<User> passwordHasher
) : ControllerBase {
/// <summary>
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
/// </summary>
/// <param name="userDto">The login credentials.</param>
/// <returns>An authentication result with tokens, or an error.</returns>
[HttpPost("login")]
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
User? user;
if (userDto.Identifier.Contains('@')) {
//search for user in database (by email)
user = userRepository.FindByEmail(userDto.Identifier);
} else {
//search for user in database (by username)
user = userRepository.FindByUsername(userDto.Identifier);
}
if (user == null) {
return NotFound(new AuthResultDto() {
Success = false,
ErrorMessage = "User or Password was wrong"
}
);
}
if (user.BannedAt != null) {
return StatusCode(
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is banned"
}
);
}
if (user.DeletedAt != null) {
return StatusCode(
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is disabled"
}
);
}
PasswordVerificationResult result = passwordHasher.VerifyHashedPassword(user, user.Password, userDto.Password);
switch (result) {
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
case PasswordVerificationResult.SuccessRehashNeeded:
user.Password = passwordHasher.HashPassword(user, userDto.Password);
userRepository.Save();
break;
case PasswordVerificationResult.Success: break;
}
user.LastLogin = DateTime.UtcNow;
var token = authService.GenerateAccessToken(user);
var refreshToken = authService.GenerateRefreshToken(user);
userRepository.Save();
return new AuthResultDto() {
UserId = user.Id,
Token = token,
RefreshToken = refreshToken,
Success = true
};
}
/// <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>
//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) {
logger.LogDebug(
$"""
Registering user:
Username: {dto.Username}
Email: {dto.Email}
"""
);
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
return Conflict("User already exists");
}
var user = new User {
Username = dto.Username,
Email = dto.Email,
AccessLevel = EAccessLevel.User,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
user.Password = passwordHasher.HashPassword(user, dto.Password);
userRepository.Insert(user);
userRepository.Save();
return Ok(user.Id);
}
/// <summary>
/// Logs out the user by invalidating their refresh token.
/// </summary>
/// <returns>200 on success, 401 if unauthorized.</returns>
[Authorize]
[HttpPost("logout")]
public ActionResult Logout() {
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
if (identity == null) { return Unauthorized(); }
//Reset token from the database
User? user = userRepository.Find(identity.Id);
if (user == null) { return Unauthorized(); }
user.RefreshToken = string.Empty;
user.RefreshTokenExpires = null;
userRepository.Save();
return Ok();
}
/// <summary>
/// Refreshes an expired JWT access token using a valid refresh token.
/// </summary>
/// <param name="token">The refresh token request data.</param>
/// <returns>A new access token, and optionally a new refresh token.</returns>
[HttpPost("refresh")]
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
User? user = userRepository.Find(token.UserId);
if (user == null) {
return NotFound(
new AuthResultDto() {
Success = false,
ErrorMessage = "No user found"
}
);
}
if (user.BannedAt != null) {
return StatusCode(
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is banned"
}
);
}
if (user.DeletedAt != null) {
return StatusCode(
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is disabled"
}
);
}
if (!authService.ValidateRefreshToken(user, token.RefreshToken)) {
return Unauthorized(
new AuthResultDto() {
Success = false,
ErrorMessage = "Invalid refresh token"
}
);
}
var authDto = new AuthResultDto() {
Success = true,
Token = authService.GenerateAccessToken(user)
};
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.UtcNow) {
var refreshToken = authService.GenerateRefreshToken(user);
authDto.RefreshToken = refreshToken;
user.RefreshToken = refreshToken;
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
userRepository.Save();
}
return authDto;
}
}

View File

@@ -0,0 +1,151 @@
using Butter.Dtos.Folder;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Manages file system folders for asset scanning (admin only).
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="folderRepository">Folder repository.</param>
/// <param name="assetRepository">Asset repository for sample path queries.</param>
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
public class FolderController(
//TODO: add logging
//ILogger<FolderController> logger,
LactoseAuthService authService,
IFolderRepository folderRepository,
IAssetRepository assetRepository
) : ControllerBase {
/// <summary>
/// Creates a new folder for asset scanning.
/// </summary>
/// <param name="folderCreateDto">The folder creation data.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Create([FromBody] FolderCreateDto folderCreateDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
Folder newFolder = new Folder {
BasePath = folderCreateDto.BasePath,
RegexPattern = folderCreateDto.RegexPattern,
Active = false
};
folderRepository.Create(newFolder);
return Ok();
}
/// <summary>
/// Gets all tracked folders.
/// </summary>
/// <returns>A list of all folders.</returns>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<List<FolderFullDto>> GetAll() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(
folderRepository.GetAll()
.Select(folder => folder.ToFolderDto())
.ToList()
);
}
/// <summary>
/// Gets a specific folder by ID.
/// </summary>
/// <param name="id">The folder ID.</param>
/// <returns>The folder details, or 404 if not found.</returns>
[HttpGet("{id}")]
[Authorize(Roles = "Admin")]
public ActionResult<FolderFullDto> Get([FromRoute] Guid id) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var folder = folderRepository.Get(id);
if (folder == null) return NotFound();
return Ok(folder.ToFolderDto());
}
/// <summary>
/// Updates an existing folder.
/// </summary>
/// <param name="id">The folder ID.</param>
/// <param name="folderUpdateDto">The folder update data.</param>
/// <returns>200 on success, 404 if not found.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin")]
public ActionResult Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
Folder? folder = folderRepository.Get(id);
if (folder == null) return NotFound();
folder.BasePath = folderUpdateDto.BasePath ?? folder.BasePath;
folder.Active = folderUpdateDto.Active ?? folder.Active;
folder.RegexPattern = folderUpdateDto.RegexPattern ?? folder.RegexPattern;
folderRepository.Update(id, folder);
return Ok();
}
/// <summary>
/// Gets a random sample of asset paths from the folder for testing regex patterns.
/// </summary>
/// <param name="id">The folder ID.</param>
/// <param name="count">Number of sample paths to return (default 10, max 50).</param>
/// <returns>A list of up to <paramref name="count"/> random asset paths, or 404 if the folder is not found.</returns>
[HttpGet("{id}/sample-paths")]
[Authorize(Roles = "Admin")]
public ActionResult<FolderSamplePathsDto> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var folder = folderRepository.Get(id);
if (folder == null) return NotFound();
count = Math.Clamp(count, 1, 50);
var paths = assetRepository.GetRandomPathsByFolder(id, count);
return Ok(new FolderSamplePathsDto {
FolderId = id,
Paths = paths
});
}
/// <summary>
/// Deletes a folder (stops tracking it).
/// </summary>
/// <param name="id">The folder ID.</param>
/// <returns>200 on success.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public ActionResult Delete([FromRoute] Guid id) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
folderRepository.Delete(id);
return Ok();
}
}

View File

@@ -0,0 +1,153 @@
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Jobs;
using Lactose.Mapper;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Manages job lifecycle — listing active/past jobs, cancelling, and enqueuing new jobs.
/// </summary>
/// <param name="jobManager">The job manager for job lifecycle.</param>
/// <param name="jobScheduler">The job scheduler for queueing jobs.</param>
/// <param name="authService">The authentication service.</param>
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
/// <summary>
/// Returns active root jobs only — no children, no past jobs. Lightweight, polled frequently.
/// </summary>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetActiveRoots() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.GetActiveRootJobs());
}
/// <summary>
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
/// </summary>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
}
/// <summary>
/// Returns children of a specific parent job (active + past).
/// Supports delta sync — pass <paramref name="since"/> to receive only children changed after that timestamp.
/// The <see cref="ChildrenResponse.Since"/> field contains the timestamp to use for the next delta poll.
/// </summary>
[HttpGet("{parentId}/children")]
[Authorize(Roles = "Admin")]
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var children = jobManager.GetChildren(parentId, since);
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
}
/// <summary>
/// Returns the status of a specific job by its ID.
/// </summary>
/// <param name="jobId">The ID of the job.</param>
/// <returns>The job status, or 404 if not found.</returns>
[HttpGet("{jobId}")]
[Authorize(Roles = "Admin")]
public ActionResult<JobStatusDto> Get(Guid jobId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var job = jobManager.GetJobStatus(jobId);
if (job == null) { return NotFound(); }
return Ok(job);
}
/// <summary>
/// Cancels a running or queued job.
/// </summary>
/// <param name="jobId">The ID of the job to cancel.</param>
/// <returns>200 if cancelled, 404 if not found.</returns>
[HttpDelete("{jobId}")]
[Authorize(Roles = "Admin")]
public ActionResult Cancel(Guid jobId) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var job = jobManager.GetJob(jobId);
if (job == null) { return NotFound(); }
job.Cancel();
return Ok();
}
/// <summary>
/// Enqueues a new job of the specified type.
/// </summary>
/// <param name="jobRequest">The job request containing the job type and optional parameters.</param>
/// <returns>200 if enqueued, 400 if invalid request, 501 if not implemented.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
switch (jobRequest.JobType) {
case EJobType.FileSystemScan:
if (jobRequest.Parameters.Count != 0)
return BadRequest("FullFileSystemScan job does not require any parameters.");
jobScheduler.QueueFileSystemCrawl(null);
return Ok();
case EJobType.ThumbnailGeneration:
if (jobRequest.Parameters.Count != 0)
return BadRequest("ThumbnailGeneration job does not require any parameters.");
jobScheduler.QueueThumbnailGeneration(null);
return Ok();
case EJobType.PreviewGeneration:
if (jobRequest.Parameters.Count != 0)
return BadRequest("PreviewGeneration job does not require any parameters.");
jobScheduler.QueuePreviewGeneration(null);
return Ok();
case EJobType.MetadataExtraction:
if (jobRequest.Parameters.Count != 0)
return BadRequest("MetadataExtraction job does not require any parameters.");
jobScheduler.QueueMetadataExtraction(null);
return Ok();
case EJobType.IntegrityCheck:
if (jobRequest.Parameters.Count != 0)
return BadRequest("IntegrityCheck job does not require any parameters.");
jobScheduler.QueueIntegrityCheck(null);
return Ok();
case EJobType.CreatePersons:
if (jobRequest.Parameters.Count != 0)
return BadRequest("CreatePersons job does not require any parameters.");
jobScheduler.QueueCreatePersons(null);
return Ok();
case EJobType.CreateAlbums:
if (jobRequest.Parameters.Count != 0)
return BadRequest("CreateAlbums job does not require any parameters.");
jobScheduler.QueueCreateAlbums(null);
return Ok();
case EJobType.PHashGeneration:
if (jobRequest.Parameters.Count > 1)
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
return Ok();
default: return BadRequest("Invalid job type.");
}
}
}

View File

@@ -0,0 +1,135 @@
using Butter.Types;
using Lactose.Configuration;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace Lactose.Controllers;
/// <summary>
/// Serves media files (original, thumbnail, preview) for assets.
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="mediaRepository">Media repository.</param>
/// <param name="signKeyOptions">JWT signing key configuration.</param>
[ApiController]
[Authorize]
[AllowAnonymous]
[Route("api/[controller]")]
public class MediaController(
LactoseAuthService authService,
IMediaRepository mediaRepository,
IAssetRepository assetRepository,
IOptions<SignKeyConfiguration> signKeyOptions
) : ControllerBase {
/// <summary>
/// Gets the original image file for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
[HttpGet("{id}")]
[HttpGet("original/{id}")]
public ActionResult GetImage(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
return PhysicalFile(asset.OriginalPath, asset.MimeType);
}
var data = mediaRepository.GetOriginalData(id, user?.Id);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
/// <summary>
/// Gets the thumbnail image for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
[HttpGet("thumb/{id}")]
public ActionResult GetThumb(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
return PhysicalFile(asset.ThumbnailPath, "image/webp");
}
var data = mediaRepository.GetThumbData(id, user?.Id);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
/// <summary>
/// Gets the preview image for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
[HttpGet("preview/{id}")]
public ActionResult GetPreview(Guid id) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
return PhysicalFile(asset.PreviewPath, "image/webp");
}
var data = mediaRepository.GetPreviewData(id, user?.Id);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
/// <summary>
/// Extracts the authenticated user from the request, first trying the Authorization header,
/// then falling back to the <c>token</c> query parameter for browser image requests.
/// </summary>
private LactoseAuthenticatedUser? GetUserFromRequest() {
var user = authService.GetUserData(User);
if (user != null) return user;
var token = Request.Query["token"].FirstOrDefault();
if (string.IsNullOrEmpty(token)) return null;
try {
var handler = new JwtSecurityTokenHandler();
var key = signKeyOptions.Value.GetSecurityKey();
var principal = handler.ValidateToken(token, new TokenValidationParameters {
IssuerSigningKey = key,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true
}, out _);
return authService.GetUserData(principal);
} catch {
return null;
}
}
/// <summary>
/// Determines whether the requester can access asset files directly without repository-level filtering.
/// </summary>
/// <param name="user">The authenticated user, if any.</param>
/// <param name="deletedAt">The asset deletion timestamp.</param>
/// <returns><see langword="true"/> for admins, or for curators when the asset is not deleted; otherwise <see langword="false"/>.</returns>
private static bool CanAccessAssetDirectly(LactoseAuthenticatedUser? user, DateTime? deletedAt) {
return user?.AccessLevel switch {
EAccessLevel.Admin => true,
EAccessLevel.Curator => deletedAt == null,
_ => false
};
}
}

View File

@@ -0,0 +1,203 @@
using Butter.Dtos;
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Manages people (faces/individuals identified in assets).
/// </summary>
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class PersonController(
IPersonRepository personRepository,
IAlbumRepository albumRepository,
LactoseAuthService authService
) : ControllerBase {
/// <summary>
/// Gets a person by ID with their associated albums, filtered by access level.
/// </summary>
/// <summary>
/// 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>
/// <returns>The person details with albums, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedParametersDto albumPaging) {
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 (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());
}
/// <summary>
/// Searches people with pagination, 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>
[HttpGet]
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PagedSearchParametersDto 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();
return Ok(people);
}
/// <summary>
/// Updates a person.
/// </summary>
/// <param name="id">The person ID.</param>
/// <param name="dto">The updated person data.</param>
/// <returns>200 on success.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin, Curator")]
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
var person = personRepository.Find(id);
if (person == null) return NotFound();
person.Name = dto.Name;
person.ProfileAssetId = dto.ProfileAssetId;
person.ProfileCropX = dto.ProfileCropX;
person.ProfileCropY = dto.ProfileCropY;
person.ProfileCropZoom = dto.ProfileCropZoom;
person.UpdatedAt = DateTime.UtcNow;
personRepository.Save();
return Ok();
}
/// <summary>
/// Updates multiple people in a bulk operation.
/// </summary>
/// <param name="personBulkDto">The bulk update data.</param>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
foreach (var person in people) {
person.Name = personBulkDto.Data.Name;
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId;
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
person.UpdatedAt = DateTime.UtcNow;
}
personRepository.Save();
return Ok();
}
/// <summary>
/// Deletes a person by ID (soft delete).
/// </summary>
/// <param name="id">The person ID.</param>
/// <returns>200 on success, 404 if not found.</returns>
/// <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")]
public IActionResult Delete([FromRoute] Guid id) {
var person = personRepository.Find(id);
if (person == null) return NotFound();
// Unlink all albums owned by this person
var albums = albumRepository.FindByPerson(id).ToList();
foreach (var album in albums) {
album.PersonOwnerId = null;
}
personRepository.Remove(person);
personRepository.Save();
return Ok();
}
/// <summary>
/// Deletes multiple people in a bulk operation.
/// </summary>
/// <param name="ids">The list of person IDs to delete.</param>
/// <returns>200 on success.</returns>
/// <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")]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
var people = personRepository.FindBulk(ids).ToList();
foreach (var person in people) {
// Unlink all albums owned by this person
var albums = albumRepository.FindByPerson(person.Id).ToList();
foreach (var album in albums) {
album.PersonOwnerId = null;
}
personRepository.Remove(person);
}
personRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new person.
/// </summary>
/// <param name="dto">The person creation data.</param>
/// <returns>200 with the new person ID.</returns>
[HttpPut]
[Authorize(Roles = "Admin, Curator")]
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto) {
var person = new Models.Person {
Id = Guid.NewGuid(),
Name = dto.Name,
ProfileAssetId = dto.ProfileAssetId,
CreatedAt = DateTime.UtcNow
};
personRepository.Insert(person);
personRepository.Save();
return Ok(person.Id);
}
}

View File

@@ -0,0 +1,108 @@
using Butter.Dtos.Settings;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Manages application settings (admin only).
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="settingsRepository">Settings repository.</param>
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
public class SettingsController(
//TODO: Add logging
//ILogger<SettingsController> logger,
LactoseAuthService authService,
ISettingsRepository settingsRepository
) : ControllerBase {
/// <summary>
/// Creates a new application setting.
/// </summary>
/// <param name="settingDto">The setting to create.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Create([FromBody] SettingDto settingDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
settingsRepository.Create(
new Setting {
Name = settingDto.Name,
Value = settingDto.Value,
Description = settingDto.Description,
Type = settingDto.Type,
DisplayType = settingDto.DisplayType
}
);
return Ok();
}
/// <summary>
/// Gets all application settings.
/// </summary>
/// <returns>A list of all settings.</returns>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<SettingDto> Get() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
}
/// <summary>
/// Updates an existing application setting.
/// </summary>
/// <param name="settingDto">The updated setting data.</param>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public ActionResult Update([FromBody] SettingDto settingDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Update(settingDto.ToSetting());
return Ok();
}
/// <summary>
/// Deletes a specific application setting.
/// </summary>
/// <param name="settingDto">The setting to delete.</param>
/// <returns>200 on success.</returns>
[HttpDelete]
[Authorize(Roles = "Admin")]
public ActionResult Delete([FromBody] SettingDto settingDto) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Delete(settingDto.ToSetting());
return Ok();
}
/// <summary>
/// Deletes all application settings.
/// </summary>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
[Route("delete-all")]
public ActionResult DeleteAll() {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Delete();
return Ok();
}
}

View File

@@ -0,0 +1,30 @@
using Butter.Dtos.Stats;
using Lactose.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Provides aggregate statistics about the MilkyShots instance.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="statsRepository">Stats repository.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Curator,Admin")]
public class StatsController(
ILogger<StatsController> logger,
IStatsRepository statsRepository
) : ControllerBase {
/// <summary>
/// Returns comprehensive aggregate statistics about the instance.
/// </summary>
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
[HttpGet]
public ActionResult<StatsDto> Get() {
logger.LogTrace("Stats requested");
var stats = statsRepository.GetStats();
return Ok(stats);
}
}

View File

@@ -0,0 +1,217 @@
using Butter.Dtos;
using Butter.Dtos.Tag;
using Lactose.Mapper;
using Lactose.Models;
using Lactose.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Lactose.Controllers;
//TODO: logging
/// <summary>
/// Manages tags — hierarchical categorization of assets.
/// </summary>
/// <param name="tagRepository">Tag repository.</param>
[Authorize]
[ApiController]
[Route("api/[controller]")]
public class TagController(
//TODO: add logging
//ILogger<TagController> logger,
ITagRepository tagRepository
) : ControllerBase {
/// <summary>
/// Gets a tag by its ID, optionally including its children and ancestors.
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="children">Whether to include child tags.</param>
/// <param name="ancestors">Whether to include ancestor tags.</param>
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
Tag? tag = tagRepository.Find(id);
if (tag == null) { return NotFound(); }
var tagDto = TagsMapper.ToTagDto(tag);
tagDto.Parent = tag.Parent == null ? null : TagsMapper.ToTagDto(tag.Parent);
if (children) {
tagDto.Children = [];
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
}
//TODO: Protect from overflow
if (ancestors) {
Tag? index = tag.Parent;
TagDto? indexDto = tagDto;
List<Tag> ancestorsTags = [];
// This is redundant, because circular references shouldn't be present
while (index != null && !ancestorsTags.Contains(tag)) {
ancestorsTags.Add(index);
indexDto.Parent = TagsMapper.ToTagDto(index);
index = index.Parent;
indexDto = indexDto.Parent;
}
}
return tagDto;
}
/// <summary>
/// Searches tags with pagination and optional search term.
/// </summary>
/// <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) {
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"); }
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
List<TagDto> tagsDto = [];
tags.ForEach(
t => {
var tagDto = TagsMapper.ToTagDto(t);
tagDto.Parent = t.Parent == null ? null : TagsMapper.ToTagDto(t.Parent);
tagsDto.Add(tagDto);
}
);
return tagsDto;
}
/// <summary>
/// Updates an existing tag.
/// </summary>
/// <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")]
[HttpPost("{id}")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent);
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
return result;
}
/// <summary>
/// Updates multiple tags in a bulk operation.
/// </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")]
[HttpPost]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
foreach (Guid id in tagBulkDto.Ids) {
IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
return Ok();
}
/// <summary>
/// Deletes a tag by its ID (soft delete).
/// </summary>
/// <param name="id">The tag ID.</param>
/// <returns>200 on success, 404 if not found.</returns>
[Authorize(Roles = "Admin, Curator")]
[HttpDelete("{id}")]
public ActionResult Delete([FromRoute] Guid id) {
IStatusCodeActionResult result = DeleteTag(id);
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
return (ActionResult)result;
}
/// <summary>
/// Deletes multiple tags in a bulk operation.
/// </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")]
[HttpDelete]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
foreach (Guid id in ids) {
IStatusCodeActionResult result = DeleteTag(id);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new tag.
/// </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")]
[HttpPut]
public ActionResult Create([FromBody] TagCreateDto tagDto) {
if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
var tag = new Tag() {
Name = tagDto.Name
};
if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) {
return BadRequest($"No Tag with id {tagDto.Parent} found");
}
tag.ParentId = tagDto.Parent;
tagRepository.Insert(tag);
tagRepository.Save();
return Ok();
}
IStatusCodeActionResult DeleteTag(Guid id) {
Tag? tag = tagRepository.Find(id);
if (tag == null) { return NotFound($"Tag {id} not found"); }
tagRepository.Delete(tag);
return Ok();
}
IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) {
Tag? tag = tagRepository.Find(id);
if (tag == null) { return NotFound($"Tag {id} not found"); }
if (name != null) {
Tag? foundTag = tagRepository.FindByName(name);
if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); }
tag.Name = name;
}
if (parentId == null) { return Ok(); }
if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else {
Tag? parent = tagRepository.Find(parentId.Value);
if (parent == null) { return NotFound($"Parent tag on {id} was not found"); }
tag.Parent = parent;
}
return Ok();
}
}

View File

@@ -1,119 +1,213 @@
using Lactose.Context; using Butter.Dtos.User;
using Lactose.Dtos; using Butter.Types;
using Lactose.Mapper; using Lactose.Mapper;
using Lactose.Models; using Lactose.Models;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Lactose.Controllers; namespace Lactose.Controllers {
/// <summary>
/// Manages user accounts — creation, retrieval, updates, and deletion.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="authService">Authentication service.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="passwordHasher">Password hasher.</param>
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class UserController(
ILogger<UserController> logger,
LactoseAuthService authService,
IUserRepository userRepository,
IPasswordHasher<User> passwordHasher
) : ControllerBase {
/// <summary>
/// Creates a new user (admin only).
/// </summary>
/// <param name="userCreateDto">The user creation data.</param>
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
[Authorize(Roles = "Admin")]
[HttpPut]
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser != null) {
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
}
if (authenticatedUser != null && authenticatedUser.AccessLevel != EAccessLevel.Admin) return Forbid();
[ApiController]
[Route("api/[controller]")]
public class UserController(ILogger<UserController> logger, TestDbContext context) : ControllerBase {
[HttpPost("register")]
public ActionResult<Guid> Register([FromBody] RegisterUserDto userDto) {
logger.LogDebug( logger.LogDebug(
$""" $"""
Registering user: Registering user:
Username: {userDto.Username} Username: {userCreateDto.Username}
Email: {userDto.Email} Email: {userCreateDto.Email}
Access: {userDto.AccessLevel} Access: {userCreateDto.AccessLevel}
""" """
); );
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
var user = new User { var user = new User {
Username = userDto.Username, Username = userCreateDto.Username,
Email = userDto.Email, Email = userCreateDto.Email,
Password = userDto.Password Password = userCreateDto.Password,
AccessLevel = userCreateDto.AccessLevel ?? EAccessLevel.User
}; };
//TODO: add a check for permission to create a privileged account user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
if (true) { user.AccessLevel = userDto.AccessLevel; } else { return Unauthorized(); }
userRepository.Insert(user);
userRepository.Save();
context.Users.Add(user);
context.SaveChanges();
return Ok(user.Id); return Ok(user.Id);
} }
/// <summary>
/// Gets a user by their ID.
/// </summary>
/// <param name="id">The user ID.</param>
/// <returns>The user info, or 404 if not found.</returns>
[HttpGet("{id}")] [HttpGet("{id}")]
public ActionResult<GetUserDto> Get(Guid id) { public ActionResult<UserInfoDto> Get(Guid id) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
logger.LogWarning(@"Anonymous User hasn't enough permissions to read User data. it shouldn't be here");
return Unauthorized("Unauthorized");
}
//TODO: Lower level of access should fill less information of users
logger.LogDebug($"Sending user with id {id}"); logger.LogDebug($"Sending user with id {id}");
//TODO: add authentication check User? user = userRepository.Find(id);
if (false) return Unauthorized();
User? user = context.Users.Find(id); if (user is null) return NotFound();
if (user == null) return NotFound();
return Ok(user.ToGetUsersDto()); return Ok(user.ToGetUsersDto());
} }
/// <summary>
/// Gets all users.
/// </summary>
/// <returns>A list of all users.</returns>
[HttpGet] [HttpGet]
public ActionResult<List<GetUserDto>> GetAll() { public ActionResult<List<UserInfoDto>> GetAll() {
logger.LogDebug("Sending Users list"); logger.LogDebug("Sending Users list");
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
//TODO: add authentication check if (authenticatedUser == null) {
if (false) return Unauthorized(); logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
return Unauthorized("Unauthorized");
}
return Ok(context.Users.Select(u => u.ToGetUsersDto()).ToList()); //TODO: Lower level of access should fill less information of users
//if (false) return Unauthorized();
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
} }
/// <summary>
/// Updates an existing user's profile.
/// </summary>
/// <param name="dto">The user update data.</param>
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
[HttpPost("update")] [HttpPost("update")]
public ActionResult<GetUserDto> Update([FromBody] UpdateUserDto userDto) { public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(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( logger.LogDebug(
$""" $"""
Editing user: Editing user:
Id: {userDto.Id} Id: {dto.Id}
Username: {userDto.Username} Username: {dto.Username}
Email: {userDto.Email} Email: {dto.Email}
IsBanned: {userDto.IsBanned} IsBanned: {dto.IsBanned}
Access: {userDto.AccessLevel} IsDeleted:{dto.IsDeleted}
Access: {dto.AccessLevel}
""" """
); );
//TODO: add authorization checks if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
if (true) return Unauthorized();
User user = context.Users.Find(userDto.Id); User? user = userRepository.Find(dto.Id);
if (user == null) return NotFound(); if (user == null) return NotFound();
user.Username = userDto.Username; if (dto.Username != null) { user.Username = dto.Username; }
user.Email = userDto.Email;
user.Password = userDto.Password;
//TODO: add a check for permission to execute these two operations if (dto.Email != null) { user.Email = dto.Email; }
if (true) {
//if the user is already banned, do not update banned date if (dto.Password != null) {
if (user.BannedAt != null) { if (dto.OldPassword != null) {
// if the user is being banned set banned date to now otherwise set banned date to null (unban) if (passwordHasher.VerifyHashedPassword(user, user.Password, dto.OldPassword) == PasswordVerificationResult.Failed) {
user.BannedAt = userDto.IsBanned ? DateTime.Now : null; return BadRequest("Old password is incorrect");
}
} else if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
return BadRequest("Old password is required to change your own password");
} }
user.AccessLevel = userDto.AccessLevel; if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
} else { return Unauthorized(); } if (dto.Password.Length < 8) return BadRequest("Password must be at least 8 characters long");
if (!dto.Password.Any(char.IsUpper)) return BadRequest("Password must contain at least one uppercase letter");
if (!dto.Password.Any(char.IsLower)) return BadRequest("Password must contain at least one lowercase letter");
if (!dto.Password.Any(c => char.IsDigit(c) || char.IsPunctuation(c) || char.IsSymbol(c))) return BadRequest("Password must contain at least one number or special character");
}
context.SaveChanges(); user.Password = passwordHasher.HashPassword(user, dto.Password);
}
if (dto.IsDeleted != null) { user.DeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null; }
if (authenticatedUser.AccessLevel == EAccessLevel.Admin) {
if (dto.IsBanned != null) {
if (user.BannedAt != null) user.BannedAt = dto.IsBanned.Value ? DateTime.UtcNow : null;
}
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
}
userRepository.Save();
return Ok(user.ToGetUsersDto()); return Ok(user.ToGetUsersDto());
} }
/// <summary>
/// Soft-deletes a user by their ID (admin only).
/// </summary>
/// <param name="id">The user ID.</param>
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")] [HttpDelete("{id}")]
public ActionResult<Guid> Delete(Guid id) { public ActionResult<Guid> Delete([FromRoute] Guid id) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
logger.LogWarning(@"Anonymous hasn't enough permissions to delete a User. it shouldn't be here");
return Unauthorized("Unauthorized");
}
logger.LogDebug($"Deleting user: {id}"); logger.LogDebug($"Deleting user: {id}");
//TODO: add authorization checks User? user = userRepository.Find(id);
if (false) return Unauthorized();
User? user = context.Users.Find(id);
if (user == null) return NotFound(); if (user == null) return NotFound();
user.DeletedAt = DateTime.Now; userRepository.Delete(user);
//TODO: eventually remove/hide all of the user content if required //TODO: eventually remove/hide all of the user content if required
userRepository.Save();
return Ok(id); return Ok(id);
} }
}
} }

View File

@@ -0,0 +1,135 @@
[
{
"Name": "User Registration Enabled",
"Value": "false",
"Description": "Sets if user registration is enabled or not.",
"Type": 2,
"DisplayType": 5
},
{
"Name": "Folder Scan Enabled",
"Value": "true",
"Description": "Sets if the folder scan service should be running or not.",
"Type": 2,
"DisplayType": 5
},
{
"Name": "Folder Scan Interval",
"Value": "",
"Description": "Time interval (minutes) after the previous scan has finished before a new folder scan is started.",
"Type": 4,
"DisplayType": 7,
"Options": [
"5",
"360",
"5"
]
},
{
"Name": "File Upload Enabled",
"Value": "false",
"Description": "NOT IMPLEMENTED YET: Sets if the file upload service should be running or not.",
"Type": 2,
"DisplayType": 5
},
{
"Name": "File Upload MaxSize",
"Value": "0",
"Description": "Max file size for uploads in bytes.",
"Type": 1,
"DisplayType": 2
},
{
"Name": "Thumbnail Path",
"Value": "",
"Description": "Path where thumbnails are stored.",
"Type": 0,
"DisplayType": 0
},
{
"Name": "Preview Path",
"Value": "",
"Description": "Path where thumbnails are stored.",
"Type": 0,
"DisplayType": 0
},
{
"Name": "Thumbnail Size",
"Value": "512",
"Description": "Size for longest side of the thumbnail in pixels.",
"Type": 1,
"DisplayType": 8,
"Options": [
"64",
"1024",
"1"
]
},
{
"Name": "Preview Size",
"Value": "2048",
"Description": "Size for longest side of the preview in pixels.",
"Type": 1,
"DisplayType": 8,
"Options": [
"256",
"4096",
"1"
]
},
{
"Name": "Max Concurrent Jobs",
"Value": "4",
"Description": "Max number of concurrent jobs",
"Type": 1,
"DisplayType": 2,
"Options": [
"1",
"32",
"1"
]
},
{
"Name": "Job Retention Days",
"Value": "30",
"Description": "Number of days to keep completed/failed/canceled jobs before automatic cleanup.",
"Type": 1,
"DisplayType": 2,
"Options": [
"1",
"365",
"1"
]
},
{
"Name": "Thumbnail Quality",
"Value": "75",
"Description": "WebP encoding quality for thumbnails (1-100).",
"Type": 1,
"DisplayType": 7,
"Options": [
"1",
"100",
"1"
]
},
{
"Name": "Preview Quality",
"Value": "85",
"Description": "WebP encoding quality for previews (1-100).",
"Type": 1,
"DisplayType": 7,
"Options": [
"1",
"100",
"1"
]
},
{
"Name": "Job Batch Size",
"Value": "200",
"Description": "Number of assets to process per sub-job batch.",
"Type": 1,
"DisplayType": 2
}
]

View File

@@ -1,24 +1,26 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
RUN mkdir -p /app/thumbnails /app/previews && chown -R $APP_UID /app
USER $APP_UID USER $APP_UID
WORKDIR /app WORKDIR /app
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
EXPOSE 5162 EXPOSE 5162
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR "/src"
COPY ["WepApiTest/WepApiTest.csproj", "WepApiTest/"] COPY ["Lactose/Lactose.csproj", "Lactose/"]
RUN dotnet restore "WepApiTest/WepApiTest.csproj" RUN dotnet restore "Lactose/Lactose.csproj"
COPY . . COPY . .
WORKDIR "/src/WepApiTest" WORKDIR "/src/Lactose"
RUN dotnet build "WepApiTest.csproj" -c $BUILD_CONFIGURATION -o /app/build RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish FROM build AS publish
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "WepApiTest.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final FROM base AS final
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "WepApiTest.dll"]
ENTRYPOINT ["dotnet", "Lactose.dll"]

View File

@@ -1,14 +0,0 @@
using Lactose.Models;
namespace Lactose.Dtos;
public class GetUserDto {
public Guid Id { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public DateTime? LastLogin { get; set; }
public DateTime? BannedAt { get; set; }
public bool IsBanned { get; set; }
}

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