Compare commits
334
Commits
31d917c56e
...
v1.0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee07306a4d | ||
|
|
294f7a6cae | ||
|
|
595399385b | ||
|
|
77ee80cd63 | ||
|
|
9e67c597ef | ||
|
|
05ff6e8f2d | ||
|
|
07e43b5023 | ||
|
|
00e0dd42d1 | ||
|
|
6f60ade6df | ||
|
|
eaa067134e | ||
|
|
701c4360b4 | ||
|
|
e132d269eb | ||
|
|
79252d363d | ||
|
|
8ff21fa56b | ||
|
|
2305e44e68 | ||
|
|
439df56f2b | ||
|
|
f70f256c07 | ||
|
|
3b1f26cdf3 | ||
|
|
9965a13f40 | ||
|
|
fbafa54322 | ||
|
|
9607746912 | ||
|
|
785d33e289 | ||
|
|
a3eeb8b6d1 | ||
|
|
cae61116c8 | ||
|
|
ced0f61b87 | ||
|
|
9751c81574 | ||
|
|
550f195cf2 | ||
|
|
06c6cf7648 | ||
|
|
a5b6642b94 | ||
|
|
f0b67b0404 | ||
|
|
fb2b02e71e | ||
|
|
4658ed1a27 | ||
|
|
52c08089b2 | ||
|
|
111875670b | ||
|
|
9c50f84783 | ||
|
|
593d88d3e3 | ||
|
|
abef50b010 | ||
|
|
ce221428f5 | ||
|
|
213c643263 | ||
|
|
ecc6e02c78 | ||
|
|
b78c5772b4 | ||
|
|
6463df51d1 | ||
|
|
dd2d703fc0 | ||
|
|
a388887f56 | ||
|
|
7d30ea2394 | ||
|
|
ab420fe167 | ||
|
|
19e177504f | ||
|
|
f57e2864c5 | ||
|
|
d89f6f0a21 | ||
|
|
ca217ddb59 | ||
|
|
e025866357 | ||
|
|
1449664678 | ||
|
|
cf8bee555a | ||
|
|
664ac54a7c | ||
|
|
df48dd17f4 | ||
|
|
f15fe8f31a | ||
|
|
8589a85625 | ||
|
|
983cd8baef | ||
|
|
f52b376fca | ||
|
|
75e57353e7 | ||
|
|
eae6152738 | ||
|
|
c4788e1572 | ||
|
|
1dfa9eaf41 | ||
|
|
50b5a28841 | ||
|
|
580cdee67d | ||
|
|
8f0267b0f7 | ||
|
|
5d9aa21987 | ||
|
|
ad1f9e2222 | ||
|
|
90c81c2d20 | ||
|
|
0cc80a084a | ||
|
|
ad7c38b20e | ||
|
|
e0a5bd0ce4 | ||
|
|
e41e7d774f | ||
|
|
34220e93d4 | ||
|
|
22706a09cd | ||
|
|
664c1de5da | ||
|
|
081295af86 | ||
|
|
7dd00c0a5e | ||
|
|
3a195dbc15 | ||
|
|
cce310ee14 | ||
|
|
ada113914d | ||
|
|
a2a25673de | ||
|
|
963e897fe5 | ||
|
|
f9fe91e975 | ||
|
|
01fc121bc3 | ||
|
|
da8a5ad539 | ||
|
|
ff2ed4f7cd | ||
|
|
2c292d2d81 | ||
|
|
f064277c31 | ||
|
|
c5ea749517 | ||
|
|
ebe83fe691 | ||
|
|
ab58ac7dd8 | ||
|
|
17d3f0f2a7 | ||
|
|
1e2bc83b20 | ||
|
|
58014a37bd | ||
|
|
c79ed47e41 | ||
|
|
e48da562fa | ||
|
|
b943d2e595 | ||
|
|
0950106609 | ||
|
|
7ba1ab1551 | ||
|
|
656e6fa9bf | ||
|
|
5e1306e466 | ||
|
|
22565c4f92 | ||
|
|
e62a567e4e | ||
|
|
e5ffe86077 | ||
|
|
d4a53cb2dc | ||
|
|
548b0d5bfd | ||
|
|
92fb5a0149 | ||
|
|
6542dcafd6 | ||
|
|
b7690b5828 | ||
|
|
f3f2bdba02 | ||
|
|
8bf6405c64 | ||
|
|
c1b1feef4d | ||
|
|
0e9820f7c3 | ||
|
|
133c697176 | ||
|
|
8f07d7c445 | ||
|
|
c7e83ed97b | ||
|
|
7db1738678 | ||
|
|
3e099987a3 | ||
|
|
ca50554108 | ||
|
|
2bfa370989 | ||
|
|
dbd5d15fa1 | ||
|
|
b1f32844a9 | ||
|
|
3bf98cbfed | ||
|
|
80c9c8bd6f | ||
|
|
7086fb127f | ||
|
|
19ab8c0975 | ||
|
|
d7f8350ae7 | ||
|
|
f0e3cc9c37 | ||
|
|
4f56af252b | ||
|
|
8aeb4df064 | ||
|
|
dc2983ae55 | ||
|
|
a50dc3aa30 | ||
|
|
3cdad1cebd | ||
|
|
0903da4490 | ||
|
|
2fcd6467e1 | ||
|
|
10337c735c | ||
|
|
e3d4cf9673 | ||
|
|
a203885377 | ||
|
|
fff5ea65df | ||
|
|
86f95d7461 | ||
|
|
ec287f087c | ||
|
|
758696f7c9 | ||
|
|
de9cd618b6 | ||
|
|
e7fb01be9f | ||
|
|
e4a34a7dad | ||
|
|
d799c3eb1f | ||
|
|
c5fabf0e07 | ||
|
|
3cdf9eb185 | ||
|
|
6efccae030 | ||
|
|
93cbbea3c7 | ||
|
|
cace8640e9 | ||
|
|
a3ce98a984 | ||
|
|
4f3e3a28cc | ||
|
|
657ffaae11 | ||
|
|
700d5a63a7 | ||
|
|
9531ac2e23 | ||
|
|
288da412d9 | ||
|
|
1b34520a66 | ||
|
|
23b285cf49 | ||
|
|
27707b120c | ||
|
|
14978087e3 | ||
|
|
3021788271 | ||
|
|
358bf0918e | ||
|
|
656784af00 | ||
|
|
c8b71f290b | ||
|
|
8f109bf92f | ||
|
|
268b380867 | ||
|
|
71889ddfac | ||
|
|
0a74ff8770 | ||
|
|
f158181ccf | ||
|
|
e585abc378 | ||
|
|
ccf16b6abc | ||
|
|
7dcb78db97 | ||
|
|
26984f4452 | ||
|
|
f32d0678df | ||
|
|
05b99ab6c5 | ||
|
|
88a0098314 | ||
|
|
8b53899648 | ||
|
|
e2b0eaba37 | ||
|
|
e1504dbf72 | ||
|
|
b67b2260fe | ||
|
|
06fd26e641 | ||
|
|
74c74cbbdf | ||
|
|
774ee0d7c7 | ||
|
|
45b3c56ce2 | ||
|
|
b0fa104642 | ||
|
|
d4cd305c28 | ||
|
|
671783fd16 | ||
|
|
674710986f | ||
|
|
77a6ee9747 | ||
|
|
1f2bf02d02 | ||
|
|
c5b8c93a39 | ||
|
|
b9251c3e19 | ||
|
|
0d1ce9feb3 | ||
|
|
0673017765 | ||
|
|
08c389026a | ||
|
|
bbb2ed8f73 | ||
|
|
fe33fe2044 | ||
|
|
a449c792c1 | ||
|
|
9323111d0d | ||
|
|
c2326414b4 | ||
|
|
747ad77f4c | ||
|
|
f77a4574ac | ||
|
|
c64c1947e6 | ||
|
|
1b444dab93 | ||
|
|
50bf0a2a78 | ||
|
|
2335e8e23a | ||
|
|
d02a010083 | ||
|
|
6676347353 | ||
|
|
c9391d840b | ||
|
|
51323b4a64 | ||
|
|
842ed76345 | ||
|
|
f3ed658dd0 | ||
|
|
6c83b8395b | ||
|
|
e58524ce19 | ||
|
|
9dced21554 | ||
|
|
3fdf55b53c | ||
|
|
f5b28e5a21 | ||
|
|
7a380d7df5 | ||
|
|
f7d6348ea9 | ||
|
|
46871299ca | ||
|
|
c281746fdc | ||
|
|
be7532dd74 | ||
|
|
108b53a7e2 | ||
|
|
38793efe6f | ||
|
|
4205188855 | ||
|
|
d2daed27a8 | ||
|
|
87b69d5ee8 | ||
|
|
e0d46aa87b | ||
|
|
dbe55be63f | ||
|
|
28f9e7faae | ||
|
|
820d3b1c8b | ||
|
|
278648aae6 | ||
|
|
9d9491253b | ||
|
|
499c6375f6 | ||
|
|
2b150060f4 | ||
|
|
b93cb73e0a | ||
|
|
d2d584d509 | ||
|
|
8bcb408a5b | ||
|
|
445b030160 | ||
|
|
8195808500 | ||
|
|
393ad136f6 | ||
|
|
00acfef28c | ||
|
|
efeed2b668 | ||
|
|
efc6379bb3 | ||
|
|
20a456591d | ||
|
|
1b301ff2c0 | ||
|
|
50c58e22ff | ||
|
|
ad2f08698d | ||
|
|
e05ab3beaf | ||
|
|
48b6d4f149 | ||
|
|
89edf290ff | ||
|
|
c77096b0f9 | ||
|
|
e333d0e9db | ||
|
|
324c1b1a17 | ||
|
|
ee3511c09a | ||
|
|
41c262d157 | ||
|
|
837491fa4a | ||
|
|
5a2f61d261 | ||
|
|
851cdfa924 | ||
|
|
8dd5e7158f | ||
|
|
9e52cc2351 | ||
|
|
ca8119f356 | ||
|
|
bef29f5b22 | ||
|
|
e27d63cf32 | ||
|
|
fd46faec21 | ||
|
|
648531fcc4 | ||
|
|
dda062d959 | ||
|
|
02c62d0dd8 | ||
|
|
2249e0fb2e | ||
|
|
f626228c2f | ||
|
|
b9fd5e172c | ||
|
|
3b05061a31 | ||
|
|
7ea72e13ac | ||
|
|
faa8e93d55 | ||
|
|
c5ba90202d | ||
|
|
5fba560bec | ||
|
|
f2059e0dd7 | ||
|
|
e02466771a | ||
|
|
0b8c435f7c | ||
|
|
fc50aec996 | ||
|
|
bc72243c3e | ||
|
|
c723a1f265 | ||
|
|
e772e6d2b9 | ||
|
|
48ed84b7bd | ||
|
|
9cfab29e6c | ||
|
|
c177afe078 | ||
|
|
69cdc30ae9 | ||
|
|
0d33be8637 | ||
|
|
9f709e5a2e | ||
|
|
12cb8aa15b | ||
|
|
c96488b1eb | ||
|
|
51bb14862d | ||
|
|
4cffc9b424 | ||
|
|
4ea0afe9f6 | ||
|
|
de049d9d33 | ||
|
|
26a59b4ac2 | ||
|
|
6a3daa397b | ||
|
|
7f0c51edc4 | ||
|
|
46a68d9dd4 | ||
|
|
ff76673ee1 | ||
|
|
b0b1856c01 | ||
|
|
9e40add0d5 | ||
|
|
2886c0d629 | ||
|
|
ed2ce83b58 | ||
|
|
334451238b | ||
|
|
48ebbebee1 | ||
|
|
bd924f9e23 | ||
|
|
3633b418ea | ||
|
|
46382e2aed | ||
|
|
be7dc9bb91 | ||
|
|
d010b51687 | ||
|
|
07c0a15369 | ||
|
|
6e091299e4 | ||
|
|
74882904a0 | ||
|
|
7639c6909a | ||
|
|
b58066d563 | ||
|
|
14df8f1e8d | ||
|
|
188e4568da | ||
|
|
5006d81d00 | ||
|
|
97e5404069 | ||
|
|
aa9aca8f6f | ||
|
|
dddc4baa5d | ||
|
|
3d2c14ef41 | ||
|
|
628cfea773 | ||
|
|
d2d1fc7e50 | ||
|
|
27f89f3820 | ||
|
|
ecac0cdc2e | ||
|
|
5fd338906c | ||
|
|
88476e0409 | ||
|
|
7848923445 | ||
|
|
3b51a35e2c | ||
|
|
7686f04023 |
@@ -0,0 +1,63 @@
|
|||||||
|
name: Build and Push Containers
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version tag (e.g., v1.2.3)'
|
||||||
|
required: true
|
||||||
|
default: 'manual'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
service: [Lactose, MilkStream]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.r3d.codes
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Determine version tag
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
echo "VERSION=$(echo '${{ github.event.inputs.version }}' | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "VERSION=$(echo '${{ gitea.ref_name }}' | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Lowercase repository name
|
||||||
|
run: echo "REPO_LC=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Lowercase service name
|
||||||
|
run: echo "SERVICE_LC=$(echo '${{ matrix.service }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Prepare build args for ${{ matrix.service }}
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.service }}" = "Lactose" ]; then
|
||||||
|
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and push ${{ matrix.service }}
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./${{ matrix.service }}/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:${{ env.VERSION }}
|
||||||
|
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:latest
|
||||||
|
build-args: ${{ env.BUILD_ARGS }}
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
# MilkyShots – Agent Guidelines
|
|
||||||
|
|
||||||
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
MilkyShots is a .NET 8 media-library application composed of four projects:
|
|
||||||
|
|
||||||
| Project | Role |
|
|
||||||
|---|---|
|
|
||||||
| **Butter** | Shared class library – DTOs, enums, MIME-type tables, settings definitions |
|
|
||||||
| **Lactose** | ASP.NET Core Web API backend – controllers, EF Core models, repositories, services, background jobs |
|
|
||||||
| **MilkStream** | Blazor WebAssembly host – serves static WASM files and provides dynamic configuration |
|
|
||||||
| **MilkStream.Client** | Blazor WASM client – Razor components, frontend services, SCSS styles (runs in browser) |
|
|
||||||
|
|
||||||
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
|
|
||||||
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technology Stack
|
|
||||||
|
|
||||||
- **.NET 8 / C# 12** — target framework and language version for all projects.
|
|
||||||
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
|
|
||||||
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
|
|
||||||
- **JWT Bearer** authentication + refresh-token rotation.
|
|
||||||
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
|
|
||||||
- **Docker / docker-compose** for containerised deployment.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Solution Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
MilkyShots/
|
|
||||||
├── Butter/ # Shared library
|
|
||||||
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
|
|
||||||
│ ├── Settings/ # Settings enum + extensions
|
|
||||||
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
|
|
||||||
├── Lactose/ # Web API
|
|
||||||
│ ├── Authorization/ # Claims transformation + authorization handlers
|
|
||||||
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
|
|
||||||
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
|
|
||||||
│ ├── Controllers/ # API controllers
|
|
||||||
│ ├── Jobs/ # Background job infrastructure + concrete jobs
|
|
||||||
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
|
|
||||||
│ ├── Migrations/ # EF Core migrations (generated – do NOT hand-edit)
|
|
||||||
│ ├── Models/ # EF Core entity classes
|
|
||||||
│ ├── Repositories/ # Interfaces + implementations for data access
|
|
||||||
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
|
|
||||||
│ └── Utils/ # Small helpers / extension methods
|
|
||||||
├── MilkStream/ # Blazor WASM static host
|
|
||||||
│ ├── Program.cs # Minimal API – serves WASM files + dynamic /appsettings.json endpoint
|
|
||||||
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
|
|
||||||
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
|
|
||||||
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
|
|
||||||
├── Services/ # Frontend services (LoginService, MediaService, …)
|
|
||||||
├── Styles/ # SCSS source files
|
|
||||||
└── wwwroot/ # Static assets bundled into WASM
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
### C# identifiers
|
|
||||||
|
|
||||||
| Category | Pattern | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
|
|
||||||
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
|
|
||||||
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
|
|
||||||
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
|
|
||||||
| Repository interfaces | `I…Repository` | `IAssetRepository` |
|
|
||||||
| Repository implementations | `…Repository` | `AssetRepository` |
|
|
||||||
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
|
|
||||||
| Controller classes | `…Controller` | `AssetController` |
|
|
||||||
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
|
|
||||||
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
|
|
||||||
|
|
||||||
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
|
|
||||||
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
|
|
||||||
|
|
||||||
### Files and directories
|
|
||||||
|
|
||||||
- One public type per file; file name equals the type name.
|
|
||||||
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories` → `Lactose/Repositories/`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C# Coding Style
|
|
||||||
|
|
||||||
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
|
|
||||||
- Prefer **primary constructors** (C# 12) for constructor injection:
|
|
||||||
```csharp
|
|
||||||
public class AssetController(
|
|
||||||
ILogger<AssetController> logger,
|
|
||||||
LactoseAuthService authService,
|
|
||||||
IAssetRepository assetRepository
|
|
||||||
) : ControllerBase { … }
|
|
||||||
```
|
|
||||||
- Use **`var`** for local variables when the type is obvious from the right-hand side.
|
|
||||||
- Use **object initialisers** when creating entity or DTO instances.
|
|
||||||
- Use the **`required`** keyword on properties that must be set during object construction.
|
|
||||||
- Default string properties to `string.Empty`, not `null`.
|
|
||||||
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
|
|
||||||
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
|
|
||||||
|
|
||||||
### Documentation comments
|
|
||||||
|
|
||||||
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
|
|
||||||
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
|
|
||||||
- **Mapper classes** must have a `<summary>` on the class and on each method.
|
|
||||||
- Controllers may omit XML comments on action methods (use inline comments instead).
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
- Inject `ILogger<T>` into every controller and service.
|
|
||||||
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
|
|
||||||
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
|
|
||||||
- Log-level conventions:
|
|
||||||
|
|
||||||
| Level | When to use |
|
|
||||||
|---|---|
|
|
||||||
| `LogTrace` | Detailed per-request data (IDs, field values) |
|
|
||||||
| `LogDebug` | Medium-detail operational info |
|
|
||||||
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
|
|
||||||
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
|
|
||||||
| `LogError` | Exceptions and failures |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Entity / Model Rules
|
|
||||||
|
|
||||||
- Primary keys are **`Guid`** annotated with `[Key]`.
|
|
||||||
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
|
|
||||||
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
|
|
||||||
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
|
|
||||||
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
|
|
||||||
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
|
|
||||||
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Repository Pattern
|
|
||||||
|
|
||||||
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
|
|
||||||
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
|
|
||||||
- Standard method set:
|
|
||||||
|
|
||||||
| Method | Signature |
|
|
||||||
|---|---|
|
|
||||||
| Find by PK | `T? Find(Guid id)` |
|
|
||||||
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
|
|
||||||
| Insert | `void Insert(T entity)` |
|
|
||||||
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
|
|
||||||
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
|
|
||||||
| Save | `void Save()` → `context.SaveChanges()` |
|
|
||||||
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
|
|
||||||
|
|
||||||
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
|
|
||||||
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
|
|
||||||
- Register all repositories as **`Transient`** in `Program.cs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mapper / DTO Rules
|
|
||||||
|
|
||||||
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
|
|
||||||
- Mapping methods are **extension methods** on the model type:
|
|
||||||
```csharp
|
|
||||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
|
|
||||||
```
|
|
||||||
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
|
|
||||||
- Never put business logic in mappers — only field assignments.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Controller Rules
|
|
||||||
|
|
||||||
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
|
|
||||||
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
|
|
||||||
- HTTP verb → CRUD mapping:
|
|
||||||
|
|
||||||
| Verb | Operation |
|
|
||||||
|---|---|
|
|
||||||
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
|
|
||||||
| `[HttpPut]` | Create a new resource |
|
|
||||||
| `[HttpPost("{id}")]` | Update a specific resource |
|
|
||||||
| `[HttpPost]` | Bulk update |
|
|
||||||
| `[HttpDelete("{id}")]` | Delete a specific resource |
|
|
||||||
| `[HttpDelete]` | Bulk delete |
|
|
||||||
|
|
||||||
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
|
|
||||||
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
|
|
||||||
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
|
|
||||||
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Authentication & Authorisation
|
|
||||||
|
|
||||||
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
|
|
||||||
- Access is controlled by three roles (in ascending privilege order):
|
|
||||||
|
|
||||||
| Role | Description |
|
|
||||||
|---|---|
|
|
||||||
| `User` | Read-only access to publicly shared / explicitly shared content |
|
|
||||||
| `Curator` | Can create/update/delete their own content |
|
|
||||||
| `Admin` | Unrestricted access |
|
|
||||||
|
|
||||||
- `[Authorize]` with no roles = any authenticated user.
|
|
||||||
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
|
|
||||||
Note the space after the comma in the role list — this is the project's established style.
|
|
||||||
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
|
|
||||||
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
|
|
||||||
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
|
|
||||||
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Background Jobs System
|
|
||||||
|
|
||||||
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
|
|
||||||
- Override `TaskJob(CancellationToken token)` to implement job logic.
|
|
||||||
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
|
|
||||||
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
|
|
||||||
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
|
|
||||||
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
|
|
||||||
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
|
|
||||||
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
|
|
||||||
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings System
|
|
||||||
|
|
||||||
- Runtime settings are persisted in the `Settings` database table.
|
|
||||||
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
|
|
||||||
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
|
|
||||||
- When adding a new setting:
|
|
||||||
1. Add an entry to `Settings` enum and `AsString()`.
|
|
||||||
2. Add the default entry to `DefaultSettings.json`.
|
|
||||||
3. React to changes via `SettingsRepository.SettingChanged` event.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend (MilkStream.Client / Blazor WASM)
|
|
||||||
|
|
||||||
- Use **Blazor WebAssembly** – the client runs entirely in the browser.
|
|
||||||
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
|
|
||||||
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
|
|
||||||
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
|
|
||||||
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
|
|
||||||
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
|
|
||||||
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
|
|
||||||
|
|
||||||
### Dynamic Configuration
|
|
||||||
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
|
|
||||||
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
|
|
||||||
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
|
|
||||||
|
|
||||||
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker / Deployment
|
|
||||||
|
|
||||||
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
|
|
||||||
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
|
|
||||||
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
|
|
||||||
- Data-protection keys for MilkStream are persisted via a named Docker volume.
|
|
||||||
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
|
|
||||||
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration & Secrets
|
|
||||||
|
|
||||||
- `appsettings.json` contains placeholder/default values only.
|
|
||||||
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
|
|
||||||
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
|
|
||||||
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git & Commit Conventions
|
|
||||||
|
|
||||||
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
|
|
||||||
|
|
||||||
| Prefix | Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `feat:` | New feature |
|
|
||||||
| `fix:` | Bug fix |
|
|
||||||
| `refactor:` | Code restructuring without behaviour change |
|
|
||||||
| `perf:` | Performance improvement |
|
|
||||||
| `doc:` | Documentation changes |
|
|
||||||
| `style:` | Formatting / style only |
|
|
||||||
| `test:` | Tests |
|
|
||||||
| `chore:` / `ci:` | Build system, tooling, CI |
|
|
||||||
| `revert:` | Revert a previous commit |
|
|
||||||
|
|
||||||
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
|
|
||||||
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TODOs & Known Gaps (as of initial analysis)
|
|
||||||
|
|
||||||
The following items appear as `TODO` comments in the codebase and should be tracked:
|
|
||||||
|
|
||||||
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
|
|
||||||
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
|
|
||||||
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
|
|
||||||
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
|
|
||||||
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
|
|
||||||
- `TagController.Get` (ancestors traversal) — not protected against circular references.
|
|
||||||
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
|
|
||||||
@@ -7,3 +7,6 @@ riderModule.iml
|
|||||||
storageImages/
|
storageImages/
|
||||||
dotnet-tools.json
|
dotnet-tools.json
|
||||||
.opencode/
|
.opencode/
|
||||||
|
*.css.map
|
||||||
|
# SixLabors license file
|
||||||
|
/Lactose/sixlabors.lic
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
</settings>
|
</settings>
|
||||||
</deployment>
|
</deployment>
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||||
<method v="2" />
|
<method v="2">
|
||||||
|
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||||
|
</method>
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
</settings>
|
</settings>
|
||||||
</deployment>
|
</deployment>
|
||||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||||
<method v="2" />
|
<method v="2">
|
||||||
|
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Unnamed" run_configuration_type="ShConfigurationType" />
|
||||||
|
</method>
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
@@ -6,17 +6,33 @@
|
|||||||
|
|
||||||
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
|
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
|
||||||
- **PostgreSQL with pgvector extension**
|
- **PostgreSQL with pgvector extension**
|
||||||
- **Sass CLI** for SCSS compilation (see SCSS section)
|
- **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`
|
- Run `dotnet tool restore` before first migration — installs `dotnet-ef` (tool manifest at `dotnet-tools.json` in repo root)
|
||||||
|
|
||||||
## Projects (4 in solution)
|
## SCSS
|
||||||
|
|
||||||
|
SCSS source is in `MilkStream.Client/Styles/`. Compiled via `AspNetCore.SassCompiler` MSBuild target:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build # compiles SCSS automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
- Config: `MilkStream.Client/Styles/sasscompiler.json` (expanded in Debug, compressed in Release)
|
||||||
|
- Source maps disabled
|
||||||
|
- Bootstrap 5.3.7 SCSS vendored in `Styles/bootstrap/`
|
||||||
|
- Theme variables adjustable in `Styles/_variables.scss` (`$accent` for primary color)
|
||||||
|
- Aggressive gradient mixins in `Styles/_gradients.scss`
|
||||||
|
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
|
||||||
|
|
||||||
|
## Projects (5 in solution)
|
||||||
|
|
||||||
| Project | Role | Entrypoint |
|
| Project | Role | Entrypoint |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
|
||||||
|
| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — |
|
||||||
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
|
| **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** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
|
||||||
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services | `MilkStream.Client/Program.cs` |
|
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
|
||||||
|
|
||||||
## Build & run
|
## Build & run
|
||||||
|
|
||||||
@@ -32,12 +48,12 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
|||||||
|
|
||||||
## Infrastructure gotchas
|
## 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.
|
- **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.
|
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
|
||||||
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
|
- **`dotnet-ef` CLI vs Microsoft.EntityFrameworkCore.Tools:** The global `dotnet-ef` (from `dotnet-tools.json`, v10.0.9) is the correct CLI to use. The Tools package reference in `Lactose.csproj` (v10.0.9) provides the `dotnet ef` target for `dotnet build`-time validation.
|
||||||
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
|
- **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.
|
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
|
||||||
|
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001, `DateTime.Today` as error MS002, `DateTime.Date` as warning MS003, and non-UTC `SpecifyKind` as error MS004. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
|
||||||
|
|
||||||
## XML docs enforced
|
## XML docs enforced
|
||||||
|
|
||||||
@@ -50,11 +66,54 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
|||||||
- **Repository `Save()` must be called explicitly** after insert/update/delete
|
- **Repository `Save()` must be called explicitly** after insert/update/delete
|
||||||
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
|
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
|
||||||
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
|
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
|
||||||
|
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1–250).
|
||||||
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
|
- **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
|
- **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.
|
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
|
||||||
|
|
||||||
## Modularity
|
## Visibility Rules (R1–R7)
|
||||||
|
|
||||||
|
Defined in issue [#129](https://git.r3d.codes/MilkyShots/MilkyShots/issues/129). Each entity (Asset, Album, Person) carries **independent** `EVisibility` — no inheritance from container to child.
|
||||||
|
|
||||||
|
### R1 — Independent Visibility
|
||||||
|
No entity's visibility is overridden by its container. An asset in a Public album does NOT become Public; the asset is only visible if the viewer has permission for the asset's own visibility.
|
||||||
|
|
||||||
|
### R2 — Asset Visibility (Per-Asset Gate)
|
||||||
|
|
||||||
|
| Actor | Public | Protected | Private |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Anonymous | ✅ | ❌ | ❌ |
|
||||||
|
| User | ✅ | ✅ | ❌ |
|
||||||
|
| Maintainer (not owning / not linked) | ✅ | ✅ | ❌ |
|
||||||
|
| Maintainer (uploaded by self) | ✅ | ✅ | ✅ |
|
||||||
|
| Maintainer (album → person they maintain) | ✅ | ✅ | ✅ |
|
||||||
|
| Curator (non-deleted) | ✅ | ✅ | ✅ |
|
||||||
|
| Curator (deleted, own upload) | ✅ | ✅ | ✅ |
|
||||||
|
| Admin (all, including deleted) | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
### R3 — Album Access Gating
|
||||||
|
Album's own visibility determines who can access it. Not visible → 404.
|
||||||
|
|
||||||
|
| Actor | Public | Protected | Private |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Anonymous / User | ✅ | ✅ | ❌ |
|
||||||
|
| Maintainer (unassigned) | ✅ | ✅ | ❌ |
|
||||||
|
| Maintainer (maintains PersonOwner) | ✅ | ✅ | ✅ |
|
||||||
|
| Curator / Admin | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
### R4 — Person Access Gating
|
||||||
|
Same as R3. Not visible → 404.
|
||||||
|
|
||||||
|
### R5 — Media Serving
|
||||||
|
Uses per-asset rules (R2). Album provenance not checked — too complex, marginal benefit.
|
||||||
|
|
||||||
|
### R6 — Edit Mode (Frontend)
|
||||||
|
API returns Private assets to privileged users (Admin/Curator/Maintainer-on-owned-content). Frontend hides them in browse mode, shows them in edit/curator mode. No backend flag.
|
||||||
|
|
||||||
|
### R7 — Deleted Assets
|
||||||
|
- **Admin**: all deleted assets visible
|
||||||
|
- **Curator**: own deleted uploads visible
|
||||||
|
- **Everyone else**: deleted assets hidden (`DeletedAt IS NULL`)
|
||||||
|
|
||||||
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
|
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
|
||||||
When a markup pattern appears in 2+ places, extract it into a shared component.
|
When a markup pattern appears in 2+ places, extract it into a shared component.
|
||||||
@@ -75,8 +134,56 @@ dotnet ef migrations add <Name> --project Lactose
|
|||||||
dotnet ef database update --project Lactose
|
dotnet ef database update --project Lactose
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v10.0.9 vs Design v10.0.9 mismatch). Example:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search.
|
||||||
|
- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
No test project found in solution.
|
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
|
||||||
|
|
||||||
|
- **`Lactose/WepApiTest.http`** — 100+ numbered tests covering all API endpoints across 12 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Data Visibility → Visibility Gates → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin) and include comprehensive visibility gate tests (R2–R4). Self-contained — all test data is created at runtime and cleaned up.
|
||||||
|
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
|
||||||
|
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
|
||||||
|
- Server must be running locally (default `http://localhost:5162`).
|
||||||
|
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.
|
||||||
|
|
||||||
|
## PR workflow
|
||||||
|
|
||||||
|
- Before starting work, ask the user whether they want the fix in a PR or directly on `develop` (for quick/trivial fixes).
|
||||||
|
- Larger work always goes in a PR.
|
||||||
|
- Always mark the PR as **draft** until the user confirms they are satisfied and have tested it works.
|
||||||
|
- When making the PR ready (removing draft status), mark it ready for review and request:
|
||||||
|
- **REDCODE** when the PR contains frontend changes
|
||||||
|
- **Fastwind** when the PR contains backend changes
|
||||||
|
- **Both** when both areas are touched
|
||||||
|
|
||||||
|
## Data sizes
|
||||||
|
|
||||||
|
Approximate record counts in the database:
|
||||||
|
|
||||||
|
| Entity | Count |
|
||||||
|
|---|---|
|
||||||
|
| Assets | 1,000,000+ |
|
||||||
|
| Albums | 10,000+ |
|
||||||
|
| People | 1,000+ |
|
||||||
|
| Users | 100+ |
|
||||||
|
| Maintainers | 10+ |
|
||||||
|
| Curators | single digit |
|
||||||
|
| Admins | single digit |
|
||||||
|
|
||||||
|
## Gitea labels
|
||||||
|
|
||||||
|
Labels live at two levels:
|
||||||
|
- **Org level** (`MilkyShots` org): generic issue types (`bug`, `enhancement`, `help wanted`, `question`, `duplicate`, `invalid`, `wontfix`)
|
||||||
|
- **Repo level** (`MilkyShots/MilkyShots`): project-specific labels (`area:frontend`, `area:backend`, `page:*`, `type:refactor`, `priority:*`, etc.)
|
||||||
|
- When labelling issues, check both levels with the `label_read` tool (`list_org_labels` / `list_repo_labels`).
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Album;
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,25 +9,33 @@ public class AlbumAssetPreviewDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the asset ID.
|
/// Gets or sets the asset ID.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the resolution width.
|
/// Gets or sets the resolution width.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResolutionWidth { get; set; }
|
public int ResolutionWidth { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the resolution height.
|
/// Gets or sets the resolution height.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResolutionHeight { get; set; }
|
public int ResolutionHeight { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset has a thumbnail.
|
/// Gets or sets whether the asset has a thumbnail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasThumbnail { get; set; }
|
public bool HasThumbnail { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset has a preview.
|
/// Gets or sets whether the asset has a preview.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasPreview { get; set; }
|
public bool HasPreview { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the original filename of the asset.
|
/// Gets or sets the original filename of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? FileName { get; set; }
|
public string? FileName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Album;
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,21 +9,21 @@ public class AlbumCreateDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the album.
|
/// Gets or sets the name of the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the owner ID of the album.
|
|
||||||
/// </summary>
|
|
||||||
public Guid? Owner { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the person ID associated with the album.
|
/// Gets or sets the person ID associated with the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? Person { get; set; }
|
public Guid? Person { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the cover asset ID for the album.
|
/// Gets or sets the cover asset ID for the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? CoverAssetId { get; set; }
|
public Guid? CoverAssetId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of asset IDs in the album.
|
/// Gets or sets the list of asset IDs in the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Guid>? Assets { get; set; }
|
public List<Guid>? Assets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to merge multiple albums into a single destination album.
|
||||||
|
/// </summary>
|
||||||
|
public class AlbumMergeDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the destination album that will receive all assets.
|
||||||
|
/// Must not be present in <see cref="SourceIds"/>.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid DestinationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of source album IDs whose assets will be moved to the destination.
|
||||||
|
/// Source albums will be hard-deleted after the merge.
|
||||||
|
/// </summary>
|
||||||
|
public required List<Guid> SourceIds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Album;
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,29 +9,41 @@ public class AlbumPreviewDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the unique identifier of the album.
|
/// Gets or sets the unique identifier of the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the album.
|
/// Gets or sets the name of the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the owner ID of the album.
|
|
||||||
/// </summary>
|
|
||||||
public Guid? Owner { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the person ID associated with the album.
|
/// Gets or sets the person ID associated with the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? Person { get; set; }
|
public Guid? Person { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the person name associated with the album.
|
/// Gets or sets the person name associated with the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? PersonName { get; set; }
|
public string? PersonName { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the ID of the cover asset for this album.
|
/// Gets or sets the ID of the cover asset for this album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? CoverAssetId { get; set; }
|
public Guid? CoverAssetId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cover image width, if a cover exists.
|
||||||
|
/// </summary>
|
||||||
|
public int? CoverWidth { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cover image height, if a cover exists.
|
||||||
|
/// </summary>
|
||||||
|
public int? CoverHeight { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of assets in the album.
|
/// Gets or sets the number of assets in the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int AssetCount { get; set; }
|
public int AssetCount { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the soft-delete timestamp. Always null for albums (not supported).
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
}
|
}
|
||||||
@@ -8,4 +8,10 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
|||||||
/// Gets or sets whether to return only albums with no person assigned.
|
/// Gets or sets whether to return only albums with no person assigned.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Unassigned { get; set; }
|
public bool Unassigned { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the user ID to filter albums by.
|
||||||
|
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? AssetUploadedBy { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Album;
|
namespace Butter.Dtos.Album;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,25 +9,25 @@ public class AlbumUpdateDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the album.
|
/// Gets or sets the name of the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the owner ID of the album.
|
|
||||||
/// </summary>
|
|
||||||
public Guid? Owner { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the person ID associated with the album.
|
/// Gets or sets the person ID associated with the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? Person { get; set; }
|
public Guid? Person { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether to remove the person assignment from the album.
|
/// Gets or sets whether to remove the person assignment from the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RemovePerson { get; set; }
|
public bool RemovePerson { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the cover asset ID for the album.
|
/// Gets or sets the cover asset ID for the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? CoverAssetId { get; set; }
|
public Guid? CoverAssetId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of asset IDs in the album.
|
/// Gets or sets the list of asset IDs in the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Guid>? Assets { get; set; }
|
public List<Guid>? Assets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the parameters for browsing assets within a folder at a specific directory level.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetBrowseOptionsDto : PagedSearchParametersDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the root folder ID to browse within.
|
||||||
|
/// </summary>
|
||||||
|
public Guid FolderId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the current path relative to the folder root (empty for root).
|
||||||
|
/// </summary>
|
||||||
|
public string Path { get; set; } = "";
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
|
||||||
|
/// </summary>
|
||||||
|
public bool UnlinkedOnly { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the result of browsing unlinked assets at a specific directory level within a folder.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetBrowseResultDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of immediate subdirectories at the current path level.
|
||||||
|
/// </summary>
|
||||||
|
public List<DirectoryEntryDto> Directories { get; set; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of assets located directly at the current path level.
|
||||||
|
/// </summary>
|
||||||
|
public List<AssetPreviewDto> Assets { get; set; } = [];
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the current browsing path relative to the folder root.
|
||||||
|
/// </summary>
|
||||||
|
public string CurrentPath { get; set; } = "";
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total count of assets at the current path level (for pagination).
|
||||||
|
/// </summary>
|
||||||
|
public int TotalAssetCount { get; set; }
|
||||||
|
}
|
||||||
@@ -9,41 +9,42 @@ public class AssetCreateDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the creation date of the asset.
|
/// Gets or sets the creation date of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the owner ID of the asset.
|
/// Gets or sets the ID of the user who uploaded the asset.
|
||||||
|
/// Null for scanner-created assets (system uploader will be used).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid Owner { get; set; }
|
public Guid? UploadedBy { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the type of the asset.
|
/// Gets or sets the type of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public EAssetType AssetType { get; set; }
|
public EAssetType AssetType { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset is publicly shared.
|
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPublic { get; set; }
|
public EVisibility Visibility { get; set; } = EVisibility.Private;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the MIME type of the asset.
|
/// Gets or sets the MIME type of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string MimeType { get; set; } = string.Empty;
|
public string MimeType { get; set; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the resolution width in pixels.
|
/// Gets or sets the resolution width in pixels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResolutionWidth { get; set; }
|
public int ResolutionWidth { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the resolution height in pixels.
|
/// Gets or sets the resolution height in pixels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResolutionHeight { get; set; }
|
public int ResolutionHeight { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the file size in bytes.
|
/// Gets or sets the file size in bytes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long FileSize { get; set; }
|
public long FileSize { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the duration in seconds (for video/audio).
|
/// Gets or sets the duration in seconds (for video/audio).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? Duration { get; set; }
|
public int? Duration { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the frame rate (for video).
|
/// Gets or sets the frame rate (for video).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? FrameRate { get; set; }
|
public int? FrameRate { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,41 +10,33 @@ public class AssetDto : AssetPreviewDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the type of the asset (e.g., Image, Video).
|
/// Gets or sets the type of the asset (e.g., Image, Video).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public EAssetType AssetType { get; set; }
|
public EAssetType AssetType { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset is publicly shared.
|
/// Gets or sets the user who uploaded the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPublic { get; set; }
|
public required UserInfoDto UploadedBy { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the owner of the asset.
|
|
||||||
/// </summary>
|
|
||||||
public required UserInfoDto Owner { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the creation date of the asset.
|
/// Gets or sets the creation date of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime CreatedAt { get; set; }
|
public DateTime CreatedAt { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the last updated date of the asset.
|
/// Gets or sets the last updated date of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the deletion date of the asset.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime? DeletedAt { get; set; }
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the MIME type of the asset.
|
/// Gets or sets the MIME type of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public new string MimeType { get; set; } = string.Empty;
|
public new string MimeType { get; set; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the file size of the asset in bytes.
|
/// Gets or sets the file size of the asset in bytes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long FileSize { get; set; }
|
public long FileSize { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the duration of the asset in seconds (for video/audio).
|
/// Gets or sets the duration of the asset in seconds (for video/audio).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float Duration { get; set; }
|
public float Duration { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the frame rate of the asset (for video).
|
/// Gets or sets the frame rate of the asset (for video).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float FrameRate { get; set; }
|
public float FrameRate { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a group of unlinked assets (not assigned to any album),
|
||||||
|
/// grouped by folder for drill-down browsing in the asset picker.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetGroupDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the folder ID of the group. Null for assets with no folder or for the maintainer's "My Uploads" group.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? FolderId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the display name of the group (folder base path, or "My Uploads" for maintainers).
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the count of unlinked assets in this group.
|
||||||
|
/// </summary>
|
||||||
|
public int AssetCount { get; set; }
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Asset;
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,45 +9,53 @@ public class AssetPreviewDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the unique identifier of the asset.
|
/// Gets or sets the unique identifier of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the MIME type of the asset.
|
/// Gets or sets the MIME type of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string MimeType { get; set; } = string.Empty;
|
public string MimeType { get; set; } = string.Empty;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the resolution width of the asset in pixels.
|
/// Gets or sets the resolution width of the asset in pixels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResolutionWidth { get; set; }
|
public int ResolutionWidth { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the resolution height of the asset in pixels.
|
/// Gets or sets the resolution height of the asset in pixels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ResolutionHeight { get; set; }
|
public int ResolutionHeight { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset has a generated thumbnail.
|
/// Gets or sets whether the asset has a generated thumbnail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasThumbnail { get; set; }
|
public bool HasThumbnail { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset has a generated preview.
|
/// Gets or sets whether the asset has a generated preview.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasPreview { get; set; }
|
public bool HasPreview { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of album titles associated with the asset.
|
/// Gets or sets the list of album titles associated with the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string>? AlbumNames { get; set; }
|
public List<string>? AlbumNames { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
|
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Guid>? AlbumIds { get; set; }
|
public List<Guid>? AlbumIds { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
|
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<string>? CosplayerNames { get; set; }
|
public List<string>? CosplayerNames { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
|
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Guid>? CosplayerIds { get; set; }
|
public List<Guid>? CosplayerIds { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the original filename of the asset.
|
/// Gets or sets the original filename of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? FileName { get; set; }
|
public string? FileName { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? DeletedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,16 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
|||||||
/// Gets or sets whether to order results randomly instead of by date.
|
/// Gets or sets whether to order results randomly instead of by date.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Random { get; set; }
|
public bool Random { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets whether to filter for assets not assigned to any album (unlinked).
|
||||||
|
/// </summary>
|
||||||
|
public bool Unlinked { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the folder ID to filter assets by their scan folder.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? FolderId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the uploader user ID to filter assets by their uploader.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? UploadedBy { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Asset;
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -5,15 +7,15 @@ namespace Butter.Dtos.Asset;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class AssetUpdateDto {
|
public class AssetUpdateDto {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the new owner ID for the asset.
|
/// Gets or sets the ID of the user who uploaded the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? Owner { get; set; }
|
public Guid? UploadedBy { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the deletion date to set on the asset.
|
/// Gets or sets the deletion date to set on the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? DeletedAt { get; set; }
|
public DateTime? DeletedAt { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets whether the asset is publicly shared.
|
/// Gets or sets the visibility level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool? IsPublic { get; set; }
|
public EVisibility? Visibility { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Butter.Dtos.Asset;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a subdirectory entry in the asset file browser.
|
||||||
|
/// </summary>
|
||||||
|
public class DirectoryEntryDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the directory name (a single path segment, not a full path).
|
||||||
|
/// </summary>
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ namespace Butter.Dtos;
|
|||||||
/// Represents pagination parameters for a query.
|
/// Represents pagination parameters for a query.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class PagedParametersDto {
|
public class PagedParametersDto {
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contains the global constant for max pages.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxPageSize = 250;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the page number (zero-based).
|
/// Gets or sets the page number (zero-based).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -12,6 +18,16 @@ public class PagedParametersDto {
|
|||||||
/// Gets or sets the number of items per page.
|
/// Gets or sets the number of items per page.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PageSize { get; set; } = 150;
|
public int PageSize { get; set; } = 150;
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the field to sort by (e.g. "name", "created").
|
||||||
|
/// When <c>null</c>, the default sort order is applied.
|
||||||
|
/// </summary>
|
||||||
|
public string? SortBy { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the sort direction.
|
||||||
|
/// <c>false</c> = descending (default), <c>true</c> = ascending.
|
||||||
|
/// </summary>
|
||||||
|
public bool SortAsc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Person;
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,21 +9,25 @@ public class PersonCreateDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the person.
|
/// Gets or sets the name of the person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the asset ID to use as the profile picture.
|
/// Gets or sets the asset ID to use as the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? ProfileAssetId { get; set; }
|
public Guid? ProfileAssetId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropX { get; set; }
|
public float? ProfileCropX { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the vertical crop offset for the profile picture.
|
/// Gets or sets the vertical crop offset for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropY { get; set; }
|
public float? ProfileCropY { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the zoom level for the profile picture.
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropZoom { get; set; }
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility Visibility { get; set; } = EVisibility.Protected;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Butter.Dtos.Album;
|
using Butter.Dtos.Album;
|
||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Person;
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
@@ -39,7 +40,19 @@ public class PersonDetailedDto {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int TotalAssets { get; set; }
|
public int TotalAssets { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
|
/// <summary>
|
||||||
/// Gets or sets the list of album previews associated with this person.
|
/// Gets or sets the list of album previews associated with this person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<AlbumPreviewDto>? Albums { get; set; }
|
public List<AlbumPreviewDto>? Albums { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the IDs of users who maintain this person.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? MaintainerUserIds { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the usernames of users who maintain this person.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? MaintainerUsernames { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a request to merge multiple people into a single destination person.
|
||||||
|
/// </summary>
|
||||||
|
public class PersonMergeDto {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the destination person that will receive all associated data.
|
||||||
|
/// Must not be present in <see cref="SourceIds"/>.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid DestinationId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of source person IDs whose albums, faces, and maintainers
|
||||||
|
/// will be reassigned to the destination. Source people will be hard-deleted.
|
||||||
|
/// </summary>
|
||||||
|
public required List<Guid> SourceIds { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Person;
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,29 +9,33 @@ public class PersonPreviewDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the unique identifier of the person.
|
/// Gets or sets the unique identifier of the person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required Guid Id { get; set; }
|
public required Guid Id { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the person.
|
/// Gets or sets the name of the person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the asset ID used as the profile picture.
|
/// Gets or sets the asset ID used as the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? ProfileAssetId { get; set; }
|
public Guid? ProfileAssetId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropX { get; set; }
|
public float? ProfileCropX { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the vertical crop offset for the profile picture.
|
/// Gets or sets the vertical crop offset for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropY { get; set; }
|
public float? ProfileCropY { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the zoom level for the profile picture.
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropZoom { get; set; }
|
public float? ProfileCropZoom { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of albums associated with this person.
|
/// Gets or sets the number of albums associated with this person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int TotalAlbums { get; set; }
|
public int TotalAlbums { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level. Only populated for curators and admins.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Search and filter parameters for person (cosplayer) queries.
|
||||||
|
/// </summary>
|
||||||
|
public class PersonSearchParametersDto : PagedSearchParametersDto {
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Butter.Dtos.Person;
|
namespace Butter.Dtos.Person;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,21 +9,29 @@ public class PersonUpdateDto {
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the name of the person.
|
/// Gets or sets the name of the person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the asset ID to use as the profile picture.
|
/// Gets or sets the asset ID to use as the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? ProfileAssetId { get; set; }
|
public Guid? ProfileAssetId { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the horizontal crop offset for the profile picture.
|
/// Gets or sets the horizontal crop offset for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropX { get; set; }
|
public float? ProfileCropX { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the vertical crop offset for the profile picture.
|
/// Gets or sets the vertical crop offset for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropY { get; set; }
|
public float? ProfileCropY { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the zoom level for the profile picture.
|
/// Gets or sets the zoom level for the profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropZoom { get; set; }
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility? Visibility { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? MaintainerUserIds { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ public class StatsDto {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int PublicAssets { get; set; }
|
public int PublicAssets { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of non-publicly-shared assets.
|
/// Gets or sets the number of assets with protected visibility (authenticated only).
|
||||||
|
/// </summary>
|
||||||
|
public int ProtectedAssets { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the number of private assets.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int PrivateAssets { get; set; }
|
public int PrivateAssets { get; set; }
|
||||||
|
|
||||||
@@ -116,10 +120,9 @@ public class StatsDto {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int AlbumsMissingCover { get; set; }
|
public int AlbumsMissingCover { get; set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
|
/// Gets or sets the number of people/cosplayers without a profile picture.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Not wired yet — reserved for future use.</remarks>
|
public int CosplayersMissingProfile { get; set; }
|
||||||
public int CosplayersMissingCover { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the top tags by asset count.
|
/// Gets or sets the top tags by asset count.
|
||||||
|
|||||||
@@ -46,4 +46,13 @@ public class UserInfoDto {
|
|||||||
/// Gets or sets the access level of the user.
|
/// Gets or sets the access level of the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
|
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
|
||||||
|
/// </summary>
|
||||||
|
public List<string>? MaintainedPersonNames { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,8 @@ public string? OldPassword { get; set; }
|
|||||||
/// Gets or sets the new access level.
|
/// Gets or sets the new access level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public EAccessLevel? AccessLevel { get; set; }
|
public EAccessLevel? AccessLevel { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
|
||||||
|
/// </summary>
|
||||||
|
public List<Guid>? MaintainedPersonIds { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ public enum DisplayType {
|
|||||||
TimePicker = 7,
|
TimePicker = 7,
|
||||||
/// <summary>Power of 2 selection.</summary>
|
/// <summary>Power of 2 selection.</summary>
|
||||||
Po2W = 8,
|
Po2W = 8,
|
||||||
|
/// <summary>User picker (searchable dropdown).</summary>
|
||||||
|
UserSelect = 9,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ public enum Settings {
|
|||||||
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
/// <summary>Quality setting (1–100) for WebP thumbnail encoding.</summary>
|
||||||
ThumbnailQuality,
|
ThumbnailQuality,
|
||||||
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
/// <summary>Quality setting (1–100) for WebP preview encoding.</summary>
|
||||||
PreviewQuality
|
PreviewQuality,
|
||||||
|
/// <summary>User ID used for scanner-created asset attribution.</summary>
|
||||||
|
SystemUploaderId
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -59,6 +61,7 @@ public static class SettingsExtensions {
|
|||||||
Settings.JobBatchSize => "Job Batch Size",
|
Settings.JobBatchSize => "Job Batch Size",
|
||||||
Settings.ThumbnailQuality => "Thumbnail Quality",
|
Settings.ThumbnailQuality => "Thumbnail Quality",
|
||||||
Settings.PreviewQuality => "Preview Quality",
|
Settings.PreviewQuality => "Preview Quality",
|
||||||
|
Settings.SystemUploaderId => "System Uploader Id",
|
||||||
_ => setting.ToString() // Fallback to the enum name
|
_ => setting.ToString() // Fallback to the enum name
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ namespace Butter.Types;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public enum EAccessLevel {
|
public enum EAccessLevel {
|
||||||
/// <summary>Standard user with basic permissions.</summary>
|
/// <summary>Standard user with basic permissions.</summary>
|
||||||
User,
|
User = 0,
|
||||||
|
/// <summary>Maintainer — scoped cosplayer management and tag management.</summary>
|
||||||
|
Maintainer = 1,
|
||||||
/// <summary>Curator with elevated permissions for managing assets.</summary>
|
/// <summary>Curator with elevated permissions for managing assets.</summary>
|
||||||
Curator,
|
Curator = 2,
|
||||||
/// <summary>Administrator with full system access.</summary>
|
/// <summary>Administrator with full system access.</summary>
|
||||||
Admin
|
Admin = 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace Butter.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines visibility levels for entities in the system.
|
||||||
|
/// </summary>
|
||||||
|
public enum EVisibility {
|
||||||
|
/// <summary>Visible to everyone, including anonymous users.</summary>
|
||||||
|
Public = 0,
|
||||||
|
/// <summary>Visible to authenticated users only.</summary>
|
||||||
|
Protected = 1,
|
||||||
|
/// <summary>Visible only to authorized users (maintainers, curators, admins).</summary>
|
||||||
|
Private = 2
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
using Microsoft.CodeAnalysis.CSharp;
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.CodeAnalysis.Diagnostics;
|
using Microsoft.CodeAnalysis.Diagnostics;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
|
||||||
namespace Lactose.Analyzers;
|
namespace Lactose.Analyzers;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
using System.Collections.Immutable;
|
|
||||||
using System.Composition;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
using Microsoft.CodeAnalysis.CodeActions;
|
using Microsoft.CodeAnalysis.CodeActions;
|
||||||
using Microsoft.CodeAnalysis.CodeFixes;
|
using Microsoft.CodeAnalysis.CodeFixes;
|
||||||
using Microsoft.CodeAnalysis.CSharp;
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.CodeAnalysis.Editing;
|
using Microsoft.CodeAnalysis.Editing;
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Composition;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace Lactose.Analyzers;
|
namespace Lactose.Analyzers;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace Lactose.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configuration for rate-limit buckets applied to different API route groups.
|
||||||
|
/// </summary>
|
||||||
|
public record RateLimitBucketOptions {
|
||||||
|
/// <summary>
|
||||||
|
/// The configuration section name for rate limiting.
|
||||||
|
/// </summary>
|
||||||
|
public const string SectionName = "RateLimiting";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default bucket applied to all routes that are not explicitly assigned.
|
||||||
|
/// </summary>
|
||||||
|
public BucketOptions Global { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Strict bucket for authentication endpoints (login, register, refresh).
|
||||||
|
/// </summary>
|
||||||
|
public BucketOptions Auth { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket for serving original (full-size) media files.
|
||||||
|
/// </summary>
|
||||||
|
public BucketOptions MediaOriginal { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket for serving thumbnail media files.
|
||||||
|
/// </summary>
|
||||||
|
public BucketOptions MediaThumb { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket for serving preview media files.
|
||||||
|
/// </summary>
|
||||||
|
public BucketOptions MediaPreview { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bucket for the long-polling jobs endpoint. Uses a shorter window.
|
||||||
|
/// </summary>
|
||||||
|
public BucketOptions Jobs { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Settings for an individual rate-limit bucket.
|
||||||
|
/// </summary>
|
||||||
|
public class BucketOptions {
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of permits allowed within the window.
|
||||||
|
/// </summary>
|
||||||
|
public int PermitLimit { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Length of the fixed window in seconds.
|
||||||
|
/// </summary>
|
||||||
|
public int WindowSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of queued requests waiting for a permit. Null disables queueing.
|
||||||
|
/// </summary>
|
||||||
|
public int? QueueLimit { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Lactose.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures <see cref="RateLimitBucketOptions"/> from the application configuration.
|
||||||
|
/// </summary>
|
||||||
|
public class RateLimitBucketOptionsProvider(IConfiguration configuration) : IConfigureOptions<RateLimitBucketOptions> {
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(RateLimitBucketOptions options) {
|
||||||
|
var section = configuration.GetSection(RateLimitBucketOptions.SectionName);
|
||||||
|
if (!section.Exists())
|
||||||
|
return;
|
||||||
|
|
||||||
|
section.Bind(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ public class LactoseDbContext : DbContext {
|
|||||||
/// Gets or sets the job records DbSet.
|
/// Gets or sets the job records DbSet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<JobRecord> JobRecords { get; set; }
|
public DbSet<JobRecord> JobRecords { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person-maintainer join table.
|
||||||
|
/// </summary>
|
||||||
|
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
|
||||||
@@ -60,18 +64,34 @@ public class LactoseDbContext : DbContext {
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||||
modelBuilder.HasPostgresExtension("vector");
|
modelBuilder.HasPostgresExtension("vector");
|
||||||
|
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||||
//Album Relationships
|
//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.PersonOwner).WithMany(e => e.Albums);
|
||||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||||
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
|
||||||
//Asset Relationships
|
//Asset Relationships
|
||||||
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
|
modelBuilder.Entity<Asset>().HasOne(e => e.Uploader).WithMany(e => e.UploadedAssets).HasForeignKey(e => e.UploadedBy);
|
||||||
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.Albums).WithMany(e => e.Assets);
|
||||||
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).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>().HasMany(e => e.Faces).WithOne(e => e.Asset);
|
||||||
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
|
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
|
||||||
|
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
|
||||||
|
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
|
||||||
|
.HasMethod("gin")
|
||||||
|
.HasOperators("gin_trgm_ops");
|
||||||
|
// People
|
||||||
|
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
|
||||||
|
.HasDatabaseName("IX_People_Name_Trgm")
|
||||||
|
.HasMethod("gin")
|
||||||
|
.HasOperators("gin_trgm_ops");
|
||||||
|
// Albums
|
||||||
|
modelBuilder.Entity<Album>().HasIndex(a => a.Title)
|
||||||
|
.HasDatabaseName("IX_Albums_Title_Trgm")
|
||||||
|
.HasMethod("gin")
|
||||||
|
.HasOperators("gin_trgm_ops");
|
||||||
//Face Relationships
|
//Face Relationships
|
||||||
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
|
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
|
||||||
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
|
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
|
||||||
@@ -79,12 +99,16 @@ public class LactoseDbContext : DbContext {
|
|||||||
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
|
modelBuilder.Entity<Person>().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.Faces).WithOne(e => e.Person);
|
||||||
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
|
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
|
||||||
|
modelBuilder.Entity<Person>().HasMany(e => e.Maintainers).WithMany(e => e.MaintainedPersons)
|
||||||
|
.UsingEntity<PersonMaintainer>(
|
||||||
|
j => j.HasOne(pm => pm.User).WithMany().HasForeignKey(pm => pm.UserId),
|
||||||
|
j => j.HasOne(pm => pm.Person).WithMany().HasForeignKey(pm => pm.PersonId)
|
||||||
|
);
|
||||||
//Tag Relationships
|
//Tag Relationships
|
||||||
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
|
||||||
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
|
||||||
//User Relationships
|
//User Relationships
|
||||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
|
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
|
||||||
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
|
|
||||||
//Folder Relationships
|
//Folder Relationships
|
||||||
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ namespace Lactose.Controllers;
|
|||||||
/// <param name="authService">Authentication service.</param>
|
/// <param name="authService">Authentication service.</param>
|
||||||
/// <param name="albumRepository">Album repository.</param>
|
/// <param name="albumRepository">Album repository.</param>
|
||||||
/// <param name="assetRepository">Asset repository.</param>
|
/// <param name="assetRepository">Asset repository.</param>
|
||||||
|
/// <param name="personRepository">Person repository.</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
@@ -24,7 +25,8 @@ public class AlbumController(
|
|||||||
//ILogger<AlbumController> logger,
|
//ILogger<AlbumController> logger,
|
||||||
LactoseAuthService authService,
|
LactoseAuthService authService,
|
||||||
IAlbumRepository albumRepository,
|
IAlbumRepository albumRepository,
|
||||||
IAssetRepository assetRepository
|
IAssetRepository assetRepository,
|
||||||
|
IPersonRepository personRepository
|
||||||
) : ControllerBase {
|
) : ControllerBase {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets an album by its ID with filtered assets based on access level.
|
/// Gets an album by its ID with filtered assets based on access level.
|
||||||
@@ -36,70 +38,39 @@ public class AlbumController(
|
|||||||
Guid? uid = authService.GetUserData(User)?.Id;
|
Guid? uid = authService.GetUserData(User)?.Id;
|
||||||
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
var album = albumRepository.Find(id);
|
var album = albumRepository.FindVisible(id, uid, accessLevel);
|
||||||
|
|
||||||
// If the album does not exist, return a 404
|
// If the album does not exist, return a 404
|
||||||
if (album == null) return NotFound();
|
if (album == null) return NotFound();
|
||||||
|
|
||||||
// If the album has no assets, return an empty list
|
return Ok(album.ToAlbumFullDto(accessLevel, uid));
|
||||||
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>
|
/// <summary>
|
||||||
/// Searches albums with pagination, optional search term, and optional unassigned filter.
|
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pagingOptions">Pagination, search, and filter parameters.</param>
|
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
|
||||||
/// <returns>A list of album previews accessible to the user.</returns>
|
/// <returns>A list of album previews accessible to the user.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
|
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
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)
|
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
|
||||||
albums = albums.Where(a => a.PersonOwnerId == null).ToList();
|
|
||||||
|
var albums = albumRepository.SearchQuery(
|
||||||
switch (accessLevel) {
|
pagingOptions.Search ?? string.Empty,
|
||||||
default:
|
pagingOptions.Page,
|
||||||
case EAccessLevel.User:
|
pagingOptions.PageSize,
|
||||||
List<Album> filteredAlbums;
|
pagingOptions.SortBy,
|
||||||
// Only publicly shared albums or owned by user
|
pagingOptions.SortAsc,
|
||||||
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList();
|
pagingOptions.Unassigned,
|
||||||
// Add shared albums
|
uid ?? default,
|
||||||
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid))));
|
accessLevel,
|
||||||
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
pagingOptions.AssetUploadedBy
|
||||||
case EAccessLevel.Curator:
|
).ToList();
|
||||||
case EAccessLevel.Admin:
|
|
||||||
// All albums
|
return Ok(albums);
|
||||||
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -109,7 +80,7 @@ public class AlbumController(
|
|||||||
/// <param name="albumDto">The album update data.</param>
|
/// <param name="albumDto">The album update data.</param>
|
||||||
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
|
||||||
Guid? uid = authService.GetUserData(User)?.Id;
|
Guid? uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
@@ -119,22 +90,19 @@ public class AlbumController(
|
|||||||
// If the album does not exist, return a 404
|
// If the album does not exist, return a 404
|
||||||
if (album == null) return NotFound();
|
if (album == null) return NotFound();
|
||||||
|
|
||||||
|
// Maintainer scope check — must maintain the album's person
|
||||||
switch (accessLevel) {
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||||
|
return Forbid();
|
||||||
case EAccessLevel.User:
|
case EAccessLevel.User:
|
||||||
// Users can't update albums they don't own
|
return Forbid();
|
||||||
if(album.UserOwnerId != uid) return Forbid();
|
|
||||||
break;
|
|
||||||
// Admins and Curator can update any album
|
|
||||||
case EAccessLevel.Curator:
|
|
||||||
case EAccessLevel.Admin:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update album properties
|
// Update album properties
|
||||||
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
|
||||||
album.UpdatedAt = DateTime.UtcNow;
|
album.UpdatedAt = DateTime.UtcNow;
|
||||||
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
|
||||||
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
|
album.Visibility = albumDto.Visibility ?? album.Visibility;
|
||||||
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
|
||||||
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
|
||||||
|
|
||||||
@@ -151,7 +119,7 @@ public class AlbumController(
|
|||||||
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
|
||||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
@@ -162,9 +130,13 @@ public class AlbumController(
|
|||||||
case EAccessLevel.User:
|
case EAccessLevel.User:
|
||||||
// Users can't update albums
|
// Users can't update albums
|
||||||
return Forbid();
|
return Forbid();
|
||||||
|
case EAccessLevel.Maintainer:
|
||||||
|
// Maintainers can only update albums of persons they maintain
|
||||||
|
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||||
|
if (albums.Count == 0) return Forbid();
|
||||||
|
break;
|
||||||
case EAccessLevel.Curator:
|
case EAccessLevel.Curator:
|
||||||
// Curators can only update albums they own
|
// Curators can update any album
|
||||||
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
|
|
||||||
break;
|
break;
|
||||||
case EAccessLevel.Admin:
|
case EAccessLevel.Admin:
|
||||||
// Admins can update any album
|
// Admins can update any album
|
||||||
@@ -175,7 +147,7 @@ public class AlbumController(
|
|||||||
album.Title = bulkDto.Data.Name ?? album.Title;
|
album.Title = bulkDto.Data.Name ?? album.Title;
|
||||||
album.UpdatedAt = DateTime.UtcNow;
|
album.UpdatedAt = DateTime.UtcNow;
|
||||||
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
|
||||||
album.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
|
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
|
||||||
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
|
||||||
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
|
||||||
}
|
}
|
||||||
@@ -192,14 +164,10 @@ public class AlbumController(
|
|||||||
/// <param name="albumDto">The album creation data.</param>
|
/// <param name="albumDto">The album creation data.</param>
|
||||||
/// <returns>200 on success.</returns>
|
/// <returns>200 on success.</returns>
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
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 assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
|
||||||
|
|
||||||
var album = new Album {
|
var album = new Album {
|
||||||
@@ -207,7 +175,7 @@ public class AlbumController(
|
|||||||
Title = albumDto.Name,
|
Title = albumDto.Name,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
PersonOwnerId = albumDto.Person ?? null,
|
PersonOwnerId = albumDto.Person ?? null,
|
||||||
UserOwnerId = albumDto.Owner,
|
Visibility = albumDto.Visibility,
|
||||||
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
|
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
|
||||||
Assets = assets
|
Assets = assets
|
||||||
};
|
};
|
||||||
@@ -218,24 +186,49 @@ public class AlbumController(
|
|||||||
return Ok(album.Id);
|
return Ok(album.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges multiple source albums into a destination album.
|
||||||
|
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||||
|
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||||
|
[HttpPost("merge")]
|
||||||
|
[Authorize(Roles = "Curator,Admin")]
|
||||||
|
public ActionResult Merge([FromBody] AlbumMergeDto dto) {
|
||||||
|
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||||
|
return BadRequest("Destination album ID must not appear in source album IDs.");
|
||||||
|
|
||||||
|
if (albumRepository.Find(dto.DestinationId) == null)
|
||||||
|
return NotFound("Destination album not found.");
|
||||||
|
|
||||||
|
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
|
||||||
|
albumRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes multiple albums (soft delete).
|
/// Removes multiple albums (soft delete).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="albumIds">The list of album IDs to remove.</param>
|
/// <param name="albumIds">The list of album IDs to remove.</param>
|
||||||
/// <returns>200 on success, 403 if forbidden.</returns>
|
/// <returns>200 on success, 403 if forbidden.</returns>
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
var albums = albumRepository.FindBulk(albumIds).ToList();
|
var albums = albumRepository.FindBulk(albumIds).ToList();
|
||||||
|
|
||||||
// If the user is not an admin, filter out albums they do not own
|
// Filter by access level
|
||||||
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.Maintainer:
|
||||||
|
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
|
||||||
|
break;
|
||||||
|
case EAccessLevel.User:
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
// If no albums are left after filtering, return a 403
|
if (albums.Count == 0) return Forbid();
|
||||||
if (albumIds.Count == 0) return Forbid();
|
|
||||||
|
|
||||||
albums.ForEach(albumRepository.Remove);
|
albums.ForEach(albumRepository.Remove);
|
||||||
albumRepository.Save();
|
albumRepository.Save();
|
||||||
@@ -249,7 +242,7 @@ public class AlbumController(
|
|||||||
/// <param name="id">The album ID.</param>
|
/// <param name="id">The album ID.</param>
|
||||||
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public ActionResult Delete(Guid id) {
|
public ActionResult Delete(Guid id) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
@@ -258,8 +251,13 @@ public class AlbumController(
|
|||||||
|
|
||||||
// If the album does not exist, return a 404
|
// If the album does not exist, return a 404
|
||||||
if (album == null) return NotFound();
|
if (album == null) return NotFound();
|
||||||
// If the user is not the owner or an admin, return a 403
|
|
||||||
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) return Forbid();
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
|
||||||
|
return Forbid();
|
||||||
|
case EAccessLevel.User:
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
albumRepository.Remove(album);
|
albumRepository.Remove(album);
|
||||||
albumRepository.Save();
|
albumRepository.Save();
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ namespace Lactose.Controllers;
|
|||||||
/// <param name="authService">Authentication service.</param>
|
/// <param name="authService">Authentication service.</param>
|
||||||
/// <param name="assetRepository">Asset repository.</param>
|
/// <param name="assetRepository">Asset repository.</param>
|
||||||
/// <param name="userRepository">User repository.</param>
|
/// <param name="userRepository">User repository.</param>
|
||||||
|
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AssetController(
|
public class AssetController(
|
||||||
ILogger<AssetController> logger,
|
ILogger<AssetController> logger,
|
||||||
LactoseAuthService authService,
|
LactoseAuthService authService,
|
||||||
IAssetRepository assetRepository,
|
IAssetRepository assetRepository,
|
||||||
IUserRepository userRepository
|
IUserRepository userRepository,
|
||||||
|
IPersonRepository personRepository
|
||||||
) : ControllerBase {
|
) : ControllerBase {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets an asset by its ID with full details, respecting access level.
|
/// Gets an asset by its ID with full details, respecting access level.
|
||||||
@@ -44,50 +46,14 @@ public class AssetController(
|
|||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
var asset = assetRepository.Find(id);
|
var asset = assetRepository.FindVisible(id, uid, accessLevel);
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
logger.LogWarning($"Request asset {id} not found or not visible!");
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (accessLevel) {
|
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
|
||||||
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(
|
logger.LogTrace(
|
||||||
$"""
|
$"""
|
||||||
@@ -95,11 +61,11 @@ public class AssetController(
|
|||||||
dto.Id: {dto.Id}
|
dto.Id: {dto.Id}
|
||||||
dto.FileName: {dto.FileName}
|
dto.FileName: {dto.FileName}
|
||||||
dto.AssetType: {dto.AssetType}
|
dto.AssetType: {dto.AssetType}
|
||||||
dto.IsPublic: {dto.IsPublic}
|
dto.Visibility: {dto.Visibility}
|
||||||
dto.CreatedAt: {dto.CreatedAt}
|
dto.CreatedAt: {dto.CreatedAt}
|
||||||
dto.UpdatedAt: {dto.UpdatedAt}
|
dto.UpdatedAt: {dto.UpdatedAt}
|
||||||
dto.DeletedAt: {dto.DeletedAt}
|
dto.DeletedAt: {dto.DeletedAt}
|
||||||
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
|
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -107,9 +73,9 @@ public class AssetController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches assets with optional date range, type filter, and random ordering with pagination.
|
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
|
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
|
||||||
/// <returns>A list of asset previews.</returns>
|
/// <returns>A list of asset previews.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
|
||||||
@@ -124,14 +90,16 @@ public class AssetController(
|
|||||||
return BadRequest();
|
return BadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchOptionsDto.Page < 1 || searchOptionsDto.PageSize < 1) {
|
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
|
||||||
logger.LogWarning("Invalid pagination parameters provided");
|
logger.LogWarning("Invalid pagination parameters provided");
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
var assets = assetRepository.GetAssets(
|
var assets = assetRepository.GetAssets(
|
||||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total
|
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||||
|
uid, accessLevel,
|
||||||
|
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.LogTrace(
|
logger.LogTrace(
|
||||||
@@ -146,27 +114,56 @@ public class AssetController(
|
|||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (accessLevel) {
|
var dtoList = assets.ToAssetPreviewDto(accessLevel, uid).ToList();
|
||||||
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})");
|
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||||
Response.Headers["X-Total-Count"] = total.ToString();
|
Response.Headers["X-Total-Count"] = total.ToString();
|
||||||
return Ok(dtoList);
|
return Ok(dtoList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||||
|
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
|
||||||
|
/// "My Uploads" group with their own unlinked upload count.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||||
|
[HttpGet("unlinked-groups")]
|
||||||
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
|
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
|
||||||
|
var userData = authService.GetUserData(User);
|
||||||
|
var uid = userData?.Id;
|
||||||
|
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
|
||||||
|
|
||||||
|
var groups = assetRepository.GetFolderGroups(uid, accessLevel, unlinkedOnly);
|
||||||
|
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
|
||||||
|
return Ok(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||||
|
/// and a paginated list of assets at that level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Browse options including folder ID, path, pagination, search, and unlinked-only filter.</param>
|
||||||
|
/// <returns>A browse result with subdirectories and assets at the current level.</returns>
|
||||||
|
[HttpGet("browse")]
|
||||||
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
|
public ActionResult<AssetBrowseResultDto> Browse([FromQuery] AssetBrowseOptionsDto options) {
|
||||||
|
var userData = authService.GetUserData(User);
|
||||||
|
var uid = userData?.Id;
|
||||||
|
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
|
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
logger.LogTrace("Browse: folderId={FolderId}, path={Path}, page={Page}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, accessLevel, options.UnlinkedOnly);
|
||||||
|
|
||||||
|
var result = assetRepository.BrowseAssets(options.FolderId, options.Path ?? "", options.Page, options.PageSize, uid, accessLevel, options.Search, options.UnlinkedOnly);
|
||||||
|
if (result == null) return NotFound();
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates an asset's metadata.
|
/// Updates an asset's metadata.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -174,32 +171,35 @@ public class AssetController(
|
|||||||
/// <param name="dto">The asset update data.</param>
|
/// <param name="dto">The asset update data.</param>
|
||||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
[Authorize(Roles = "Curator,Admin")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
var asset = assetRepository.Find(id);
|
var asset = assetRepository.FindWithAlbums(id);
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||||
|
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
string log = "";
|
string log = "";
|
||||||
|
|
||||||
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
|
asset.Visibility = dto.Visibility ?? asset.Visibility;
|
||||||
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
|
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
|
||||||
|
|
||||||
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
|
||||||
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
|
||||||
|
|
||||||
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
|
if (accesslevel == EAccessLevel.Admin) {
|
||||||
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
|
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
|
||||||
|
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
|
||||||
|
}
|
||||||
|
|
||||||
logger.LogTrace($"Updating asset {id}\n{log}");
|
logger.LogTrace($"Updating asset {id}\n{log}");
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ public class AssetController(
|
|||||||
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
|
||||||
/// <returns>200 on success, or an error response.</returns>
|
/// <returns>200 on success, or an error response.</returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Curator,Admin")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
@@ -234,17 +234,24 @@ public class AssetController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
//If the user is not an admin, they can only update their own assets
|
//If the user is not an admin, they can only update their own assets
|
||||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||||
|
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||||
|
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||||
|
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||||
|
|
||||||
enumerable.ForEach(
|
enumerable.ForEach(
|
||||||
x => {
|
x => {
|
||||||
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
|
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
|
||||||
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
|
||||||
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
|
|
||||||
|
if (accesslevel == EAccessLevel.Admin) {
|
||||||
|
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
assetRepository.UpdateBulk(enumerable);
|
assetRepository.UpdateBulk(enumerable);
|
||||||
|
assetRepository.Save();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,18 +261,19 @@ public class AssetController(
|
|||||||
/// <param name="id">The asset ID.</param>
|
/// <param name="id">The asset ID.</param>
|
||||||
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "Curator,Admin")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
var asset = assetRepository.Find(id);
|
var asset = assetRepository.FindWithAlbums(id);
|
||||||
|
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
logger.LogWarning($"Request asset {id} not found in the database!");
|
logger.LogWarning($"Request asset {id} not found in the database!");
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
|
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
|
||||||
|
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
|
||||||
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
}
|
}
|
||||||
@@ -283,7 +291,7 @@ public class AssetController(
|
|||||||
/// <param name="ids">The list of asset IDs to delete.</param>
|
/// <param name="ids">The list of asset IDs to delete.</param>
|
||||||
/// <returns>200 on success, or an error response.</returns>
|
/// <returns>200 on success, or an error response.</returns>
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Authorize(Roles = "Curator,Admin")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
@@ -302,11 +310,15 @@ public class AssetController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
//If the user is not an admin, they can only delete their own assets
|
//If the user is not an admin, they can only delete their own assets
|
||||||
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
|
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
|
||||||
|
enumerable = accesslevel == EAccessLevel.Maintainer
|
||||||
|
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
|
||||||
|
: enumerable.Where(x => x.UploadedBy == uid).ToList();
|
||||||
|
|
||||||
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
|
||||||
|
|
||||||
assetRepository.UpdateBulk(enumerable);
|
assetRepository.UpdateBulk(enumerable);
|
||||||
|
assetRepository.Save();
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,23 +334,22 @@ public class AssetController(
|
|||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
var accesslevel = authService.GetUserData(User)!.AccessLevel;
|
||||||
|
|
||||||
if (userRepository.Find(dto.Owner) == null) {
|
if (dto.UploadedBy.HasValue) {
|
||||||
logger.LogWarning($"User {dto.Owner} does not exist in the database");
|
if (userRepository.Find(dto.UploadedBy.Value) == null) {
|
||||||
return BadRequest();
|
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
|
||||||
}
|
return BadRequest();
|
||||||
|
}
|
||||||
|
|
||||||
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
|
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
|
||||||
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
|
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
|
||||||
return Unauthorized();
|
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 {
|
var asset = new Asset {
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Type = dto.AssetType,
|
Type = dto.AssetType,
|
||||||
IsPubliclyShared = dto.IsPublic,
|
Visibility = dto.Visibility,
|
||||||
OwnerId = dto.Owner,
|
UploadedBy = dto.UploadedBy,
|
||||||
CreatedAt = dto.CreatedAt,
|
CreatedAt = dto.CreatedAt,
|
||||||
MimeType = dto.MimeType,
|
MimeType = dto.MimeType,
|
||||||
ResolutionWidth = dto.ResolutionWidth,
|
ResolutionWidth = dto.ResolutionWidth,
|
||||||
@@ -355,4 +366,14 @@ public class AssetController(
|
|||||||
|
|
||||||
return Ok(asset.Id);
|
return Ok(asset.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
|
||||||
|
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uid">The maintainer's user ID.</param>
|
||||||
|
/// <param name="asset">The asset to check.</param>
|
||||||
|
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
|
||||||
|
private bool IsMaintainedByUser(Guid uid, Asset asset) =>
|
||||||
|
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Butter.Dtos;
|
using Butter.Dtos;
|
||||||
using Butter.Dtos.User;
|
using Butter.Dtos.User;
|
||||||
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
@@ -7,6 +8,7 @@ using Lactose.Services;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace Lactose.Controllers;
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
@@ -17,13 +19,16 @@ namespace Lactose.Controllers;
|
|||||||
/// <param name="authService">Authentication service.</param>
|
/// <param name="authService">Authentication service.</param>
|
||||||
/// <param name="userRepository">User repository.</param>
|
/// <param name="userRepository">User repository.</param>
|
||||||
/// <param name="passwordHasher">Password hasher.</param>
|
/// <param name="passwordHasher">Password hasher.</param>
|
||||||
|
/// <param name="settingsRepository">Settings repository.</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[EnableRateLimiting("auth")]
|
||||||
public class AuthController(
|
public class AuthController(
|
||||||
ILogger<AuthController> logger,
|
ILogger<AuthController> logger,
|
||||||
LactoseAuthService authService,
|
LactoseAuthService authService,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IPasswordHasher<User> passwordHasher
|
IPasswordHasher<User> passwordHasher,
|
||||||
|
ISettingsRepository settingsRepository
|
||||||
) : ControllerBase {
|
) : ControllerBase {
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -92,14 +97,28 @@ public class AuthController(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether user registration is currently enabled.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>200 if enabled, 403 if disabled.</returns>
|
||||||
|
[HttpGet("register")]
|
||||||
|
public ActionResult CheckRegistrationEnabled() {
|
||||||
|
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||||
|
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a new user account.
|
/// Registers a new user account.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dto">The registration data.</param>
|
/// <param name="dto">The registration data.</param>
|
||||||
/// <returns>200 with the new user ID, or 409 if the user already exists.</returns>
|
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
|
||||||
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
public ActionResult Register([FromBody] UserRegisterDto dto) {
|
||||||
|
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
|
||||||
|
if (regSetting?.Value != "true")
|
||||||
|
return StatusCode(403, "Registration is currently disabled on this server");
|
||||||
|
|
||||||
logger.LogDebug(
|
logger.LogDebug(
|
||||||
$"""
|
$"""
|
||||||
Registering user:
|
Registering user:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
using Butter.Dtos.Jobs;
|
using Butter.Dtos.Jobs;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Jobs;
|
using Lactose.Jobs;
|
||||||
using Lactose.Mapper;
|
|
||||||
using Lactose.Services;
|
using Lactose.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace Lactose.Controllers;
|
namespace Lactose.Controllers;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ namespace Lactose.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
|
[EnableRateLimiting("jobs")]
|
||||||
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
|
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -35,7 +37,8 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("past")]
|
[HttpGet("past")]
|
||||||
[Authorize(Roles = "Admin")]
|
[Authorize(Roles = "Admin")]
|
||||||
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
|
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
|
||||||
|
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||||
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
|
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Lactose.Repositories;
|
|||||||
using Lactose.Services;
|
using Lactose.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
@@ -15,6 +16,7 @@ namespace Lactose.Controllers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authService">Authentication service.</param>
|
/// <param name="authService">Authentication service.</param>
|
||||||
/// <param name="mediaRepository">Media repository.</param>
|
/// <param name="mediaRepository">Media repository.</param>
|
||||||
|
/// <param name="assetRepository">Asset repository.</param>
|
||||||
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
/// <param name="signKeyOptions">JWT signing key configuration.</param>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
@@ -34,6 +36,7 @@ public class MediaController(
|
|||||||
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[HttpGet("original/{id}")]
|
[HttpGet("original/{id}")]
|
||||||
|
[EnableRateLimiting("media_original")]
|
||||||
public ActionResult GetImage(Guid id) {
|
public ActionResult GetImage(Guid id) {
|
||||||
var user = GetUserFromRequest();
|
var user = GetUserFromRequest();
|
||||||
var asset = assetRepository.Find(id);
|
var asset = assetRepository.Find(id);
|
||||||
@@ -43,7 +46,7 @@ public class MediaController(
|
|||||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||||
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
return PhysicalFile(asset.OriginalPath, asset.MimeType);
|
||||||
}
|
}
|
||||||
var data = mediaRepository.GetOriginalData(id, user?.Id);
|
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||||
if (data == null) return NotFound();
|
if (data == null) return NotFound();
|
||||||
|
|
||||||
return PhysicalFile(data.Path, data.MimeType);
|
return PhysicalFile(data.Path, data.MimeType);
|
||||||
@@ -55,6 +58,7 @@ public class MediaController(
|
|||||||
/// <param name="id">The asset ID.</param>
|
/// <param name="id">The asset ID.</param>
|
||||||
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
|
||||||
[HttpGet("thumb/{id}")]
|
[HttpGet("thumb/{id}")]
|
||||||
|
[EnableRateLimiting("media_thumb")]
|
||||||
public ActionResult GetThumb(Guid id) {
|
public ActionResult GetThumb(Guid id) {
|
||||||
var user = GetUserFromRequest();
|
var user = GetUserFromRequest();
|
||||||
var asset = assetRepository.Find(id);
|
var asset = assetRepository.Find(id);
|
||||||
@@ -65,7 +69,7 @@ public class MediaController(
|
|||||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||||
}
|
}
|
||||||
var data = mediaRepository.GetThumbData(id, user?.Id);
|
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||||
if (data == null) return NotFound();
|
if (data == null) return NotFound();
|
||||||
|
|
||||||
return PhysicalFile(data.Path, data.MimeType);
|
return PhysicalFile(data.Path, data.MimeType);
|
||||||
@@ -77,6 +81,7 @@ public class MediaController(
|
|||||||
/// <param name="id">The asset ID.</param>
|
/// <param name="id">The asset ID.</param>
|
||||||
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
|
||||||
[HttpGet("preview/{id}")]
|
[HttpGet("preview/{id}")]
|
||||||
|
[EnableRateLimiting("media_preview")]
|
||||||
public ActionResult GetPreview(Guid id) {
|
public ActionResult GetPreview(Guid id) {
|
||||||
var user = GetUserFromRequest();
|
var user = GetUserFromRequest();
|
||||||
var asset = assetRepository.Find(id);
|
var asset = assetRepository.Find(id);
|
||||||
@@ -87,7 +92,7 @@ public class MediaController(
|
|||||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||||
}
|
}
|
||||||
var data = mediaRepository.GetPreviewData(id, user?.Id);
|
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
|
||||||
if (data == null) return NotFound();
|
if (data == null) return NotFound();
|
||||||
|
|
||||||
return PhysicalFile(data.Path, data.MimeType);
|
return PhysicalFile(data.Path, data.MimeType);
|
||||||
|
|||||||
@@ -27,57 +27,42 @@ public class PersonController(
|
|||||||
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
|
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The person ID.</param>
|
/// <param name="id">The person ID.</param>
|
||||||
/// <param name="albumPaging">Pagination parameters for albums.</param>
|
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
|
||||||
/// <returns>The person details with albums, or 404 if not found.</returns>
|
/// <returns>The person details with albums, or 404 if not found.</returns>
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedParametersDto albumPaging) {
|
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
var person = personRepository.Find(id);
|
var person = personRepository.FindVisible(id, uid, accessLevel,
|
||||||
|
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
|
||||||
|
albumPaging.Page, albumPaging.PageSize);
|
||||||
if (person == null) return NotFound();
|
if (person == null) return NotFound();
|
||||||
|
|
||||||
if (person.Albums != null && accessLevel == EAccessLevel.User) {
|
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
|
||||||
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>
|
/// <summary>
|
||||||
/// Searches people with pagination, filtering out cosplayers with no visible content for non-admin users.
|
/// Searches people with pagination, optional search term, sort options, filtering out cosplayers with no visible content for non-admin users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
|
||||||
/// <returns>A list of people previews.</returns>
|
/// <returns>A paginated list of person previews.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
|
||||||
var uid = authService.GetUserData(User)?.Id;
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
var people = personRepository.GetAllVisible(uid!.Value, accessLevel)
|
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
|
||||||
.Select(p => p.ToPersonPreviewDto())
|
|
||||||
.ToList();
|
var people = personRepository.SearchQuery(
|
||||||
|
pagedSearch.Search ?? string.Empty,
|
||||||
|
pagedSearch.Page,
|
||||||
|
pagedSearch.PageSize,
|
||||||
|
pagedSearch.SortBy,
|
||||||
|
pagedSearch.SortAsc,
|
||||||
|
uid ?? default,
|
||||||
|
accessLevel
|
||||||
|
).ToList();
|
||||||
return Ok(people);
|
return Ok(people);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,16 +73,31 @@ public class PersonController(
|
|||||||
/// <param name="dto">The updated person data.</param>
|
/// <param name="dto">The updated person data.</param>
|
||||||
/// <returns>200 on success.</returns>
|
/// <returns>200 on success.</returns>
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
|
||||||
|
var uid = authService.GetUserData(User)?.Id;
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
var person = personRepository.Find(id);
|
var person = personRepository.Find(id);
|
||||||
if (person == null) return NotFound();
|
if (person == null) return NotFound();
|
||||||
|
|
||||||
|
if (accessLevel == EAccessLevel.Maintainer) {
|
||||||
|
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
person.Name = dto.Name;
|
person.Name = dto.Name;
|
||||||
person.ProfileAssetId = dto.ProfileAssetId;
|
person.ProfileAssetId = dto.ProfileAssetId;
|
||||||
person.ProfileCropX = dto.ProfileCropX;
|
person.ProfileCropX = dto.ProfileCropX;
|
||||||
person.ProfileCropY = dto.ProfileCropY;
|
person.ProfileCropY = dto.ProfileCropY;
|
||||||
person.ProfileCropZoom = dto.ProfileCropZoom;
|
person.ProfileCropZoom = dto.ProfileCropZoom;
|
||||||
|
|
||||||
|
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
|
||||||
|
person.Visibility = dto.Visibility.Value;
|
||||||
|
|
||||||
|
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||||
|
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
|
||||||
|
|
||||||
person.UpdatedAt = DateTime.UtcNow;
|
person.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
personRepository.Save();
|
personRepository.Save();
|
||||||
@@ -112,6 +112,7 @@ public class PersonController(
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
|
||||||
|
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||||
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
|
||||||
|
|
||||||
foreach (var person in people) {
|
foreach (var person in people) {
|
||||||
@@ -120,7 +121,11 @@ public class PersonController(
|
|||||||
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
|
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
|
||||||
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
|
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
|
||||||
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
|
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
|
||||||
|
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
|
||||||
person.UpdatedAt = DateTime.UtcNow;
|
person.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
|
||||||
|
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
personRepository.Save();
|
personRepository.Save();
|
||||||
@@ -135,7 +140,6 @@ public class PersonController(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The person ID.</param>
|
|
||||||
/// <returns>200 on success, 404 if not found.</returns>
|
/// <returns>200 on success, 404 if not found.</returns>
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
@@ -162,7 +166,6 @@ public class PersonController(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
|
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ids">The list of person IDs to delete.</param>
|
|
||||||
/// <returns>200 on success.</returns>
|
/// <returns>200 on success.</returns>
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
@@ -182,6 +185,26 @@ public class PersonController(
|
|||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges multiple source people into a destination person.
|
||||||
|
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The merge request with destination and source IDs.</param>
|
||||||
|
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
|
||||||
|
[HttpPost("merge")]
|
||||||
|
[Authorize(Roles = "Admin, Curator")]
|
||||||
|
public IActionResult Merge([FromBody] PersonMergeDto dto) {
|
||||||
|
if (dto.SourceIds.Contains(dto.DestinationId))
|
||||||
|
return BadRequest("Destination person ID must not appear in source person IDs.");
|
||||||
|
|
||||||
|
if (personRepository.Find(dto.DestinationId) == null)
|
||||||
|
return NotFound("Destination person not found.");
|
||||||
|
|
||||||
|
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
|
||||||
|
personRepository.Save();
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new person.
|
/// Creates a new person.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ public class TagController(
|
|||||||
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
/// <param name="pagedSearch">Pagination and search parameters.</param>
|
||||||
/// <returns>A list of tags matching the search criteria.</returns>
|
/// <returns>A list of tags matching the search criteria.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<List<TagDto>> GetAll(PagedSearchParametersDto pagedSearch) {
|
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
|
||||||
pagedSearch.Search ??= string.Empty;
|
pagedSearch.Search ??= string.Empty;
|
||||||
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
|
||||||
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
|
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
|
||||||
|
|
||||||
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
|
||||||
List<TagDto> tagsDto = [];
|
List<TagDto> tagsDto = [];
|
||||||
@@ -92,7 +92,7 @@ public class TagController(
|
|||||||
/// <param name="id">The tag ID.</param>
|
/// <param name="id">The tag ID.</param>
|
||||||
/// <param name="tagDto">The updated tag data.</param>
|
/// <param name="tagDto">The updated tag data.</param>
|
||||||
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ public class TagController(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
|
||||||
/// <returns>200 on success, or the first error response.</returns>
|
/// <returns>200 on success, or the first error response.</returns>
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ public class TagController(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The tag ID.</param>
|
/// <param name="id">The tag ID.</param>
|
||||||
/// <returns>200 on success, 404 if not found.</returns>
|
/// <returns>200 on success, 404 if not found.</returns>
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public ActionResult Delete([FromRoute] Guid id) {
|
public ActionResult Delete([FromRoute] Guid id) {
|
||||||
IStatusCodeActionResult result = DeleteTag(id);
|
IStatusCodeActionResult result = DeleteTag(id);
|
||||||
@@ -142,7 +142,7 @@ public class TagController(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ids">The list of tag IDs to delete.</param>
|
/// <param name="ids">The list of tag IDs to delete.</param>
|
||||||
/// <returns>200 on success, or the first error response.</returns>
|
/// <returns>200 on success, or the first error response.</returns>
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
|
||||||
foreach (Guid id in ids) {
|
foreach (Guid id in ids) {
|
||||||
@@ -160,7 +160,7 @@ public class TagController(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tagDto">The tag creation data.</param>
|
/// <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>
|
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
|
||||||
[Authorize(Roles = "Admin, Curator")]
|
[Authorize(Roles = "Maintainer,Curator,Admin")]
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
public ActionResult Create([FromBody] TagCreateDto tagDto) {
|
||||||
|
|
||||||
|
|||||||
@@ -83,34 +83,28 @@ namespace Lactose.Controllers {
|
|||||||
return Unauthorized("Unauthorized");
|
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}");
|
||||||
|
|
||||||
User? user = userRepository.Find(id);
|
User? user = userRepository.FindWithMaintainedPersons(id);
|
||||||
|
|
||||||
if (user is null) return NotFound();
|
if (user is null) return NotFound();
|
||||||
|
|
||||||
return Ok(user.ToGetUsersDto());
|
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets all users.
|
/// Gets all users (admin/curator only).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A list of all users.</returns>
|
/// <returns>A list of all users.</returns>
|
||||||
|
[Authorize(Roles = "Admin,Curator")]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<List<UserInfoDto>> GetAll() {
|
public ActionResult<List<UserInfoDto>> GetAll() {
|
||||||
logger.LogDebug("Sending Users list");
|
|
||||||
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
|
||||||
|
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
|
||||||
|
|
||||||
if (authenticatedUser == null) {
|
logger.LogDebug("Sending Users list");
|
||||||
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
|
|
||||||
return Unauthorized("Unauthorized");
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: Lower level of access should fill less information of users
|
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
|
||||||
//if (false) return Unauthorized();
|
|
||||||
|
|
||||||
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -176,10 +170,14 @@ namespace Lactose.Controllers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
|
||||||
|
|
||||||
|
if (dto.MaintainedPersonIds != null) {
|
||||||
|
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
userRepository.Save();
|
userRepository.Save();
|
||||||
return Ok(user.ToGetUsersDto());
|
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -203,7 +201,7 @@ namespace Lactose.Controllers {
|
|||||||
|
|
||||||
if (user == null) return NotFound();
|
if (user == null) return NotFound();
|
||||||
|
|
||||||
userRepository.Delete(user);
|
user.DeletedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
//TODO: eventually remove/hide all of the user content if required
|
//TODO: eventually remove/hide all of the user content if required
|
||||||
userRepository.Save();
|
userRepository.Save();
|
||||||
|
|||||||
@@ -131,5 +131,12 @@
|
|||||||
"Description": "Number of assets to process per sub-job batch.",
|
"Description": "Number of assets to process per sub-job batch.",
|
||||||
"Type": 1,
|
"Type": 1,
|
||||||
"DisplayType": 2
|
"DisplayType": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "System Uploader Id",
|
||||||
|
"Value": "",
|
||||||
|
"Description": "User ID to attribute scanner-created assets to. Leave empty for no default owner.",
|
||||||
|
"Type": 0,
|
||||||
|
"DisplayType": 9
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+8
-4
@@ -8,16 +8,20 @@ EXPOSE 5162
|
|||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
ARG SixLaborsLicenseKey
|
||||||
|
|
||||||
WORKDIR "/src"
|
WORKDIR "/src"
|
||||||
COPY ["Lactose/Lactose.csproj", "Lactose/"]
|
COPY ["Lactose/Lactose.csproj", "Lactose/"]
|
||||||
RUN dotnet restore "Lactose/Lactose.csproj"
|
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||||
|
dotnet restore "Lactose/Lactose.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/Lactose"
|
WORKDIR "/src/Lactose"
|
||||||
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||||
|
dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
FROM build AS publish
|
FROM build AS publish
|
||||||
ARG BUILD_CONFIGURATION=Release
|
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
|
||||||
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
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
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Lactose.Jobs;
|
namespace Lactose.Jobs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Lactose.Jobs;
|
namespace Lactose.Jobs;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Butter;
|
using Butter;
|
||||||
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
@@ -17,6 +18,7 @@ public sealed class FileSystemCrawlJob : Job {
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override JobStatus JobStatus { get; }
|
public override JobStatus JobStatus { get; }
|
||||||
IAssetRepository assetRepository;
|
IAssetRepository assetRepository;
|
||||||
|
ISettingsRepository settingsRepository;
|
||||||
string workingPath;
|
string workingPath;
|
||||||
Guid folderId;
|
Guid folderId;
|
||||||
ILogger<FileSystemCrawlJob> logger;
|
ILogger<FileSystemCrawlJob> logger;
|
||||||
@@ -31,17 +33,20 @@ public sealed class FileSystemCrawlJob : Job {
|
|||||||
/// <param name="workingPath">The base path to crawl.</param>
|
/// <param name="workingPath">The base path to crawl.</param>
|
||||||
/// <param name="logger">Logger instance.</param>
|
/// <param name="logger">Logger instance.</param>
|
||||||
/// <param name="assetRepository">The asset repository.</param>
|
/// <param name="assetRepository">The asset repository.</param>
|
||||||
|
/// <param name="settingsRepository">The settings repository.</param>
|
||||||
/// <param name="jobManager">The job manager.</param>
|
/// <param name="jobManager">The job manager.</param>
|
||||||
public FileSystemCrawlJob(
|
public FileSystemCrawlJob(
|
||||||
Guid folderId,
|
Guid folderId,
|
||||||
string workingPath,
|
string workingPath,
|
||||||
ILogger<FileSystemCrawlJob> logger,
|
ILogger<FileSystemCrawlJob> logger,
|
||||||
IAssetRepository assetRepository,
|
IAssetRepository assetRepository,
|
||||||
|
ISettingsRepository settingsRepository,
|
||||||
JobManager jobManager
|
JobManager jobManager
|
||||||
) {
|
) {
|
||||||
JobStatus = new(this);
|
JobStatus = new(this);
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.assetRepository = assetRepository;
|
this.assetRepository = assetRepository;
|
||||||
|
this.settingsRepository = settingsRepository;
|
||||||
this.workingPath = workingPath;
|
this.workingPath = workingPath;
|
||||||
this.folderId = folderId;
|
this.folderId = folderId;
|
||||||
this.jobManager = jobManager;
|
this.jobManager = jobManager;
|
||||||
@@ -64,6 +69,12 @@ public sealed class FileSystemCrawlJob : Job {
|
|||||||
int steps,
|
int steps,
|
||||||
totalSteps;
|
totalSteps;
|
||||||
|
|
||||||
|
// Resolve system uploader from settings once
|
||||||
|
Guid? uploaderId = null;
|
||||||
|
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
|
||||||
|
if (Guid.TryParse(uploaderSetting, out var parsed))
|
||||||
|
uploaderId = parsed;
|
||||||
|
|
||||||
var files = dir.GetFiles();
|
var files = dir.GetFiles();
|
||||||
logger.LogDebug($"Found {files.Length} files.");
|
logger.LogDebug($"Found {files.Length} files.");
|
||||||
|
|
||||||
@@ -115,14 +126,21 @@ public sealed class FileSystemCrawlJob : Job {
|
|||||||
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
|
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
|
||||||
existing.FileSize = file.Length;
|
existing.FileSize = file.Length;
|
||||||
existing.UpdatedAt = file.LastWriteTimeUtc;
|
existing.UpdatedAt = file.LastWriteTimeUtc;
|
||||||
|
if (existing.UploadedBy == null && uploaderId.HasValue)
|
||||||
|
existing.UploadedBy = uploaderId;
|
||||||
assetRepository.Update(existing);
|
assetRepository.Update(existing);
|
||||||
assetRepository.Save();
|
assetRepository.Save();
|
||||||
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
|
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
|
||||||
|
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
|
||||||
|
existing.UploadedBy = uploaderId;
|
||||||
|
assetRepository.Update(existing);
|
||||||
|
assetRepository.Save();
|
||||||
|
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var asset = AssetFromPath(file.FullName);
|
var asset = AssetFromPath(file.FullName, uploaderId);
|
||||||
if (asset == null) continue;
|
if (asset == null) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -153,7 +171,7 @@ public sealed class FileSystemCrawlJob : Job {
|
|||||||
JobStatus.Complete("Crawl completed successfully.");
|
JobStatus.Complete("Crawl completed successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
Asset? AssetFromPath(string filePath) {
|
Asset? AssetFromPath(string filePath, Guid? uploaderId) {
|
||||||
logger.LogTrace("Loading asset from file path {filePath}", filePath);
|
logger.LogTrace("Loading asset from file path {filePath}", filePath);
|
||||||
// Get the file info
|
// Get the file info
|
||||||
var finfo = new FileInfo(filePath);
|
var finfo = new FileInfo(filePath);
|
||||||
@@ -176,7 +194,8 @@ public sealed class FileSystemCrawlJob : Job {
|
|||||||
OriginalPath = finfo.FullName,
|
OriginalPath = finfo.FullName,
|
||||||
OriginalFilename = finfo.Name,
|
OriginalFilename = finfo.Name,
|
||||||
Type = type.Value,
|
Type = type.Value,
|
||||||
IsPubliclyShared = false,
|
Visibility = EVisibility.Private,
|
||||||
|
UploadedBy = uploaderId,
|
||||||
CreatedAt = finfo.CreationTimeUtc,
|
CreatedAt = finfo.CreationTimeUtc,
|
||||||
UpdatedAt = finfo.LastWriteTimeUtc,
|
UpdatedAt = finfo.LastWriteTimeUtc,
|
||||||
MimeType = type.Value switch {
|
MimeType = type.Value switch {
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,4 @@
|
|||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace Lactose.Jobs;
|
namespace Lactose.Jobs;
|
||||||
|
|
||||||
@@ -147,4 +146,22 @@ public abstract class Job {
|
|||||||
/// Raised when the job is done, regardless of the final status (Completed, Canceled, or Failed).
|
/// Raised when the job is done, regardless of the final status (Completed, Canceled, or Failed).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public virtual event EventHandler<JobStatus>? Done;
|
public virtual event EventHandler<JobStatus>? Done;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the StartedWaiting event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The job status.</param>
|
||||||
|
protected virtual void OnStartedWaiting(JobStatus e) { StartedWaiting?.Invoke(this, e); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the FinishedWaiting event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The job status.</param>
|
||||||
|
protected virtual void OnFinishedWaiting(JobStatus e) { FinishedWaiting?.Invoke(this, e); }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raises the ProgressChanged event.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="e">The job status.</param>
|
||||||
|
protected virtual void OnProgressChanged(JobStatus e) { ProgressChanged?.Invoke(this, e); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Butter.Settings;
|
|||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Mapper;
|
using Lactose.Mapper;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
using Lactose.Utils;
|
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
namespace Lactose.Jobs;
|
namespace Lactose.Jobs;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
using CoenM.ImageHash.HashAlgorithms;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
using SixLabors.ImageSharp;
|
|
||||||
using CoenM.ImageHash.HashAlgorithms;
|
|
||||||
using Lactose.Utils;
|
using Lactose.Utils;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
using SixLabors.ImageSharp.PixelFormats;
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using Butter.Dtos.Settings;
|
|
||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using Butter.Dtos.Settings;
|
|
||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Repositories;
|
using Lactose.Repositories;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
|
|||||||
@@ -14,19 +14,17 @@
|
|||||||
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.15">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
|
||||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Butter.Dtos.Album;
|
using Butter.Dtos.Album;
|
||||||
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
|
||||||
namespace Lactose.Mapper;
|
namespace Lactose.Mapper;
|
||||||
@@ -8,44 +9,52 @@ namespace Lactose.Mapper;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AlbumMapper {
|
public static class AlbumMapper {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps an Album object to an AlbumFullDto object.
|
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="album">The album to map.</param>
|
/// <param name="album">The album to map.</param>
|
||||||
/// <returns>An AlbumFullDto object.</returns>
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
public static AlbumFullDto ToAlbumFullDto(this Album album) => new AlbumFullDto {
|
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||||
|
/// <returns>An AlbumPreviewDto object.</returns>
|
||||||
|
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel, Guid? viewerId = null) => new AlbumPreviewDto {
|
||||||
Id = album.Id,
|
Id = album.Id,
|
||||||
Name = album.Title,
|
Name = album.Title,
|
||||||
Owner = album.UserOwnerId,
|
|
||||||
Person = album.PersonOwnerId,
|
Person = album.PersonOwnerId,
|
||||||
PersonName = album.PersonOwner?.Name,
|
PersonName = album.PersonOwner?.Name,
|
||||||
CoverAssetId = album.CoverAssetId,
|
CoverAssetId = album.CoverAssetId,
|
||||||
AssetCount = album.Assets?.Count ?? 0,
|
CoverWidth = album.CoverAsset?.ResolutionWidth,
|
||||||
Images = album.Assets!.Select(asset => asset.Id).ToList(),
|
CoverHeight = album.CoverAsset?.ResolutionHeight,
|
||||||
AssetPreviews = album.Assets!.Select(asset => new AlbumAssetPreviewDto {
|
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||||
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||||
|
DeletedAt = null
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps an Album object to an AlbumFullDto object.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="album">The album to map.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||||
|
/// <returns>An AlbumFullDto object.</returns>
|
||||||
|
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
|
||||||
|
Id = album.Id,
|
||||||
|
Name = album.Title,
|
||||||
|
Person = album.PersonOwnerId,
|
||||||
|
PersonName = album.PersonOwner?.Name,
|
||||||
|
CoverAssetId = album.CoverAssetId,
|
||||||
|
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||||
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||||
|
DeletedAt = null,
|
||||||
|
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
|
||||||
|
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||||
Id = asset.Id,
|
Id = asset.Id,
|
||||||
ResolutionWidth = asset.ResolutionWidth,
|
ResolutionWidth = asset.ResolutionWidth,
|
||||||
ResolutionHeight = asset.ResolutionHeight,
|
ResolutionHeight = asset.ResolutionHeight,
|
||||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||||
FileName = asset.OriginalFilename
|
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||||
}).ToList()
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||||
|
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||||
|
}).ToList() ?? []
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps an Album object to an AlbumPreviewDto object.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="album">The album to map.</param>
|
|
||||||
/// <returns>An AlbumPreviewDto object.</returns>
|
|
||||||
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
|
|
||||||
Id = album.Id,
|
|
||||||
Name = album.Title,
|
|
||||||
Owner = album.UserOwnerId,
|
|
||||||
Person = album.PersonOwnerId,
|
|
||||||
PersonName = album.PersonOwner?.Name,
|
|
||||||
CoverAssetId = album.CoverAssetId,
|
|
||||||
AssetCount = album.Assets?.Count ?? 0
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Butter.Dtos.Asset;
|
using Butter.Dtos.Asset;
|
||||||
|
using Butter.Dtos.User;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
|
||||||
@@ -9,41 +10,45 @@ namespace Lactose.Mapper;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AssetsMapper {
|
public static class AssetsMapper {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps an Asset object to a GetFullAssetDto object.
|
/// Maps an Asset object to a AssetDto object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="asset">The asset to map.</param>
|
/// <param name="asset">The asset to map.</param>
|
||||||
/// <param name="accessLevel">The access level of the user.</param>
|
/// <param name="accessLevel">The access level of the user.</param>
|
||||||
/// <returns>A GetFullAssetDto object.</returns>
|
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||||
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
|
/// <returns>A AssetDto object.</returns>
|
||||||
|
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
|
||||||
Id = asset.Id,
|
Id = asset.Id,
|
||||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||||
AssetType = asset.Type,
|
AssetType = asset.Type,
|
||||||
IsPublic = asset.IsPubliclyShared,
|
|
||||||
CreatedAt = asset.CreatedAt,
|
CreatedAt = asset.CreatedAt,
|
||||||
UpdatedAt = asset.UpdatedAt,
|
UpdatedAt = asset.UpdatedAt,
|
||||||
DeletedAt = asset.DeletedAt,
|
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
|
||||||
Owner = asset.Owner!.ToGetUsersDto(),
|
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
|
||||||
|
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
|
||||||
MimeType = asset.MimeType,
|
MimeType = asset.MimeType,
|
||||||
ResolutionWidth = asset.ResolutionWidth,
|
ResolutionWidth = asset.ResolutionWidth,
|
||||||
ResolutionHeight = asset.ResolutionHeight,
|
ResolutionHeight = asset.ResolutionHeight,
|
||||||
FileSize = asset.FileSize,
|
FileSize = asset.FileSize,
|
||||||
Duration = asset.Duration ?? 0,
|
Duration = asset.Duration ?? 0,
|
||||||
FrameRate = asset.FrameRate ?? 0
|
FrameRate = asset.FrameRate ?? 0,
|
||||||
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps an Asset object to a GetAssetPreviewDto object.
|
/// Maps an Asset object to a AssetPreviewDto object.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="asset">The asset to map.</param>
|
/// <param name="asset">The asset to map.</param>
|
||||||
/// <returns>A GetAssetPreviewDto object.</returns>
|
/// <param name="accessLevel">The access level of the user.</param>
|
||||||
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
|
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||||
|
/// <returns>A AssetPreviewDto object.</returns>
|
||||||
|
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto {
|
||||||
Id = asset.Id,
|
Id = asset.Id,
|
||||||
MimeType = asset.MimeType,
|
MimeType = asset.MimeType,
|
||||||
ResolutionWidth = asset.ResolutionWidth,
|
ResolutionWidth = asset.ResolutionWidth,
|
||||||
ResolutionHeight = asset.ResolutionHeight,
|
ResolutionHeight = asset.ResolutionHeight,
|
||||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||||
FileName = asset.OriginalFilename,
|
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||||
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
|
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
|
||||||
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
|
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
|
||||||
CosplayerNames = asset.Albums?
|
CosplayerNames = asset.Albums?
|
||||||
@@ -55,14 +60,18 @@ public static class AssetsMapper {
|
|||||||
.Where(a => a.PersonOwner != null)
|
.Where(a => a.PersonOwner != null)
|
||||||
.Select(a => a.PersonOwner!.Id)
|
.Select(a => a.PersonOwner!.Id)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList()
|
.ToList(),
|
||||||
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||||
|
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a collection of Asset objects to a collection of GetAssetPreviewDto objects.
|
/// Maps a collection of Asset objects to a collection of AssetPreviewDto objects.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="assets">The collection of assets to map.</param>
|
/// <param name="assets">The collection of assets to map.</param>
|
||||||
/// <returns>A collection of GetAssetPreviewDto objects.</returns>
|
/// <param name="accessLevel">The access level of the user.</param>
|
||||||
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets)
|
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||||
=> assets.Select(ToAssetPreviewDto);
|
/// <returns>A collection of AssetPreviewDto objects.</returns>
|
||||||
|
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel, Guid? viewerId)
|
||||||
|
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel, viewerId));
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Butter.Dtos.Album;
|
using Butter.Dtos.Album;
|
||||||
using Butter.Dtos.Person;
|
using Butter.Dtos.Person;
|
||||||
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
|
||||||
namespace Lactose.Mapper;
|
namespace Lactose.Mapper;
|
||||||
@@ -12,9 +13,12 @@ public static class PersonMapper {
|
|||||||
/// Maps a Person to a PersonDetailedDto with album previews and stats.
|
/// Maps a Person to a PersonDetailedDto with album previews and stats.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="person">The person to map.</param>
|
/// <param name="person">The person to map.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||||
/// <returns>A PersonDetailedDto.</returns>
|
/// <returns>A PersonDetailedDto.</returns>
|
||||||
public static PersonDetailedDto ToPersonDetailedDto(this Person person) {
|
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel, Guid? viewerId = null) {
|
||||||
var albums = person.Albums ?? [];
|
var albums = person.Albums ?? [];
|
||||||
|
var maintainers = person.Maintainers ?? [];
|
||||||
|
|
||||||
return new PersonDetailedDto {
|
return new PersonDetailedDto {
|
||||||
Id = person.Id,
|
Id = person.Id,
|
||||||
@@ -23,9 +27,12 @@ public static class PersonMapper {
|
|||||||
ProfileCropX = person.ProfileCropX,
|
ProfileCropX = person.ProfileCropX,
|
||||||
ProfileCropY = person.ProfileCropY,
|
ProfileCropY = person.ProfileCropY,
|
||||||
ProfileCropZoom = person.ProfileCropZoom,
|
ProfileCropZoom = person.ProfileCropZoom,
|
||||||
TotalAlbums = albums.Count,
|
TotalAlbums = person.AlbumTotalCount > 0 ? person.AlbumTotalCount : albums.Count,
|
||||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||||
Albums = albums.Select(ToAlbumPreviewDto).ToList()
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||||
|
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
|
||||||
|
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||||
|
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,24 +40,16 @@ public static class PersonMapper {
|
|||||||
/// Maps a Person to a PersonPreviewDto.
|
/// Maps a Person to a PersonPreviewDto.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="person">The person to map.</param>
|
/// <param name="person">The person to map.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
/// <returns>A PersonPreviewDto.</returns>
|
/// <returns>A PersonPreviewDto.</returns>
|
||||||
public static PersonPreviewDto ToPersonPreviewDto(this Person person) => new() {
|
public static PersonPreviewDto ToPersonPreviewDto(this Person person, EAccessLevel accessLevel) => new() {
|
||||||
Id = person.Id,
|
Id = person.Id,
|
||||||
Name = person.Name,
|
Name = person.Name,
|
||||||
ProfileAssetId = person.ProfileAssetId,
|
ProfileAssetId = person.ProfileAssetId,
|
||||||
ProfileCropX = person.ProfileCropX,
|
ProfileCropX = person.ProfileCropX,
|
||||||
ProfileCropY = person.ProfileCropY,
|
ProfileCropY = person.ProfileCropY,
|
||||||
ProfileCropZoom = person.ProfileCropZoom,
|
ProfileCropZoom = person.ProfileCropZoom,
|
||||||
TotalAlbums = person.Albums?.Count ?? 0
|
TotalAlbums = person.Albums?.Count ?? 0,
|
||||||
};
|
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null
|
||||||
|
|
||||||
private static AlbumPreviewDto ToAlbumPreviewDto(Album album) => new() {
|
|
||||||
Id = album.Id,
|
|
||||||
Name = album.Title,
|
|
||||||
Owner = album.UserOwnerId,
|
|
||||||
Person = album.PersonOwnerId,
|
|
||||||
PersonName = album.PersonOwner?.Name,
|
|
||||||
CoverAssetId = album.CoverAssetId,
|
|
||||||
AssetCount = album.Assets?.Count(asset => asset.DeletedAt == null) ?? 0
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Butter.Dtos.User;
|
using Butter.Dtos.User;
|
||||||
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
|
||||||
namespace Lactose.Mapper;
|
namespace Lactose.Mapper;
|
||||||
@@ -8,22 +9,33 @@ namespace Lactose.Mapper;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class UsersMapper {
|
public static class UsersMapper {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a User object to a UserInfoDto object.
|
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
|
||||||
|
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
|
||||||
|
/// when the viewer is an admin or viewing their own profile.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The User object to map.</param>
|
/// <param name="user">The User object to map.</param>
|
||||||
|
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
|
||||||
|
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
|
||||||
/// <returns>A UserInfoDto object containing the mapped data.</returns>
|
/// <returns>A UserInfoDto object containing the mapped data.</returns>
|
||||||
public static UserInfoDto ToGetUsersDto(this User user) {
|
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
|
||||||
|
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
|
||||||
|
bool isSelf = viewerId == user.Id;
|
||||||
|
bool showSensitive = isAdmin || isSelf;
|
||||||
|
|
||||||
return new UserInfoDto {
|
return new UserInfoDto {
|
||||||
Id = user.Id,
|
Id = user.Id,
|
||||||
Email = user.Email,
|
Username = user.Username,
|
||||||
Username = user.Username,
|
Email = showSensitive ? user.Email : null,
|
||||||
CreatedAt = user.CreatedAt,
|
CreatedAt = user.CreatedAt,
|
||||||
IsBanned = user.BannedAt != null,
|
UpdatedAt = showSensitive ? user.UpdatedAt : null,
|
||||||
BannedAt = user.BannedAt,
|
LastLogin = showSensitive ? user.LastLogin : null,
|
||||||
LastLogin = user.LastLogin,
|
BannedAt = showSensitive ? user.BannedAt : null,
|
||||||
UpdatedAt = user.UpdatedAt,
|
DeletedAt = isAdmin ? user.DeletedAt : null,
|
||||||
DeletedAt = user.DeletedAt,
|
IsBanned = showSensitive && user.BannedAt != null,
|
||||||
AccessLevel = user.AccessLevel,
|
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
|
||||||
|
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
|
||||||
|
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+581
@@ -0,0 +1,581 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using Lactose.Context;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LactoseDbContext))]
|
||||||
|
[Migration("20260712160403_AddAssetsSearchVisibilityIndex")]
|
||||||
|
partial class AddAssetsSearchVisibilityIndex
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AlbumsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AlbumsId", "AssetsId");
|
||||||
|
|
||||||
|
b.HasIndex("AssetsId");
|
||||||
|
|
||||||
|
b.ToTable("AlbumAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("TagsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AssetsId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("AssetTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SharedAssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("SharedWithId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||||
|
|
||||||
|
b.HasIndex("SharedWithId");
|
||||||
|
|
||||||
|
b.ToTable("AssetUser");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CoverAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonOwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserOwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonOwnerId");
|
||||||
|
|
||||||
|
b.HasIndex("UserOwnerId");
|
||||||
|
|
||||||
|
b.ToTable("Albums");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<float?>("Duration")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FolderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("FrameRate")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<BitArray>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bit(64)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPubliclyShared")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalFilename")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("ThumbnailSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FolderId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("OwnerId");
|
||||||
|
|
||||||
|
b.HasIndex("IsPubliclyShared", "OwnerId")
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
b.ToTable("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.ToTable("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("RegexPattern")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Finished")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("JobType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ModifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentJobId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float>("Progress")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Started")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobRecords");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProfileAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropX")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropY")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropZoom")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ProfileAssetId");
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("Options")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentId");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessLevel")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLogin")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpires")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Album", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AlbumsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Tag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SharedAssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SharedWithId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||||
|
.WithMany("Albums")
|
||||||
|
.HasForeignKey("PersonOwnerId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||||
|
.WithMany("OwnedAlbums")
|
||||||
|
.HasForeignKey("UserOwnerId");
|
||||||
|
|
||||||
|
b.Navigation("CoverAsset");
|
||||||
|
|
||||||
|
b.Navigation("PersonOwner");
|
||||||
|
|
||||||
|
b.Navigation("UserOwner");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||||
|
.WithMany("Assets")
|
||||||
|
.HasForeignKey("FolderId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "Owner")
|
||||||
|
.WithMany("OwnedAssets")
|
||||||
|
.HasForeignKey("OwnerId");
|
||||||
|
|
||||||
|
b.Navigation("Folder");
|
||||||
|
|
||||||
|
b.Navigation("Owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("AssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("PersonId");
|
||||||
|
|
||||||
|
b.Navigation("Asset");
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProfileAssetId");
|
||||||
|
|
||||||
|
b.Navigation("ProfileAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentId");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Albums");
|
||||||
|
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("OwnedAlbums");
|
||||||
|
|
||||||
|
b.Navigation("OwnedAssets");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAssetsSearchVisibilityIndex : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Assets_VisibleForSearch",
|
||||||
|
table: "Assets",
|
||||||
|
columns: new[] { "IsPubliclyShared", "OwnerId" },
|
||||||
|
filter: "\"DeletedAt\" IS NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Assets_VisibleForSearch",
|
||||||
|
table: "Assets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using Lactose.Context;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LactoseDbContext))]
|
||||||
|
[Migration("20260712162406_AddTrigramIndexes")]
|
||||||
|
partial class AddTrigramIndexes
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AlbumsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AlbumsId", "AssetsId");
|
||||||
|
|
||||||
|
b.HasIndex("AssetsId");
|
||||||
|
|
||||||
|
b.ToTable("AlbumAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("TagsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AssetsId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("AssetTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("SharedAssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("SharedWithId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("SharedAssetsId", "SharedWithId");
|
||||||
|
|
||||||
|
b.HasIndex("SharedWithId");
|
||||||
|
|
||||||
|
b.ToTable("AssetUser");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CoverAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonOwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UserOwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonOwnerId");
|
||||||
|
|
||||||
|
b.HasIndex("Title")
|
||||||
|
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("UserOwnerId");
|
||||||
|
|
||||||
|
b.ToTable("Albums");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<float?>("Duration")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FolderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("FrameRate")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<BitArray>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bit(64)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPubliclyShared")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalFilename")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("OwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("ThumbnailSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FolderId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("OwnerId");
|
||||||
|
|
||||||
|
b.HasIndex("IsPubliclyShared", "OwnerId")
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
b.ToTable("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.ToTable("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("RegexPattern")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Finished")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("JobType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ModifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentJobId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float>("Progress")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Started")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobRecords");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProfileAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropX")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropY")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropZoom")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.HasDatabaseName("IX_People_Name_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("ProfileAssetId");
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("Options")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentId");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessLevel")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLogin")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpires")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Album", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AlbumsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Tag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetUser", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SharedAssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SharedWithId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||||
|
.WithMany("Albums")
|
||||||
|
.HasForeignKey("PersonOwnerId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "UserOwner")
|
||||||
|
.WithMany("OwnedAlbums")
|
||||||
|
.HasForeignKey("UserOwnerId");
|
||||||
|
|
||||||
|
b.Navigation("CoverAsset");
|
||||||
|
|
||||||
|
b.Navigation("PersonOwner");
|
||||||
|
|
||||||
|
b.Navigation("UserOwner");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||||
|
.WithMany("Assets")
|
||||||
|
.HasForeignKey("FolderId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "Owner")
|
||||||
|
.WithMany("OwnedAssets")
|
||||||
|
.HasForeignKey("OwnerId");
|
||||||
|
|
||||||
|
b.Navigation("Folder");
|
||||||
|
|
||||||
|
b.Navigation("Owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("AssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("PersonId");
|
||||||
|
|
||||||
|
b.Navigation("Asset");
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProfileAssetId");
|
||||||
|
|
||||||
|
b.Navigation("ProfileAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentId");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Albums");
|
||||||
|
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("OwnedAlbums");
|
||||||
|
|
||||||
|
b.Navigation("OwnedAssets");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTrigramIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
|
||||||
|
.Annotation("Npgsql:PostgresExtension:vector", ",,")
|
||||||
|
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_People_Name_Trgm",
|
||||||
|
table: "People",
|
||||||
|
column: "Name")
|
||||||
|
.Annotation("Npgsql:IndexMethod", "gin")
|
||||||
|
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Albums_Title_Trgm",
|
||||||
|
table: "Albums",
|
||||||
|
column: "Title")
|
||||||
|
.Annotation("Npgsql:IndexMethod", "gin")
|
||||||
|
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_People_Name_Trgm",
|
||||||
|
table: "People");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Albums_Title_Trgm",
|
||||||
|
table: "Albums");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:PostgresExtension:vector", ",,")
|
||||||
|
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,")
|
||||||
|
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+591
@@ -0,0 +1,591 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using Lactose.Context;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LactoseDbContext))]
|
||||||
|
[Migration("20260714154509_AuthRedesign_VisibilityAccessLevel")]
|
||||||
|
partial class AuthRedesign_VisibilityAccessLevel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AlbumsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AlbumsId", "AssetsId");
|
||||||
|
|
||||||
|
b.HasIndex("AssetsId");
|
||||||
|
|
||||||
|
b.ToTable("AlbumAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("TagsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AssetsId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("AssetTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CoverAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonOwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonOwnerId");
|
||||||
|
|
||||||
|
b.HasIndex("Title")
|
||||||
|
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.ToTable("Albums");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<float?>("Duration")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FolderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("FrameRate")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<BitArray>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bit(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalFilename")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("ThumbnailSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UploadedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FolderId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UploadedBy");
|
||||||
|
|
||||||
|
b.HasIndex("Visibility", "UploadedBy")
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
b.ToTable("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.ToTable("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("RegexPattern")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Finished")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("JobType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ModifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentJobId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float>("Progress")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Started")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobRecords");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProfileAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropX")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropY")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropZoom")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.HasDatabaseName("IX_People_Name_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("ProfileAssetId");
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("PersonId", "UserId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("PersonMaintainers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("Options")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentId");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessLevel")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLogin")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpires")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Album", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AlbumsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Tag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||||
|
.WithMany("Albums")
|
||||||
|
.HasForeignKey("PersonOwnerId");
|
||||||
|
|
||||||
|
b.Navigation("CoverAsset");
|
||||||
|
|
||||||
|
b.Navigation("PersonOwner");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||||
|
.WithMany("Assets")
|
||||||
|
.HasForeignKey("FolderId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "Uploader")
|
||||||
|
.WithMany("UploadedAssets")
|
||||||
|
.HasForeignKey("UploadedBy");
|
||||||
|
|
||||||
|
b.Navigation("Folder");
|
||||||
|
|
||||||
|
b.Navigation("Uploader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("AssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("PersonId");
|
||||||
|
|
||||||
|
b.Navigation("Asset");
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProfileAssetId");
|
||||||
|
|
||||||
|
b.Navigation("ProfileAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentId");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Albums");
|
||||||
|
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("UploadedAssets");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AuthRedesign_VisibilityAccessLevel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Albums_Users_UserOwnerId",
|
||||||
|
table: "Albums");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Assets_Users_OwnerId",
|
||||||
|
table: "Assets");
|
||||||
|
|
||||||
|
// Remap old EAccessLevel values: old Admin (2) → new Admin (3) first, then old Curator (1) → new Curator (2)
|
||||||
|
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 3 WHERE \"AccessLevel\" = 2");
|
||||||
|
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 2 WHERE \"AccessLevel\" = 1");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AssetUser");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Assets_VisibleForSearch",
|
||||||
|
table: "Assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Albums_UserOwnerId",
|
||||||
|
table: "Albums");
|
||||||
|
|
||||||
|
// Add Visibility columns with default Protected (1) before dropping IsPubliclyShared,
|
||||||
|
// so we can map old values via SQL
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "People",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "Assets",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "Albums",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
// Map old IsPubliclyShared = true assets back to Public (0)
|
||||||
|
// Assets that were private default to Protected (1) from the column default
|
||||||
|
migrationBuilder.Sql("UPDATE \"Assets\" SET \"Visibility\" = 0 WHERE \"IsPubliclyShared\" = true");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsPubliclyShared",
|
||||||
|
table: "Assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UserOwnerId",
|
||||||
|
table: "Albums");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "OwnerId",
|
||||||
|
table: "Assets",
|
||||||
|
newName: "UploadedBy");
|
||||||
|
|
||||||
|
migrationBuilder.RenameIndex(
|
||||||
|
name: "IX_Assets_OwnerId",
|
||||||
|
table: "Assets",
|
||||||
|
newName: "IX_Assets_UploadedBy");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PersonMaintainers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
PersonId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PersonMaintainers", x => new { x.PersonId, x.UserId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PersonMaintainers_People_PersonId",
|
||||||
|
column: x => x.PersonId,
|
||||||
|
principalTable: "People",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_PersonMaintainers_Users_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Assets_VisibleForSearch",
|
||||||
|
table: "Assets",
|
||||||
|
columns: new[] { "Visibility", "UploadedBy" },
|
||||||
|
filter: "\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PersonMaintainers_UserId",
|
||||||
|
table: "PersonMaintainers",
|
||||||
|
column: "UserId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Assets_Users_UploadedBy",
|
||||||
|
table: "Assets",
|
||||||
|
column: "UploadedBy",
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Assets_Users_UploadedBy",
|
||||||
|
table: "Assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PersonMaintainers");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Assets_VisibleForSearch",
|
||||||
|
table: "Assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "People");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "Assets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Visibility",
|
||||||
|
table: "Albums");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "UploadedBy",
|
||||||
|
table: "Assets",
|
||||||
|
newName: "OwnerId");
|
||||||
|
|
||||||
|
migrationBuilder.RenameIndex(
|
||||||
|
name: "IX_Assets_UploadedBy",
|
||||||
|
table: "Assets",
|
||||||
|
newName: "IX_Assets_OwnerId");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsPubliclyShared",
|
||||||
|
table: "Assets",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "UserOwnerId",
|
||||||
|
table: "Albums",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AssetUser",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AssetUser_Assets_SharedAssetsId",
|
||||||
|
column: x => x.SharedAssetsId,
|
||||||
|
principalTable: "Assets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AssetUser_Users_SharedWithId",
|
||||||
|
column: x => x.SharedWithId,
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Assets_VisibleForSearch",
|
||||||
|
table: "Assets",
|
||||||
|
columns: new[] { "IsPubliclyShared", "OwnerId" },
|
||||||
|
filter: "\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Albums_UserOwnerId",
|
||||||
|
table: "Albums",
|
||||||
|
column: "UserOwnerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AssetUser_SharedWithId",
|
||||||
|
table: "AssetUser",
|
||||||
|
column: "SharedWithId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Albums_Users_UserOwnerId",
|
||||||
|
table: "Albums",
|
||||||
|
column: "UserOwnerId",
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Assets_Users_OwnerId",
|
||||||
|
table: "Assets",
|
||||||
|
column: "OwnerId",
|
||||||
|
principalTable: "Users",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using Lactose.Context;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LactoseDbContext))]
|
||||||
|
[Migration("20260716185022_AddUnlinkedAssetFilters")]
|
||||||
|
partial class AddUnlinkedAssetFilters
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AlbumsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AlbumsId", "AssetsId");
|
||||||
|
|
||||||
|
b.HasIndex("AssetsId");
|
||||||
|
|
||||||
|
b.ToTable("AlbumAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("AssetsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("TagsId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("AssetsId", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
b.ToTable("AssetTag");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("CoverAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonOwnerId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonOwnerId");
|
||||||
|
|
||||||
|
b.HasIndex("Title")
|
||||||
|
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.ToTable("Albums");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<float?>("Duration")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<long>("FileSize")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<Guid?>("FolderId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("FrameRate")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<BitArray>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bit(64)");
|
||||||
|
|
||||||
|
b.Property<string>("MimeType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalFilename")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("OriginalPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("PreviewPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("PreviewSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ResolutionWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(16)");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailPath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("ThumbnailSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UploadedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FolderId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalFilename")
|
||||||
|
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("OriginalPath")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UploadedBy");
|
||||||
|
|
||||||
|
b.HasIndex("Visibility", "UploadedBy")
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
|
b.ToTable("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxX2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY1")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BoundingBoxY2")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageHeight")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("ImageWidth")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid?>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AssetId");
|
||||||
|
|
||||||
|
b.HasIndex("PersonId");
|
||||||
|
|
||||||
|
b.ToTable("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("Active")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("BasePath")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("RegexPattern")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("Created")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Finished")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("JobType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Message")
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ModifiedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentJobId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float>("Progress")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Started")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobRecords");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ProfileAssetId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropX")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropY")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<float?>("ProfileCropZoom")
|
||||||
|
.HasColumnType("real");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.HasDatabaseName("IX_People_Name_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
|
b.HasIndex("ProfileAssetId");
|
||||||
|
|
||||||
|
b.ToTable("People");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("PersonId", "UserId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("PersonMaintainers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("character varying(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.PrimitiveCollection<string[]>("Options")
|
||||||
|
.HasColumnType("text[]");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("character varying(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Name");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ParentId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentId");
|
||||||
|
|
||||||
|
b.ToTable("Tags");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("AccessLevel")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(128)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastLogin")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Password")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(255)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RefreshTokenExpires")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("VARCHAR(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AlbumAsset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Album", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AlbumsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("AssetTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Tag", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CoverAssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||||
|
.WithMany("Albums")
|
||||||
|
.HasForeignKey("PersonOwnerId");
|
||||||
|
|
||||||
|
b.Navigation("CoverAsset");
|
||||||
|
|
||||||
|
b.Navigation("PersonOwner");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||||
|
.WithMany("Assets")
|
||||||
|
.HasForeignKey("FolderId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "Uploader")
|
||||||
|
.WithMany("UploadedAssets")
|
||||||
|
.HasForeignKey("UploadedBy");
|
||||||
|
|
||||||
|
b.Navigation("Folder");
|
||||||
|
|
||||||
|
b.Navigation("Uploader");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("AssetId");
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany("Faces")
|
||||||
|
.HasForeignKey("PersonId");
|
||||||
|
|
||||||
|
b.Navigation("Asset");
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ProfileAssetId");
|
||||||
|
|
||||||
|
b.Navigation("ProfileAsset");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentId");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Assets");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Albums");
|
||||||
|
|
||||||
|
b.Navigation("Faces");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("UploadedAssets");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Lactose.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUnlinkedAssetFilters : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Assets_OriginalFilename_Trgm",
|
||||||
|
table: "Assets",
|
||||||
|
column: "OriginalFilename")
|
||||||
|
.Annotation("Npgsql:IndexMethod", "gin")
|
||||||
|
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Assets_OriginalFilename_Trgm",
|
||||||
|
table: "Assets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ namespace Lactose.Migrations
|
|||||||
.HasAnnotation("ProductVersion", "10.0.9")
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
@@ -54,21 +55,6 @@ namespace Lactose.Migrations
|
|||||||
b.ToTable("AssetTag");
|
b.ToTable("AssetTag");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("AssetUser", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("SharedAssetsId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<Guid>("SharedWithId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.HasKey("SharedAssetsId", "SharedWithId");
|
|
||||||
|
|
||||||
b.HasIndex("SharedWithId");
|
|
||||||
|
|
||||||
b.ToTable("AssetUser");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -91,8 +77,8 @@ namespace Lactose.Migrations
|
|||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid?>("UserOwnerId")
|
b.Property<int>("Visibility")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
@@ -100,7 +86,11 @@ namespace Lactose.Migrations
|
|||||||
|
|
||||||
b.HasIndex("PersonOwnerId");
|
b.HasIndex("PersonOwnerId");
|
||||||
|
|
||||||
b.HasIndex("UserOwnerId");
|
b.HasIndex("Title")
|
||||||
|
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
b.ToTable("Albums");
|
b.ToTable("Albums");
|
||||||
});
|
});
|
||||||
@@ -133,9 +123,6 @@ namespace Lactose.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("bit(64)");
|
.HasColumnType("bit(64)");
|
||||||
|
|
||||||
b.Property<bool>("IsPubliclyShared")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("MimeType")
|
b.Property<string>("MimeType")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("VARCHAR(255)");
|
.HasColumnType("VARCHAR(255)");
|
||||||
@@ -148,9 +135,6 @@ namespace Lactose.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("VARCHAR(2048)");
|
.HasColumnType("VARCHAR(2048)");
|
||||||
|
|
||||||
b.Property<Guid?>("OwnerId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b.Property<string>("PreviewFormat")
|
b.Property<string>("PreviewFormat")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("VARCHAR(16)");
|
.HasColumnType("VARCHAR(16)");
|
||||||
@@ -185,14 +169,30 @@ namespace Lactose.Migrations
|
|||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<Guid?>("UploadedBy")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("FolderId");
|
b.HasIndex("FolderId");
|
||||||
|
|
||||||
|
b.HasIndex("OriginalFilename")
|
||||||
|
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
b.HasIndex("OriginalPath")
|
b.HasIndex("OriginalPath")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.HasIndex("OwnerId");
|
b.HasIndex("UploadedBy");
|
||||||
|
|
||||||
|
b.HasIndex("Visibility", "UploadedBy")
|
||||||
|
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||||
|
.HasFilter("\"DeletedAt\" IS NULL");
|
||||||
|
|
||||||
b.ToTable("Assets");
|
b.ToTable("Assets");
|
||||||
});
|
});
|
||||||
@@ -328,13 +328,37 @@ namespace Lactose.Migrations
|
|||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Visibility")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Name")
|
||||||
|
.HasDatabaseName("IX_People_Name_Trgm");
|
||||||
|
|
||||||
|
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||||
|
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||||
|
|
||||||
b.HasIndex("ProfileAssetId");
|
b.HasIndex("ProfileAssetId");
|
||||||
|
|
||||||
b.ToTable("People");
|
b.ToTable("People");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("PersonId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("PersonId", "UserId");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("PersonMaintainers");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
@@ -461,21 +485,6 @@ namespace Lactose.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("AssetUser", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("Lactose.Models.Asset", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SharedAssetsId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("Lactose.Models.User", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("SharedWithId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||||
@@ -486,15 +495,9 @@ namespace Lactose.Migrations
|
|||||||
.WithMany("Albums")
|
.WithMany("Albums")
|
||||||
.HasForeignKey("PersonOwnerId");
|
.HasForeignKey("PersonOwnerId");
|
||||||
|
|
||||||
b.HasOne("Lactose.Models.User", "UserOwner")
|
|
||||||
.WithMany("OwnedAlbums")
|
|
||||||
.HasForeignKey("UserOwnerId");
|
|
||||||
|
|
||||||
b.Navigation("CoverAsset");
|
b.Navigation("CoverAsset");
|
||||||
|
|
||||||
b.Navigation("PersonOwner");
|
b.Navigation("PersonOwner");
|
||||||
|
|
||||||
b.Navigation("UserOwner");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||||
@@ -503,13 +506,13 @@ namespace Lactose.Migrations
|
|||||||
.WithMany("Assets")
|
.WithMany("Assets")
|
||||||
.HasForeignKey("FolderId");
|
.HasForeignKey("FolderId");
|
||||||
|
|
||||||
b.HasOne("Lactose.Models.User", "Owner")
|
b.HasOne("Lactose.Models.User", "Uploader")
|
||||||
.WithMany("OwnedAssets")
|
.WithMany("UploadedAssets")
|
||||||
.HasForeignKey("OwnerId");
|
.HasForeignKey("UploadedBy");
|
||||||
|
|
||||||
b.Navigation("Folder");
|
b.Navigation("Folder");
|
||||||
|
|
||||||
b.Navigation("Owner");
|
b.Navigation("Uploader");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||||
@@ -536,6 +539,25 @@ namespace Lactose.Migrations
|
|||||||
b.Navigation("ProfileAsset");
|
b.Navigation("ProfileAsset");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Lactose.Models.Person", "Person")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PersonId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Lactose.Models.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Person");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||||
@@ -564,9 +586,7 @@ namespace Lactose.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("OwnedAlbums");
|
b.Navigation("UploadedAssets");
|
||||||
|
|
||||||
b.Navigation("OwnedAssets");
|
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-11
@@ -1,3 +1,4 @@
|
|||||||
|
using Butter.Types;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
@@ -30,12 +31,6 @@ public class Album {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the user owner identifier.
|
|
||||||
/// </summary>
|
|
||||||
[ForeignKey(nameof(UserOwner))]
|
|
||||||
public Guid? UserOwnerId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the person owner identifier.
|
/// Gets or sets the person owner identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,13 +43,13 @@ public class Album {
|
|||||||
[ForeignKey(nameof(CoverAsset))]
|
[ForeignKey(nameof(CoverAsset))]
|
||||||
public Guid? CoverAssetId { get; set; }
|
public Guid? CoverAssetId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level of the album.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility Visibility { get; set; }
|
||||||
|
|
||||||
#region Navigation Properties
|
#region Navigation Properties
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the user owner of the album. This is the user account who created/owns the album.
|
|
||||||
/// </summary>
|
|
||||||
public User? UserOwner { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the person owner of the album. This is the person/model who appears in the album.
|
/// Gets or sets the person owner of the album. This is the person/model who appears in the album.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
+8
-13
@@ -75,15 +75,15 @@ public class Asset {
|
|||||||
public required EAssetType Type { get; set; }
|
public required EAssetType Type { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the owner ID of the asset.
|
/// Gets or sets the ID of the user who uploaded the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[ForeignKey(nameof(Owner))]
|
[ForeignKey(nameof(UploadedBy))]
|
||||||
public Guid? OwnerId { get; set; }
|
public Guid? UploadedBy { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets a value indicating whether the asset is publicly shared.
|
/// Gets or sets the visibility level of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsPubliclyShared { get; set; }
|
public EVisibility Visibility { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the creation date of the asset.
|
/// Gets or sets the creation date of the asset.
|
||||||
@@ -136,7 +136,7 @@ public class Asset {
|
|||||||
/// Computed hash of the asset.
|
/// Computed hash of the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Required][Column(TypeName = "bit(64)")]
|
[Required][Column(TypeName = "bit(64)")]
|
||||||
public BitArray Hash { get; set; }
|
public BitArray Hash { get; set; } = new BitArray(64);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or Sets which folder the asset is in.
|
/// Gets or Sets which folder the asset is in.
|
||||||
@@ -147,14 +147,9 @@ public class Asset {
|
|||||||
#region Navigation Properties
|
#region Navigation Properties
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the owner of the asset.
|
/// Gets or sets the user who uploaded the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public User? Owner { get; set; }
|
public User? Uploader { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of users with whom the asset is shared.
|
|
||||||
/// </summary>
|
|
||||||
public List<User>? SharedWith { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of albums associated with the asset.
|
/// Gets or sets the list of albums associated with the asset.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Butter.Types;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
@@ -50,6 +51,11 @@ public class Person {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public float? ProfileCropZoom { get; set; }
|
public float? ProfileCropZoom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the visibility level of the person.
|
||||||
|
/// </summary>
|
||||||
|
public EVisibility Visibility { get; set; }
|
||||||
|
|
||||||
#region Navigation Properties
|
#region Navigation Properties
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,5 +73,17 @@ public class Person {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Album>? Albums { get; set; }
|
public List<Album>? Albums { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the list of users who maintain this cosplayer.
|
||||||
|
/// </summary>
|
||||||
|
public List<User>? Maintainers { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the total number of albums (before pagination).
|
||||||
|
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||||
|
/// </summary>
|
||||||
|
[NotMapped]
|
||||||
|
public int AlbumTotalCount { get; set; }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace Lactose.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the many-to-many relationship between maintainer users and the cosplayers they maintain.
|
||||||
|
/// </summary>
|
||||||
|
[PrimaryKey(nameof(PersonId), nameof(UserId))]
|
||||||
|
public class PersonMaintainer {
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person (cosplayer) ID.
|
||||||
|
/// </summary>
|
||||||
|
[ForeignKey(nameof(Person))]
|
||||||
|
public Guid PersonId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the person (cosplayer).
|
||||||
|
/// </summary>
|
||||||
|
public Person Person { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maintainer user ID.
|
||||||
|
/// </summary>
|
||||||
|
[ForeignKey(nameof(User))]
|
||||||
|
public Guid UserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maintainer user.
|
||||||
|
/// </summary>
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -78,19 +78,14 @@ public class User {
|
|||||||
#region Navigation Properties
|
#region Navigation Properties
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of assets owned by the user.
|
/// Gets or sets the list of assets uploaded by the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Asset>? OwnedAssets { get; set; }
|
public List<Asset>? UploadedAssets { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the list of albums owned by the user.
|
/// Gets or sets the list of people (cosplayers) maintained by this user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public List<Album>? OwnedAlbums { get; set; }
|
public List<Person>? MaintainedPersons { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the list of assets shared with the user.
|
|
||||||
/// </summary>
|
|
||||||
public IEnumerable<Asset>? SharedAssets { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-5
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
using Lactose.Authorization;
|
using Lactose.Authorization;
|
||||||
using Lactose.Configuration;
|
using Lactose.Configuration;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
@@ -9,17 +11,13 @@ using Microsoft.AspNetCore.Authentication;
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Pgvector.EntityFrameworkCore;
|
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
//Adds configurations to the builder
|
|
||||||
builder.Configuration.AddJsonFile("appsettings.json", true, true)
|
|
||||||
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", true, true);
|
|
||||||
|
|
||||||
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
|
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
|
||||||
|
|
||||||
// CORS: allow the Blazor WASM frontend to call this API from the browser.
|
// CORS: allow the Blazor WASM frontend to call this API from the browser.
|
||||||
@@ -49,6 +47,68 @@ builder.Services.AddCors(options => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.ConfigureOptions<SignKeyProvider>();
|
builder.Services.ConfigureOptions<SignKeyProvider>();
|
||||||
|
builder.Services.ConfigureOptions<RateLimitBucketOptionsProvider>();
|
||||||
|
builder.Services.AddRateLimiter(options => {
|
||||||
|
options.RejectionStatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||||
|
|
||||||
|
options.OnRejected = (context, cancellationToken) => {
|
||||||
|
context.HttpContext.Response.ContentType = "application/json";
|
||||||
|
var retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfterValue)
|
||||||
|
? (int)retryAfterValue.TotalSeconds
|
||||||
|
: 60;
|
||||||
|
context.HttpContext.Response.Headers.RetryAfter = retryAfter.ToString();
|
||||||
|
return new ValueTask(context.HttpContext.Response.WriteAsJsonAsync(new {
|
||||||
|
error = "rate limit exceeded",
|
||||||
|
retryAfter
|
||||||
|
}, cancellationToken));
|
||||||
|
};
|
||||||
|
|
||||||
|
var bucketConfig = builder.Configuration
|
||||||
|
.GetSection(RateLimitBucketOptions.SectionName)
|
||||||
|
.Get<RateLimitBucketOptions>() ?? new RateLimitBucketOptions();
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("global", opt => {
|
||||||
|
opt.PermitLimit = bucketConfig.Global.PermitLimit;
|
||||||
|
opt.Window = TimeSpan.FromSeconds(bucketConfig.Global.WindowSeconds);
|
||||||
|
opt.QueueLimit = bucketConfig.Global.QueueLimit ?? 0;
|
||||||
|
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("auth", opt => {
|
||||||
|
opt.PermitLimit = bucketConfig.Auth.PermitLimit;
|
||||||
|
opt.Window = TimeSpan.FromSeconds(bucketConfig.Auth.WindowSeconds);
|
||||||
|
opt.QueueLimit = bucketConfig.Auth.QueueLimit ?? 0;
|
||||||
|
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("media_original", opt => {
|
||||||
|
opt.PermitLimit = bucketConfig.MediaOriginal.PermitLimit;
|
||||||
|
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaOriginal.WindowSeconds);
|
||||||
|
opt.QueueLimit = bucketConfig.MediaOriginal.QueueLimit ?? 0;
|
||||||
|
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("media_thumb", opt => {
|
||||||
|
opt.PermitLimit = bucketConfig.MediaThumb.PermitLimit;
|
||||||
|
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaThumb.WindowSeconds);
|
||||||
|
opt.QueueLimit = bucketConfig.MediaThumb.QueueLimit ?? 0;
|
||||||
|
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("media_preview", opt => {
|
||||||
|
opt.PermitLimit = bucketConfig.MediaPreview.PermitLimit;
|
||||||
|
opt.Window = TimeSpan.FromSeconds(bucketConfig.MediaPreview.WindowSeconds);
|
||||||
|
opt.QueueLimit = bucketConfig.MediaPreview.QueueLimit ?? 0;
|
||||||
|
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
});
|
||||||
|
|
||||||
|
options.AddFixedWindowLimiter("jobs", opt => {
|
||||||
|
opt.PermitLimit = bucketConfig.Jobs.PermitLimit;
|
||||||
|
opt.Window = TimeSpan.FromSeconds(bucketConfig.Jobs.WindowSeconds);
|
||||||
|
opt.QueueLimit = bucketConfig.Jobs.QueueLimit ?? 0;
|
||||||
|
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
string? user = builder.Configuration["DatabaseCredentials:UserID"];
|
string? user = builder.Configuration["DatabaseCredentials:UserID"];
|
||||||
string? password = builder.Configuration["DatabaseCredentials:Password"];
|
string? password = builder.Configuration["DatabaseCredentials:Password"];
|
||||||
@@ -179,6 +239,7 @@ if (app.Environment.IsDevelopment()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.UseCors("MilkStreamPolicy");
|
app.UseCors("MilkStreamPolicy");
|
||||||
|
app.UseRateLimiter();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Album;
|
||||||
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
|
using Lactose.Mapper;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
@@ -24,14 +27,82 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150) {
|
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
|
||||||
return context.Albums
|
IQueryable<Album> albumsQuery = context.Albums;
|
||||||
.Include(a => a.CoverAsset)
|
|
||||||
|
if (!string.IsNullOrEmpty(query))
|
||||||
|
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
|
||||||
|
|
||||||
|
if (unassigned)
|
||||||
|
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == null);
|
||||||
|
|
||||||
|
if (uploadedBy.HasValue)
|
||||||
|
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
|
||||||
|
|
||||||
|
// Apply visibility filter scoped to access level
|
||||||
|
albumsQuery = accessLevel switch {
|
||||||
|
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
|
||||||
|
EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected
|
||||||
|
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == a.PersonOwnerId)),
|
||||||
|
_ => albumsQuery
|
||||||
|
};
|
||||||
|
|
||||||
|
IOrderedQueryable<Album> ordered = sortBy?.ToLower() switch
|
||||||
|
{
|
||||||
|
"name" => sortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title),
|
||||||
|
"updated" => sortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt),
|
||||||
|
"assets" => sortAsc
|
||||||
|
? albumsQuery.OrderBy(a => a.Assets!.Count)
|
||||||
|
: albumsQuery.OrderByDescending(a => a.Assets!.Count),
|
||||||
|
"person" => sortAsc ? albumsQuery.OrderBy(a => a.PersonOwner!.Name) : albumsQuery.OrderByDescending(a => a.PersonOwner!.Name),
|
||||||
|
_ => sortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
var pageOffset = page * pageSize;
|
||||||
|
var albumIds = ordered
|
||||||
|
.Skip(pageOffset)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(a => a.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (albumIds.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var pagedAlbums = context.Albums
|
||||||
.Include(a => a.PersonOwner)
|
.Include(a => a.PersonOwner)
|
||||||
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
.Include(a => a.CoverAsset)
|
||||||
.Where(x => x.Title.Contains(query))
|
.Where(a => albumIds.Contains(a.Id))
|
||||||
.Skip(page * pageSize)
|
.ToList();
|
||||||
.Take(pageSize);
|
|
||||||
|
// Compute visible asset count per album at the DB level
|
||||||
|
Dictionary<Guid, int> assetCounts;
|
||||||
|
if (accessLevel >= EAccessLevel.Curator) {
|
||||||
|
assetCounts = context.Albums
|
||||||
|
.Where(a => albumIds.Contains(a.Id))
|
||||||
|
.Select(a => new { a.Id, Count = a.Assets!.Count() })
|
||||||
|
.ToDictionary(x => x.Id, x => x.Count);
|
||||||
|
} else {
|
||||||
|
assetCounts = context.Albums
|
||||||
|
.Where(a => albumIds.Contains(a.Id))
|
||||||
|
.Select(a => new {
|
||||||
|
a.Id,
|
||||||
|
Count = a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||||
|
asset.Visibility == EVisibility.Public ||
|
||||||
|
(asset.Visibility == EVisibility.Protected && userId != default) ||
|
||||||
|
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.ToDictionary(x => x.Id, x => x.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
var dtos = pagedAlbums.Select(a => {
|
||||||
|
var dto = a.ToAlbumPreviewDto(accessLevel, userId);
|
||||||
|
dto.AssetCount = assetCounts.GetValueOrDefault(a.Id, 0);
|
||||||
|
return dto;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||||
|
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -39,13 +110,62 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Album? Find(Guid id) => context.Albums
|
public Album? Find(Guid id) => context.Albums
|
||||||
|
.AsSplitQuery()
|
||||||
.Include(a => a.CoverAsset)
|
.Include(a => a.CoverAsset)
|
||||||
.Include(a => a.PersonOwner)
|
.Include(a => a.PersonOwner)
|
||||||
.Include(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
.Include(a => a.Assets)
|
||||||
.FirstOrDefault(a => a.Id == id);
|
.FirstOrDefault(a => a.Id == id);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Album> FindByOwner(Guid ownerId) => context.Albums.Where(a => a.UserOwnerId == ownerId);
|
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||||
|
var album = context.Albums
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.CoverAsset)
|
||||||
|
.Include(a => a.PersonOwner)
|
||||||
|
.Include(a => a.Assets)
|
||||||
|
.FirstOrDefault(a => a.Id == id);
|
||||||
|
|
||||||
|
if (album == null) return null;
|
||||||
|
|
||||||
|
// R3: Gate album visibility
|
||||||
|
bool canSeeAlbum = accessLevel switch {
|
||||||
|
< EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
|
||||||
|
EAccessLevel.Maintainer when userId.HasValue => album.Visibility <= EVisibility.Protected
|
||||||
|
|| (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||||
|
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)),
|
||||||
|
EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canSeeAlbum) return null;
|
||||||
|
|
||||||
|
if (album.Assets == null) return album;
|
||||||
|
|
||||||
|
album.Assets = accessLevel switch {
|
||||||
|
EAccessLevel.Admin => [.. album.Assets.OrderBy(a => a.OriginalFilename)],
|
||||||
|
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).OrderBy(a => a.OriginalFilename).ToList(),
|
||||||
|
EAccessLevel.Maintainer when userId.HasValue => [.. FilterAssetsForMaintainer(album, userId.Value).OrderBy(a => a.OriginalFilename)],
|
||||||
|
_ => album.Assets.Where(a => a.DeletedAt == null && (
|
||||||
|
a.Visibility == EVisibility.Public ||
|
||||||
|
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||||
|
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||||
|
)).OrderBy(a => a.OriginalFilename).ToList()
|
||||||
|
};
|
||||||
|
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Asset> FilterAssetsForMaintainer(Album album, Guid userId) {
|
||||||
|
if (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||||
|
pm.UserId == userId && pm.PersonId == album.PersonOwnerId.Value))
|
||||||
|
return album.Assets!.Where(a => a.DeletedAt == null).ToList();
|
||||||
|
|
||||||
|
return album.Assets!.Where(a => a.DeletedAt == null && (
|
||||||
|
a.Visibility == EVisibility.Public ||
|
||||||
|
a.Visibility == EVisibility.Protected ||
|
||||||
|
a.UploadedBy == userId
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
||||||
@@ -57,6 +177,33 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
|||||||
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
|
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
|
||||||
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void MergeAlbums(Guid destId, List<Guid> sourceIds) {
|
||||||
|
var dest = context.Albums
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Assets)
|
||||||
|
.FirstOrDefault(a => a.Id == destId);
|
||||||
|
if (dest == null) return;
|
||||||
|
|
||||||
|
var sources = context.Albums
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Assets)
|
||||||
|
.Where(a => sourceIds.Contains(a.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existingAssetIds = dest.Assets?.Select(a => a.Id).ToHashSet() ?? [];
|
||||||
|
|
||||||
|
foreach (var source in sources) {
|
||||||
|
if (source.Assets == null) continue;
|
||||||
|
foreach (var asset in source.Assets) {
|
||||||
|
if (existingAssetIds.Add(asset.Id))
|
||||||
|
dest.Assets?.Add(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Albums.RemoveRange(sources);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Remove(Album album) => context.Albums.Remove(album);
|
public void Remove(Album album) => context.Albums.Remove(album);
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,58 @@
|
|||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
|
using Lactose.Mapper;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Pgvector.EntityFrameworkCore;
|
using Pgvector.EntityFrameworkCore;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository> logger) : IAssetRepository {
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Asset? Find(Guid id) => context.Assets.Find(id);
|
public Asset? Find(Guid id) => context.Assets.Find(id);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||||
|
IQueryable<Asset> query = context.Assets
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Uploader)
|
||||||
|
.Where(a => a.Id == id);
|
||||||
|
|
||||||
|
return accessLevel switch {
|
||||||
|
>= EAccessLevel.Admin => query.FirstOrDefault(),
|
||||||
|
EAccessLevel.Curator => query.FirstOrDefault(a =>
|
||||||
|
a.DeletedAt == null || a.UploadedBy == userId),
|
||||||
|
EAccessLevel.Maintainer when userId.HasValue => query
|
||||||
|
.Include(a => a.Albums)
|
||||||
|
.FirstOrDefault(a => a.DeletedAt == null && (
|
||||||
|
a.Visibility == EVisibility.Public ||
|
||||||
|
a.Visibility == EVisibility.Protected ||
|
||||||
|
a.UploadedBy == userId ||
|
||||||
|
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||||
|
context.PersonMaintainers.Any(pm =>
|
||||||
|
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
|
||||||
|
)),
|
||||||
|
_ => query.FirstOrDefault(a =>
|
||||||
|
a.DeletedAt == null && (
|
||||||
|
a.Visibility == EVisibility.Public ||
|
||||||
|
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||||
|
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
|
||||||
|
))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Asset? FindWithAlbums(Guid id) => context.Assets
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Albums)
|
||||||
|
.FirstOrDefault(a => a.Id == id);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
|
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
|
||||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
|
||||||
@@ -21,7 +60,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
|
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
|
||||||
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
|
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
.Skip(pageNumber * pageSize)
|
||||||
.Take(pageSize);
|
.Take(pageSize);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -50,10 +89,13 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Asset> FindByOwner(Guid ownerId) => context.Assets.Where(a => a.Owner!.Id == ownerId);
|
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.Where(a => a.UploadedBy == uploaderId);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets.Where(a => ids.Contains(a.Id));
|
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Albums)
|
||||||
|
.Where(a => ids.Contains(a.Id));
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
|
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
|
||||||
@@ -135,7 +177,7 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
.Take(limit);
|
.Take(limit);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total) {
|
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null) {
|
||||||
var query = context.Assets.AsQueryable();
|
var query = context.Assets.AsQueryable();
|
||||||
if (type.HasValue)
|
if (type.HasValue)
|
||||||
query = query.Where(a => a.Type == type.Value);
|
query = query.Where(a => a.Type == type.Value);
|
||||||
@@ -143,6 +185,33 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
query = query.Where(a => a.CreatedAt >= from.Value);
|
query = query.Where(a => a.CreatedAt >= from.Value);
|
||||||
if (to.HasValue)
|
if (to.HasValue)
|
||||||
query = query.Where(a => a.UpdatedAt <= to.Value);
|
query = query.Where(a => a.UpdatedAt <= to.Value);
|
||||||
|
if (unlinked)
|
||||||
|
query = query.Where(a => a.Albums!.Count == 0);
|
||||||
|
if (folderId.HasValue)
|
||||||
|
query = query.Where(a => a.FolderId == folderId.Value);
|
||||||
|
if (uploadedBy.HasValue)
|
||||||
|
query = query.Where(a => a.UploadedBy == uploadedBy.Value);
|
||||||
|
if (!string.IsNullOrEmpty(search))
|
||||||
|
query = query.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
|
||||||
|
|
||||||
|
// Apply visibility filter
|
||||||
|
query = accessLevel switch {
|
||||||
|
EAccessLevel.Admin => query,
|
||||||
|
EAccessLevel.Curator => query.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||||
|
EAccessLevel.Maintainer => query.Where(a => a.DeletedAt == null && (
|
||||||
|
a.Visibility == EVisibility.Public ||
|
||||||
|
a.Visibility == EVisibility.Protected ||
|
||||||
|
a.UploadedBy == userId ||
|
||||||
|
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
|
||||||
|
context.PersonMaintainers.Any(pm =>
|
||||||
|
pm.UserId == userId && pm.PersonId == al.PersonOwnerId.Value))
|
||||||
|
)),
|
||||||
|
_ => query.Where(a => a.DeletedAt == null && (
|
||||||
|
a.Visibility == EVisibility.Public ||
|
||||||
|
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||||
|
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
total = query.Count();
|
total = query.Count();
|
||||||
|
|
||||||
@@ -151,11 +220,12 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
var seedHash = seed.Value.GetHashCode();
|
var seedHash = seed.Value.GetHashCode();
|
||||||
var shuffledIds = allIds
|
var shuffledIds = allIds
|
||||||
.OrderBy(id => DeterministicHash(id, seedHash))
|
.OrderBy(id => DeterministicHash(id, seedHash))
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
.Skip(pageNumber * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var assets = context.Assets
|
var assets = context.Assets
|
||||||
|
.AsSplitQuery()
|
||||||
.Where(a => shuffledIds.Contains(a.Id))
|
.Where(a => shuffledIds.Contains(a.Id))
|
||||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -163,11 +233,13 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
IQueryable<Asset> dataQuery = query.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
IQueryable<Asset> dataQuery = query
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||||
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
||||||
|
|
||||||
return dataQuery
|
return dataQuery
|
||||||
.Skip((pageNumber - 1) * pageSize)
|
.Skip(pageNumber * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@@ -193,4 +265,149 @@ public class AssetRepository(LactoseDbContext context) : IAssetRepository {
|
|||||||
.Take(count)
|
.Take(count)
|
||||||
.Select(a => a.OriginalPath)
|
.Select(a => a.OriginalPath)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true) {
|
||||||
|
logger.LogTrace("GetFolderGroups: userId={UserId}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", userId, accessLevel, unlinkedOnly);
|
||||||
|
|
||||||
|
var query = context.Assets
|
||||||
|
.Where(a => a.DeletedAt == null);
|
||||||
|
|
||||||
|
if (unlinkedOnly)
|
||||||
|
query = query.Where(a => a.Albums!.Count == 0);
|
||||||
|
|
||||||
|
if (accessLevel < EAccessLevel.Curator) {
|
||||||
|
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||||
|
var count = query.Count(a => a.UploadedBy == userId);
|
||||||
|
return [new Butter.Dtos.Asset.AssetGroupDto {
|
||||||
|
FolderId = null,
|
||||||
|
Name = "My Uploads",
|
||||||
|
AssetCount = count
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups = query
|
||||||
|
.GroupBy(a => a.FolderId)
|
||||||
|
.Select(g => new {
|
||||||
|
FolderId = g.Key,
|
||||||
|
AssetCount = g.Count()
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var folderNames = context.Folders
|
||||||
|
.Where(f => groups.Select(g => g.FolderId).Contains(f.Id))
|
||||||
|
.ToDictionary(f => f.Id, f => f.BasePath);
|
||||||
|
|
||||||
|
var result = groups
|
||||||
|
.Where(g => g.AssetCount > 0)
|
||||||
|
.Select(g => new Butter.Dtos.Asset.AssetGroupDto {
|
||||||
|
FolderId = g.FolderId,
|
||||||
|
Name = g.FolderId.HasValue && folderNames.TryGetValue(g.FolderId.Value, out var name) ? name : "No Folder",
|
||||||
|
AssetCount = g.AssetCount
|
||||||
|
})
|
||||||
|
.OrderByDescending(g => g.AssetCount)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true) {
|
||||||
|
var folder = context.Folders.Find(folderId);
|
||||||
|
if (folder == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var basePath = folder.BasePath.TrimEnd('/') + "/";
|
||||||
|
var prefix = string.IsNullOrEmpty(currentPath) ? basePath : basePath + currentPath.TrimEnd('/') + "/";
|
||||||
|
var likePattern = prefix + "%";
|
||||||
|
|
||||||
|
var baseQuery = context.Assets
|
||||||
|
.Where(a => a.FolderId == folderId)
|
||||||
|
.Where(a => a.DeletedAt == null);
|
||||||
|
|
||||||
|
if (unlinkedOnly)
|
||||||
|
baseQuery = baseQuery.Where(a => a.Albums!.Count == 0);
|
||||||
|
|
||||||
|
baseQuery = accessLevel switch {
|
||||||
|
EAccessLevel.Admin => baseQuery,
|
||||||
|
EAccessLevel.Curator => baseQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||||
|
EAccessLevel.Maintainer => baseQuery.Where(a => a.UploadedBy == userId),
|
||||||
|
_ => baseQuery.Where(a => false)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(search))
|
||||||
|
baseQuery = baseQuery.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
|
||||||
|
|
||||||
|
var dirs = GetDirectoryNames(prefix, prefix.Length, folderId, userId, accessLevel, search, unlinkedOnly);
|
||||||
|
|
||||||
|
var levelQuery = baseQuery
|
||||||
|
.Where(a => EF.Functions.Like(a.OriginalPath, likePattern))
|
||||||
|
.Where(a => !EF.Functions.Like(a.OriginalPath, prefix + "%/%"));
|
||||||
|
|
||||||
|
var totalAtLevel = levelQuery.Count();
|
||||||
|
|
||||||
|
var assetEntities = levelQuery
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
|
||||||
|
.OrderBy(a => a.OriginalFilename)
|
||||||
|
.Skip(page * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var assetDtos = assetEntities.Select(a => a.ToAssetPreviewDto(accessLevel, userId)).ToList();
|
||||||
|
|
||||||
|
return new Butter.Dtos.Asset.AssetBrowseResultDto {
|
||||||
|
Directories = dirs,
|
||||||
|
Assets = assetDtos,
|
||||||
|
CurrentPath = currentPath,
|
||||||
|
TotalAssetCount = totalAtLevel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Butter.Dtos.Asset.DirectoryEntryDto> GetDirectoryNames(string prefix, int prefixLen, Guid folderId, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly) {
|
||||||
|
var sql = new System.Text.StringBuilder();
|
||||||
|
sql.Append("SELECT DISTINCT SPLIT_PART(SUBSTRING(a.\"OriginalPath\", LENGTH({0}) + 1), '/', 1) AS \"Name\" ");
|
||||||
|
sql.Append("FROM \"Assets\" a ");
|
||||||
|
if (unlinkedOnly)
|
||||||
|
sql.Append("LEFT JOIN \"AlbumAsset\" aa ON a.\"Id\" = aa.\"AssetsId\" ");
|
||||||
|
sql.Append("WHERE a.\"FolderId\" = {1} ");
|
||||||
|
sql.Append("AND a.\"DeletedAt\" IS NULL ");
|
||||||
|
if (unlinkedOnly)
|
||||||
|
sql.Append("AND aa.\"AssetsId\" IS NULL ");
|
||||||
|
sql.Append("AND a.\"OriginalPath\" LIKE {2} ");
|
||||||
|
sql.Append("AND a.\"OriginalPath\" LIKE {3} ");
|
||||||
|
sql.Append("AND LENGTH(a.\"OriginalPath\") > LENGTH({0}) ");
|
||||||
|
|
||||||
|
var parms = new List<object> { prefix, folderId, prefix + "%", prefix + "%/%" };
|
||||||
|
int p = 4;
|
||||||
|
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
sql.Append($"AND (a.\"DeletedAt\" IS NULL OR a.\"UploadedBy\" = {{{p}}}) ");
|
||||||
|
parms.Add(userId ?? Guid.Empty);
|
||||||
|
p++;
|
||||||
|
break;
|
||||||
|
case EAccessLevel.Maintainer:
|
||||||
|
sql.Append($"AND a.\"UploadedBy\" = {{{p}}} ");
|
||||||
|
parms.Add(userId ?? Guid.Empty);
|
||||||
|
p++;
|
||||||
|
break;
|
||||||
|
case < EAccessLevel.Curator:
|
||||||
|
sql.Append("AND FALSE ");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(search)) {
|
||||||
|
sql.Append($"AND a.\"OriginalFilename\" ILIKE '%' || {{{p}}} || '%' ");
|
||||||
|
parms.Add(search!);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.Append("ORDER BY \"Name\"");
|
||||||
|
|
||||||
|
return context.Database
|
||||||
|
.SqlQueryRaw<Butter.Dtos.Asset.DirectoryEntryDto>(sql.ToString(), parms.ToArray())
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Services;
|
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Album;
|
||||||
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
@@ -37,11 +40,13 @@ public interface IAlbumRepository : IDisposable {
|
|||||||
public Album? Find(Guid id);
|
public Album? Find(Guid id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds albums by the owner's ID.
|
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ownerId">The ID of the owner.</param>
|
/// <param name="id">The ID of the album.</param>
|
||||||
/// <returns>A collection of albums owned by the specified owner.</returns>
|
/// <param name="userId">The requesting user's ID for visibility-scoped asset filtering.</param>
|
||||||
public IEnumerable<Album> FindByOwner(Guid ownerId);
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <returns>The album if found; otherwise, null.</returns>
|
||||||
|
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds albums by the person's ID.
|
/// Finds albums by the person's ID.
|
||||||
@@ -65,6 +70,14 @@ public interface IAlbumRepository : IDisposable {
|
|||||||
/// <returns>A collection of albums created or updated within the specified date range.</returns>
|
/// <returns>A collection of albums created or updated within the specified date range.</returns>
|
||||||
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
|
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges multiple source albums into a destination album.
|
||||||
|
/// All assets from source albums are moved to the destination, then source albums are hard-deleted.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="destId">The ID of the destination album.</param>
|
||||||
|
/// <param name="sourceIds">The IDs of the source albums to merge.</param>
|
||||||
|
public void MergeAlbums(Guid destId, List<Guid> sourceIds);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes an album from the database.
|
/// Removes an album from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -72,12 +85,18 @@ public interface IAlbumRepository : IDisposable {
|
|||||||
public void Remove(Album album);
|
public void Remove(Album album);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches for albums based on a query string.
|
/// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="query">The search query string.</param>
|
/// <param name="query">The search query string to filter by title.</param>
|
||||||
/// <param name="page">The page number for pagination (default is 0).</param>
|
/// <param name="page">The page number for pagination (default is 0).</param>
|
||||||
/// <param name="pageSize">The number of results per page (default is 150).</param>
|
/// <param name="pageSize">The number of results per page (default is <see cref="PagedParametersDto.MaxPageSize"/>).</param>
|
||||||
/// <returns>A list of album IDs that match the search query.</returns>
|
/// <param name="sortBy">The field to sort by (e.g. "name", "created", "updated", "assets", "person"). When <c>null</c>, defaults to created descending.</param>
|
||||||
public IEnumerable<Album> SearchQuery(string query, int page = 0, int pageSize = 150);
|
/// <param name="sortAsc">Whether to sort ascending. Default is <c>false</c> (descending).</param>
|
||||||
|
/// <param name="unassigned">When <c>true</c>, only return albums with no person assigned.</param>
|
||||||
|
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||||
|
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
|
||||||
|
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
|
||||||
|
/// <returns>A list of album previews matching the search query.</returns>
|
||||||
|
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using System.Collections;
|
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
@@ -15,6 +14,23 @@ public interface IAssetRepository : IDisposable {
|
|||||||
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
/// <returns>Null if not found, otherwise the requested asset.</returns>
|
||||||
public Asset? Find(Guid id);
|
public Asset? Find(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds an asset by its ID, respecting the requesting user's access level.
|
||||||
|
/// Returns null if the asset does not exist or is not visible to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The ID of the asset.</param>
|
||||||
|
/// <param name="userId">The requesting user's ID.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <returns>The asset if found and visible; otherwise null.</returns>
|
||||||
|
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds an asset by its ID with its album navigation properties loaded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The ID of the asset.</param>
|
||||||
|
/// <returns>Null if not found, otherwise the requested asset with albums.</returns>
|
||||||
|
public Asset? FindWithAlbums(Guid id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds a set of assets by their IDs.
|
/// Finds a set of assets by their IDs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -23,11 +39,11 @@ public interface IAssetRepository : IDisposable {
|
|||||||
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
|
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds all assets owned by the user.
|
/// Finds all assets uploaded by the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="ownerId">The ID of the owner.</param>
|
/// <param name="uploaderId">The ID of the uploader.</param>
|
||||||
/// <returns>A collection of assets owned by the specified user.</returns>
|
/// <returns>A collection of assets uploaded by the specified user.</returns>
|
||||||
public IEnumerable<Asset> FindByOwner(Guid ownerId);
|
public IEnumerable<Asset> FindByUploader(Guid uploaderId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds all assets created or updated between the given dates.
|
/// Finds all assets created or updated between the given dates.
|
||||||
@@ -161,16 +177,48 @@ public interface IAssetRepository : IDisposable {
|
|||||||
List<string> GetRandomPathsByFolder(Guid folderId, int count);
|
List<string> GetRandomPathsByFolder(Guid folderId, int count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Queries assets with optional type filter, date range, and random ordering.
|
/// Queries assets with optional type filter, date range, random ordering, and visibility scoping.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="type">Optional asset type to filter by.</param>
|
/// <param name="type">Optional asset type to filter by.</param>
|
||||||
/// <param name="from">Optional start date for the date range filter.</param>
|
/// <param name="from">Optional start date for the date range filter.</param>
|
||||||
/// <param name="to">Optional end date for the date range filter.</param>
|
/// <param name="to">Optional end date for the date range filter.</param>
|
||||||
/// <param name="orderRandomly">If true, orders randomly instead of by date.</param>
|
/// <param name="orderRandomly">If true, orders randomly instead of by date.</param>
|
||||||
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
|
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
|
||||||
/// <param name="pageNumber">The 1-based page number.</param>
|
/// <param name="pageNumber">The zero-based page number.</param>
|
||||||
/// <param name="pageSize">The number of items per page.</param>
|
/// <param name="pageSize">The number of items per page.</param>
|
||||||
/// <param name="total">The total count of matching assets.</param>
|
/// <param name="total">The total count of matching assets (after visibility filtering).</param>
|
||||||
|
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level for visibility filtering.</param>
|
||||||
|
/// <param name="unlinked">If true, filters for assets not assigned to any album.</param>
|
||||||
|
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
|
||||||
|
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
|
||||||
|
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||||
/// <returns>A paginated collection of assets matching the filters.</returns>
|
/// <returns>A paginated collection of assets matching the filters.</returns>
|
||||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total);
|
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||||
|
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
|
||||||
|
/// "My Uploads" group with their own unlinked upload count.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The requesting user's ID.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <param name="unlinkedOnly">If true (default), only counts assets not assigned to any album.</param>
|
||||||
|
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
|
||||||
|
List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
|
||||||
|
/// and the assets located directly at that level with pagination.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="folderId">The root folder ID to browse within.</param>
|
||||||
|
/// <param name="currentPath">The current path relative to the folder root (empty string for root).</param>
|
||||||
|
/// <param name="page">The zero-based page number for asset pagination.</param>
|
||||||
|
/// <param name="pageSize">The number of assets per page.</param>
|
||||||
|
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||||
|
/// <param name="unlinkedOnly">If true (default), only includes assets not assigned to any album.</param>
|
||||||
|
/// <returns>A browse result containing subdirectories and a page of assets at the current level.</returns>
|
||||||
|
Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Butter.Dtos;
|
using Butter.Dtos;
|
||||||
|
using Butter.Types;
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
@@ -11,22 +12,25 @@ public interface IMediaRepository {
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The ID of the media.</param>
|
/// <param name="id">The ID of the media.</param>
|
||||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||||
public MediaDto? GetOriginalData(Guid id, Guid? userId);
|
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the thumbnail media data.
|
/// Retrieves the thumbnail media data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The ID of the media.</param>
|
/// <param name="id">The ID of the media.</param>
|
||||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||||
public MediaDto? GetThumbData(Guid id, Guid? userId);
|
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the preview media data.
|
/// Retrieves the preview media data.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The ID of the media.</param>
|
/// <param name="id">The ID of the media.</param>
|
||||||
/// <param name="userId">The ID of the user requesting the media.</param>
|
/// <param name="userId">The ID of the user requesting the media.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
|
||||||
public MediaDto? GetPreviewData(Guid id, Guid? userId);
|
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
using Lactose.Models;
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Person;
|
||||||
namespace Lactose.Repositories;
|
|
||||||
|
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
|
using Lactose.Models;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interface for person repository operations.
|
/// Interface for person repository operations.
|
||||||
@@ -26,6 +25,22 @@ public interface IPersonRepository : IDisposable {
|
|||||||
/// <returns>The person if found; otherwise, null.</returns>
|
/// <returns>The person if found; otherwise, null.</returns>
|
||||||
Person? Find(Guid id);
|
Person? Find(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds a person by their ID with albums and assets filtered by the requesting user's access level.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The ID of the person.</param>
|
||||||
|
/// <param name="userId">The requesting user's ID for visibility-scoped filtering.</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||||
|
/// <param name="albumSearch">Optional search term to filter albums by title.</param>
|
||||||
|
/// <param name="albumSortBy">Optional field to sort albums by ("name", "created", "updated", "assets").</param>
|
||||||
|
/// <param name="albumSortAsc">Whether to sort albums ascending.</param>
|
||||||
|
/// <param name="albumPage">Zero-based page number for album pagination.</param>
|
||||||
|
/// <param name="albumPageSize">Number of albums per page (default 150, max 250).</param>
|
||||||
|
/// <returns>The person if found; otherwise, null.</returns>
|
||||||
|
Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||||
|
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||||
|
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds a person by their name (case-sensitive).
|
/// Finds a person by their name (case-sensitive).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -53,6 +68,20 @@ public interface IPersonRepository : IDisposable {
|
|||||||
/// <param name="accessLevel">The current user's access level.</param>
|
/// <param name="accessLevel">The current user's access level.</param>
|
||||||
IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel);
|
IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for people based on a query string with optional sorting and pagination.
|
||||||
|
/// Respects access-level filtering for regular users.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query string to filter by name.</param>
|
||||||
|
/// <param name="page">The page number for pagination (default is 0).</param>
|
||||||
|
/// <param name="pageSize">The number of results per page (default is 30).</param>
|
||||||
|
/// <param name="sortBy">The field to sort by (e.g. "name", "created", "albums"). When <c>null</c>, defaults to name ascending.</param>
|
||||||
|
/// <param name="sortAsc">Whether to sort ascending. Default is <c>true</c>.</param>
|
||||||
|
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||||
|
/// <param name="accessLevel">The current user's access level.</param>
|
||||||
|
/// <returns>A paginated list of person previews matching the query.</returns>
|
||||||
|
IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds multiple people by their IDs.
|
/// Finds multiple people by their IDs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -60,9 +89,33 @@ public interface IPersonRepository : IDisposable {
|
|||||||
/// <returns>A collection of matching people.</returns>
|
/// <returns>A collection of matching people.</returns>
|
||||||
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
|
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges multiple source people into a destination person.
|
||||||
|
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="destId">The ID of the destination person.</param>
|
||||||
|
/// <param name="sourceIds">The IDs of the source people to merge.</param>
|
||||||
|
void MergePeople(Guid destId, List<Guid> sourceIds);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes a person from the database.
|
/// Removes a person from the database.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="person">The person to remove.</param>
|
/// <param name="person">The person to remove.</param>
|
||||||
void Remove(Person person);
|
void Remove(Person person);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a user is a maintainer of a specific person.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user ID to check.</param>
|
||||||
|
/// <param name="personId">The person ID to check.</param>
|
||||||
|
/// <returns><see langword="true"/> if the user is a maintainer of the person.</returns>
|
||||||
|
bool IsMaintainerOf(Guid userId, Guid personId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the list of user IDs that maintain this person.
|
||||||
|
/// Replaces any existing maintainer assignments.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="personId">The person ID.</param>
|
||||||
|
/// <param name="userIds">The user IDs to assign as maintainers.</param>
|
||||||
|
void SetMaintainers(Guid personId, IEnumerable<Guid> userIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,17 @@ public interface IUserRepository : IDisposable {
|
|||||||
User? FindByUsername(string username);
|
User? FindByUsername(string username);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Delete a user from the database.
|
/// Get the user with their maintained persons eagerly loaded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user"></param>
|
/// <param name="id">The user ID.</param>
|
||||||
void Delete(User user);
|
/// <returns>Returns the user with maintained persons if found.</returns>
|
||||||
|
User? FindWithMaintainedPersons(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the list of person (cosplayer) IDs that a user maintains.
|
||||||
|
/// Replaces any existing maintainer assignments.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The user ID.</param>
|
||||||
|
/// <param name="personIds">The person IDs to assign.</param>
|
||||||
|
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
|
|||||||
|
|
||||||
return query
|
return query
|
||||||
.OrderByDescending(j => j.Finished ?? j.Created)
|
.OrderByDescending(j => j.Finished ?? j.Created)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip(page * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,59 @@
|
|||||||
using Butter.Dtos;
|
using Butter.Dtos;
|
||||||
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
public class MediaRepository(LactoseDbContext context, IPersonRepository personRepository) : IMediaRepository {
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Trys to get the asset with the given ID, and checks if it is shared with the given user or public.
|
/// Trys to get the asset with the given ID, and checks visibility for the requesting user.
|
||||||
|
/// Public assets are accessible to all; Protected assets require authentication;
|
||||||
|
/// Private assets require the user to be the uploader, a curator+, or a maintainer
|
||||||
|
/// of the cosplayer whose album contains the asset.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">Asset ID</param>
|
/// <param name="id">Asset ID</param>
|
||||||
/// <param name="userId">UserID we are trying to get this as</param>
|
/// <param name="userId">UserID we are trying to get this as</param>
|
||||||
|
/// <param name="accessLevel">The requesting user's access level</param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
Asset? GetAssetAsUserId(Guid id, Guid? userId) {
|
Asset? GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||||
// Should be equivalent to:
|
var query = context.Assets.Where(asset => asset.Id == id);
|
||||||
// media.IsPubliclyShared && media.SharedWith.Any(user => user.Id == userId)
|
|
||||||
return context.Assets.Include(asset => asset.SharedWith)
|
if (accessLevel >= EAccessLevel.Admin)
|
||||||
.FirstOrDefault(asset => asset.Id == id && (
|
return query.FirstOrDefault();
|
||||||
asset.IsPubliclyShared ||
|
|
||||||
asset.SharedWith!.Any(user => user.Id == userId)
|
if (accessLevel >= EAccessLevel.Curator)
|
||||||
));
|
return query.FirstOrDefault(asset =>
|
||||||
|
asset.DeletedAt == null || asset.UploadedBy == userId);
|
||||||
|
|
||||||
|
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
|
||||||
|
var asset = query
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(a => a.Albums)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (asset == null || asset.DeletedAt != null) return null;
|
||||||
|
if (asset.Visibility == EVisibility.Public) return asset;
|
||||||
|
if (asset.Visibility == EVisibility.Protected) return asset;
|
||||||
|
if (asset.UploadedBy == userId) return asset;
|
||||||
|
if (asset.Albums != null && asset.Albums.Any(a =>
|
||||||
|
a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(userId.Value, a.PersonOwnerId.Value)))
|
||||||
|
return asset;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.FirstOrDefault(asset =>
|
||||||
|
asset.DeletedAt == null && (
|
||||||
|
asset.Visibility == EVisibility.Public ||
|
||||||
|
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||||
|
(asset.Visibility == EVisibility.Private && userId.HasValue && asset.UploadedBy == userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public MediaDto? GetOriginalData(Guid id, Guid? userId) {
|
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||||
|
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||||
var media = GetAssetAsUserId(id, userId);
|
|
||||||
|
|
||||||
if (media == null) return null;
|
if (media == null) return null;
|
||||||
|
|
||||||
@@ -37,12 +65,12 @@ public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
|||||||
|
|
||||||
//TODO: for Thumbnails the MimeType should be the correct one (depending on which format is used for thumbnails)
|
//TODO: for Thumbnails the MimeType should be the correct one (depending on which format is used for thumbnails)
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public MediaDto? GetThumbData(Guid id, Guid? userId) {
|
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||||
var media = GetAssetAsUserId(id, userId);
|
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||||
|
|
||||||
if(media == null) return null;
|
if(media == null) return null;
|
||||||
|
|
||||||
if (media.ThumbnailPath == String.Empty) return null;
|
if (string.IsNullOrEmpty(media.ThumbnailPath)) return null;
|
||||||
|
|
||||||
return new MediaDto {
|
return new MediaDto {
|
||||||
MimeType = "image/webp",
|
MimeType = "image/webp",
|
||||||
@@ -52,12 +80,12 @@ public class MediaRepository(LactoseDbContext context) : IMediaRepository {
|
|||||||
|
|
||||||
//TODO: for Previews the MimeType should be the correct one (depending on which format is used for previews and if the asset is a video)
|
//TODO: for Previews the MimeType should be the correct one (depending on which format is used for previews and if the asset is a video)
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public MediaDto? GetPreviewData(Guid id, Guid? userId) {
|
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||||
var media = GetAssetAsUserId(id, userId);
|
var media = GetVisibleAsset(id, userId, accessLevel);
|
||||||
|
|
||||||
if(media == null) return null;
|
if(media == null) return null;
|
||||||
|
|
||||||
if (media.PreviewPath == String.Empty) return null;
|
if (string.IsNullOrEmpty(media.PreviewPath)) return null;
|
||||||
|
|
||||||
return new MediaDto {
|
return new MediaDto {
|
||||||
MimeType = "image/webp",
|
MimeType = "image/webp",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
using Butter.Dtos;
|
||||||
|
using Butter.Dtos.Person;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
|
using Lactose.Mapper;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
@@ -15,34 +17,136 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Person? Find(Guid id) => context.People
|
public Person? Find(Guid id) => context.People
|
||||||
|
.AsSplitQuery()
|
||||||
.Include(p => p.ProfileAsset)
|
.Include(p => p.ProfileAsset)
|
||||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)!.ThenInclude(a => a.SharedWith)
|
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||||
.Include(p => p.Albums)!.ThenInclude(a => a.UserOwner)
|
.Include(p => p.Maintainers)
|
||||||
.FirstOrDefault(p => p.Id == id);
|
.FirstOrDefault(p => p.Id == id);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||||
|
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||||
|
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
|
||||||
|
var person = context.People
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(p => p.ProfileAsset)
|
||||||
|
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||||
|
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||||
|
.Include(p => p.Maintainers)
|
||||||
|
.FirstOrDefault(p => p.Id == id);
|
||||||
|
|
||||||
|
if (person == null) return null;
|
||||||
|
|
||||||
|
// R4: Gate person visibility
|
||||||
|
bool canSeePerson = accessLevel switch {
|
||||||
|
< EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
|
||||||
|
EAccessLevel.Maintainer when userId.HasValue => person.Visibility <= EVisibility.Protected
|
||||||
|
|| context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id),
|
||||||
|
EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
|
||||||
|
_ => true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canSeePerson) return null;
|
||||||
|
|
||||||
|
if (person.Albums == null) return person;
|
||||||
|
|
||||||
|
// Filter albums and assets by visibility
|
||||||
|
switch (accessLevel) {
|
||||||
|
case EAccessLevel.Admin:
|
||||||
|
case EAccessLevel.Curator:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EAccessLevel.Maintainer when userId.HasValue
|
||||||
|
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
person.Albums = person.Albums
|
||||||
|
.Where(a => a.Visibility <= EVisibility.Protected || (
|
||||||
|
a.Assets != null && a.Assets.Any(asset =>
|
||||||
|
asset.DeletedAt == null && (
|
||||||
|
asset.Visibility == EVisibility.Public ||
|
||||||
|
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||||
|
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var album in person.Albums) {
|
||||||
|
if (album.Assets != null) {
|
||||||
|
album.Assets = album.Assets
|
||||||
|
.Where(asset => asset.DeletedAt == null && (
|
||||||
|
asset.Visibility == EVisibility.Public ||
|
||||||
|
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||||
|
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||||
|
))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply album search
|
||||||
|
if (!string.IsNullOrEmpty(albumSearch)) {
|
||||||
|
person.Albums = person.Albums
|
||||||
|
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply album sorting
|
||||||
|
if (!string.IsNullOrEmpty(albumSortBy)) {
|
||||||
|
person.Albums = albumSortBy.ToLowerInvariant() switch {
|
||||||
|
"name" => albumSortAsc
|
||||||
|
? [..person.Albums.OrderBy(a => a.Title)]
|
||||||
|
: [..person.Albums.OrderByDescending(a => a.Title)],
|
||||||
|
"created" => albumSortAsc
|
||||||
|
? [..person.Albums.OrderBy(a => a.CreatedAt)]
|
||||||
|
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
|
||||||
|
"updated" => albumSortAsc
|
||||||
|
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
|
||||||
|
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
|
||||||
|
"assets" => albumSortAsc
|
||||||
|
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
|
||||||
|
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
|
||||||
|
_ => person.Albums
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply album pagination
|
||||||
|
person.AlbumTotalCount = person.Albums.Count;
|
||||||
|
person.Albums = person.Albums
|
||||||
|
.Skip(albumPage * albumPageSize)
|
||||||
|
.Take(albumPageSize)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return person;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Person> GetAll() =>
|
public IEnumerable<Person> GetAll() =>
|
||||||
context.People.Include(p => p.Albums);
|
context.People.Include(p => p.Albums);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) {
|
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) =>
|
||||||
if (accessLevel != EAccessLevel.User)
|
accessLevel switch {
|
||||||
return GetAll();
|
// Admin and curator.
|
||||||
|
>= EAccessLevel.Curator => GetAll(),
|
||||||
|
|
||||||
var all = context.People
|
EAccessLevel.Maintainer => context.People
|
||||||
.Include(p => p.Albums)!
|
.Include(p => p.Albums)
|
||||||
.ThenInclude(a => a.Assets)!
|
.Where(p => p.Visibility <= EVisibility.Protected
|
||||||
.ThenInclude(a => a.SharedWith)
|
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id))
|
||||||
.ToList();
|
.ToList(),
|
||||||
|
|
||||||
return all.Where(p => p.Albums != null && p.Albums.Any(a =>
|
EAccessLevel.User => context.People
|
||||||
a.Assets != null && a.Assets.Any(asset => asset.DeletedAt == null && (
|
.Include(p => p.Albums)
|
||||||
asset.IsPubliclyShared ||
|
.Where(p => p.Visibility <= EVisibility.Protected)
|
||||||
asset.OwnerId == userId ||
|
.ToList(),
|
||||||
(asset.SharedWith != null && asset.SharedWith.Any(u => u.Id == userId))
|
|
||||||
))));
|
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||||
}
|
};
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Person? FindByName(string name) =>
|
public Person? FindByName(string name) =>
|
||||||
@@ -59,6 +163,104 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Remove(Person person) => context.People.Remove(person);
|
public void Remove(Person person) => context.People.Remove(person);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
|
||||||
|
IQueryable<Person> peopleQuery = context.People;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(query))
|
||||||
|
peopleQuery = peopleQuery.Where(p => EF.Functions.ILike(p.Name, $"%{query}%"));
|
||||||
|
|
||||||
|
// Apply visibility filter
|
||||||
|
peopleQuery = accessLevel switch {
|
||||||
|
EAccessLevel.Admin or EAccessLevel.Curator => peopleQuery,
|
||||||
|
|
||||||
|
EAccessLevel.Maintainer => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected
|
||||||
|
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id)),
|
||||||
|
|
||||||
|
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
|
||||||
|
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply sorting — use subquery counts for "albums"
|
||||||
|
IOrderedQueryable<Person> ordered = sortBy?.ToLower() switch
|
||||||
|
{
|
||||||
|
"created" => sortAsc ? peopleQuery.OrderBy(p => p.CreatedAt) : peopleQuery.OrderByDescending(p => p.CreatedAt),
|
||||||
|
"albums" => sortAsc
|
||||||
|
? peopleQuery.OrderBy(p => p.Albums!.Count)
|
||||||
|
: peopleQuery.OrderByDescending(p => p.Albums!.Count),
|
||||||
|
_ => sortAsc ? peopleQuery.OrderBy(p => p.Name) : peopleQuery.OrderByDescending(p => p.Name),
|
||||||
|
};
|
||||||
|
|
||||||
|
var pageOffset = page * pageSize;
|
||||||
|
var personIds = ordered
|
||||||
|
.Skip(pageOffset)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(p => p.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (personIds.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var pagedPeople = context.People
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(p => p.Albums)
|
||||||
|
.Where(p => personIds.Contains(p.Id))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
|
||||||
|
|
||||||
|
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||||
|
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsMaintainerOf(Guid userId, Guid personId) =>
|
||||||
|
context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == personId);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetMaintainers(Guid personId, IEnumerable<Guid> userIds) {
|
||||||
|
var existing = context.PersonMaintainers.Where(pm => pm.PersonId == personId);
|
||||||
|
context.PersonMaintainers.RemoveRange(existing);
|
||||||
|
|
||||||
|
foreach (var userId in userIds) {
|
||||||
|
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void MergePeople(Guid destId, List<Guid> sourceIds) {
|
||||||
|
var dest = context.People
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(p => p.Maintainers)
|
||||||
|
.FirstOrDefault(p => p.Id == destId);
|
||||||
|
if (dest == null) return;
|
||||||
|
|
||||||
|
// Reassign albums
|
||||||
|
var albums = context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToList();
|
||||||
|
foreach (var album in albums)
|
||||||
|
album.PersonOwnerId = destId;
|
||||||
|
|
||||||
|
// Reassign faces
|
||||||
|
var faces = context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToList();
|
||||||
|
foreach (var face in faces)
|
||||||
|
face.PersonId = destId;
|
||||||
|
|
||||||
|
// Reassign maintainers
|
||||||
|
var existingMaintainerIds = dest.Maintainers?.Select(m => m.Id).ToHashSet() ?? [];
|
||||||
|
var sourceMaintainers = context.PersonMaintainers
|
||||||
|
.Where(pm => sourceIds.Contains(pm.PersonId))
|
||||||
|
.ToList();
|
||||||
|
foreach (var pm in sourceMaintainers) {
|
||||||
|
if (!existingMaintainerIds.Contains(pm.UserId))
|
||||||
|
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = destId, UserId = pm.UserId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-delete source people
|
||||||
|
var people = context.People.Where(p => sourceIds.Contains(p.Id)).ToList();
|
||||||
|
context.People.RemoveRange(people);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose() => context.Dispose();
|
public void Dispose() => context.Dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
using Butter.Settings;
|
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Lactose.Services;
|
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public class SettingsRepository(LactoseDbContext context, IServiceProvider serviceProvider) : ISettingsRepository {
|
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Create(Setting setting) {
|
public void Create(Setting setting) {
|
||||||
//Check if the settings already exist
|
//Check if the settings already exist
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Butter.Dtos.Stats;
|
|||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
|
using Lactose.Models;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
|
|
||||||
namespace Lactose.Repositories;
|
namespace Lactose.Repositories;
|
||||||
@@ -51,8 +52,9 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
|||||||
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
|
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
|
||||||
|
|
||||||
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
|
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
|
||||||
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.IsPubliclyShared);
|
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Public);
|
||||||
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && !a.IsPubliclyShared);
|
dto.ProtectedAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Protected);
|
||||||
|
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Private);
|
||||||
|
|
||||||
dto.AssetsMissingMetadata = context.Assets.Count(a =>
|
dto.AssetsMissingMetadata = context.Assets.Count(a =>
|
||||||
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
|
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
|
||||||
@@ -82,28 +84,33 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
|
|||||||
dto.AssetsMissingPhash = context.Assets.Count(a =>
|
dto.AssetsMissingPhash = context.Assets.Count(a =>
|
||||||
a.Hash == emptyHash && a.DeletedAt == null);
|
a.Hash == emptyHash && a.DeletedAt == null);
|
||||||
|
|
||||||
|
var albumAssetSet = context.Set<Dictionary<string, object>>("AlbumAsset");
|
||||||
|
|
||||||
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
|
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
|
||||||
a.DeletedAt == null && !a.Albums.Any());
|
a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
|
||||||
|
|
||||||
dto.AssetsWithNoPerson = context.Assets.Count(a =>
|
dto.AssetsWithNoPerson = context.Assets.Count(a =>
|
||||||
a.DeletedAt == null && !a.Albums.Any(al => al.PersonOwnerId != null));
|
a.DeletedAt == null && !albumAssetSet.Any(aa =>
|
||||||
|
EF.Property<Guid?>(aa, "AssetsId") == a.Id
|
||||||
|
&& context.Albums.Any(al => al.Id == EF.Property<Guid?>(aa, "AlbumsId") && al.PersonOwnerId != null)));
|
||||||
|
|
||||||
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
|
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
|
||||||
|
|
||||||
dto.CosplayersMissingCover = 0;
|
dto.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
|
||||||
|
|
||||||
dto.TopTags = context.Assets
|
dto.TopTags = (
|
||||||
.Where(a => a.DeletedAt == null)
|
from at in context.Set<Dictionary<string, object>>("AssetTag")
|
||||||
.SelectMany(a => a.Tags)
|
join a in context.Assets.Where(a => a.DeletedAt == null)
|
||||||
.GroupBy(t => new { t.Id, t.Name })
|
on EF.Property<Guid?>(at, "AssetsId") equals (Guid?)a.Id
|
||||||
.Select(g => new TagStatDto {
|
join t in context.Tags
|
||||||
|
on EF.Property<Guid?>(at, "TagsId") equals (Guid?)t.Id
|
||||||
|
group t by new { t.Id, t.Name } into g
|
||||||
|
select new TagStatDto {
|
||||||
Id = g.Key.Id,
|
Id = g.Key.Id,
|
||||||
Name = g.Key.Name,
|
Name = g.Key.Name,
|
||||||
AssetCount = g.Count()
|
AssetCount = g.Count()
|
||||||
})
|
}
|
||||||
.OrderByDescending(t => t.AssetCount)
|
).OrderByDescending(t => t.AssetCount).Take(10).ToList();
|
||||||
.Take(10)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var resolutions = context.Assets
|
var resolutions = context.Assets
|
||||||
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
|||||||
public void Dispose() => context.Dispose();
|
public void Dispose() => context.Dispose();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IEnumerable<User> GetAll() => context.Users.AsEnumerable();
|
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Insert(User user) => context.Users.Add(user);
|
public void Insert(User user) => context.Users.Add(user);
|
||||||
@@ -27,5 +27,16 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
|
|||||||
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
|
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Delete(User user) => context.Users.Remove(user);
|
public User? FindWithMaintainedPersons(Guid id) =>
|
||||||
|
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
|
||||||
|
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
|
||||||
|
context.PersonMaintainers.RemoveRange(existing);
|
||||||
|
|
||||||
|
foreach (var personId in personIds) {
|
||||||
|
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = personId, UserId = userId });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
using Butter;
|
|
||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Context;
|
using Lactose.Context;
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Lactose.Services;
|
namespace Lactose.Services;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Butter.Dtos.Settings;
|
|
||||||
using Butter.Settings;
|
using Butter.Settings;
|
||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Jobs;
|
using Lactose.Jobs;
|
||||||
@@ -159,9 +158,19 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
|
|||||||
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
|
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// if the scheduled scan list isn't populated, fetch active folders directly from DB
|
||||||
|
var targetFolders = folders;
|
||||||
|
if (targetFolders == null || targetFolders.Count == 0) {
|
||||||
|
using var scope = serviceProvider.CreateScope();
|
||||||
|
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
|
||||||
|
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
|
||||||
|
}
|
||||||
// queue a crawl job for each active folder
|
// queue a crawl job for each active folder
|
||||||
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
|
targetFolders.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
|
||||||
logger?.LogInformation("Queued file system crawl jobs.");
|
if (targetFolders.Count > 0)
|
||||||
|
logger?.LogInformation("Queued {Count} file system crawl job(s).", targetFolders.Count);
|
||||||
|
else
|
||||||
|
logger?.LogWarning("No active folders to scan.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
using Butter.Types;
|
using Butter.Types;
|
||||||
using Lactose.Configuration;
|
|
||||||
using Lactose.Models;
|
using Lactose.Models;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Lactose.Services;
|
namespace Lactose.Services;
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
using Lactose.Configuration;
|
using Lactose.Configuration;
|
||||||
using Lactose.Models;
|
|
||||||
using Lactose.Repositories;
|
|
||||||
using Lactose.Utils;
|
using Lactose.Utils;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
|||||||
+2166
-478
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user