mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1811d42d79 | |||
| 34caed3b2b | |||
| 677cb660f5 | |||
| 9b0b2bfcf2 | |||
| ac6938a629 | |||
| 16749ff8ba | |||
| bba4a00eb1 | |||
| 9dafc8e8e9 | |||
| 4e44fb9cf7 | |||
| 82db581cc5 | |||
| b66c97b785 | |||
| ff936f901d | |||
| 48fe111daa | |||
| 0581b49750 | |||
| 2c6d4f3fe1 | |||
| 55513cd59f | |||
| 10fa928abe | |||
| e322d44f95 | |||
| c2a279e49e | |||
| 226b9390db | |||
| 754f072ef9 | |||
| c91d8745b4 | |||
| f3b7cd6198 | |||
| 990aff441b | |||
| 001d7d083f | |||
| 3fd24e2083 | |||
| 6bb8f4fcc4 | |||
| d4605b21d9 | |||
| 3bd37ebbfb | |||
| 5c3777ab46 |
@@ -24,7 +24,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Check for breaking API changes
|
- name: Check for breaking API changes
|
||||||
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
|
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
|
||||||
with:
|
with:
|
||||||
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
|
||||||
revision: open-api/immich-openapi-specs.json
|
revision: open-api/immich-openapi-specs.json
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
name: Check PR Template
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||||
|
types: [opened, edited]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
parse:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: ${{ github.event.pull_request.head.repo.fork == true }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
uses_template: ${{ steps.check.outputs.uses_template }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
sparse-checkout: .github/pull_request_template.md
|
||||||
|
sparse-checkout-cone-mode: false
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Check required sections
|
||||||
|
id: check
|
||||||
|
env:
|
||||||
|
BODY: ${{ github.event.pull_request.body }}
|
||||||
|
run: |
|
||||||
|
OK=true
|
||||||
|
while IFS= read -r header; do
|
||||||
|
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
|
||||||
|
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ")
|
||||||
|
echo "uses_template=$OK" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
act:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: parse
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: Close PR
|
||||||
|
if: ${{ needs.parse.outputs.uses_template == 'false' && github.event.pull_request.state != 'closed' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||||
|
run: |
|
||||||
|
gh api graphql \
|
||||||
|
-f prId="$NODE_ID" \
|
||||||
|
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
|
||||||
|
-f query='
|
||||||
|
mutation CommentAndClosePR($prId: ID!, $body: String!) {
|
||||||
|
addComment(input: {
|
||||||
|
subjectId: $prId,
|
||||||
|
body: $body
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
closePullRequest(input: {
|
||||||
|
pullRequestId: $prId
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
|
||||||
|
- name: Reopen PR (sections now present, PR closed)
|
||||||
|
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
NODE_ID: ${{ github.event.pull_request.node_id }}
|
||||||
|
run: |
|
||||||
|
gh api graphql \
|
||||||
|
-f prId="$NODE_ID" \
|
||||||
|
-f query='
|
||||||
|
mutation ReopenPR($prId: ID!) {
|
||||||
|
reopenPullRequest(input: {
|
||||||
|
pullRequestId: $prId
|
||||||
|
}) {
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}'
|
||||||
@@ -42,10 +42,10 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||||
@@ -83,6 +83,6 @@ jobs:
|
|||||||
# ./location_of_script_within_repo/buildscript.sh
|
# ./location_of_script_within_repo/buildscript.sh
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
|
||||||
with:
|
with:
|
||||||
category: '/language:${{matrix.language}}'
|
category: '/language:${{matrix.language}}'
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './docs/.nvmrc'
|
node-version-file: './docs/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ jobs:
|
|||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
|
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
node-version-file: './open-api/typescript-sdk/.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|||||||
+27
-27
@@ -75,9 +75,9 @@ jobs:
|
|||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -119,9 +119,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -166,9 +166,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './cli/.nvmrc'
|
node-version-file: './cli/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -208,9 +208,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -252,9 +252,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -290,9 +290,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './web/.nvmrc'
|
node-version-file: './web/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -338,9 +338,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -385,9 +385,9 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -424,9 +424,9 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -496,9 +496,9 @@ jobs:
|
|||||||
submodules: 'recursive'
|
submodules: 'recursive'
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './e2e/.nvmrc'
|
node-version-file: './e2e/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -620,7 +620,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.11
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -661,9 +661,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './.github/.nvmrc'
|
node-version-file: './.github/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -712,9 +712,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -774,9 +774,9 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
token: ${{ steps.token.outputs.token }}
|
token: ${{ steps.token.outputs.token }}
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version-file: './server/.nvmrc'
|
node-version-file: './server/.nvmrc'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export enum OAuthClient {
|
|||||||
export enum OAuthUser {
|
export enum OAuthUser {
|
||||||
NO_EMAIL = 'no-email',
|
NO_EMAIL = 'no-email',
|
||||||
NO_NAME = 'no-name',
|
NO_NAME = 'no-name',
|
||||||
|
ID_TOKEN_CLAIMS = 'id-token-claims',
|
||||||
WITH_QUOTA = 'with-quota',
|
WITH_QUOTA = 'with-quota',
|
||||||
WITH_USERNAME = 'with-username',
|
WITH_USERNAME = 'with-username',
|
||||||
WITH_ROLE = 'with-role',
|
WITH_ROLE = 'with-role',
|
||||||
@@ -52,12 +53,25 @@ const withDefaultClaims = (sub: string) => ({
|
|||||||
email_verified: true,
|
email_verified: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
const getClaims = (sub: string, use?: string) => {
|
||||||
|
if (sub === OAuthUser.ID_TOKEN_CLAIMS) {
|
||||||
|
return {
|
||||||
|
sub,
|
||||||
|
email: `oauth-${sub}@immich.app`,
|
||||||
|
email_verified: true,
|
||||||
|
name: use === 'id_token' ? 'ID Token User' : 'Userinfo User',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
|
||||||
|
};
|
||||||
|
|
||||||
const setup = async () => {
|
const setup = async () => {
|
||||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||||
|
|
||||||
const redirectUris = ['http://127.0.0.1:2285/auth/login', 'https://photos.immich.app/oauth/mobile-redirect'];
|
const redirectUris = [
|
||||||
|
'http://127.0.0.1:2285/auth/login',
|
||||||
|
'https://photos.immich.app/oauth/mobile-redirect',
|
||||||
|
];
|
||||||
const port = 2286;
|
const port = 2286;
|
||||||
const host = '0.0.0.0';
|
const host = '0.0.0.0';
|
||||||
const oidc = new Provider(`http://${host}:${port}`, {
|
const oidc = new Provider(`http://${host}:${port}`, {
|
||||||
@@ -66,7 +80,10 @@ const setup = async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
ctx.body = 'Internal Server Error';
|
ctx.body = 'Internal Server Error';
|
||||||
},
|
},
|
||||||
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
|
findAccount: (ctx, sub) => ({
|
||||||
|
accountId: sub,
|
||||||
|
claims: (use) => getClaims(sub, use),
|
||||||
|
}),
|
||||||
scopes: ['openid', 'email', 'profile'],
|
scopes: ['openid', 'email', 'profile'],
|
||||||
claims: {
|
claims: {
|
||||||
openid: ['sub'],
|
openid: ['sub'],
|
||||||
@@ -94,6 +111,7 @@ const setup = async () => {
|
|||||||
state: 'oidc.state',
|
state: 'oidc.state',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
conformIdTokenClaims: false,
|
||||||
pkce: {
|
pkce: {
|
||||||
required: () => false,
|
required: () => false,
|
||||||
},
|
},
|
||||||
@@ -125,7 +143,10 @@ const setup = async () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const onStart = () => console.log(`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`);
|
const onStart = () =>
|
||||||
|
console.log(
|
||||||
|
`[e2e-auth-server] http://${host}:${port}/.well-known/openid-configuration`,
|
||||||
|
);
|
||||||
const app = oidc.listen(port, host, onStart);
|
const app = oidc.listen(port, host, onStart);
|
||||||
return () => app.close();
|
return () => app.close();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -380,4 +380,23 @@ describe(`/oauth`, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('idTokenClaims', () => {
|
||||||
|
it('should use claims from the ID token if IDP includes them', async () => {
|
||||||
|
await setupOAuth(admin.accessToken, {
|
||||||
|
enabled: true,
|
||||||
|
clientId: OAuthClient.DEFAULT,
|
||||||
|
clientSecret: OAuthClient.DEFAULT,
|
||||||
|
});
|
||||||
|
const callbackParams = await loginWithOAuth(OAuthUser.ID_TOKEN_CLAIMS);
|
||||||
|
const { status, body } = await request(app).post('/oauth/callback').send(callbackParams);
|
||||||
|
expect(status).toBe(201);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
accessToken: expect.any(String),
|
||||||
|
name: 'ID Token User',
|
||||||
|
userEmail: 'oauth-id-token-claims@immich.app',
|
||||||
|
userId: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -438,6 +438,16 @@ describe('/shared-links', () => {
|
|||||||
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
expect(body).toEqual(errorDto.badRequest('Invalid shared link type'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject guests removing assets from an individual shared link', async () => {
|
||||||
|
const { status, body } = await request(app)
|
||||||
|
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||||
|
.query({ key: linkWithAssets.key })
|
||||||
|
.send({ assetIds: [asset1.id] });
|
||||||
|
|
||||||
|
expect(status).toBe(403);
|
||||||
|
expect(body).toEqual(errorDto.forbidden);
|
||||||
|
});
|
||||||
|
|
||||||
it('should remove assets from a shared link (individual)', async () => {
|
it('should remove assets from a shared link (individual)', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
.delete(`/shared-links/${linkWithAssets.id}/assets`)
|
||||||
|
|||||||
@@ -12,15 +12,18 @@ import { asBearerAuth, utils } from 'src/utils';
|
|||||||
test.describe('Shared Links', () => {
|
test.describe('Shared Links', () => {
|
||||||
let admin: LoginResponseDto;
|
let admin: LoginResponseDto;
|
||||||
let asset: AssetMediaResponseDto;
|
let asset: AssetMediaResponseDto;
|
||||||
|
let asset2: AssetMediaResponseDto;
|
||||||
let album: AlbumResponseDto;
|
let album: AlbumResponseDto;
|
||||||
let sharedLink: SharedLinkResponseDto;
|
let sharedLink: SharedLinkResponseDto;
|
||||||
let sharedLinkPassword: SharedLinkResponseDto;
|
let sharedLinkPassword: SharedLinkResponseDto;
|
||||||
|
let individualSharedLink: SharedLinkResponseDto;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
utils.initSdk();
|
utils.initSdk();
|
||||||
await utils.resetDatabase();
|
await utils.resetDatabase();
|
||||||
admin = await utils.adminSetup();
|
admin = await utils.adminSetup();
|
||||||
asset = await utils.createAsset(admin.accessToken);
|
asset = await utils.createAsset(admin.accessToken);
|
||||||
|
asset2 = await utils.createAsset(admin.accessToken);
|
||||||
album = await createAlbum(
|
album = await createAlbum(
|
||||||
{
|
{
|
||||||
createAlbumDto: {
|
createAlbumDto: {
|
||||||
@@ -39,6 +42,10 @@ test.describe('Shared Links', () => {
|
|||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
password: 'test-password',
|
password: 'test-password',
|
||||||
});
|
});
|
||||||
|
individualSharedLink = await utils.createSharedLink(admin.accessToken, {
|
||||||
|
type: SharedLinkType.Individual,
|
||||||
|
assetIds: [asset.id, asset2.id],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('download from a shared link', async ({ page }) => {
|
test('download from a shared link', async ({ page }) => {
|
||||||
@@ -109,4 +116,21 @@ test.describe('Shared Links', () => {
|
|||||||
await page.waitForURL('/photos');
|
await page.waitForURL('/photos');
|
||||||
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
await page.locator(`[data-asset-id="${asset.id}"]`).waitFor();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('owner can remove assets from an individual shared link', async ({ context, page }) => {
|
||||||
|
await utils.setAuthCookies(context, admin.accessToken);
|
||||||
|
|
||||||
|
await page.goto(`/share/${individualSharedLink.key}`);
|
||||||
|
await page.locator(`[data-asset="${asset.id}"]`).waitFor();
|
||||||
|
await expect(page.locator(`[data-asset]`)).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.locator(`[data-asset="${asset.id}"]`).hover();
|
||||||
|
await page.locator(`[data-asset="${asset.id}"] [role="checkbox"]`).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Remove from shared link' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.locator(`[data-asset="${asset.id}"]`)).toHaveCount(0);
|
||||||
|
await expect(page.locator(`[data-asset="${asset2.id}"]`)).toHaveCount(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1651,6 +1651,7 @@
|
|||||||
"only_favorites": "Only favorites",
|
"only_favorites": "Only favorites",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
"open_calendar": "Open calendar",
|
"open_calendar": "Open calendar",
|
||||||
|
"open_in_browser": "Open in browser",
|
||||||
"open_in_map_view": "Open in map view",
|
"open_in_map_view": "Open in map view",
|
||||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||||
"open_the_search_filters": "Open the search filters",
|
"open_the_search_filters": "Open the search filters",
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ dependencies {
|
|||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
|
||||||
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
implementation 'org.chromium.net:cronet-embedded:143.7445.0'
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:1.9.2")
|
||||||
|
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
implementation "androidx.work:work-runtime-ktx:$work_version"
|
implementation "androidx.work:work-runtime-ktx:$work_version"
|
||||||
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
|
|||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
import app.alextran.immich.core.NetworkApiPlugin
|
import app.alextran.immich.core.NetworkApiPlugin
|
||||||
|
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
||||||
import app.alextran.immich.images.LocalImageApi
|
import app.alextran.immich.images.LocalImageApi
|
||||||
import app.alextran.immich.images.LocalImagesImpl
|
import app.alextran.immich.images.LocalImagesImpl
|
||||||
import app.alextran.immich.images.RemoteImageApi
|
import app.alextran.immich.images.RemoteImageApi
|
||||||
@@ -31,6 +32,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||||
HttpClientManager.initialize(ctx)
|
HttpClientManager.initialize(ctx)
|
||||||
|
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
||||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||||
|
|
||||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ package app.alextran.immich.core
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.security.KeyChain
|
import android.security.KeyChain
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.ResolvingDataSource
|
||||||
|
import androidx.media3.datasource.cronet.CronetDataSource
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import app.alextran.immich.BuildConfig
|
import app.alextran.immich.BuildConfig
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
@@ -16,15 +22,22 @@ import okhttp3.Headers
|
|||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.chromium.net.CronetEngine
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.net.Authenticator
|
||||||
|
import java.net.CookieHandler
|
||||||
|
import java.net.PasswordAuthentication
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
|
import java.net.URI
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.Principal
|
import java.security.Principal
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.net.ssl.HttpsURLConnection
|
import javax.net.ssl.HttpsURLConnection
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
@@ -56,6 +69,7 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
|
|||||||
*/
|
*/
|
||||||
object HttpClientManager {
|
object HttpClientManager {
|
||||||
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
private const val CACHE_SIZE_BYTES = 100L * 1024 * 1024 // 100MiB
|
||||||
|
const val MEDIA_CACHE_SIZE_BYTES = 1024L * 1024 * 1024 // 1GiB
|
||||||
private const val KEEP_ALIVE_CONNECTIONS = 10
|
private const val KEEP_ALIVE_CONNECTIONS = 10
|
||||||
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
private const val KEEP_ALIVE_DURATION_MINUTES = 5L
|
||||||
private const val MAX_REQUESTS_PER_HOST = 64
|
private const val MAX_REQUESTS_PER_HOST = 64
|
||||||
@@ -67,6 +81,11 @@ object HttpClientManager {
|
|||||||
private lateinit var appContext: Context
|
private lateinit var appContext: Context
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
|
|
||||||
|
var cronetEngine: CronetEngine? = null
|
||||||
|
private set
|
||||||
|
private lateinit var cronetStorageDir: File
|
||||||
|
val cronetExecutor: ExecutorService = Executors.newFixedThreadPool(4)
|
||||||
|
|
||||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||||
|
|
||||||
var keyChainAlias: String? = null
|
var keyChainAlias: String? = null
|
||||||
@@ -89,6 +108,25 @@ object HttpClientManager {
|
|||||||
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
|
||||||
|
|
||||||
cookieJar.init(prefs)
|
cookieJar.init(prefs)
|
||||||
|
System.setProperty("http.agent", USER_AGENT)
|
||||||
|
Authenticator.setDefault(object : Authenticator() {
|
||||||
|
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||||
|
val url = requestingURL ?: return null
|
||||||
|
if (url.userInfo.isNullOrEmpty()) return null
|
||||||
|
val parts = url.userInfo.split(":", limit = 2)
|
||||||
|
return PasswordAuthentication(parts[0], parts.getOrElse(1) { "" }.toCharArray())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
CookieHandler.setDefault(object : CookieHandler() {
|
||||||
|
override fun get(uri: URI, requestHeaders: Map<String, List<String>>): Map<String, List<String>> {
|
||||||
|
val httpUrl = uri.toString().toHttpUrlOrNull() ?: return emptyMap()
|
||||||
|
val cookies = cookieJar.loadForRequest(httpUrl)
|
||||||
|
if (cookies.isEmpty()) return emptyMap()
|
||||||
|
return mapOf("Cookie" to listOf(cookies.joinToString("; ") { "${it.name}=${it.value}" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun put(uri: URI, responseHeaders: Map<String, List<String>>) {}
|
||||||
|
})
|
||||||
|
|
||||||
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
val savedHeaders = prefs.getString(PREFS_HEADERS, null)
|
||||||
if (savedHeaders != null) {
|
if (savedHeaders != null) {
|
||||||
@@ -107,6 +145,10 @@ object HttpClientManager {
|
|||||||
|
|
||||||
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
|
||||||
client = build(cacheDir)
|
client = build(cacheDir)
|
||||||
|
|
||||||
|
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
|
||||||
|
cronetEngine = buildCronetEngine()
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +265,53 @@ object HttpClientManager {
|
|||||||
?.joinToString("; ") { "${it.name}=${it.value}" }
|
?.joinToString("; ") { "${it.name}=${it.value}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getAuthHeaders(url: String): Map<String, String> {
|
||||||
|
val result = mutableMapOf<String, String>()
|
||||||
|
headers.forEach { (key, value) -> result[key] = value }
|
||||||
|
loadCookieHeader(url)?.let { result["Cookie"] = it }
|
||||||
|
url.toHttpUrlOrNull()?.let { httpUrl ->
|
||||||
|
if (httpUrl.username.isNotEmpty()) {
|
||||||
|
result["Authorization"] = Credentials.basic(httpUrl.username, httpUrl.password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rebuildCronetEngine(): CronetEngine {
|
||||||
|
val old = cronetEngine!!
|
||||||
|
cronetEngine = buildCronetEngine()
|
||||||
|
return old
|
||||||
|
}
|
||||||
|
|
||||||
|
val cronetStoragePath: File get() = cronetStorageDir
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
fun createDataSourceFactory(headers: Map<String, String>): DataSource.Factory {
|
||||||
|
return if (isMtls) {
|
||||||
|
OkHttpDataSource.Factory(client.newBuilder().cache(null).build())
|
||||||
|
} else {
|
||||||
|
ResolvingDataSource.Factory(
|
||||||
|
CronetDataSource.Factory(cronetEngine!!, cronetExecutor)
|
||||||
|
) { dataSpec ->
|
||||||
|
val newHeaders = dataSpec.httpRequestHeaders.toMutableMap()
|
||||||
|
newHeaders.putAll(getAuthHeaders(dataSpec.uri.toString()))
|
||||||
|
newHeaders["Cache-Control"] = "no-store"
|
||||||
|
dataSpec.buildUpon().setHttpRequestHeaders(newHeaders).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildCronetEngine(): CronetEngine {
|
||||||
|
return CronetEngine.Builder(appContext)
|
||||||
|
.enableHttp2(true)
|
||||||
|
.enableQuic(true)
|
||||||
|
.enableBrotli(true)
|
||||||
|
.setStoragePath(cronetStorageDir.absolutePath)
|
||||||
|
.setUserAgent(USER_AGENT)
|
||||||
|
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, MEDIA_CACHE_SIZE_BYTES)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
private fun build(cacheDir: File): OkHttpClient {
|
private fun build(cacheDir: File): OkHttpClient {
|
||||||
val connectionPool = ConnectionPool(
|
val connectionPool = ConnectionPool(
|
||||||
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
|
|||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import app.alextran.immich.NativeByteBuffer
|
import app.alextran.immich.NativeByteBuffer
|
||||||
import app.alextran.immich.core.HttpClientManager
|
import app.alextran.immich.core.HttpClientManager
|
||||||
import app.alextran.immich.core.USER_AGENT
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
@@ -15,9 +14,6 @@ import okhttp3.Callback
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Credentials
|
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|
||||||
import org.chromium.net.CronetEngine
|
|
||||||
import org.chromium.net.CronetException
|
import org.chromium.net.CronetException
|
||||||
import org.chromium.net.UrlRequest
|
import org.chromium.net.UrlRequest
|
||||||
import org.chromium.net.UrlResponseInfo
|
import org.chromium.net.UrlResponseInfo
|
||||||
@@ -31,10 +27,6 @@ import java.nio.file.Path
|
|||||||
import java.nio.file.SimpleFileVisitor
|
import java.nio.file.SimpleFileVisitor
|
||||||
import java.nio.file.attribute.BasicFileAttributes
|
import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
|
|
||||||
private const val CACHE_SIZE_BYTES = 1024L * 1024 * 1024
|
|
||||||
|
|
||||||
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
private class RemoteRequest(val cancellationSignal: CancellationSignal)
|
||||||
|
|
||||||
@@ -101,7 +93,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private object ImageFetcherManager {
|
private object ImageFetcherManager {
|
||||||
private lateinit var appContext: Context
|
|
||||||
private lateinit var cacheDir: File
|
private lateinit var cacheDir: File
|
||||||
private lateinit var fetcher: ImageFetcher
|
private lateinit var fetcher: ImageFetcher
|
||||||
private var initialized = false
|
private var initialized = false
|
||||||
@@ -110,7 +101,6 @@ private object ImageFetcherManager {
|
|||||||
if (initialized) return
|
if (initialized) return
|
||||||
synchronized(this) {
|
synchronized(this) {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
appContext = context.applicationContext
|
|
||||||
cacheDir = context.cacheDir
|
cacheDir = context.cacheDir
|
||||||
fetcher = build()
|
fetcher = build()
|
||||||
HttpClientManager.addClientChangedListener(::invalidate)
|
HttpClientManager.addClientChangedListener(::invalidate)
|
||||||
@@ -143,7 +133,7 @@ private object ImageFetcherManager {
|
|||||||
return if (HttpClientManager.isMtls) {
|
return if (HttpClientManager.isMtls) {
|
||||||
OkHttpImageFetcher.create(cacheDir)
|
OkHttpImageFetcher.create(cacheDir)
|
||||||
} else {
|
} else {
|
||||||
CronetImageFetcher(appContext, cacheDir)
|
CronetImageFetcher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,19 +151,11 @@ private sealed interface ImageFetcher {
|
|||||||
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
fun clearCache(onCleared: (Result<Long>) -> Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
|
private class CronetImageFetcher : ImageFetcher {
|
||||||
private val ctx = context
|
|
||||||
private var engine: CronetEngine
|
|
||||||
private val executor = Executors.newFixedThreadPool(4)
|
|
||||||
private val stateLock = Any()
|
private val stateLock = Any()
|
||||||
private var activeCount = 0
|
private var activeCount = 0
|
||||||
private var draining = false
|
private var draining = false
|
||||||
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
|
||||||
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
|
|
||||||
|
|
||||||
init {
|
|
||||||
engine = build(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fetch(
|
override fun fetch(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -190,30 +172,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
}
|
}
|
||||||
|
|
||||||
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
|
||||||
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
|
val requestBuilder = HttpClientManager.cronetEngine!!
|
||||||
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
|
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
|
||||||
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
|
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
|
||||||
url.toHttpUrlOrNull()?.let { httpUrl ->
|
requestBuilder.addHeader(key, value)
|
||||||
if (httpUrl.username.isNotEmpty()) {
|
|
||||||
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val request = requestBuilder.build()
|
val request = requestBuilder.build()
|
||||||
signal.setOnCancelListener(request::cancel)
|
signal.setOnCancelListener(request::cancel)
|
||||||
request.start()
|
request.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun build(ctx: Context): CronetEngine {
|
|
||||||
return CronetEngine.Builder(ctx)
|
|
||||||
.enableHttp2(true)
|
|
||||||
.enableQuic(true)
|
|
||||||
.enableBrotli(true)
|
|
||||||
.setStoragePath(storageDir.absolutePath)
|
|
||||||
.setUserAgent(USER_AGENT)
|
|
||||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, CACHE_SIZE_BYTES)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onComplete() {
|
private fun onComplete() {
|
||||||
val didDrain = synchronized(stateLock) {
|
val didDrain = synchronized(stateLock) {
|
||||||
activeCount--
|
activeCount--
|
||||||
@@ -236,19 +204,16 @@ private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetche
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onDrained() {
|
private fun onDrained() {
|
||||||
engine.shutdown()
|
|
||||||
val onCacheCleared = synchronized(stateLock) {
|
val onCacheCleared = synchronized(stateLock) {
|
||||||
val onCacheCleared = onCacheCleared
|
val onCacheCleared = onCacheCleared
|
||||||
this.onCacheCleared = null
|
this.onCacheCleared = null
|
||||||
onCacheCleared
|
onCacheCleared
|
||||||
}
|
}
|
||||||
if (onCacheCleared == null) {
|
if (onCacheCleared != null) {
|
||||||
executor.shutdown()
|
val oldEngine = HttpClientManager.rebuildCronetEngine()
|
||||||
} else {
|
oldEngine.shutdown()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
val result = runCatching { deleteFolderAndGetSize(storageDir.toPath()) }
|
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
|
||||||
// Cronet is very good at self-repair, so it shouldn't fail here regardless of clear result
|
|
||||||
engine = build(ctx)
|
|
||||||
synchronized(stateLock) { draining = false }
|
synchronized(stateLock) { draining = false }
|
||||||
onCacheCleared(result)
|
onCacheCleared(result)
|
||||||
}
|
}
|
||||||
@@ -375,7 +340,7 @@ private class OkHttpImageFetcher private constructor(
|
|||||||
val dir = File(cacheDir, "okhttp")
|
val dir = File(cacheDir, "okhttp")
|
||||||
|
|
||||||
val client = HttpClientManager.getClient().newBuilder()
|
val client = HttpClientManager.getClient().newBuilder()
|
||||||
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
|
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return OkHttpImageFetcher(client)
|
return OkHttpImageFetcher(client)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import native_video_player
|
||||||
import network_info_plus
|
import network_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import permission_handler_apple
|
import permission_handler_apple
|
||||||
@@ -18,6 +19,8 @@ import UIKit
|
|||||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
|
||||||
|
URLSessionManager.patchBackgroundDownloader()
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
|
||||||
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class URLSessionManager: NSObject {
|
|||||||
diskCapacity: 1024 * 1024 * 1024,
|
diskCapacity: 1024 * 1024 * 1024,
|
||||||
directory: cacheDir
|
directory: cacheDir
|
||||||
)
|
)
|
||||||
private static let userAgent: String = {
|
static let userAgent: String = {
|
||||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
|
||||||
return "Immich_iOS_\(version)"
|
return "Immich_iOS_\(version)"
|
||||||
}()
|
}()
|
||||||
@@ -158,6 +158,49 @@ class URLSessionManager: NSObject {
|
|||||||
|
|
||||||
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Patches background_downloader's URLSession to use shared auth configuration.
|
||||||
|
/// Must be called before background_downloader creates its session (i.e. early in app startup).
|
||||||
|
static func patchBackgroundDownloader() {
|
||||||
|
// Swizzle URLSessionConfiguration.background(withIdentifier:) to inject shared config
|
||||||
|
let originalSel = NSSelectorFromString("backgroundSessionConfigurationWithIdentifier:")
|
||||||
|
let swizzledSel = #selector(URLSessionConfiguration.immich_background(withIdentifier:))
|
||||||
|
if let original = class_getClassMethod(URLSessionConfiguration.self, originalSel),
|
||||||
|
let swizzled = class_getClassMethod(URLSessionConfiguration.self, swizzledSel) {
|
||||||
|
method_exchangeImplementations(original, swizzled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth challenge handling to background_downloader's UrlSessionDelegate
|
||||||
|
guard let targetClass = NSClassFromString("background_downloader.UrlSessionDelegate") else { return }
|
||||||
|
|
||||||
|
let sessionBlock: @convention(block) (AnyObject, URLSession, URLAuthenticationChallenge,
|
||||||
|
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
||||||
|
= { _, session, challenge, completion in
|
||||||
|
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion)
|
||||||
|
}
|
||||||
|
class_replaceMethod(targetClass,
|
||||||
|
NSSelectorFromString("URLSession:didReceiveChallenge:completionHandler:"),
|
||||||
|
imp_implementationWithBlock(sessionBlock), "v@:@@@?")
|
||||||
|
|
||||||
|
let taskBlock: @convention(block) (AnyObject, URLSession, URLSessionTask, URLAuthenticationChallenge,
|
||||||
|
@escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) -> Void
|
||||||
|
= { _, session, task, challenge, completion in
|
||||||
|
URLSessionManager.shared.delegate.handleChallenge(session, challenge, completion, task: task)
|
||||||
|
}
|
||||||
|
class_replaceMethod(targetClass,
|
||||||
|
NSSelectorFromString("URLSession:task:didReceiveChallenge:completionHandler:"),
|
||||||
|
imp_implementationWithBlock(taskBlock), "v@:@@@@?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension URLSessionConfiguration {
|
||||||
|
@objc dynamic class func immich_background(withIdentifier id: String) -> URLSessionConfiguration {
|
||||||
|
// After swizzle, this calls the original implementation
|
||||||
|
let config = immich_background(withIdentifier: id)
|
||||||
|
config.httpCookieStorage = URLSessionManager.cookieStorage
|
||||||
|
config.httpAdditionalHeaders = ["User-Agent": URLSessionManager.userAgent]
|
||||||
|
return config
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
|
class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWebSocketDelegate {
|
||||||
@@ -168,7 +211,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
) {
|
) {
|
||||||
handleChallenge(session, challenge, completionHandler)
|
handleChallenge(session, challenge, completionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
task: URLSessionTask,
|
task: URLSessionTask,
|
||||||
@@ -177,7 +220,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
) {
|
) {
|
||||||
handleChallenge(session, challenge, completionHandler, task: task)
|
handleChallenge(session, challenge, completionHandler, task: task)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChallenge(
|
func handleChallenge(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
_ challenge: URLAuthenticationChallenge,
|
_ challenge: URLAuthenticationChallenge,
|
||||||
@@ -190,7 +233,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
default: completionHandler(.performDefaultHandling, nil)
|
default: completionHandler(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleClientCertificate(
|
private func handleClientCertificate(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
@@ -200,7 +243,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
kSecAttrLabel as String: CLIENT_CERT_LABEL,
|
||||||
kSecReturnRef as String: true,
|
kSecReturnRef as String: true,
|
||||||
]
|
]
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
if status == errSecSuccess, let identity = item {
|
if status == errSecSuccess, let identity = item {
|
||||||
@@ -214,7 +257,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
|
|||||||
}
|
}
|
||||||
completion(.performDefaultHandling, nil)
|
completion(.performDefaultHandling, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleBasicAuth(
|
private func handleBasicAuth(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
task: URLSessionTask?,
|
task: URLSessionTask?,
|
||||||
|
|||||||
@@ -357,6 +357,12 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
completionHandler: { error in
|
completionHandler: { error in
|
||||||
let result: HashResult? = switch (error) {
|
let result: HashResult? = switch (error) {
|
||||||
case let e as PHPhotosError where e.code == .userCancelled: nil
|
case let e as PHPhotosError where e.code == .userCancelled: nil
|
||||||
|
case let e as PHPhotosError where e.code == .networkAccessRequired:
|
||||||
|
HashResult(
|
||||||
|
assetId: asset.localIdentifier,
|
||||||
|
error: "ICLOUD_ONLY",
|
||||||
|
hash: nil
|
||||||
|
)
|
||||||
case let .some(e): HashResult(
|
case let .some(e): HashResult(
|
||||||
assetId: asset.localIdentifier,
|
assetId: asset.localIdentifier,
|
||||||
error: "Failed to hash asset: \(e.localizedDescription)",
|
error: "Failed to hash asset: \(e.localizedDescription)",
|
||||||
|
|||||||
@@ -109,9 +109,11 @@ class HashService {
|
|||||||
_log.fine("Hashing ${toHash.length} files");
|
_log.fine("Hashing ${toHash.length} files");
|
||||||
|
|
||||||
final hashed = <String, String>{};
|
final hashed = <String, String>{};
|
||||||
|
// Never download from iCloud just to hash. iCloud-only assets will be
|
||||||
|
// uploaded directly and the server will compute + return their checksum.
|
||||||
final hashResults = await _nativeSyncApi.hashAssets(
|
final hashResults = await _nativeSyncApi.hashAssets(
|
||||||
toHash.keys.toList(),
|
toHash.keys.toList(),
|
||||||
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
|
allowNetworkAccess: false,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
hashResults.length == toHash.length,
|
hashResults.length == toHash.length,
|
||||||
@@ -127,6 +129,10 @@ class HashService {
|
|||||||
final hashResult = hashResults[i];
|
final hashResult = hashResults[i];
|
||||||
if (hashResult.hash != null) {
|
if (hashResult.hash != null) {
|
||||||
hashed[hashResult.assetId] = hashResult.hash!;
|
hashed[hashResult.assetId] = hashResult.hash!;
|
||||||
|
} else if (hashResult.error == 'ICLOUD_ONLY') {
|
||||||
|
// Asset is in iCloud and not available locally. It will be uploaded
|
||||||
|
// directly and the server will compute its checksum.
|
||||||
|
_log.fine("Skipping iCloud-only asset ${hashResult.assetId} from album: ${album.name}");
|
||||||
} else {
|
} else {
|
||||||
final asset = toHash[hashResult.assetId];
|
final asset = toHash[hashResult.assetId];
|
||||||
_log.warning(
|
_log.warning(
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).archive(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).archive(source);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
|
|||||||
if (confirm != true) return;
|
if (confirm != true) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
+3
-3
@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
|||||||
false;
|
false;
|
||||||
if (!confirm) return;
|
if (!confirm) return;
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'delete_permanently_action_prompt'.t(
|
final successMessage = 'delete_permanently_action_prompt'.t(
|
||||||
context: context,
|
context: context,
|
||||||
args: {'count': result.count.toString()},
|
args: {'count': result.count.toString()},
|
||||||
|
|||||||
+3
-3
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
final successMessage = 'move_to_lock_folder_action_prompt'.t(
|
||||||
context: context,
|
context: context,
|
||||||
args: {'count': result.count.toString()},
|
args: {'count': result.count.toString()},
|
||||||
|
|||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
|
import 'package:immich_mobile/entities/store.entity.dart';
|
||||||
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class OpenInBrowserActionButton extends ConsumerWidget {
|
||||||
|
final String remoteId;
|
||||||
|
final TimelineOrigin origin;
|
||||||
|
final bool iconOnly;
|
||||||
|
final bool menuItem;
|
||||||
|
final Color? iconColor;
|
||||||
|
|
||||||
|
const OpenInBrowserActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.remoteId,
|
||||||
|
required this.origin,
|
||||||
|
this.iconOnly = false,
|
||||||
|
this.menuItem = false,
|
||||||
|
this.iconColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
void _onTap() async {
|
||||||
|
final serverEndpoint = Store.get(StoreKey.serverEndpoint).replaceFirst('/api', '');
|
||||||
|
|
||||||
|
String originPath = '';
|
||||||
|
switch (origin) {
|
||||||
|
case TimelineOrigin.favorite:
|
||||||
|
originPath = '/favorites';
|
||||||
|
break;
|
||||||
|
case TimelineOrigin.trash:
|
||||||
|
originPath = '/trash';
|
||||||
|
break;
|
||||||
|
case TimelineOrigin.archive:
|
||||||
|
originPath = '/archive';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final url = '$serverEndpoint$originPath/photos/$remoteId';
|
||||||
|
if (await canLaunchUrl(Uri.parse(url))) {
|
||||||
|
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return BaseActionButton(
|
||||||
|
label: 'open_in_browser'.t(context: context),
|
||||||
|
iconData: Icons.open_in_browser,
|
||||||
|
iconColor: iconColor,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
onPressed: _onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'remove_from_album_action_prompt'.t(
|
final successMessage = 'remove_from_album_action_prompt'.t(
|
||||||
context: context,
|
context: context,
|
||||||
args: {'count': result.count.toString()},
|
args: {'count': result.count.toString()},
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).trash(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).trash(source);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|||||||
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
|
||||||
|
|
||||||
if (source == ActionSource.viewer) {
|
if (source == ActionSource.viewer) {
|
||||||
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
EventStream.shared.emit(const ViewerReloadAssetEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final result = await ref.read(actionProvider.notifier).unArchive(source);
|
||||||
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|||||||
@@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
|
||||||
|
|
||||||
late int _currentPage = widget.initialIndex;
|
late int _currentPage = widget.initialIndex;
|
||||||
|
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||||
|
|
||||||
StreamSubscription? _reloadSubscription;
|
StreamSubscription? _reloadSubscription;
|
||||||
KeepAliveLink? _stackChildrenKeepAlive;
|
KeepAliveLink? _stackChildrenKeepAlive;
|
||||||
|
|
||||||
bool _assetReloadRequested = false;
|
|
||||||
|
|
||||||
void _onTapNavigate(int direction) {
|
void _onTapNavigate(int direction) {
|
||||||
final page = _pageController.page?.toInt();
|
final page = _pageController.page?.toInt();
|
||||||
if (page == null) return;
|
if (page == null) return;
|
||||||
final target = page + direction;
|
final target = page + direction;
|
||||||
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
|
final maxPage = _totalAssets - 1;
|
||||||
if (target >= 0 && target <= maxPage) {
|
if (target >= 0 && target <= maxPage) {
|
||||||
_currentPage = target;
|
|
||||||
_pageController.jumpToPage(target);
|
_pageController.jumpToPage(target);
|
||||||
_onAssetChanged(target);
|
_onAssetChanged(target);
|
||||||
}
|
}
|
||||||
@@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
final page = _pageController.page?.round();
|
final page = _pageController.page?.round();
|
||||||
if (page != null && page != _currentPage) {
|
if (page != null && page != _currentPage) {
|
||||||
_currentPage = page;
|
|
||||||
_onAssetChanged(page);
|
_onAssetChanged(page);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetChanged(int index) async {
|
void _onAssetChanged(int index) async {
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
_currentPage = index;
|
||||||
final asset = await timelineService.getAssetAsync(index);
|
|
||||||
|
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
|
||||||
if (asset == null) return;
|
if (asset == null) return;
|
||||||
|
|
||||||
AssetViewer._setAsset(ref, asset);
|
AssetViewer._setAsset(ref, asset);
|
||||||
@@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
case TimelineReloadEvent():
|
case TimelineReloadEvent():
|
||||||
_onTimelineReloadEvent();
|
_onTimelineReloadEvent();
|
||||||
case ViewerReloadAssetEvent():
|
case ViewerReloadAssetEvent():
|
||||||
_assetReloadRequested = true;
|
_onViewerReloadEvent();
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onViewerReloadEvent() {
|
||||||
|
if (_totalAssets <= 1) return;
|
||||||
|
|
||||||
|
final index = _pageController.page?.round() ?? 0;
|
||||||
|
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
|
||||||
|
_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut);
|
||||||
|
_onAssetChanged(target);
|
||||||
|
}
|
||||||
|
|
||||||
void _onTimelineReloadEvent() {
|
void _onTimelineReloadEvent() {
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
final timelineService = ref.read(timelineServiceProvider);
|
||||||
final totalAssets = timelineService.totalAssets;
|
final totalAssets = timelineService.totalAssets;
|
||||||
@@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var index = _pageController.page?.round() ?? 0;
|
|
||||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
||||||
if (currentAsset != null) {
|
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
|
||||||
final newIndex = timelineService.getIndex(currentAsset.heroTag);
|
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
|
||||||
if (newIndex != null && newIndex != index) {
|
|
||||||
index = newIndex;
|
|
||||||
_currentPage = index;
|
|
||||||
_pageController.jumpToPage(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (index >= totalAssets) {
|
if (index != _currentPage) {
|
||||||
index = totalAssets - 1;
|
|
||||||
_currentPage = index;
|
|
||||||
_pageController.jumpToPage(index);
|
_pageController.jumpToPage(index);
|
||||||
|
_onAssetChanged(index);
|
||||||
|
} else if (currentAsset != null && assetIndex == null) {
|
||||||
|
_onAssetChanged(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_assetReloadRequested) {
|
if (_totalAssets != totalAssets) {
|
||||||
_assetReloadRequested = false;
|
setState(() {
|
||||||
_onAssetReloadEvent(index);
|
_totalAssets = totalAssets;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssetReloadEvent(int index) async {
|
|
||||||
final timelineService = ref.read(timelineServiceProvider);
|
|
||||||
|
|
||||||
final newAsset = await timelineService.getAssetAsync(index);
|
|
||||||
if (newAsset == null) return;
|
|
||||||
|
|
||||||
final currentAsset = ref.read(assetViewerProvider).currentAsset;
|
|
||||||
|
|
||||||
// Do not reload if the asset has not changed
|
|
||||||
if (newAsset.heroTag == currentAsset?.heroTag) return;
|
|
||||||
|
|
||||||
_onAssetChanged(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setSystemUIMode(bool controls, bool details) {
|
void _setSystemUIMode(bool controls, bool details) {
|
||||||
final mode = !controls || (CurrentPlatform.isIOS && details)
|
final mode = !controls || (CurrentPlatform.isIOS && details)
|
||||||
? SystemUiMode.immersiveSticky
|
? SystemUiMode.immersiveSticky
|
||||||
@@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
: CurrentPlatform.isIOS
|
: CurrentPlatform.isIOS
|
||||||
? const FastScrollPhysics()
|
? const FastScrollPhysics()
|
||||||
: const FastClampingScrollPhysics(),
|
: const FastClampingScrollPhysics(),
|
||||||
itemCount: ref.read(timelineServiceProvider).totalAssets,
|
itemCount: _totalAssets,
|
||||||
itemBuilder: (context, index) =>
|
itemBuilder: (context, index) =>
|
||||||
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -136,7 +136,10 @@ class UploadRepository {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final responseBody = jsonDecode(responseBodyString);
|
final responseBody = jsonDecode(responseBodyString);
|
||||||
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
return UploadResult.success(
|
||||||
|
remoteAssetId: responseBody['id'] as String,
|
||||||
|
checksum: responseBody['checksum'] as String?,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return UploadResult.error(errorMessage: 'Failed to parse server response');
|
return UploadResult.error(errorMessage: 'Failed to parse server response');
|
||||||
}
|
}
|
||||||
@@ -182,6 +185,7 @@ class UploadResult {
|
|||||||
final bool isSuccess;
|
final bool isSuccess;
|
||||||
final bool isCancelled;
|
final bool isCancelled;
|
||||||
final String? remoteAssetId;
|
final String? remoteAssetId;
|
||||||
|
final String? checksum;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final int? statusCode;
|
final int? statusCode;
|
||||||
|
|
||||||
@@ -189,12 +193,13 @@ class UploadResult {
|
|||||||
required this.isSuccess,
|
required this.isSuccess,
|
||||||
required this.isCancelled,
|
required this.isCancelled,
|
||||||
this.remoteAssetId,
|
this.remoteAssetId,
|
||||||
|
this.checksum,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.statusCode,
|
this.statusCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UploadResult.success({required String remoteAssetId}) {
|
factory UploadResult.success({required String remoteAssetId, String? checksum}) {
|
||||||
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
|
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId, checksum: checksum);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory UploadResult.error({String? errorMessage, int? statusCode}) {
|
factory UploadResult.error({String? errorMessage, int? statusCode}) {
|
||||||
|
|||||||
@@ -176,10 +176,6 @@ class ApiService {
|
|||||||
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
|
||||||
urls.add(serverEndpoint);
|
urls.add(serverEndpoint);
|
||||||
}
|
}
|
||||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
|
||||||
if (serverUrl != null && serverUrl.isNotEmpty) {
|
|
||||||
urls.add(serverUrl);
|
|
||||||
}
|
|
||||||
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
|
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
|
||||||
if (localEndpoint != null && localEndpoint.isNotEmpty) {
|
if (localEndpoint != null && localEndpoint.isNotEmpty) {
|
||||||
urls.add(localEndpoint);
|
urls.add(localEndpoint);
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ class BackgroundUploadService {
|
|||||||
await _storageRepository.clearCache();
|
await _storageRepository.clearCache();
|
||||||
shouldAbortQueuingTasks = false;
|
shouldAbortQueuingTasks = false;
|
||||||
|
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
final candidates = await _backupRepository.getCandidates(userId, onlyHashed: false);
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
_logger.info("No new backup candidates found, finishing background upload");
|
_logger.info("No new backup candidates found, finishing background upload");
|
||||||
return;
|
return;
|
||||||
@@ -210,6 +210,7 @@ class BackgroundUploadService {
|
|||||||
switch (update.status) {
|
switch (update.status) {
|
||||||
case TaskStatus.complete:
|
case TaskStatus.complete:
|
||||||
unawaited(_handleLivePhoto(update));
|
unawaited(_handleLivePhoto(update));
|
||||||
|
unawaited(_storeServerChecksum(update));
|
||||||
|
|
||||||
if (CurrentPlatform.isIOS) {
|
if (CurrentPlatform.isIOS) {
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +228,20 @@ class BackgroundUploadService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _storeServerChecksum(TaskStatusUpdate update) async {
|
||||||
|
try {
|
||||||
|
if (update.responseBody == null || update.responseBody!.isEmpty) return;
|
||||||
|
final response = jsonDecode(update.responseBody!);
|
||||||
|
final checksum = response['checksum'] as String?;
|
||||||
|
if (checksum == null) return;
|
||||||
|
final deviceAssetId = update.task.taskId;
|
||||||
|
if (deviceAssetId.isEmpty) return;
|
||||||
|
await _localAssetRepository.updateHashes({deviceAssetId: checksum});
|
||||||
|
} catch (e) {
|
||||||
|
_logger.warning('Failed to store server checksum: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
Future<void> _handleLivePhoto(TaskStatusUpdate update) async {
|
||||||
try {
|
try {
|
||||||
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
if (update.task.metaData.isEmpty || update.task.metaData == '') {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
|
|||||||
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
import 'package:immich_mobile/extensions/network_capability_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
|
||||||
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
import 'package:immich_mobile/platform/connectivity_api.g.dart';
|
||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
import 'package:immich_mobile/repositories/asset_media.repository.dart';
|
||||||
@@ -37,6 +39,7 @@ final foregroundUploadServiceProvider = Provider((ref) {
|
|||||||
return ForegroundUploadService(
|
return ForegroundUploadService(
|
||||||
ref.watch(uploadRepositoryProvider),
|
ref.watch(uploadRepositoryProvider),
|
||||||
ref.watch(storageRepositoryProvider),
|
ref.watch(storageRepositoryProvider),
|
||||||
|
ref.watch(localAssetRepository),
|
||||||
ref.watch(backupRepositoryProvider),
|
ref.watch(backupRepositoryProvider),
|
||||||
ref.watch(connectivityApiProvider),
|
ref.watch(connectivityApiProvider),
|
||||||
ref.watch(appSettingsServiceProvider),
|
ref.watch(appSettingsServiceProvider),
|
||||||
@@ -53,6 +56,7 @@ class ForegroundUploadService {
|
|||||||
ForegroundUploadService(
|
ForegroundUploadService(
|
||||||
this._uploadRepository,
|
this._uploadRepository,
|
||||||
this._storageRepository,
|
this._storageRepository,
|
||||||
|
this._localAssetRepository,
|
||||||
this._backupRepository,
|
this._backupRepository,
|
||||||
this._connectivityApi,
|
this._connectivityApi,
|
||||||
this._appSettingsService,
|
this._appSettingsService,
|
||||||
@@ -61,6 +65,7 @@ class ForegroundUploadService {
|
|||||||
|
|
||||||
final UploadRepository _uploadRepository;
|
final UploadRepository _uploadRepository;
|
||||||
final StorageRepository _storageRepository;
|
final StorageRepository _storageRepository;
|
||||||
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
final DriftBackupRepository _backupRepository;
|
final DriftBackupRepository _backupRepository;
|
||||||
final ConnectivityApi _connectivityApi;
|
final ConnectivityApi _connectivityApi;
|
||||||
final AppSettingsService _appSettingsService;
|
final AppSettingsService _appSettingsService;
|
||||||
@@ -84,7 +89,7 @@ class ForegroundUploadService {
|
|||||||
UploadCallbacks callbacks = const UploadCallbacks(),
|
UploadCallbacks callbacks = const UploadCallbacks(),
|
||||||
bool useSequentialUpload = false,
|
bool useSequentialUpload = false,
|
||||||
}) async {
|
}) async {
|
||||||
final candidates = await _backupRepository.getCandidates(userId);
|
final candidates = await _backupRepository.getCandidates(userId, onlyHashed: false);
|
||||||
if (candidates.isEmpty) {
|
if (candidates.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -387,6 +392,10 @@ class ForegroundUploadService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.isSuccess && result.remoteAssetId != null) {
|
if (result.isSuccess && result.remoteAssetId != null) {
|
||||||
|
// Store server-computed checksum so iCloud-only assets don't need re-upload
|
||||||
|
if (result.checksum != null) {
|
||||||
|
await _localAssetRepository.updateHashes({asset.id: result.checksum!});
|
||||||
|
}
|
||||||
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
callbacks.onSuccess?.call(asset.localId!, result.remoteAssetId!);
|
||||||
} else if (result.isCancelled) {
|
} else if (result.isCancelled) {
|
||||||
_logger.warning(() => "Backup was cancelled by the user");
|
_logger.warning(() => "Backup was cancelled by the user");
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permane
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/like_activity_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/open_in_browser_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_album_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/remove_from_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
|
||||||
@@ -75,6 +76,7 @@ enum ActionButtonType {
|
|||||||
viewInTimeline,
|
viewInTimeline,
|
||||||
download,
|
download,
|
||||||
upload,
|
upload,
|
||||||
|
openInBrowser,
|
||||||
unstack,
|
unstack,
|
||||||
archive,
|
archive,
|
||||||
unarchive,
|
unarchive,
|
||||||
@@ -149,6 +151,7 @@ enum ActionButtonType {
|
|||||||
context.isOwner && //
|
context.isOwner && //
|
||||||
!context.isInLockedView && //
|
!context.isInLockedView && //
|
||||||
context.isStacked,
|
context.isStacked,
|
||||||
|
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
|
||||||
ActionButtonType.likeActivity =>
|
ActionButtonType.likeActivity =>
|
||||||
!context.isInLockedView &&
|
!context.isInLockedView &&
|
||||||
context.currentAlbum != null &&
|
context.currentAlbum != null &&
|
||||||
@@ -236,6 +239,13 @@ enum ActionButtonType {
|
|||||||
),
|
),
|
||||||
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.likeActivity => LikeActivityActionButton(iconOnly: iconOnly, menuItem: menuItem),
|
||||||
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
ActionButtonType.unstack => UnStackActionButton(source: context.source, iconOnly: iconOnly, menuItem: menuItem),
|
||||||
|
ActionButtonType.openInBrowser => OpenInBrowserActionButton(
|
||||||
|
remoteId: context.asset.remoteId!,
|
||||||
|
origin: context.timelineOrigin,
|
||||||
|
iconOnly: iconOnly,
|
||||||
|
menuItem: menuItem,
|
||||||
|
iconColor: context.originalTheme?.iconTheme.color,
|
||||||
|
),
|
||||||
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
ActionButtonType.similarPhotos => SimilarPhotosActionButton(
|
||||||
assetId: (context.asset as RemoteAsset).id,
|
assetId: (context.asset as RemoteAsset).id,
|
||||||
iconOnly: iconOnly,
|
iconOnly: iconOnly,
|
||||||
|
|||||||
+3
-18
@@ -427,11 +427,7 @@ class SharedLinksApi {
|
|||||||
/// * [String] id (required):
|
/// * [String] id (required):
|
||||||
///
|
///
|
||||||
/// * [AssetIdsDto] assetIdsDto (required):
|
/// * [AssetIdsDto] assetIdsDto (required):
|
||||||
///
|
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async {
|
||||||
/// * [String] key:
|
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
|
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/shared-links/{id}/assets'
|
final apiPath = r'/shared-links/{id}/assets'
|
||||||
.replaceAll('{id}', id);
|
.replaceAll('{id}', id);
|
||||||
@@ -443,13 +439,6 @@ class SharedLinksApi {
|
|||||||
final headerParams = <String, String>{};
|
final headerParams = <String, String>{};
|
||||||
final formParams = <String, String>{};
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
if (key != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'key', key));
|
|
||||||
}
|
|
||||||
if (slug != null) {
|
|
||||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentTypes = <String>['application/json'];
|
const contentTypes = <String>['application/json'];
|
||||||
|
|
||||||
|
|
||||||
@@ -473,12 +462,8 @@ class SharedLinksApi {
|
|||||||
/// * [String] id (required):
|
/// * [String] id (required):
|
||||||
///
|
///
|
||||||
/// * [AssetIdsDto] assetIdsDto (required):
|
/// * [AssetIdsDto] assetIdsDto (required):
|
||||||
///
|
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async {
|
||||||
/// * [String] key:
|
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,);
|
||||||
///
|
|
||||||
/// * [String] slug:
|
|
||||||
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, String? slug, }) async {
|
|
||||||
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto, key: key, slug: slug, );
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
if (response.statusCode >= HttpStatus.badRequest) {
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -1194,10 +1194,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
mime:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1218,8 +1218,8 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
path: "."
|
path: "."
|
||||||
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
|
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||||
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
|
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
|
||||||
url: "https://github.com/immich-app/native_video_player"
|
url: "https://github.com/immich-app/native_video_player"
|
||||||
source: git
|
source: git
|
||||||
version: "1.3.1"
|
version: "1.3.1"
|
||||||
@@ -1897,10 +1897,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
thumbhash:
|
thumbhash:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ dependencies:
|
|||||||
native_video_player:
|
native_video_player:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/immich-app/native_video_player
|
url: https://github.com/immich-app/native_video_player
|
||||||
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
|
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
|
||||||
network_info_plus: ^6.1.3
|
network_info_plus: ^6.1.3
|
||||||
octo_image: ^2.1.0
|
octo_image: ^2.1.0
|
||||||
openapi:
|
openapi:
|
||||||
|
|||||||
@@ -11605,22 +11605,6 @@
|
|||||||
"format": "uuid",
|
"format": "uuid",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "key",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "slug",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@@ -11677,6 +11661,7 @@
|
|||||||
"state": "Stable"
|
"state": "Stable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"x-immich-permission": "sharedLink.update",
|
||||||
"x-immich-state": "Stable"
|
"x-immich-state": "Stable"
|
||||||
},
|
},
|
||||||
"put": {
|
"put": {
|
||||||
|
|||||||
@@ -5987,19 +5987,14 @@ export function updateSharedLink({ id, sharedLinkEditDto }: {
|
|||||||
/**
|
/**
|
||||||
* Remove assets from a shared link
|
* Remove assets from a shared link
|
||||||
*/
|
*/
|
||||||
export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: {
|
export function removeSharedLinkAssets({ id, assetIdsDto }: {
|
||||||
id: string;
|
id: string;
|
||||||
key?: string;
|
|
||||||
slug?: string;
|
|
||||||
assetIdsDto: AssetIdsDto;
|
assetIdsDto: AssetIdsDto;
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
data: AssetIdsResponseDto[];
|
data: AssetIdsResponseDto[];
|
||||||
}>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({
|
}>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({
|
||||||
key,
|
|
||||||
slug
|
|
||||||
}))}`, oazapfts.json({
|
|
||||||
...opts,
|
...opts,
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
body: assetIdsDto
|
body: assetIdsDto
|
||||||
|
|||||||
Generated
+13
-7
@@ -845,6 +845,12 @@ importers:
|
|||||||
tabbable:
|
tabbable:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.4.0
|
version: 6.4.0
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.5.0
|
||||||
|
version: 3.5.0
|
||||||
|
tailwind-variants:
|
||||||
|
specifier: ^3.2.2
|
||||||
|
version: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
|
||||||
thumbhash:
|
thumbhash:
|
||||||
specifier: ^0.1.1
|
specifier: ^0.1.1
|
||||||
version: 0.1.1
|
version: 0.1.1
|
||||||
@@ -11252,8 +11258,8 @@ packages:
|
|||||||
tabbable@6.4.0:
|
tabbable@6.4.0:
|
||||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||||
|
|
||||||
tailwind-merge@3.4.0:
|
tailwind-merge@3.5.0:
|
||||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||||
|
|
||||||
tailwind-variants@3.2.2:
|
tailwind-variants@3.2.2:
|
||||||
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
|
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
|
||||||
@@ -14959,8 +14965,8 @@ snapshots:
|
|||||||
simple-icons: 16.9.0
|
simple-icons: 16.9.0
|
||||||
svelte: 5.53.7
|
svelte: 5.53.7
|
||||||
svelte-highlight: 7.9.0
|
svelte-highlight: 7.9.0
|
||||||
tailwind-merge: 3.4.0
|
tailwind-merge: 3.5.0
|
||||||
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1)
|
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
|
||||||
tailwindcss: 4.2.1
|
tailwindcss: 4.2.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@sveltejs/kit'
|
- '@sveltejs/kit'
|
||||||
@@ -24554,13 +24560,13 @@ snapshots:
|
|||||||
|
|
||||||
tabbable@6.4.0: {}
|
tabbable@6.4.0: {}
|
||||||
|
|
||||||
tailwind-merge@3.4.0: {}
|
tailwind-merge@3.5.0: {}
|
||||||
|
|
||||||
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1):
|
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
tailwindcss: 4.2.1
|
tailwindcss: 4.2.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
tailwind-merge: 3.4.0
|
tailwind-merge: 3.5.0
|
||||||
|
|
||||||
tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
|
tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||||
import { SharedLinkType } from 'src/enum';
|
import { Permission, SharedLinkType } from 'src/enum';
|
||||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
|
import { factory } from 'test/small.factory';
|
||||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||||
|
|
||||||
describe(SharedLinkController.name, () => {
|
describe(SharedLinkController.name, () => {
|
||||||
@@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => {
|
|||||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DELETE /shared-links/:id/assets', () => {
|
||||||
|
it('should require shared link update permission', async () => {
|
||||||
|
await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] });
|
||||||
|
|
||||||
|
expect(ctx.authenticate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class SharedLinkController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/assets')
|
@Delete(':id/assets')
|
||||||
@Authenticated({ sharedLink: true })
|
@Authenticated({ permission: Permission.SharedLinkUpdate })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
summary: 'Remove assets from a shared link',
|
summary: 'Remove assets from a shared link',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -154,10 +154,11 @@ export class StorageCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveAssetVideo(asset: StorageAsset) {
|
async moveAssetVideo(asset: StorageAsset) {
|
||||||
|
const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||||
return this.moveFile({
|
return this.moveFile({
|
||||||
entityId: asset.id,
|
entityId: asset.id,
|
||||||
pathType: AssetPathType.EncodedVideo,
|
pathType: AssetPathType.EncodedVideo,
|
||||||
oldPath: asset.encodedVideoPath,
|
oldPath: encodedVideoFile?.path || null,
|
||||||
newPath: StorageCore.getEncodedVideoPath(asset),
|
newPath: StorageCore.getEncodedVideoPath(asset),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -303,21 +304,15 @@ export class StorageCore {
|
|||||||
case AssetPathType.Original: {
|
case AssetPathType.Original: {
|
||||||
return this.assetRepository.update({ id, originalPath: newPath });
|
return this.assetRepository.update({ id, originalPath: newPath });
|
||||||
}
|
}
|
||||||
case AssetFileType.FullSize: {
|
|
||||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
case AssetFileType.FullSize:
|
||||||
}
|
case AssetFileType.EncodedVideo:
|
||||||
case AssetFileType.Preview: {
|
case AssetFileType.Thumbnail:
|
||||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
case AssetFileType.Preview:
|
||||||
}
|
|
||||||
case AssetFileType.Thumbnail: {
|
|
||||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
|
|
||||||
}
|
|
||||||
case AssetPathType.EncodedVideo: {
|
|
||||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
|
||||||
}
|
|
||||||
case AssetFileType.Sidecar: {
|
case AssetFileType.Sidecar: {
|
||||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
case PersonPathType.Face: {
|
case PersonPathType.Face: {
|
||||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export type StorageAsset = {
|
|||||||
id: string;
|
id: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
files: AssetFile[];
|
files: AssetFile[];
|
||||||
encodedVideoPath: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Stack = {
|
export type Stack = {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export class AssetMediaResponseDto {
|
|||||||
status!: AssetMediaStatus;
|
status!: AssetMediaStatus;
|
||||||
@ApiProperty({ description: 'Asset media ID' })
|
@ApiProperty({ description: 'Asset media ID' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
@ApiPropertyOptional({ description: 'Asset checksum (SHA1 base64)' })
|
||||||
|
checksum?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssetUploadAction {
|
export enum AssetUploadAction {
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ export type MapAsset = {
|
|||||||
duplicateId: string | null;
|
duplicateId: string | null;
|
||||||
duration: string | null;
|
duration: string | null;
|
||||||
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||||
encodedVideoPath: string | null;
|
|
||||||
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
||||||
faces?: ShallowDehydrateObject<AssetFace>[];
|
faces?: ShallowDehydrateObject<AssetFace>[];
|
||||||
fileCreatedAt: Date;
|
fileCreatedAt: Date;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export enum AssetFileType {
|
|||||||
Preview = 'preview',
|
Preview = 'preview',
|
||||||
Thumbnail = 'thumbnail',
|
Thumbnail = 'thumbnail',
|
||||||
Sidecar = 'sidecar',
|
Sidecar = 'sidecar',
|
||||||
|
EncodedVideo = 'encoded_video',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlbumUserRole {
|
export enum AlbumUserRole {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { PATH_METADATA } from '@nestjs/common/constants';
|
|||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||||
import { NextFunction, RequestHandler } from 'express';
|
import { NextFunction, RequestHandler } from 'express';
|
||||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
import multer from 'multer';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { RouteKey } from 'src/enum';
|
import { RouteKey } from 'src/enum';
|
||||||
@@ -27,8 +29,6 @@ export function getFiles(files: UploadFiles) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DiskStorageCallback = (error: Error | null, result: string) => void;
|
|
||||||
|
|
||||||
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
||||||
|
|
||||||
interface Callback<T> {
|
interface Callback<T> {
|
||||||
@@ -36,21 +36,12 @@ interface Callback<T> {
|
|||||||
(error: null, result: T): void;
|
(error: null, result: T): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
|
|
||||||
try {
|
|
||||||
return callback(null, target());
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
return callback(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileUploadInterceptor implements NestInterceptor {
|
export class FileUploadInterceptor implements NestInterceptor {
|
||||||
private handlers: {
|
private handlers: {
|
||||||
userProfile: RequestHandler;
|
userProfile: RequestHandler;
|
||||||
assetUpload: RequestHandler;
|
assetUpload: RequestHandler;
|
||||||
};
|
};
|
||||||
private defaultStorage: StorageEngine;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private reflect: Reflector,
|
private reflect: Reflector,
|
||||||
@@ -60,11 +51,6 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
) {
|
) {
|
||||||
this.logger.setContext(FileUploadInterceptor.name);
|
this.logger.setContext(FileUploadInterceptor.name);
|
||||||
|
|
||||||
this.defaultStorage = diskStorage({
|
|
||||||
filename: this.filename.bind(this),
|
|
||||||
destination: this.destination.bind(this),
|
|
||||||
});
|
|
||||||
|
|
||||||
const instance = multer({
|
const instance = multer({
|
||||||
fileFilter: this.fileFilter.bind(this),
|
fileFilter: this.fileFilter.bind(this),
|
||||||
storage: {
|
storage: {
|
||||||
@@ -101,77 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
try {
|
||||||
}
|
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
|
||||||
|
} catch (error: Error | any) {
|
||||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
callback(error);
|
||||||
return callbackify(
|
}
|
||||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
|
||||||
callback as Callback<string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
|
||||||
return callbackify(
|
|
||||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
|
||||||
callback as Callback<string>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||||
(file as ImmichMulterFile).uuid = randomUUID();
|
|
||||||
|
|
||||||
request.on('error', (error) => {
|
request.on('error', (error) => {
|
||||||
this.logger.warn('Request error while uploading file, cleaning up', error);
|
this.logger.warn('Request error while uploading file, cleaning up', error);
|
||||||
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!this.isAssetUploadFile(file)) {
|
try {
|
||||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
(file as ImmichMulterFile).uuid = randomUUID();
|
||||||
|
|
||||||
|
const uploadRequest = asUploadRequest(request, file);
|
||||||
|
|
||||||
|
const path = join(
|
||||||
|
this.assetService.getUploadFolder(uploadRequest),
|
||||||
|
this.assetService.getUploadFilename(uploadRequest),
|
||||||
|
);
|
||||||
|
|
||||||
|
const writeStream = this.storageRepository.createWriteStream(path);
|
||||||
|
const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null;
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
file.stream.on('data', (chunk) => {
|
||||||
|
hash?.update(chunk);
|
||||||
|
size += chunk.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
pipeline(file.stream, writeStream, (error) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
|
hash?.destroy();
|
||||||
return callback(error);
|
return callback(error);
|
||||||
}
|
}
|
||||||
// Multer does not sync files to disk after writing.
|
callback(null, {
|
||||||
//
|
path,
|
||||||
// TODO: use `flush: true` in multer when available: https://github.com/expressjs/multer/issues/1381
|
size,
|
||||||
this.storageRepository
|
checksum: hash?.digest(),
|
||||||
.datasync(info!.path!)
|
});
|
||||||
.then(() => callback(null, info!))
|
|
||||||
.catch((error) => callback(error));
|
|
||||||
});
|
});
|
||||||
return;
|
} catch (error: Error | any) {
|
||||||
|
callback(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = createHash('sha1');
|
|
||||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
|
||||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
|
||||||
if (error) {
|
|
||||||
hash.destroy();
|
|
||||||
callback(error);
|
|
||||||
} else {
|
|
||||||
this.storageRepository
|
|
||||||
.datasync(info!.path!)
|
|
||||||
.then(() => callback(null, { ...info, checksum: hash.digest() }))
|
|
||||||
.catch((error) => {
|
|
||||||
hash.destroy();
|
|
||||||
callback(error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
||||||
this.defaultStorage._removeFile(request, file, callback);
|
this.storageRepository
|
||||||
}
|
.unlink(file.path)
|
||||||
|
.then(() => callback(null))
|
||||||
private isAssetUploadFile(file: Express.Multer.File) {
|
.catch(callback);
|
||||||
switch (file.fieldname as UploadFieldName) {
|
|
||||||
case UploadFieldName.ASSET_DATA: {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHandler(route: RouteKey) {
|
private getHandler(route: RouteKey) {
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ where
|
|||||||
select
|
select
|
||||||
"asset"."id",
|
"asset"."id",
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
"asset"."encodedVideoPath",
|
|
||||||
(
|
(
|
||||||
select
|
select
|
||||||
coalesce(json_agg(agg), '[]')
|
coalesce(json_agg(agg), '[]')
|
||||||
@@ -463,7 +462,6 @@ select
|
|||||||
"asset"."libraryId",
|
"asset"."libraryId",
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
"asset"."livePhotoVideoId",
|
"asset"."livePhotoVideoId",
|
||||||
"asset"."encodedVideoPath",
|
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."isOffline",
|
"asset"."isOffline",
|
||||||
to_json("asset_exif") as "exifInfo",
|
to_json("asset_exif") as "exifInfo",
|
||||||
@@ -521,12 +519,17 @@ select
|
|||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
where
|
where
|
||||||
"asset"."type" = $1
|
"asset"."type" = 'VIDEO'
|
||||||
and (
|
and not exists (
|
||||||
"asset"."encodedVideoPath" is null
|
select
|
||||||
or "asset"."encodedVideoPath" = $2
|
"asset_file"."id"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = 'encoded_video'
|
||||||
)
|
)
|
||||||
and "asset"."visibility" != $3
|
and "asset"."visibility" != 'hidden'
|
||||||
and "asset"."deletedAt" is null
|
and "asset"."deletedAt" is null
|
||||||
|
|
||||||
-- AssetJobRepository.getForVideoConversion
|
-- AssetJobRepository.getForVideoConversion
|
||||||
@@ -534,12 +537,27 @@ select
|
|||||||
"asset"."id",
|
"asset"."id",
|
||||||
"asset"."ownerId",
|
"asset"."ownerId",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."encodedVideoPath"
|
(
|
||||||
|
select
|
||||||
|
coalesce(json_agg(agg), '[]')
|
||||||
|
from
|
||||||
|
(
|
||||||
|
select
|
||||||
|
"asset_file"."id",
|
||||||
|
"asset_file"."path",
|
||||||
|
"asset_file"."type",
|
||||||
|
"asset_file"."isEdited"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
) as agg
|
||||||
|
) as "files"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
where
|
where
|
||||||
"asset"."id" = $1
|
"asset"."id" = $1
|
||||||
and "asset"."type" = $2
|
and "asset"."type" = 'VIDEO'
|
||||||
|
|
||||||
-- AssetJobRepository.streamForMetadataExtraction
|
-- AssetJobRepository.streamForMetadataExtraction
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -629,13 +629,21 @@ order by
|
|||||||
|
|
||||||
-- AssetRepository.getForVideo
|
-- AssetRepository.getForVideo
|
||||||
select
|
select
|
||||||
"asset"."encodedVideoPath",
|
"asset"."originalPath",
|
||||||
"asset"."originalPath"
|
(
|
||||||
|
select
|
||||||
|
"asset_file"."path"
|
||||||
|
from
|
||||||
|
"asset_file"
|
||||||
|
where
|
||||||
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
|
) as "encodedVideoPath"
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
where
|
where
|
||||||
"asset"."id" = $1
|
"asset"."id" = $2
|
||||||
and "asset"."type" = $2
|
and "asset"."type" = $3
|
||||||
|
|
||||||
-- AssetRepository.getForOcr
|
-- AssetRepository.getForOcr
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class AssetJobRepository {
|
|||||||
getForMigrationJob(id: string) {
|
getForMigrationJob(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
|
.select(['asset.id', 'asset.ownerId'])
|
||||||
.select(withFiles)
|
.select(withFiles)
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
@@ -268,7 +268,6 @@ export class AssetJobRepository {
|
|||||||
'asset.libraryId',
|
'asset.libraryId',
|
||||||
'asset.ownerId',
|
'asset.ownerId',
|
||||||
'asset.livePhotoVideoId',
|
'asset.livePhotoVideoId',
|
||||||
'asset.encodedVideoPath',
|
|
||||||
'asset.originalPath',
|
'asset.originalPath',
|
||||||
'asset.isOffline',
|
'asset.isOffline',
|
||||||
])
|
])
|
||||||
@@ -310,11 +309,21 @@ export class AssetJobRepository {
|
|||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.id'])
|
.select(['asset.id'])
|
||||||
.where('asset.type', '=', AssetType.Video)
|
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||||
.$if(!force, (qb) =>
|
.$if(!force, (qb) =>
|
||||||
qb
|
qb
|
||||||
.where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
|
.where((eb) =>
|
||||||
.where('asset.visibility', '!=', AssetVisibility.Hidden),
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('asset_file')
|
||||||
|
.select('asset_file.id')
|
||||||
|
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||||
|
.where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
|
||||||
)
|
)
|
||||||
.where('asset.deletedAt', 'is', null)
|
.where('asset.deletedAt', 'is', null)
|
||||||
.stream();
|
.stream();
|
||||||
@@ -324,9 +333,10 @@ export class AssetJobRepository {
|
|||||||
getForVideoConversion(id: string) {
|
getForVideoConversion(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
|
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
|
||||||
|
.select(withFiles)
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
.where('asset.type', '=', AssetType.Video)
|
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
withExif,
|
withExif,
|
||||||
withFaces,
|
withFaces,
|
||||||
withFacesAndPeople,
|
withFacesAndPeople,
|
||||||
|
withFilePath,
|
||||||
withFiles,
|
withFiles,
|
||||||
withLibrary,
|
withLibrary,
|
||||||
withOwner,
|
withOwner,
|
||||||
@@ -1019,8 +1020,21 @@ export class AssetRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
|
async deleteFile({
|
||||||
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
|
assetId,
|
||||||
|
type,
|
||||||
|
edited,
|
||||||
|
}: {
|
||||||
|
assetId: string;
|
||||||
|
type: AssetFileType;
|
||||||
|
edited?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.deleteFrom('asset_file')
|
||||||
|
.where('assetId', '=', asUuid(assetId))
|
||||||
|
.where('type', '=', type)
|
||||||
|
.$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!))
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
|
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
|
||||||
@@ -1139,7 +1153,8 @@ export class AssetRepository {
|
|||||||
async getForVideo(id: string) {
|
async getForVideo(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.encodedVideoPath', 'asset.originalPath'])
|
.select(['asset.originalPath'])
|
||||||
|
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
.where('asset.type', '=', AssetType.Video)
|
.where('asset.type', '=', AssetType.Video)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|||||||
@@ -431,7 +431,6 @@ export class DatabaseRepository {
|
|||||||
.updateTable('asset')
|
.updateTable('asset')
|
||||||
.set((eb) => ({
|
.set((eb) => ({
|
||||||
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
|
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
|
||||||
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
|
|
||||||
}))
|
}))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export class EmailRepository {
|
|||||||
host: options.host,
|
host: options.host,
|
||||||
port: options.port,
|
port: options.port,
|
||||||
tls: { rejectUnauthorized: !options.ignoreCert },
|
tls: { rejectUnauthorized: !options.ignoreCert },
|
||||||
|
secure: options.secure,
|
||||||
auth:
|
auth:
|
||||||
options.username || options.password
|
options.username || options.password
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { SourceType } from 'src/enum';
|
|||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||||
import { MediaRepository } from 'src/repositories/media.repository';
|
import { MediaRepository } from 'src/repositories/media.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
|
||||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||||
import { automock } from 'test/utils';
|
import { automock } from 'test/utils';
|
||||||
|
|
||||||
@@ -66,11 +65,8 @@ describe(MediaRepository.name, () => {
|
|||||||
let sut: MediaRepository;
|
let sut: MediaRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sut = new MediaRepository(
|
// eslint-disable-next-line no-sparse-arrays
|
||||||
// eslint-disable-next-line no-sparse-arrays
|
sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
|
||||||
automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }),
|
|
||||||
automock(StorageRepository, { args: [{ setContext: () => {} }], strict: false }),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyEdits (single actions)', () => {
|
describe('applyEdits (single actions)', () => {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { Exif } from 'src/database';
|
|||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
|
||||||
import {
|
import {
|
||||||
DecodeToBufferOptions,
|
DecodeToBufferOptions,
|
||||||
GenerateThumbhashOptions,
|
GenerateThumbhashOptions,
|
||||||
@@ -46,10 +45,7 @@ export type ExtractResult = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaRepository {
|
export class MediaRepository {
|
||||||
constructor(
|
constructor(private logger: LoggingRepository) {
|
||||||
private logger: LoggingRepository,
|
|
||||||
private storageRepository: StorageRepository,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(MediaRepository.name);
|
this.logger.setContext(MediaRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +116,6 @@ export class MediaRepository {
|
|||||||
ignoreMinorErrors: true,
|
ignoreMinorErrors: true,
|
||||||
writeArgs: ['-overwrite_original'],
|
writeArgs: ['-overwrite_original'],
|
||||||
});
|
});
|
||||||
await this.storageRepository.datasync(output);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
||||||
@@ -138,7 +133,6 @@ export class MediaRepository {
|
|||||||
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await this.storageRepository.datasync(target);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
||||||
@@ -186,7 +180,6 @@ export class MediaRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await decoded.toFile(output);
|
await decoded.toFile(output);
|
||||||
await this.storageRepository.datasync(output);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||||
@@ -281,18 +274,14 @@ export class MediaRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||||
if (!options.twoPass) {
|
if (!options.twoPass) {
|
||||||
await new Promise<void>((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.configureFfmpegCall(input, output, options)
|
this.configureFfmpegCall(input, output, options)
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
.on('end', () => resolve())
|
.on('end', () => resolve())
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
if (typeof output === 'string') {
|
|
||||||
await this.storageRepository.datasync(output);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof output !== 'string') {
|
if (typeof output !== 'string') {
|
||||||
@@ -301,7 +290,7 @@ export class MediaRepository {
|
|||||||
|
|
||||||
// two-pass allows for precise control of bitrate at the cost of running twice
|
// two-pass allows for precise control of bitrate at the cost of running twice
|
||||||
// recommended for vp9 for better quality and compression
|
// recommended for vp9 for better quality and compression
|
||||||
await new Promise<void>((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// first pass output is not saved as only the .log file is needed
|
// first pass output is not saved as only the .log file is needed
|
||||||
this.configureFfmpegCall(input, '/dev/null', options)
|
this.configureFfmpegCall(input, '/dev/null', options)
|
||||||
.addOptions('-pass', '1')
|
.addOptions('-pass', '1')
|
||||||
@@ -321,7 +310,6 @@ export class MediaRepository {
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
await this.storageRepository.datasync(output);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||||
import geotz from 'geo-tz';
|
import geotz from 'geo-tz';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
|
|
||||||
interface ExifDuration {
|
interface ExifDuration {
|
||||||
@@ -95,10 +94,7 @@ export class MetadataRepository {
|
|||||||
taskTimeoutMillis: 2 * 60 * 1000,
|
taskTimeoutMillis: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(private logger: LoggingRepository) {
|
||||||
private logger: LoggingRepository,
|
|
||||||
private storageRepository: StorageRepository,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(MetadataRepository.name);
|
this.logger.setContext(MetadataRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +121,6 @@ export class MetadataRepository {
|
|||||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.exiftool.write(path, tags);
|
await this.exiftool.write(path, tags);
|
||||||
await this.storageRepository.datasync(path);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,16 @@ export class OAuthRepository {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||||
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
|
||||||
|
let profile: OAuthProfile;
|
||||||
|
const tokenClaims = tokens.claims();
|
||||||
|
if (tokenClaims && 'email' in tokenClaims) {
|
||||||
|
this.logger.debug('Using ID token claims instead of userinfo endpoint');
|
||||||
|
profile = tokenClaims as OAuthProfile;
|
||||||
|
} else {
|
||||||
|
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||||
|
}
|
||||||
|
|
||||||
if (!profile.sub) {
|
if (!profile.sub) {
|
||||||
throw new Error('Unexpected profile response, no `sub`');
|
throw new Error('Unexpected profile response, no `sub`');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Album, columns } from 'src/database';
|
import { Album, columns } from 'src/database';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { SharedLinkType } from 'src/enum';
|
import { SharedLinkType } from 'src/enum';
|
||||||
import { DB } from 'src/schema';
|
import { DB } from 'src/schema';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
@@ -249,6 +249,20 @@ export class SharedLinkRepository {
|
|||||||
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ChunkedArray({ paramIndex: 1 })
|
||||||
|
async addAssets(id: string, assetIds: string[]) {
|
||||||
|
if (assetIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.db
|
||||||
|
.insertInto('shared_link_asset')
|
||||||
|
.values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id })))
|
||||||
|
.onConflict((oc) => oc.doNothing())
|
||||||
|
.returning(['shared_link_asset.assetId'])
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
private getSharedLinks(id: string) {
|
private getSharedLinks(id: string) {
|
||||||
return this.db
|
return this.db
|
||||||
|
|||||||
@@ -50,18 +50,8 @@ export class StorageRepository {
|
|||||||
return fs.readdir(folder);
|
return fs.readdir(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyFile(source: string, target: string) {
|
copyFile(source: string, target: string) {
|
||||||
await fs.copyFile(source, target);
|
return fs.copyFile(source, target);
|
||||||
await this.datasync(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
async datasync(filepath: string) {
|
|
||||||
const handle = await fs.open(filepath, 'r');
|
|
||||||
try {
|
|
||||||
await handle.datasync();
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stat(filepath: string) {
|
stat(filepath: string) {
|
||||||
@@ -69,7 +59,7 @@ export class StorageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createFile(filepath: string, buffer: Buffer) {
|
createFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'wx', flush: true });
|
return fs.writeFile(filepath, buffer, { flag: 'wx' });
|
||||||
}
|
}
|
||||||
|
|
||||||
createWriteStream(filepath: string): Writable {
|
createWriteStream(filepath: string): Writable {
|
||||||
@@ -77,11 +67,11 @@ export class StorageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'w', flush: true });
|
return fs.writeFile(filepath, buffer, { flag: 'w' });
|
||||||
}
|
}
|
||||||
|
|
||||||
overwriteFile(filepath: string, buffer: Buffer) {
|
overwriteFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'r+', flush: true });
|
return fs.writeFile(filepath, buffer, { flag: 'r+' });
|
||||||
}
|
}
|
||||||
|
|
||||||
rename(source: string, target: string) {
|
rename(source: string, target: string) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO "asset_file" ("assetId", "type", "path")
|
||||||
|
SELECT "id", 'encoded_video', "encodedVideoPath"
|
||||||
|
FROM "asset"
|
||||||
|
WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != '';
|
||||||
|
`.execute(db);
|
||||||
|
|
||||||
|
await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE "asset"
|
||||||
|
SET "encodedVideoPath" = af."path"
|
||||||
|
FROM "asset_file" af
|
||||||
|
WHERE "asset"."id" = af."assetId"
|
||||||
|
AND af."type" = 'encoded_video'
|
||||||
|
AND af."isEdited" = false;
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
@@ -92,9 +92,6 @@ export class AssetTable {
|
|||||||
@Column({ type: 'character varying', nullable: true })
|
@Column({ type: 'character varying', nullable: true })
|
||||||
duration!: string | null;
|
duration!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'character varying', nullable: true, default: '' })
|
|
||||||
encodedVideoPath!: string | null;
|
|
||||||
|
|
||||||
@Column({ type: 'bytea', index: true })
|
@Column({ type: 'bytea', index: true })
|
||||||
checksum!: Buffer; // sha1 checksum
|
checksum!: Buffer; // sha1 checksum
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { ReactionType } from 'src/dtos/activity.dto';
|
import { ReactionType } from 'src/dtos/activity.dto';
|
||||||
import { ActivityService } from 'src/services/activity.service';
|
import { ActivityService } from 'src/services/activity.service';
|
||||||
|
import { ActivityFactory } from 'test/factories/activity.factory';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
import { getForActivity } from 'test/mappers';
|
import { getForActivity } from 'test/mappers';
|
||||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
import { newUuid, newUuids } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(ActivityService.name, () => {
|
describe(ActivityService.name, () => {
|
||||||
@@ -24,7 +26,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
|
await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
|
||||||
|
|
||||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
|
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
|
||||||
});
|
});
|
||||||
@@ -36,7 +38,7 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
|
sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
|
||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
|
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
|
||||||
@@ -48,7 +50,9 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
|
await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
|
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
|
||||||
});
|
});
|
||||||
@@ -61,7 +65,10 @@ describe(ActivityService.name, () => {
|
|||||||
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
|
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
|
|
||||||
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 });
|
await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({
|
||||||
|
comments: 1,
|
||||||
|
likes: 3,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,18 +77,18 @@ describe(ActivityService.name, () => {
|
|||||||
const [albumId, assetId] = newUuids();
|
const [albumId, assetId] = newUuids();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a comment', async () => {
|
it('should create a comment', async () => {
|
||||||
const [albumId, assetId, userId] = newUuids();
|
const [albumId, assetId, userId] = newUuids();
|
||||||
const activity = factory.activity({ albumId, assetId, userId });
|
const activity = ActivityFactory.create({ albumId, assetId, userId });
|
||||||
|
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||||
|
|
||||||
await sut.create(factory.auth({ user: { id: userId } }), {
|
await sut.create(AuthFactory.create({ id: userId }), {
|
||||||
albumId,
|
albumId,
|
||||||
assetId,
|
assetId,
|
||||||
type: ReactionType.COMMENT,
|
type: ReactionType.COMMENT,
|
||||||
@@ -99,38 +106,38 @@ describe(ActivityService.name, () => {
|
|||||||
|
|
||||||
it('should fail because activity is disabled for the album', async () => {
|
it('should fail because activity is disabled for the album', async () => {
|
||||||
const [albumId, assetId] = newUuids();
|
const [albumId, assetId] = newUuids();
|
||||||
const activity = factory.activity({ albumId, assetId });
|
const activity = ActivityFactory.create({ albumId, assetId });
|
||||||
|
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a like', async () => {
|
it('should create a like', async () => {
|
||||||
const [albumId, assetId, userId] = newUuids();
|
const [albumId, assetId, userId] = newUuids();
|
||||||
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
|
const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true });
|
||||||
|
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||||
mocks.activity.search.mockResolvedValue([]);
|
mocks.activity.search.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
|
||||||
|
|
||||||
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
|
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if like exists', async () => {
|
it('should skip if like exists', async () => {
|
||||||
const [albumId, assetId] = newUuids();
|
const [albumId, assetId] = newUuids();
|
||||||
const activity = factory.activity({ albumId, assetId, isLiked: true });
|
const activity = ActivityFactory.create({ albumId, assetId, isLiked: true });
|
||||||
|
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||||
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
|
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
|
||||||
|
|
||||||
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
|
await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE });
|
||||||
|
|
||||||
expect(mocks.activity.create).not.toHaveBeenCalled();
|
expect(mocks.activity.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -138,29 +145,29 @@ describe(ActivityService.name, () => {
|
|||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
it('should require access', async () => {
|
it('should require access', async () => {
|
||||||
await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(mocks.activity.delete).not.toHaveBeenCalled();
|
expect(mocks.activity.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should let the activity owner delete a comment', async () => {
|
it('should let the activity owner delete a comment', async () => {
|
||||||
const activity = factory.activity();
|
const activity = ActivityFactory.create();
|
||||||
|
|
||||||
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
||||||
mocks.activity.delete.mockResolvedValue();
|
mocks.activity.delete.mockResolvedValue();
|
||||||
|
|
||||||
await sut.delete(factory.auth(), activity.id);
|
await sut.delete(AuthFactory.create(), activity.id);
|
||||||
|
|
||||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should let the album owner delete a comment', async () => {
|
it('should let the album owner delete a comment', async () => {
|
||||||
const activity = factory.activity();
|
const activity = ActivityFactory.create();
|
||||||
|
|
||||||
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
||||||
mocks.activity.delete.mockResolvedValue();
|
mocks.activity.delete.mockResolvedValue();
|
||||||
|
|
||||||
await sut.delete(factory.auth(), activity.id);
|
await sut.delete(AuthFactory.create(), activity.id);
|
||||||
|
|
||||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { ApiKeyService } from 'src/services/api-key.service';
|
import { ApiKeyService } from 'src/services/api-key.service';
|
||||||
import { factory, newUuid } from 'test/small.factory';
|
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { SessionFactory } from 'test/factories/session.factory';
|
||||||
|
import { newUuid } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(ApiKeyService.name, () => {
|
describe(ApiKeyService.name, () => {
|
||||||
@@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
it('should create a new key', async () => {
|
it('should create a new key', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] });
|
||||||
const key = 'super-secret';
|
const key = 'super-secret';
|
||||||
|
|
||||||
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
||||||
@@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not require a name', async () => {
|
it('should not require a name', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
const key = 'super-secret';
|
const key = 'super-secret';
|
||||||
|
|
||||||
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
||||||
@@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
||||||
const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } });
|
const auth = AuthFactory.from()
|
||||||
|
.apiKey({ permissions: [Permission.AssetRead] })
|
||||||
|
.build();
|
||||||
|
|
||||||
await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf(
|
await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => {
|
|||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should throw an error if the key is not found', async () => {
|
it('should throw an error if the key is not found', async () => {
|
||||||
const id = newUuid();
|
const id = newUuid();
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||||
|
|
||||||
@@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should update a key', async () => {
|
it('should update a key', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
const newName = 'New name';
|
const newName = 'New name';
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
@@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should update permissions', async () => {
|
it('should update permissions', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate];
|
const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate];
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
@@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
describe('api key auth', () => {
|
describe('api key auth', () => {
|
||||||
it('should prevent adding Permission.all', async () => {
|
it('should prevent adding Permission.all', async () => {
|
||||||
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
||||||
const auth = factory.auth({ apiKey: { permissions } });
|
const auth = AuthFactory.from().apiKey({ permissions }).build();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
|
|
||||||
@@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
|
|
||||||
it('should prevent adding a new permission', async () => {
|
it('should prevent adding a new permission', async () => {
|
||||||
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
||||||
const auth = factory.auth({ apiKey: { permissions } });
|
const auth = AuthFactory.from().apiKey({ permissions }).build();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
|
|
||||||
@@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow removing permissions', async () => {
|
it('should allow removing permissions', async () => {
|
||||||
const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } });
|
const auth = AuthFactory.from()
|
||||||
const apiKey = factory.apiKey({
|
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] })
|
||||||
|
.build();
|
||||||
|
const apiKey = ApiKeyFactory.create({
|
||||||
userId: auth.user.id,
|
userId: auth.user.id,
|
||||||
permissions: [Permission.AssetRead, Permission.AssetDelete],
|
permissions: [Permission.AssetRead, Permission.AssetDelete],
|
||||||
});
|
});
|
||||||
@@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow adding new permissions', async () => {
|
it('should allow adding new permissions', async () => {
|
||||||
const auth = factory.auth({
|
const auth = AuthFactory.from()
|
||||||
apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] },
|
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] })
|
||||||
});
|
.build();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] });
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
mocks.apiKey.update.mockResolvedValue(apiKey);
|
mocks.apiKey.update.mockResolvedValue(apiKey);
|
||||||
@@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => {
|
|||||||
|
|
||||||
describe('delete', () => {
|
describe('delete', () => {
|
||||||
it('should throw an error if the key is not found', async () => {
|
it('should throw an error if the key is not found', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const id = newUuid();
|
const id = newUuid();
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||||
@@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete a key', async () => {
|
it('should delete a key', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
mocks.apiKey.delete.mockResolvedValue();
|
mocks.apiKey.delete.mockResolvedValue();
|
||||||
@@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
|
|
||||||
describe('getMine', () => {
|
describe('getMine', () => {
|
||||||
it('should not work with a session token', async () => {
|
it('should not work with a session token', async () => {
|
||||||
const session = factory.session();
|
const session = SessionFactory.create();
|
||||||
const auth = factory.auth({ session });
|
const auth = AuthFactory.from().session(session).build();
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||||
|
|
||||||
@@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if the key is not found', async () => {
|
it('should throw an error if the key is not found', async () => {
|
||||||
const apiKey = factory.authApiKey();
|
const apiKey = ApiKeyFactory.create();
|
||||||
const auth = factory.auth({ apiKey });
|
const auth = AuthFactory.from().apiKey(apiKey).build();
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||||
|
|
||||||
@@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get a key by id', async () => {
|
it('should get a key by id', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
|
|
||||||
@@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => {
|
|||||||
|
|
||||||
describe('getById', () => {
|
describe('getById', () => {
|
||||||
it('should throw an error if the key is not found', async () => {
|
it('should throw an error if the key is not found', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const id = newUuid();
|
const id = newUuid();
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||||
@@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should get a key by id', async () => {
|
it('should get a key by id', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
|
|
||||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||||
|
|
||||||
@@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => {
|
|||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('should return all the keys for a user', async () => {
|
it('should return all the keys for a user', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||||
|
|
||||||
mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);
|
mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ const assetEntity = Object.freeze({
|
|||||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
encodedVideoPath: '',
|
|
||||||
duration: '0:00:00.000000',
|
duration: '0:00:00.000000',
|
||||||
files: [] as AssetFile[],
|
files: [] as AssetFile[],
|
||||||
exifInfo: {
|
exifInfo: {
|
||||||
@@ -711,13 +710,18 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the encoded video path if available', async () => {
|
it('should return the encoded video path if available', async () => {
|
||||||
const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' });
|
const asset = AssetFactory.from()
|
||||||
|
.file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' })
|
||||||
|
.build();
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getForVideo.mockResolvedValue(asset);
|
mocks.asset.getForVideo.mockResolvedValue({
|
||||||
|
originalPath: asset.originalPath,
|
||||||
|
encodedVideoPath: asset.files[0].path,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
path: asset.encodedVideoPath!,
|
path: '/path/to/encoded/video.mp4',
|
||||||
cacheControl: CacheControl.PrivateWithCache,
|
cacheControl: CacheControl.PrivateWithCache,
|
||||||
contentType: 'video/mp4',
|
contentType: 'video/mp4',
|
||||||
}),
|
}),
|
||||||
@@ -727,7 +731,10 @@ describe(AssetMediaService.name, () => {
|
|||||||
it('should fall back to the original path', async () => {
|
it('should fall back to the original path', async () => {
|
||||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||||
mocks.asset.getForVideo.mockResolvedValue(asset);
|
mocks.asset.getForVideo.mockResolvedValue({
|
||||||
|
originalPath: asset.originalPath,
|
||||||
|
encodedVideoPath: null,
|
||||||
|
});
|
||||||
|
|
||||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||||
new ImmichFileResponse({
|
new ImmichFileResponse({
|
||||||
|
|||||||
@@ -151,9 +151,13 @@ export class AssetMediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||||
|
|
||||||
|
if (auth.sharedLink) {
|
||||||
|
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
|
||||||
|
}
|
||||||
|
|
||||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||||
|
|
||||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
return { id: asset.id, status: AssetMediaStatus.CREATED, checksum: file.checksum.toString('base64') };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||||
}
|
}
|
||||||
@@ -341,7 +345,12 @@ export class AssetMediaService extends BaseService {
|
|||||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||||
throw new InternalServerErrorException();
|
throw new InternalServerErrorException();
|
||||||
}
|
}
|
||||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
|
||||||
|
if (auth.sharedLink) {
|
||||||
|
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId, checksum: file.checksum.toString('base64') };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AssetStats } from 'src/repositories/asset.repository';
|
|||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
|
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
|
||||||
import { factory, newUuid } from 'test/small.factory';
|
import { factory, newUuid } from 'test/small.factory';
|
||||||
@@ -80,8 +81,8 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not include partner assets if not in timeline', async () => {
|
it('should not include partner assets if not in timeline', async () => {
|
||||||
const partner = factory.partner({ inTimeline: false });
|
const partner = PartnerFactory.create({ inTimeline: false });
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
const auth = AuthFactory.create({ id: partner.sharedWithId });
|
||||||
|
|
||||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||||
@@ -92,8 +93,8 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include partner assets if in timeline', async () => {
|
it('should include partner assets if in timeline', async () => {
|
||||||
const partner = factory.partner({ inTimeline: true });
|
const partner = PartnerFactory.create({ inTimeline: true });
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
const auth = AuthFactory.create({ id: partner.sharedWithId });
|
||||||
|
|
||||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export class AssetService extends BaseService {
|
|||||||
assetFiles.editedFullsizeFile?.path,
|
assetFiles.editedFullsizeFile?.path,
|
||||||
assetFiles.editedPreviewFile?.path,
|
assetFiles.editedPreviewFile?.path,
|
||||||
assetFiles.editedThumbnailFile?.path,
|
assetFiles.editedThumbnailFile?.path,
|
||||||
asset.encodedVideoPath,
|
assetFiles.encodedVideoFile?.path,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (deleteOnDisk && !asset.isOffline) {
|
if (deleteOnDisk && !asset.isOffline) {
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
|||||||
import { AuthType, Permission } from 'src/enum';
|
import { AuthType, Permission } from 'src/enum';
|
||||||
import { AuthService } from 'src/services/auth.service';
|
import { AuthService } from 'src/services/auth.service';
|
||||||
import { UserMetadataItem } from 'src/types';
|
import { UserMetadataItem } from 'src/types';
|
||||||
|
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { SessionFactory } from 'test/factories/session.factory';
|
||||||
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
import { factory, newUuid } from 'test/small.factory';
|
import { newUuid } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const oauthResponse = ({
|
const oauthResponse = ({
|
||||||
@@ -91,8 +95,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully log the user in', async () => {
|
it('should successfully log the user in', async () => {
|
||||||
const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
|
const user = UserFactory.create({ password: 'immich_password' });
|
||||||
const session = factory.session();
|
const session = SessionFactory.create();
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(session);
|
mocks.session.create.mockResolvedValue(session);
|
||||||
|
|
||||||
@@ -113,8 +117,8 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('changePassword', () => {
|
describe('changePassword', () => {
|
||||||
it('should change the password', async () => {
|
it('should change the password', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||||
|
|
||||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
||||||
@@ -132,8 +136,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when password does not match existing password', async () => {
|
it('should throw when password does not match existing password', async () => {
|
||||||
const user = factory.user();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||||
|
|
||||||
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
||||||
@@ -144,8 +148,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when user does not have a password', async () => {
|
it('should throw when user does not have a password', async () => {
|
||||||
const user = factory.user();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||||
|
|
||||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
|
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
|
||||||
@@ -154,8 +158,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should change the password and logout other sessions', async () => {
|
it('should change the password and logout other sessions', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true };
|
const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true };
|
||||||
|
|
||||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
||||||
@@ -175,7 +179,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('logout', () => {
|
describe('logout', () => {
|
||||||
it('should return the end session endpoint', async () => {
|
it('should return the end session endpoint', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
|
|
||||||
@@ -186,7 +190,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the default redirect', async () => {
|
it('should return the default redirect', async () => {
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
|
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
|
||||||
successful: true,
|
successful: true,
|
||||||
@@ -262,11 +266,11 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should validate using authorization header', async () => {
|
it('should validate using authorization header', async () => {
|
||||||
const session = factory.session();
|
const session = SessionFactory.create();
|
||||||
const sessionWithToken = {
|
const sessionWithToken = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: UserFactory.create(),
|
||||||
pinExpiresAt: null,
|
pinExpiresAt: null,
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
};
|
};
|
||||||
@@ -340,7 +344,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a base64url key', async () => {
|
it('should accept a base64url key', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||||
|
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||||
@@ -361,7 +365,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a hex key', async () => {
|
it('should accept a hex key', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||||
|
|
||||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||||
@@ -396,7 +400,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a valid slug', async () => {
|
it('should accept a valid slug', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
|
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
|
||||||
|
|
||||||
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
|
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
|
||||||
@@ -428,11 +432,11 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return an auth dto', async () => {
|
it('should return an auth dto', async () => {
|
||||||
const session = factory.session();
|
const session = SessionFactory.create();
|
||||||
const sessionWithToken = {
|
const sessionWithToken = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: UserFactory.create(),
|
||||||
pinExpiresAt: null,
|
pinExpiresAt: null,
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
};
|
};
|
||||||
@@ -455,11 +459,11 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if admin route and not an admin', async () => {
|
it('should throw if admin route and not an admin', async () => {
|
||||||
const session = factory.session();
|
const session = SessionFactory.create();
|
||||||
const sessionWithToken = {
|
const sessionWithToken = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: UserFactory.create(),
|
||||||
isPendingSyncReset: false,
|
isPendingSyncReset: false,
|
||||||
pinExpiresAt: null,
|
pinExpiresAt: null,
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
@@ -477,11 +481,11 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should update when access time exceeds an hour', async () => {
|
it('should update when access time exceeds an hour', async () => {
|
||||||
const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
|
const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
|
||||||
const sessionWithToken = {
|
const sessionWithToken = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
updatedAt: session.updatedAt,
|
updatedAt: session.updatedAt,
|
||||||
user: factory.authUser(),
|
user: UserFactory.create(),
|
||||||
isPendingSyncReset: false,
|
isPendingSyncReset: false,
|
||||||
pinExpiresAt: null,
|
pinExpiresAt: null,
|
||||||
appVersion: null,
|
appVersion: null,
|
||||||
@@ -517,8 +521,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if api key has insufficient permissions', async () => {
|
it('should throw an error if api key has insufficient permissions', async () => {
|
||||||
const authUser = factory.authUser();
|
const authUser = UserFactory.create();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||||
|
|
||||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||||
|
|
||||||
@@ -533,8 +537,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should default to requiring the all permission when omitted', async () => {
|
it('should default to requiring the all permission when omitted', async () => {
|
||||||
const authUser = factory.authUser();
|
const authUser = UserFactory.create();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] });
|
const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] });
|
||||||
|
|
||||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||||
|
|
||||||
@@ -548,10 +552,12 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not require any permission when metadata is set to `false`', async () => {
|
it('should not require any permission when metadata is set to `false`', async () => {
|
||||||
const authUser = factory.authUser();
|
const authUser = UserFactory.create();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
|
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] })
|
||||||
|
.user(authUser)
|
||||||
|
.build();
|
||||||
|
|
||||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
|
||||||
|
|
||||||
const result = sut.authenticate({
|
const result = sut.authenticate({
|
||||||
headers: { 'x-api-key': 'auth_token' },
|
headers: { 'x-api-key': 'auth_token' },
|
||||||
@@ -562,10 +568,12 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return an auth dto', async () => {
|
it('should return an auth dto', async () => {
|
||||||
const authUser = factory.authUser();
|
const authUser = UserFactory.create();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
|
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] })
|
||||||
|
.user(authUser)
|
||||||
|
.build();
|
||||||
|
|
||||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.authenticate({
|
sut.authenticate({
|
||||||
@@ -629,12 +637,12 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should link an existing user', async () => {
|
it('should link an existing user', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(user);
|
mocks.user.getByEmail.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -649,7 +657,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not link to a user with a different oauth sub', async () => {
|
it('should not link to a user with a different oauth sub', async () => {
|
||||||
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
|
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||||
@@ -669,13 +677,13 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should allow auto registering by default', async () => {
|
it('should allow auto registering by default', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -690,13 +698,13 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||||
const user = factory.userAdmin({ isAdmin: true });
|
const user = UserFactory.create({ isAdmin: true });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(user);
|
mocks.user.getAdmin.mockResolvedValue(user);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -717,11 +725,11 @@ describe(AuthService.name, () => {
|
|||||||
'app.immich:///oauth-callback?code=abc123',
|
'app.immich:///oauth-callback?code=abc123',
|
||||||
]) {
|
]) {
|
||||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||||
|
|
||||||
@@ -735,13 +743,13 @@ describe(AuthService.name, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
it('should use the default quota', async () => {
|
it('should use the default quota', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -755,14 +763,14 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore an invalid storage quota', async () => {
|
it('should ignore an invalid storage quota', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -776,14 +784,14 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore a negative quota', async () => {
|
it('should ignore a negative quota', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||||
mocks.user.getAdmin.mockResolvedValue(user);
|
mocks.user.getAdmin.mockResolvedValue(user);
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -797,14 +805,14 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set quota for 0 quota', async () => {
|
it('should set quota for 0 quota', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -825,15 +833,15 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use a valid storage quota', async () => {
|
it('should use a valid storage quota', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -855,7 +863,7 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
it('should sync the profile picture', async () => {
|
it('should sync the profile picture', async () => {
|
||||||
const fileId = newUuid();
|
const fileId = newUuid();
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
@@ -871,7 +879,7 @@ describe(AuthService.name, () => {
|
|||||||
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
||||||
});
|
});
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -889,7 +897,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not sync the profile picture if the user already has one', async () => {
|
it('should not sync the profile picture if the user already has one', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({
|
mocks.oauth.getProfile.mockResolvedValue({
|
||||||
@@ -899,7 +907,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -914,15 +922,15 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -943,14 +951,14 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should create an admin user if the role claim is set to admin', async () => {
|
it('should create an admin user if the role claim is set to admin', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -971,7 +979,7 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should accept a custom role claim', async () => {
|
it('should accept a custom role claim', async () => {
|
||||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue({
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
||||||
@@ -980,7 +988,7 @@ describe(AuthService.name, () => {
|
|||||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||||
mocks.user.create.mockResolvedValue(user);
|
mocks.user.create.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(factory.session());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.callback(
|
sut.callback(
|
||||||
@@ -1003,8 +1011,8 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('link', () => {
|
describe('link', () => {
|
||||||
it('should link an account', async () => {
|
it('should link an account', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ apiKey: { permissions: [] }, user });
|
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
@@ -1019,8 +1027,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not link an already linked oauth.sub', async () => {
|
it('should not link an already linked oauth.sub', async () => {
|
||||||
const authUser = factory.authUser();
|
const authUser = UserFactory.create();
|
||||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||||
const auth = { user: authUser, apiKey: authApiKey };
|
const auth = { user: authUser, apiKey: authApiKey };
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
@@ -1036,8 +1044,8 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('unlink', () => {
|
describe('unlink', () => {
|
||||||
it('should unlink an account', async () => {
|
it('should unlink an account', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user, apiKey: { permissions: [] } });
|
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
@@ -1050,8 +1058,8 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('setupPinCode', () => {
|
describe('setupPinCode', () => {
|
||||||
it('should setup a PIN code', async () => {
|
it('should setup a PIN code', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
const dto = { pinCode: '123456' };
|
const dto = { pinCode: '123456' };
|
||||||
|
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
|
||||||
@@ -1065,8 +1073,8 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if the user already has a PIN code', async () => {
|
it('should fail if the user already has a PIN code', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
|
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
|
|
||||||
@@ -1076,8 +1084,8 @@ describe(AuthService.name, () => {
|
|||||||
|
|
||||||
describe('changePinCode', () => {
|
describe('changePinCode', () => {
|
||||||
it('should change the PIN code', async () => {
|
it('should change the PIN code', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
const dto = { pinCode: '123456', newPinCode: '012345' };
|
const dto = { pinCode: '123456', newPinCode: '012345' };
|
||||||
|
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
@@ -1091,37 +1099,37 @@ describe(AuthService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should fail if the PIN code does not match', async () => {
|
it('should fail if the PIN code does not match', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
|
sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }),
|
||||||
).rejects.toThrow('Wrong PIN code');
|
).rejects.toThrow('Wrong PIN code');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('resetPinCode', () => {
|
describe('resetPinCode', () => {
|
||||||
it('should reset the PIN code', async () => {
|
it('should reset the PIN code', async () => {
|
||||||
const currentSession = factory.session();
|
const currentSession = SessionFactory.create();
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
mocks.session.lockAll.mockResolvedValue(void 0);
|
mocks.session.lockAll.mockResolvedValue(void 0);
|
||||||
mocks.session.update.mockResolvedValue(currentSession);
|
mocks.session.update.mockResolvedValue(currentSession);
|
||||||
|
|
||||||
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
|
await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' });
|
||||||
|
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
||||||
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
|
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw if the PIN code does not match', async () => {
|
it('should throw if the PIN code does not match', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||||
|
|
||||||
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||||
import { CliService } from 'src/services/cli.service';
|
import { CliService } from 'src/services/cli.service';
|
||||||
import { factory } from 'test/small.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ describe(CliService.name, () => {
|
|||||||
|
|
||||||
describe('listUsers', () => {
|
describe('listUsers', () => {
|
||||||
it('should list users', async () => {
|
it('should list users', async () => {
|
||||||
mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
|
mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]);
|
||||||
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
||||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||||
});
|
});
|
||||||
@@ -32,10 +32,10 @@ describe(CliService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should default to a random password', async () => {
|
it('should default to a random password', async () => {
|
||||||
const admin = factory.userAdmin({ isAdmin: true });
|
const admin = UserFactory.create({ isAdmin: true });
|
||||||
|
|
||||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||||
|
|
||||||
const ask = vitest.fn().mockImplementation(() => {});
|
const ask = vitest.fn().mockImplementation(() => {});
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ describe(CliService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use the supplied password', async () => {
|
it('should use the supplied password', async () => {
|
||||||
const admin = factory.userAdmin({ isAdmin: true });
|
const admin = UserFactory.create({ isAdmin: true });
|
||||||
|
|
||||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||||
mocks.user.update.mockResolvedValue(admin);
|
mocks.user.update.mockResolvedValue(admin);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service';
|
|||||||
import { AlbumFactory } from 'test/factories/album.factory';
|
import { AlbumFactory } from 'test/factories/album.factory';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
import { AuthFactory } from 'test/factories/auth.factory';
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { getForAlbum, getForPartner } from 'test/mappers';
|
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(MapService.name, () => {
|
describe(MapService.name, () => {
|
||||||
@@ -40,7 +40,7 @@ describe(MapService.name, () => {
|
|||||||
|
|
||||||
it('should include partner assets', async () => {
|
it('should include partner assets', async () => {
|
||||||
const auth = AuthFactory.create();
|
const auth = AuthFactory.create();
|
||||||
const partner = factory.partner({ sharedWithId: auth.user.id });
|
const partner = PartnerFactory.create({ sharedWithId: auth.user.id });
|
||||||
|
|
||||||
const asset = AssetFactory.from()
|
const asset = AssetFactory.from()
|
||||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||||
|
|||||||
@@ -2254,7 +2254,9 @@ describe(MediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete existing transcode if current policy does not require transcoding', async () => {
|
it('should delete existing transcode if current policy does not require transcoding', async () => {
|
||||||
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
|
const asset = AssetFactory.from({ type: AssetType.Video })
|
||||||
|
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' })
|
||||||
|
.build();
|
||||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
||||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||||
@@ -2264,7 +2266,7 @@ describe(MediaService.name, () => {
|
|||||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.FileDelete,
|
name: JobName.FileDelete,
|
||||||
data: { files: [asset.encodedVideoPath] },
|
data: { files: ['/encoded/video/path.mp4'] },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
VideoInterfaces,
|
VideoInterfaces,
|
||||||
VideoStreamInfo,
|
VideoStreamInfo,
|
||||||
} from 'src/types';
|
} from 'src/types';
|
||||||
import { getDimensions } from 'src/utils/asset.util';
|
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
||||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
@@ -605,10 +605,11 @@ export class MediaService extends BaseService {
|
|||||||
let { ffmpeg } = await this.getConfig({ withCache: true });
|
let { ffmpeg } = await this.getConfig({ withCache: true });
|
||||||
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||||
if (asset.encodedVideoPath) {
|
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||||
|
if (encodedVideo) {
|
||||||
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
||||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } });
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
|
||||||
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
|
await this.assetRepository.deleteFiles([encodedVideo]);
|
||||||
} else {
|
} else {
|
||||||
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
||||||
}
|
}
|
||||||
@@ -656,7 +657,12 @@ export class MediaService extends BaseService {
|
|||||||
|
|
||||||
this.logger.log(`Successfully encoded ${asset.id}`);
|
this.logger.log(`Successfully encoded ${asset.id}`);
|
||||||
|
|
||||||
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
|
await this.assetRepository.upsertFile({
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.EncodedVideo,
|
||||||
|
path: output,
|
||||||
|
isEdited: false,
|
||||||
|
});
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
|
|||||||
duration: null,
|
duration: null,
|
||||||
fileCreatedAt: asset.fileCreatedAt,
|
fileCreatedAt: asset.fileCreatedAt,
|
||||||
fileModifiedAt: asset.fileModifiedAt,
|
fileModifiedAt: asset.fileModifiedAt,
|
||||||
localDateTime: asset.localDateTime,
|
localDateTime: asset.fileCreatedAt,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
});
|
});
|
||||||
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
|
|||||||
duration: null,
|
duration: null,
|
||||||
fileCreatedAt: asset.fileCreatedAt,
|
fileCreatedAt: asset.fileCreatedAt,
|
||||||
fileModifiedAt: asset.fileModifiedAt,
|
fileModifiedAt: asset.fileModifiedAt,
|
||||||
localDateTime: asset.localDateTime,
|
localDateTime: asset.fileCreatedAt,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||||
import { PartnerService } from 'src/services/partner.service';
|
import { PartnerService } from 'src/services/partner.service';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { getDehydrated, getForPartner } from 'test/mappers';
|
import { getForPartner } from 'test/mappers';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(PartnerService.name, () => {
|
describe(PartnerService.name, () => {
|
||||||
@@ -22,15 +23,9 @@ describe(PartnerService.name, () => {
|
|||||||
it("should return a list of partners with whom I've shared my library", async () => {
|
it("should return a list of partners with whom I've shared my library", async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const sharedWithUser2 = factory.partner({
|
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||||
sharedBy: getDehydrated(user1),
|
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
|
||||||
sharedWith: getDehydrated(user2),
|
const auth = AuthFactory.create({ id: user1.id });
|
||||||
});
|
|
||||||
const sharedWithUser1 = factory.partner({
|
|
||||||
sharedBy: getDehydrated(user2),
|
|
||||||
sharedWith: getDehydrated(user1),
|
|
||||||
});
|
|
||||||
const auth = factory.auth({ user: { id: user1.id } });
|
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
||||||
|
|
||||||
@@ -41,15 +36,9 @@ describe(PartnerService.name, () => {
|
|||||||
it('should return a list of partners who have shared their libraries with me', async () => {
|
it('should return a list of partners who have shared their libraries with me', async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const sharedWithUser2 = factory.partner({
|
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||||
sharedBy: getDehydrated(user1),
|
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
|
||||||
sharedWith: getDehydrated(user2),
|
const auth = AuthFactory.create({ id: user1.id });
|
||||||
});
|
|
||||||
const sharedWithUser1 = factory.partner({
|
|
||||||
sharedBy: getDehydrated(user2),
|
|
||||||
sharedWith: getDehydrated(user1),
|
|
||||||
});
|
|
||||||
const auth = factory.auth({ user: { id: user1.id } });
|
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
||||||
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
||||||
@@ -61,8 +50,8 @@ describe(PartnerService.name, () => {
|
|||||||
it('should create a new partner', async () => {
|
it('should create a new partner', async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||||
const auth = factory.auth({ user: { id: user1.id } });
|
const auth = AuthFactory.create({ id: user1.id });
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(void 0);
|
mocks.partner.get.mockResolvedValue(void 0);
|
||||||
mocks.partner.create.mockResolvedValue(getForPartner(partner));
|
mocks.partner.create.mockResolvedValue(getForPartner(partner));
|
||||||
@@ -78,8 +67,8 @@ describe(PartnerService.name, () => {
|
|||||||
it('should throw an error when the partner already exists', async () => {
|
it('should throw an error when the partner already exists', async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||||
const auth = factory.auth({ user: { id: user1.id } });
|
const auth = AuthFactory.create({ id: user1.id });
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||||
|
|
||||||
@@ -93,8 +82,8 @@ describe(PartnerService.name, () => {
|
|||||||
it('should remove a partner', async () => {
|
it('should remove a partner', async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||||
const auth = factory.auth({ user: { id: user1.id } });
|
const auth = AuthFactory.create({ id: user1.id });
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||||
|
|
||||||
@@ -104,8 +93,8 @@ describe(PartnerService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the partner does not exist', async () => {
|
it('should throw an error when the partner does not exist', async () => {
|
||||||
const user2 = factory.user();
|
const user2 = UserFactory.create();
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
mocks.partner.get.mockResolvedValue(void 0);
|
mocks.partner.get.mockResolvedValue(void 0);
|
||||||
|
|
||||||
@@ -117,8 +106,8 @@ describe(PartnerService.name, () => {
|
|||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should require access', async () => {
|
it('should require access', async () => {
|
||||||
const user2 = factory.user();
|
const user2 = UserFactory.create();
|
||||||
const auth = factory.auth();
|
const auth = AuthFactory.create();
|
||||||
|
|
||||||
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
@@ -126,8 +115,8 @@ describe(PartnerService.name, () => {
|
|||||||
it('should update partner', async () => {
|
it('should update partner', async () => {
|
||||||
const user1 = UserFactory.create();
|
const user1 = UserFactory.create();
|
||||||
const user2 = UserFactory.create();
|
const user2 = UserFactory.create();
|
||||||
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
|
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||||
const auth = factory.auth({ user: { id: user1.id } });
|
const auth = AuthFactory.create({ id: user1.id });
|
||||||
|
|
||||||
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
|
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
|
||||||
mocks.partner.update.mockResolvedValue(getForPartner(partner));
|
mocks.partner.update.mockResolvedValue(getForPartner(partner));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { JobStatus } from 'src/enum';
|
import { JobStatus } from 'src/enum';
|
||||||
import { SessionService } from 'src/services/session.service';
|
import { SessionService } from 'src/services/session.service';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { SessionFactory } from 'test/factories/session.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe('SessionService', () => {
|
describe('SessionService', () => {
|
||||||
@@ -25,9 +26,9 @@ describe('SessionService', () => {
|
|||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('should get the devices', async () => {
|
it('should get the devices', async () => {
|
||||||
const currentSession = factory.session();
|
const currentSession = SessionFactory.create();
|
||||||
const otherSession = factory.session();
|
const otherSession = SessionFactory.create();
|
||||||
const auth = factory.auth({ session: currentSession });
|
const auth = AuthFactory.from().session(currentSession).build();
|
||||||
|
|
||||||
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
|
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
|
||||||
|
|
||||||
@@ -42,8 +43,8 @@ describe('SessionService', () => {
|
|||||||
|
|
||||||
describe('logoutDevices', () => {
|
describe('logoutDevices', () => {
|
||||||
it('should logout all devices', async () => {
|
it('should logout all devices', async () => {
|
||||||
const currentSession = factory.session();
|
const currentSession = SessionFactory.create();
|
||||||
const auth = factory.auth({ session: currentSession });
|
const auth = AuthFactory.from().session(currentSession).build();
|
||||||
|
|
||||||
mocks.session.invalidate.mockResolvedValue();
|
mocks.session.invalidate.mockResolvedValue();
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||||
|
if (auth.sharedLink) {
|
||||||
|
this.logger.deprecate(
|
||||||
|
'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sharedLink = await this.findOrFail(auth.user.id, id);
|
const sharedLink = await this.findOrFail(auth.user.id, id);
|
||||||
|
|
||||||
if (sharedLink.type !== SharedLinkType.Individual) {
|
if (sharedLink.type !== SharedLinkType.Individual) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { SyncService } from 'src/services/sync.service';
|
import { SyncService } from 'src/services/sync.service';
|
||||||
import { AssetFactory } from 'test/factories/asset.factory';
|
import { AssetFactory } from 'test/factories/asset.factory';
|
||||||
|
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { getForAsset, getForPartner } from 'test/mappers';
|
import { getForAsset, getForPartner } from 'test/mappers';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
@@ -42,7 +43,7 @@ describe(SyncService.name, () => {
|
|||||||
|
|
||||||
describe('getChangesForDeltaSync', () => {
|
describe('getChangesForDeltaSync', () => {
|
||||||
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
||||||
const partner = factory.partner();
|
const partner = PartnerFactory.create();
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
|||||||
import { mapUserAdmin } from 'src/dtos/user.dto';
|
import { mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { JobName, UserStatus } from 'src/enum';
|
import { JobName, UserStatus } from 'src/enum';
|
||||||
import { UserAdminService } from 'src/services/user-admin.service';
|
import { UserAdminService } from 'src/services/user-admin.service';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { describe } from 'vitest';
|
import { describe } from 'vitest';
|
||||||
|
|
||||||
@@ -126,8 +127,8 @@ describe(UserAdminService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow deleting own account', async () => {
|
it('should not allow deleting own account', async () => {
|
||||||
const user = factory.userAdmin({ isAdmin: false });
|
const user = UserFactory.create({ isAdmin: false });
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database';
|
|||||||
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
|
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
|
import { AuthFactory } from 'test/factories/auth.factory';
|
||||||
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const makeDeletedAt = (daysAgo: number) => {
|
const makeDeletedAt = (daysAgo: number) => {
|
||||||
@@ -28,8 +29,8 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('admin should get all users', async () => {
|
it('admin should get all users', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
|
|
||||||
mocks.user.getList.mockResolvedValue([user]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('non-admin should get all users when publicUsers enabled', async () => {
|
it('non-admin should get all users when publicUsers enabled', async () => {
|
||||||
const user = factory.userAdmin();
|
const user = UserFactory.create();
|
||||||
const auth = factory.auth({ user });
|
const auth = AuthFactory.create(user);
|
||||||
|
|
||||||
mocks.user.getList.mockResolvedValue([user]);
|
mocks.user.getList.mockResolvedValue([user]);
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
it('should throw an error if the user profile could not be updated with the new image', async () => {
|
it('should throw an error if the user profile could not be updated with the new image', async () => {
|
||||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
|
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the previous profile image', async () => {
|
it('should delete the previous profile image', async () => {
|
||||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
const files = [user.profileImagePath];
|
const files = [user.profileImagePath];
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the profile image if user has one', async () => {
|
it('should delete the profile image if user has one', async () => {
|
||||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
const files = [user.profileImagePath];
|
const files = [user.profileImagePath];
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
@@ -178,7 +179,7 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return the profile picture', async () => {
|
it('should return the profile picture', async () => {
|
||||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
|
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
|
||||||
@@ -205,7 +206,7 @@ describe(UserService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue user ready for deletion', async () => {
|
it('should queue user ready for deletion', async () => {
|
||||||
const user = factory.user();
|
const user = UserFactory.create();
|
||||||
mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]);
|
mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]);
|
||||||
|
|
||||||
await sut.handleUserDeleteCheck();
|
await sut.handleUserDeleteCheck();
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
|||||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
||||||
|
|
||||||
|
encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAssets = async (
|
export const addAssets = async (
|
||||||
|
|||||||
@@ -355,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||||||
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
|
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
|
||||||
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
|
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
|
||||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
||||||
.$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!))
|
.$if(!!options.encodedVideoPath, (qb) =>
|
||||||
|
qb
|
||||||
|
.innerJoin('asset_file', (join) =>
|
||||||
|
join
|
||||||
|
.onRef('asset.id', '=', 'asset_file.assetId')
|
||||||
|
.on('asset_file.type', '=', AssetFileType.EncodedVideo)
|
||||||
|
.on('asset_file.isEdited', '=', false),
|
||||||
|
)
|
||||||
|
.where('asset_file.path', '=', options.encodedVideoPath!),
|
||||||
|
)
|
||||||
.$if(!!options.originalPath, (qb) =>
|
.$if(!!options.originalPath, (qb) =>
|
||||||
qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
|
qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
|
||||||
)
|
)
|
||||||
@@ -380,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||||
.$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!))
|
.$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!))
|
||||||
.$if(options.isEncoded !== undefined, (qb) =>
|
.$if(options.isEncoded !== undefined, (qb) =>
|
||||||
qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
|
qb.where((eb) => {
|
||||||
|
const exists = eb.exists((eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('asset_file')
|
||||||
|
.whereRef('assetId', '=', 'asset.id')
|
||||||
|
.where('type', '=', AssetFileType.EncodedVideo),
|
||||||
|
);
|
||||||
|
return options.isEncoded ? exists : eb.not(exists);
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.$if(options.isMotion !== undefined, (qb) =>
|
.$if(options.isMotion !== undefined, (qb) =>
|
||||||
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
|
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||||
|
import { build } from 'test/factories/builder.factory';
|
||||||
|
import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||||
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
|
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
|
export class ActivityFactory {
|
||||||
|
#user!: UserFactory;
|
||||||
|
|
||||||
|
private constructor(private value: Selectable<ActivityTable>) {}
|
||||||
|
|
||||||
|
static create(dto: ActivityLike = {}) {
|
||||||
|
return ActivityFactory.from(dto).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(dto: ActivityLike = {}) {
|
||||||
|
const userId = dto.userId ?? newUuid();
|
||||||
|
return new ActivityFactory({
|
||||||
|
albumId: newUuid(),
|
||||||
|
assetId: null,
|
||||||
|
comment: null,
|
||||||
|
createdAt: newDate(),
|
||||||
|
id: newUuid(),
|
||||||
|
isLiked: false,
|
||||||
|
userId,
|
||||||
|
updatedAt: newDate(),
|
||||||
|
updateId: newUuidV7(),
|
||||||
|
...dto,
|
||||||
|
}).user({ id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||||
|
this.#user = build(UserFactory.from(dto), builder);
|
||||||
|
this.value.userId = this.#user.build().id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return { ...this.value, user: this.#user.build() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { Permission } from 'src/enum';
|
||||||
|
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||||
|
import { build } from 'test/factories/builder.factory';
|
||||||
|
import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||||
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
|
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
|
export class ApiKeyFactory {
|
||||||
|
#user!: UserFactory;
|
||||||
|
|
||||||
|
private constructor(private value: Selectable<ApiKeyTable>) {}
|
||||||
|
|
||||||
|
static create(dto: ApiKeyLike = {}) {
|
||||||
|
return ApiKeyFactory.from(dto).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(dto: ApiKeyLike = {}) {
|
||||||
|
const userId = dto.userId ?? newUuid();
|
||||||
|
return new ApiKeyFactory({
|
||||||
|
createdAt: newDate(),
|
||||||
|
id: newUuid(),
|
||||||
|
key: Buffer.from('api-key-buffer'),
|
||||||
|
name: 'API Key',
|
||||||
|
permissions: [Permission.All],
|
||||||
|
updatedAt: newDate(),
|
||||||
|
updateId: newUuidV7(),
|
||||||
|
userId,
|
||||||
|
...dto,
|
||||||
|
}).user({ id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||||
|
this.#user = build(UserFactory.from(dto), builder);
|
||||||
|
this.value.userId = this.#user.build().id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return { ...this.value, user: this.#user.build() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
UserLike,
|
UserLike,
|
||||||
} from 'test/factories/types';
|
} from 'test/factories/types';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
export class AssetFactory {
|
export class AssetFactory {
|
||||||
#owner!: UserFactory;
|
#owner!: UserFactory;
|
||||||
@@ -43,10 +43,12 @@ export class AssetFactory {
|
|||||||
|
|
||||||
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
|
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
|
||||||
|
|
||||||
|
let now = Date.now();
|
||||||
|
|
||||||
return new AssetFactory({
|
return new AssetFactory({
|
||||||
id,
|
id,
|
||||||
createdAt: newDate(),
|
createdAt: new Date(now++),
|
||||||
updatedAt: newDate(),
|
updatedAt: new Date(now++),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
updateId: newUuidV7(),
|
updateId: newUuidV7(),
|
||||||
status: AssetStatus.Active,
|
status: AssetStatus.Active,
|
||||||
@@ -55,15 +57,14 @@ export class AssetFactory {
|
|||||||
deviceId: '',
|
deviceId: '',
|
||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
duration: null,
|
duration: null,
|
||||||
encodedVideoPath: null,
|
fileCreatedAt: new Date(now++),
|
||||||
fileCreatedAt: newDate(),
|
fileModifiedAt: new Date(now++),
|
||||||
fileModifiedAt: newDate(),
|
|
||||||
isExternal: false,
|
isExternal: false,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
libraryId: null,
|
libraryId: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
localDateTime: newDate(),
|
localDateTime: new Date(now),
|
||||||
originalFileName,
|
originalFileName,
|
||||||
originalPath: `/data/library/${originalFileName}`,
|
originalPath: `/data/library/${originalFileName}`,
|
||||||
ownerId: newUuid(),
|
ownerId: newUuid(),
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
|
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||||
import { build } from 'test/factories/builder.factory';
|
import { build } from 'test/factories/builder.factory';
|
||||||
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
||||||
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
|
import { newUuid } from 'test/small.factory';
|
||||||
|
|
||||||
export class AuthFactory {
|
export class AuthFactory {
|
||||||
#user: UserFactory;
|
#user: UserFactory;
|
||||||
#sharedLink?: SharedLinkFactory;
|
#sharedLink?: SharedLinkFactory;
|
||||||
|
#apiKey?: ApiKeyFactory;
|
||||||
|
#session?: AuthDto['session'];
|
||||||
|
|
||||||
private constructor(user: UserFactory) {
|
private constructor(user: UserFactory) {
|
||||||
this.#user = user;
|
this.#user = user;
|
||||||
@@ -20,8 +24,8 @@ export class AuthFactory {
|
|||||||
return new AuthFactory(UserFactory.from(dto));
|
return new AuthFactory(UserFactory.from(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey() {
|
apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder<ApiKeyFactory>) {
|
||||||
// TODO
|
this.#apiKey = build(ApiKeyFactory.from(dto), builder);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +34,11 @@ export class AuthFactory {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session(dto: Partial<AuthDto['session']> = {}) {
|
||||||
|
this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto };
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
build(): AuthDto {
|
build(): AuthDto {
|
||||||
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
|
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
|
||||||
|
|
||||||
@@ -43,6 +52,8 @@ export class AuthFactory {
|
|||||||
quotaSizeInBytes,
|
quotaSizeInBytes,
|
||||||
},
|
},
|
||||||
sharedLink: this.#sharedLink?.build(),
|
sharedLink: this.#sharedLink?.build(),
|
||||||
|
apiKey: this.#apiKey?.build(),
|
||||||
|
session: this.#session,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||||
|
import { build } from 'test/factories/builder.factory';
|
||||||
|
import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types';
|
||||||
|
import { UserFactory } from 'test/factories/user.factory';
|
||||||
|
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
|
export class PartnerFactory {
|
||||||
|
#sharedWith!: UserFactory;
|
||||||
|
#sharedBy!: UserFactory;
|
||||||
|
|
||||||
|
private constructor(private value: Selectable<PartnerTable>) {}
|
||||||
|
|
||||||
|
static create(dto: PartnerLike = {}) {
|
||||||
|
return PartnerFactory.from(dto).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(dto: PartnerLike = {}) {
|
||||||
|
const sharedById = dto.sharedById ?? newUuid();
|
||||||
|
const sharedWithId = dto.sharedWithId ?? newUuid();
|
||||||
|
return new PartnerFactory({
|
||||||
|
createdAt: newDate(),
|
||||||
|
createId: newUuidV7(),
|
||||||
|
inTimeline: true,
|
||||||
|
sharedById,
|
||||||
|
sharedWithId,
|
||||||
|
updatedAt: newDate(),
|
||||||
|
updateId: newUuidV7(),
|
||||||
|
...dto,
|
||||||
|
})
|
||||||
|
.sharedBy({ id: sharedById })
|
||||||
|
.sharedWith({ id: sharedWithId });
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedWith(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||||
|
this.#sharedWith = build(UserFactory.from(dto), builder);
|
||||||
|
this.value.sharedWithId = this.#sharedWith.build().id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedBy(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||||
|
this.#sharedBy = build(UserFactory.from(dto), builder);
|
||||||
|
this.value.sharedById = this.#sharedBy.build().id;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Selectable } from 'kysely';
|
||||||
|
import { SessionTable } from 'src/schema/tables/session.table';
|
||||||
|
import { SessionLike } from 'test/factories/types';
|
||||||
|
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||||
|
|
||||||
|
export class SessionFactory {
|
||||||
|
private constructor(private value: Selectable<SessionTable>) {}
|
||||||
|
|
||||||
|
static create(dto: SessionLike = {}) {
|
||||||
|
return SessionFactory.from(dto).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(dto: SessionLike = {}) {
|
||||||
|
return new SessionFactory({
|
||||||
|
appVersion: null,
|
||||||
|
createdAt: newDate(),
|
||||||
|
deviceOS: 'android',
|
||||||
|
deviceType: 'mobile',
|
||||||
|
expiresAt: null,
|
||||||
|
id: newUuid(),
|
||||||
|
isPendingSyncReset: false,
|
||||||
|
parentId: null,
|
||||||
|
pinExpiresAt: null,
|
||||||
|
token: Buffer.from('abc123'),
|
||||||
|
updateId: newUuidV7(),
|
||||||
|
updatedAt: newDate(),
|
||||||
|
userId: newUuid(),
|
||||||
|
...dto,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
return { ...this.value };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Selectable } from 'kysely';
|
import { Selectable } from 'kysely';
|
||||||
|
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
|
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||||
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||||
|
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||||
import { PersonTable } from 'src/schema/tables/person.table';
|
import { PersonTable } from 'src/schema/tables/person.table';
|
||||||
|
import { SessionTable } from 'src/schema/tables/session.table';
|
||||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||||
import { StackTable } from 'src/schema/tables/stack.table';
|
import { StackTable } from 'src/schema/tables/stack.table';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
@@ -26,3 +30,7 @@ export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
|||||||
export type PersonLike = Partial<Selectable<PersonTable>>;
|
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||||
export type StackLike = Partial<Selectable<StackTable>>;
|
export type StackLike = Partial<Selectable<StackTable>>;
|
||||||
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||||
|
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||||
|
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||||
|
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||||
|
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ export const getForAssetDeletion = (asset: ReturnType<AssetFactory['build']>) =>
|
|||||||
libraryId: asset.libraryId,
|
libraryId: asset.libraryId,
|
||||||
ownerId: asset.ownerId,
|
ownerId: asset.ownerId,
|
||||||
livePhotoVideoId: asset.livePhotoVideoId,
|
livePhotoVideoId: asset.livePhotoVideoId,
|
||||||
encodedVideoPath: asset.encodedVideoPath,
|
|
||||||
originalPath: asset.originalPath,
|
originalPath: asset.originalPath,
|
||||||
isOffline: asset.isOffline,
|
isOffline: asset.isOffline,
|
||||||
exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null,
|
exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null,
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
|||||||
walk: vitest.fn().mockImplementation(async function* () {}),
|
walk: vitest.fn().mockImplementation(async function* () {}),
|
||||||
rename: vitest.fn(),
|
rename: vitest.fn(),
|
||||||
copyFile: vitest.fn(),
|
copyFile: vitest.fn(),
|
||||||
datasync: vitest.fn(),
|
|
||||||
utimes: vitest.fn(),
|
utimes: vitest.fn(),
|
||||||
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
|
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
import { ShallowDehydrateObject } from 'kysely';
|
import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database';
|
||||||
import {
|
|
||||||
Activity,
|
|
||||||
Album,
|
|
||||||
ApiKey,
|
|
||||||
AuthApiKey,
|
|
||||||
AuthSharedLink,
|
|
||||||
AuthUser,
|
|
||||||
Exif,
|
|
||||||
Library,
|
|
||||||
Partner,
|
|
||||||
Person,
|
|
||||||
Session,
|
|
||||||
Tag,
|
|
||||||
User,
|
|
||||||
UserAdmin,
|
|
||||||
} from 'src/database';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
|
|
||||||
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
|
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
|
||||||
import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum';
|
import { AssetFileType, Permission, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
|
||||||
import { UserFactory } from 'test/factories/user.factory';
|
|
||||||
import { v4, v7 } from 'uuid';
|
import { v4, v7 } from 'uuid';
|
||||||
|
|
||||||
export const newUuid = () => v4();
|
export const newUuid = () => v4();
|
||||||
@@ -109,49 +90,6 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
|
|||||||
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
|
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
|
||||||
};
|
};
|
||||||
|
|
||||||
const partnerFactory = ({
|
|
||||||
sharedBy: sharedByProvided,
|
|
||||||
sharedWith: sharedWithProvided,
|
|
||||||
...partner
|
|
||||||
}: Partial<Partner> = {}) => {
|
|
||||||
const hydrateUser = (user: Partial<ShallowDehydrateObject<User>>) => ({
|
|
||||||
...user,
|
|
||||||
profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined,
|
|
||||||
});
|
|
||||||
const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {});
|
|
||||||
const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {});
|
|
||||||
|
|
||||||
return {
|
|
||||||
sharedById: sharedBy.id,
|
|
||||||
sharedBy,
|
|
||||||
sharedWithId: sharedWith.id,
|
|
||||||
sharedWith,
|
|
||||||
createId: newUuidV7(),
|
|
||||||
createdAt: newDate(),
|
|
||||||
updatedAt: newDate(),
|
|
||||||
updateId: newUuidV7(),
|
|
||||||
inTimeline: true,
|
|
||||||
...partner,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const sessionFactory = (session: Partial<Session> = {}) => ({
|
|
||||||
id: newUuid(),
|
|
||||||
createdAt: newDate(),
|
|
||||||
updatedAt: newDate(),
|
|
||||||
updateId: newUuidV7(),
|
|
||||||
deviceOS: 'android',
|
|
||||||
deviceType: 'mobile',
|
|
||||||
token: Buffer.from('abc123'),
|
|
||||||
parentId: null,
|
|
||||||
expiresAt: null,
|
|
||||||
userId: newUuid(),
|
|
||||||
pinExpiresAt: newDate(),
|
|
||||||
isPendingSyncReset: false,
|
|
||||||
appVersion: session.appVersion ?? null,
|
|
||||||
...session,
|
|
||||||
});
|
|
||||||
|
|
||||||
const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
||||||
active: 0,
|
active: 0,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
@@ -162,22 +100,6 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
|||||||
...dto,
|
...dto,
|
||||||
});
|
});
|
||||||
|
|
||||||
const userFactory = (user: Partial<User> = {}) => ({
|
|
||||||
id: newUuid(),
|
|
||||||
name: 'Test User',
|
|
||||||
email: 'test@immich.cloud',
|
|
||||||
avatarColor: null,
|
|
||||||
profileImagePath: '',
|
|
||||||
profileChangedAt: newDate(),
|
|
||||||
metadata: [
|
|
||||||
{
|
|
||||||
key: UserMetadataKey.Onboarding,
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
] as UserMetadataItem[],
|
|
||||||
...user,
|
|
||||||
});
|
|
||||||
|
|
||||||
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
||||||
const {
|
const {
|
||||||
id = newUuid(),
|
id = newUuid(),
|
||||||
@@ -219,34 +141,6 @@ const userAdminFactory = (user: Partial<UserAdmin> = {}) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const activityFactory = (activity: Omit<Partial<Activity>, 'user'> = {}) => {
|
|
||||||
const userId = activity.userId || newUuid();
|
|
||||||
return {
|
|
||||||
id: newUuid(),
|
|
||||||
comment: null,
|
|
||||||
isLiked: false,
|
|
||||||
userId,
|
|
||||||
user: UserFactory.create({ id: userId }),
|
|
||||||
assetId: newUuid(),
|
|
||||||
albumId: newUuid(),
|
|
||||||
createdAt: newDate(),
|
|
||||||
updatedAt: newDate(),
|
|
||||||
updateId: newUuidV7(),
|
|
||||||
...activity,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const apiKeyFactory = (apiKey: Partial<ApiKey> = {}) => ({
|
|
||||||
id: newUuid(),
|
|
||||||
userId: newUuid(),
|
|
||||||
createdAt: newDate(),
|
|
||||||
updatedAt: newDate(),
|
|
||||||
updateId: newUuidV7(),
|
|
||||||
name: 'Api Key',
|
|
||||||
permissions: [Permission.All],
|
|
||||||
...apiKey,
|
|
||||||
});
|
|
||||||
|
|
||||||
const libraryFactory = (library: Partial<Library> = {}) => ({
|
const libraryFactory = (library: Partial<Library> = {}) => ({
|
||||||
id: newUuid(),
|
id: newUuid(),
|
||||||
createdAt: newDate(),
|
createdAt: newDate(),
|
||||||
@@ -328,88 +222,15 @@ const assetOcrFactory = (
|
|||||||
...ocr,
|
...ocr,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tagFactory = (tag: Partial<Tag>): Tag => ({
|
|
||||||
id: newUuid(),
|
|
||||||
color: null,
|
|
||||||
createdAt: newDate(),
|
|
||||||
parentId: null,
|
|
||||||
updatedAt: newDate(),
|
|
||||||
value: `tag-${newUuid()}`,
|
|
||||||
...tag,
|
|
||||||
});
|
|
||||||
|
|
||||||
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
|
|
||||||
switch (edit?.action) {
|
|
||||||
case AssetEditAction.Crop: {
|
|
||||||
return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit };
|
|
||||||
}
|
|
||||||
case AssetEditAction.Mirror: {
|
|
||||||
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit };
|
|
||||||
}
|
|
||||||
case AssetEditAction.Rotate: {
|
|
||||||
return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit };
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const personFactory = (person?: Partial<Person>): Person => ({
|
|
||||||
birthDate: newDate(),
|
|
||||||
color: null,
|
|
||||||
createdAt: newDate(),
|
|
||||||
faceAssetId: null,
|
|
||||||
id: newUuid(),
|
|
||||||
isFavorite: false,
|
|
||||||
isHidden: false,
|
|
||||||
name: 'person',
|
|
||||||
ownerId: newUuid(),
|
|
||||||
thumbnailPath: '/path/to/person/thumbnail.jpg',
|
|
||||||
updatedAt: newDate(),
|
|
||||||
updateId: newUuidV7(),
|
|
||||||
...person,
|
|
||||||
});
|
|
||||||
|
|
||||||
const albumFactory = (album?: Partial<Omit<Album, 'assets'>>) => ({
|
|
||||||
albumName: 'My Album',
|
|
||||||
albumThumbnailAssetId: null,
|
|
||||||
albumUsers: [],
|
|
||||||
assets: [],
|
|
||||||
createdAt: newDate(),
|
|
||||||
deletedAt: null,
|
|
||||||
description: 'Album description',
|
|
||||||
id: newUuid(),
|
|
||||||
isActivityEnabled: false,
|
|
||||||
order: AssetOrder.Desc,
|
|
||||||
ownerId: newUuid(),
|
|
||||||
sharedLinks: [],
|
|
||||||
updatedAt: newDate(),
|
|
||||||
updateId: newUuidV7(),
|
|
||||||
...album,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const factory = {
|
export const factory = {
|
||||||
activity: activityFactory,
|
|
||||||
apiKey: apiKeyFactory,
|
|
||||||
assetOcr: assetOcrFactory,
|
assetOcr: assetOcrFactory,
|
||||||
auth: authFactory,
|
auth: authFactory,
|
||||||
authApiKey: authApiKeyFactory,
|
|
||||||
authUser: authUserFactory,
|
|
||||||
library: libraryFactory,
|
library: libraryFactory,
|
||||||
partner: partnerFactory,
|
|
||||||
queueStatistics: queueStatisticsFactory,
|
queueStatistics: queueStatisticsFactory,
|
||||||
session: sessionFactory,
|
|
||||||
user: userFactory,
|
|
||||||
userAdmin: userAdminFactory,
|
|
||||||
versionHistory: versionHistoryFactory,
|
versionHistory: versionHistoryFactory,
|
||||||
jobAssets: {
|
jobAssets: {
|
||||||
sidecarWrite: assetSidecarWriteFactory,
|
sidecarWrite: assetSidecarWriteFactory,
|
||||||
},
|
},
|
||||||
person: personFactory,
|
|
||||||
assetEdit: assetEditFactory,
|
|
||||||
tag: tagFactory,
|
|
||||||
album: albumFactory,
|
|
||||||
uuid: newUuid,
|
uuid: newUuid,
|
||||||
buffer: () => Buffer.from('this is a fake buffer'),
|
buffer: () => Buffer.from('this is a fake buffer'),
|
||||||
date: newDate,
|
date: newDate,
|
||||||
|
|||||||
@@ -60,6 +60,8 @@
|
|||||||
"svelte-maplibre": "^1.2.5",
|
"svelte-maplibre": "^1.2.5",
|
||||||
"svelte-persisted-store": "^0.12.0",
|
"svelte-persisted-store": "^0.12.0",
|
||||||
"tabbable": "^6.2.0",
|
"tabbable": "^6.2.0",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
"thumbhash": "^0.1.1",
|
"thumbhash": "^0.1.1",
|
||||||
"transformation-matrix": "^3.1.0",
|
"transformation-matrix": "^3.1.0",
|
||||||
"uplot": "^1.6.32"
|
"uplot": "^1.6.32"
|
||||||
|
|||||||
@@ -23,7 +23,25 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
|||||||
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
node.addEventListener('wheel', onInteractionStart, { capture: true });
|
||||||
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
node.addEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||||
|
|
||||||
|
// Suppress Safari's synthetic dblclick on double-tap. Without this, zoom-image's touchstart
|
||||||
|
// handler zooms to maxZoom (10x), then Safari's synthetic dblclick triggers photo-viewer's
|
||||||
|
// handler which conflicts. Chrome does not fire synthetic dblclick on touch.
|
||||||
|
let lastPointerWasTouch = false;
|
||||||
|
const trackPointerType = (event: PointerEvent) => {
|
||||||
|
lastPointerWasTouch = event.pointerType === 'touch';
|
||||||
|
};
|
||||||
|
const suppressTouchDblClick = (event: MouseEvent) => {
|
||||||
|
if (lastPointerWasTouch) {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
node.addEventListener('pointerdown', trackPointerType, { capture: true });
|
||||||
|
node.addEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||||
|
|
||||||
|
// Allow zoomed content to render outside the container bounds
|
||||||
node.style.overflow = 'visible';
|
node.style.overflow = 'visible';
|
||||||
|
// Prevent browser handling of touch gestures so zoom-image can manage them
|
||||||
|
node.style.touchAction = 'none';
|
||||||
return {
|
return {
|
||||||
update(newOptions?: { disabled?: boolean }) {
|
update(newOptions?: { disabled?: boolean }) {
|
||||||
options = newOptions;
|
options = newOptions;
|
||||||
@@ -34,6 +52,8 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
|
|||||||
}
|
}
|
||||||
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
node.removeEventListener('wheel', onInteractionStart, { capture: true });
|
||||||
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
|
||||||
|
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
|
||||||
|
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
|
||||||
zoomInstance.cleanup();
|
zoomInstance.cleanup();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -162,8 +162,9 @@
|
|||||||
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
|
||||||
{@render backdrop?.()}
|
{@render backdrop?.()}
|
||||||
|
|
||||||
|
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0"
|
class="absolute inset-0 pointer-events-none"
|
||||||
style:transform={zoomTransform}
|
style:transform={zoomTransform}
|
||||||
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
style:transform-origin={zoomTransform ? '0 0' : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { cleanClass } from '$lib';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { class: className = '' }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
<div class={cleanClass('absolute h-full w-full bg-gray-300 dark:bg-gray-700', className)}></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { cleanClass } from '$lib';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
let { class: className }: Props = $props();
|
let { class: className }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="delayed inline-flex items-center gap-1 {className}">
|
<div class={cleanClass('delayed inline-flex items-center gap-1', className)}>
|
||||||
{#each [0, 1, 2] as i (i)}
|
{#each [0, 1, 2] as i (i)}
|
||||||
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
<span class="dot block size-1.5 rounded-full bg-white shadow-[0_0_3px_rgba(0,0,0,0.6)]" style:--delay="{i * 0.25}s"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { cleanClass } from '$lib';
|
||||||
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
|
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
|
||||||
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
|
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
|
||||||
import Badge from '$lib/elements/Badge.svelte';
|
import Badge from '$lib/elements/Badge.svelte';
|
||||||
@@ -105,7 +106,10 @@
|
|||||||
|
|
||||||
<div class="mt-2 flex w-full max-w-md flex-col sm:flex-row">
|
<div class="mt-2 flex w-full max-w-md flex-col sm:flex-row">
|
||||||
<div
|
<div
|
||||||
class="{commonClasses} rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-s-lg sm:rounded-e-none"
|
class={cleanClass(
|
||||||
|
commonClasses,
|
||||||
|
'rounded-t-lg bg-immich-primary text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray sm:rounded-s-lg sm:rounded-e-none',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<p>{$t('active')}</p>
|
<p>{$t('active')}</p>
|
||||||
<p class="text-2xl">
|
<p class="text-2xl">
|
||||||
@@ -114,7 +118,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="{commonClasses} flex-row-reverse rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg"
|
class={cleanClass(
|
||||||
|
commonClasses,
|
||||||
|
'flex-row-reverse rounded-b-lg bg-gray-200 text-immich-dark-bg dark:bg-gray-700 dark:text-immich-gray sm:rounded-s-none sm:rounded-e-lg',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<p class="text-2xl">
|
<p class="text-2xl">
|
||||||
{waitingCount.toLocaleString($locale)}
|
{waitingCount.toLocaleString($locale)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user