Compare commits

..

2 Commits

Author SHA1 Message Date
Thomas Way fbdbd291ba fix(server): sync all written files to disk
Add flush/fsync to all file write paths including thumbnails, transcoded
videos, exif writes, file copies, and backups.
2026-03-15 11:57:12 +00:00
Thomas Way 0ab057f453 fix(server): sync files to disk
Ensure that all files are flushed after they've been written.

At current, files are not explicitly flushed to disk, which can cause
data corruption. In extreme circumstances, it's possible that uploaded
files may not ever be persisted at all.
2026-03-15 11:54:20 +00:00
132 changed files with 1004 additions and 1652 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
-80
View File
@@ -1,80 +0,0 @@
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
}
}'
+2 -2
View File
@@ -42,10 +42,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
languages: ${{ matrix.language }}
# 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).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
# ️ 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
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -67,10 +67,10 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'
+2 -2
View File
@@ -29,10 +29,10 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+3 -3
View File
@@ -63,13 +63,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+2 -2
View File
@@ -30,10 +30,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'
+27 -27
View File
@@ -75,9 +75,9 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -119,9 +119,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -166,9 +166,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -208,9 +208,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -252,9 +252,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -290,9 +290,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -338,9 +338,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -385,9 +385,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -424,9 +424,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -496,9 +496,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
with:
python-version: 3.11
- name: Install dependencies
@@ -661,9 +661,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -712,9 +712,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -774,9 +774,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
+4 -25
View File
@@ -10,7 +10,6 @@ export enum OAuthClient {
export enum OAuthUser {
NO_EMAIL = 'no-email',
NO_NAME = 'no-name',
ID_TOKEN_CLAIMS = 'id-token-claims',
WITH_QUOTA = 'with-quota',
WITH_USERNAME = 'with-username',
WITH_ROLE = 'with-role',
@@ -53,25 +52,12 @@ const withDefaultClaims = (sub: string) => ({
email_verified: true,
});
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 getClaims = (sub: string) => claims.find((user) => user.sub === sub) || withDefaultClaims(sub);
const setup = async () => {
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 host = '0.0.0.0';
const oidc = new Provider(`http://${host}:${port}`, {
@@ -80,10 +66,7 @@ const setup = async () => {
console.error(error);
ctx.body = 'Internal Server Error';
},
findAccount: (ctx, sub) => ({
accountId: sub,
claims: (use) => getClaims(sub, use),
}),
findAccount: (ctx, sub) => ({ accountId: sub, claims: () => getClaims(sub) }),
scopes: ['openid', 'email', 'profile'],
claims: {
openid: ['sub'],
@@ -111,7 +94,6 @@ const setup = async () => {
state: 'oidc.state',
},
},
conformIdTokenClaims: false,
pkce: {
required: () => false,
},
@@ -143,10 +125,7 @@ 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);
return () => app.close();
};
@@ -380,23 +380,4 @@ 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,16 +438,6 @@ describe('/shared-links', () => {
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 () => {
const { status, body } = await request(app)
.delete(`/shared-links/${linkWithAssets.id}/assets`)
-24
View File
@@ -12,18 +12,15 @@ import { asBearerAuth, utils } from 'src/utils';
test.describe('Shared Links', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let asset2: AssetMediaResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
let individualSharedLink: SharedLinkResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
asset2 = await utils.createAsset(admin.accessToken);
album = await createAlbum(
{
createAlbumDto: {
@@ -42,10 +39,6 @@ test.describe('Shared Links', () => {
albumId: album.id,
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 }) => {
@@ -116,21 +109,4 @@ test.describe('Shared Links', () => {
await page.waitForURL('/photos');
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);
});
});
-1
View File
@@ -1651,7 +1651,6 @@
"only_favorites": "Only favorites",
"open": "Open",
"open_calendar": "Open calendar",
"open_in_browser": "Open in browser",
"open_in_map_view": "Open in map view",
"open_in_openstreetmap": "Open in OpenStreetMap",
"open_the_search_filters": "Open the search filters",
-2
View File
@@ -113,8 +113,6 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "com.squareup.okhttp3:okhttp:$okhttp_version"
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 "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.concurrent:concurrent-futures:$concurrent_version"
@@ -12,7 +12,6 @@ import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
@@ -32,7 +31,6 @@ class MainActivity : FlutterFragmentActivity() {
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
HttpClientManager.initialize(ctx)
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger
@@ -3,13 +3,7 @@ package app.alextran.immich.core
import android.content.Context
import android.content.SharedPreferences
import android.security.KeyChain
import androidx.annotation.OptIn
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.NativeBuffer
import okhttp3.Cache
@@ -22,22 +16,15 @@ import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import org.chromium.net.CronetEngine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.File
import java.net.Authenticator
import java.net.CookieHandler
import java.net.PasswordAuthentication
import java.net.Socket
import java.net.URI
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
@@ -69,7 +56,6 @@ private enum class AuthCookie(val cookieName: String, val httpOnly: Boolean) {
*/
object HttpClientManager {
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_DURATION_MINUTES = 5L
private const val MAX_REQUESTS_PER_HOST = 64
@@ -81,11 +67,6 @@ object HttpClientManager {
private lateinit var appContext: Context
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) }
var keyChainAlias: String? = null
@@ -108,25 +89,6 @@ object HttpClientManager {
keyChainAlias = prefs.getString(PREFS_CERT_ALIAS, null)
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)
if (savedHeaders != null) {
@@ -145,10 +107,6 @@ object HttpClientManager {
val cacheDir = File(File(context.cacheDir, "okhttp"), "api")
client = build(cacheDir)
cronetStorageDir = File(context.cacheDir, "cronet").apply { mkdirs() }
cronetEngine = buildCronetEngine()
initialized = true
}
}
@@ -265,53 +223,6 @@ object HttpClientManager {
?.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 {
val connectionPool = ConnectionPool(
maxIdleConnections = KEEP_ALIVE_CONNECTIONS,
@@ -7,6 +7,7 @@ import app.alextran.immich.INITIAL_BUFFER_SIZE
import app.alextran.immich.NativeBuffer
import app.alextran.immich.NativeByteBuffer
import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.USER_AGENT
import kotlinx.coroutines.*
import okhttp3.Cache
import okhttp3.Call
@@ -14,6 +15,9 @@ import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
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.UrlRequest
import org.chromium.net.UrlResponseInfo
@@ -27,6 +31,10 @@ import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
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)
@@ -93,6 +101,7 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
}
private object ImageFetcherManager {
private lateinit var appContext: Context
private lateinit var cacheDir: File
private lateinit var fetcher: ImageFetcher
private var initialized = false
@@ -101,6 +110,7 @@ private object ImageFetcherManager {
if (initialized) return
synchronized(this) {
if (initialized) return
appContext = context.applicationContext
cacheDir = context.cacheDir
fetcher = build()
HttpClientManager.addClientChangedListener(::invalidate)
@@ -133,7 +143,7 @@ private object ImageFetcherManager {
return if (HttpClientManager.isMtls) {
OkHttpImageFetcher.create(cacheDir)
} else {
CronetImageFetcher()
CronetImageFetcher(appContext, cacheDir)
}
}
}
@@ -151,11 +161,19 @@ private sealed interface ImageFetcher {
fun clearCache(onCleared: (Result<Long>) -> Unit)
}
private class CronetImageFetcher : ImageFetcher {
private class CronetImageFetcher(context: Context, cacheDir: File) : ImageFetcher {
private val ctx = context
private var engine: CronetEngine
private val executor = Executors.newFixedThreadPool(4)
private val stateLock = Any()
private var activeCount = 0
private var draining = false
private var onCacheCleared: ((Result<Long>) -> Unit)? = null
private val storageDir = File(cacheDir, "cronet").apply { mkdirs() }
init {
engine = build(context)
}
override fun fetch(
url: String,
@@ -172,16 +190,30 @@ private class CronetImageFetcher : ImageFetcher {
}
val callback = FetchCallback(onSuccess, onFailure, ::onComplete)
val requestBuilder = HttpClientManager.cronetEngine!!
.newUrlRequestBuilder(url, callback, HttpClientManager.cronetExecutor)
HttpClientManager.getAuthHeaders(url).forEach { (key, value) ->
requestBuilder.addHeader(key, value)
val requestBuilder = engine.newUrlRequestBuilder(url, callback, executor)
HttpClientManager.headers.forEach { (key, value) -> requestBuilder.addHeader(key, value) }
HttpClientManager.loadCookieHeader(url)?.let { requestBuilder.addHeader("Cookie", it) }
url.toHttpUrlOrNull()?.let { httpUrl ->
if (httpUrl.username.isNotEmpty()) {
requestBuilder.addHeader("Authorization", Credentials.basic(httpUrl.username, httpUrl.password))
}
}
val request = requestBuilder.build()
signal.setOnCancelListener(request::cancel)
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() {
val didDrain = synchronized(stateLock) {
activeCount--
@@ -204,16 +236,19 @@ private class CronetImageFetcher : ImageFetcher {
}
private fun onDrained() {
engine.shutdown()
val onCacheCleared = synchronized(stateLock) {
val onCacheCleared = onCacheCleared
this.onCacheCleared = null
onCacheCleared
}
if (onCacheCleared != null) {
val oldEngine = HttpClientManager.rebuildCronetEngine()
oldEngine.shutdown()
if (onCacheCleared == null) {
executor.shutdown()
} else {
CoroutineScope(Dispatchers.IO).launch {
val result = runCatching { deleteFolderAndGetSize(HttpClientManager.cronetStoragePath.toPath()) }
val result = runCatching { deleteFolderAndGetSize(storageDir.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 }
onCacheCleared(result)
}
@@ -340,7 +375,7 @@ private class OkHttpImageFetcher private constructor(
val dir = File(cacheDir, "okhttp")
val client = HttpClientManager.getClient().newBuilder()
.cache(Cache(File(dir, "thumbnails"), HttpClientManager.MEDIA_CACHE_SIZE_BYTES))
.cache(Cache(File(dir, "thumbnails"), CACHE_SIZE_BYTES))
.build()
return OkHttpImageFetcher(client)
-3
View File
@@ -1,6 +1,5 @@
import BackgroundTasks
import Flutter
import native_video_player
import network_info_plus
import path_provider_foundation
import permission_handler_apple
@@ -19,8 +18,6 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
}
SwiftNativeVideoPlayerPlugin.cookieStorage = URLSessionManager.cookieStorage
URLSessionManager.patchBackgroundDownloader()
GeneratedPluginRegistrant.register(with: self)
let controller: FlutterViewController = window?.rootViewController as! FlutterViewController
AppDelegate.registerPlugins(with: controller.engine, controller: controller)
+6 -49
View File
@@ -51,7 +51,7 @@ class URLSessionManager: NSObject {
diskCapacity: 1024 * 1024 * 1024,
directory: cacheDir
)
static let userAgent: String = {
private static let userAgent: String = {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
return "Immich_iOS_\(version)"
}()
@@ -158,49 +158,6 @@ class URLSessionManager: NSObject {
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 {
@@ -211,7 +168,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) {
handleChallenge(session, challenge, completionHandler)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
@@ -220,7 +177,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
) {
handleChallenge(session, challenge, completionHandler, task: task)
}
func handleChallenge(
_ session: URLSession,
_ challenge: URLAuthenticationChallenge,
@@ -233,7 +190,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
default: completionHandler(.performDefaultHandling, nil)
}
}
private func handleClientCertificate(
_ session: URLSession,
completion: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
@@ -243,7 +200,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
kSecAttrLabel as String: CLIENT_CERT_LABEL,
kSecReturnRef as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecSuccess, let identity = item {
@@ -257,7 +214,7 @@ class URLSessionManagerDelegate: NSObject, URLSessionTaskDelegate, URLSessionWeb
}
completion(.performDefaultHandling, nil)
}
private func handleBasicAuth(
_ session: URLSession,
task: URLSessionTask?,
@@ -357,12 +357,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
completionHandler: { error in
let result: HashResult? = switch (error) {
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(
assetId: asset.localIdentifier,
error: "Failed to hash asset: \(e.localizedDescription)",
+1 -7
View File
@@ -109,11 +109,9 @@ class HashService {
_log.fine("Hashing ${toHash.length} files");
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(
toHash.keys.toList(),
allowNetworkAccess: false,
allowNetworkAccess: album.backupSelection == BackupSelection.selected,
);
assert(
hashResults.length == toHash.length,
@@ -129,10 +127,6 @@ class HashService {
final hashResult = hashResults[i];
if (hashResult.hash != null) {
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 {
final asset = toHash[hashResult.assetId];
_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 {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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()});
if (context.mounted) {
@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
if (confirm != true) return;
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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()});
if (context.mounted) {
@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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(
context: context,
args: {'count': result.count.toString()},
@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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(
context: context,
args: {'count': result.count.toString()},
@@ -1,61 +0,0 @@
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,
);
}
}
@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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(
context: context,
args: {'count': result.count.toString()},
@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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()});
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 {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
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()});
if (context.mounted) {
@@ -81,17 +81,19 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) return;
final target = page + direction;
final maxPage = _totalAssets - 1;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target);
_onAssetChanged(target);
}
@@ -139,6 +141,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
@@ -150,9 +153,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetChanged(int index) async {
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null) return;
AssetViewer._setAsset(ref, asset);
@@ -191,20 +193,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
case TimelineReloadEvent():
_onTimelineReloadEvent();
case ViewerReloadAssetEvent():
_onViewerReloadEvent();
_assetReloadRequested = true;
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() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
@@ -214,24 +207,43 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
if (index != _currentPage) {
if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
_pageController.jumpToPage(index);
_onAssetChanged(index);
} else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index);
}
if (_totalAssets != totalAssets) {
setState(() {
_totalAssets = totalAssets;
});
if (_assetReloadRequested) {
_assetReloadRequested = false;
_onAssetReloadEvent(index);
}
}
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) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
@@ -289,7 +301,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: _totalAssets,
itemCount: ref.read(timelineServiceProvider).totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),
@@ -136,10 +136,7 @@ class UploadRepository {
try {
final responseBody = jsonDecode(responseBodyString);
return UploadResult.success(
remoteAssetId: responseBody['id'] as String,
checksum: responseBody['checksum'] as String?,
);
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
} catch (e) {
return UploadResult.error(errorMessage: 'Failed to parse server response');
}
@@ -185,7 +182,6 @@ class UploadResult {
final bool isSuccess;
final bool isCancelled;
final String? remoteAssetId;
final String? checksum;
final String? errorMessage;
final int? statusCode;
@@ -193,13 +189,12 @@ class UploadResult {
required this.isSuccess,
required this.isCancelled,
this.remoteAssetId,
this.checksum,
this.errorMessage,
this.statusCode,
});
factory UploadResult.success({required String remoteAssetId, String? checksum}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId, checksum: checksum);
factory UploadResult.success({required String remoteAssetId}) {
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
}
factory UploadResult.error({String? errorMessage, int? statusCode}) {
+4
View File
@@ -176,6 +176,10 @@ class ApiService {
if (serverEndpoint != null && serverEndpoint.isNotEmpty) {
urls.add(serverEndpoint);
}
final serverUrl = Store.tryGet(StoreKey.serverUrl);
if (serverUrl != null && serverUrl.isNotEmpty) {
urls.add(serverUrl);
}
final localEndpoint = Store.tryGet(StoreKey.localEndpoint);
if (localEndpoint != null && localEndpoint.isNotEmpty) {
urls.add(localEndpoint);
@@ -162,7 +162,7 @@ class BackgroundUploadService {
await _storageRepository.clearCache();
shouldAbortQueuingTasks = false;
final candidates = await _backupRepository.getCandidates(userId, onlyHashed: false);
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
_logger.info("No new backup candidates found, finishing background upload");
return;
@@ -210,7 +210,6 @@ class BackgroundUploadService {
switch (update.status) {
case TaskStatus.complete:
unawaited(_handleLivePhoto(update));
unawaited(_storeServerChecksum(update));
if (CurrentPlatform.isIOS) {
try {
@@ -228,20 +227,6 @@ 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 {
try {
if (update.task.metaData.isEmpty || update.task.metaData == '') {
@@ -11,11 +11,9 @@ import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/network_capability_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/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/platform/connectivity_api.g.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/storage.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -39,7 +37,6 @@ final foregroundUploadServiceProvider = Provider((ref) {
return ForegroundUploadService(
ref.watch(uploadRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(localAssetRepository),
ref.watch(backupRepositoryProvider),
ref.watch(connectivityApiProvider),
ref.watch(appSettingsServiceProvider),
@@ -56,7 +53,6 @@ class ForegroundUploadService {
ForegroundUploadService(
this._uploadRepository,
this._storageRepository,
this._localAssetRepository,
this._backupRepository,
this._connectivityApi,
this._appSettingsService,
@@ -65,7 +61,6 @@ class ForegroundUploadService {
final UploadRepository _uploadRepository;
final StorageRepository _storageRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftBackupRepository _backupRepository;
final ConnectivityApi _connectivityApi;
final AppSettingsService _appSettingsService;
@@ -89,7 +84,7 @@ class ForegroundUploadService {
UploadCallbacks callbacks = const UploadCallbacks(),
bool useSequentialUpload = false,
}) async {
final candidates = await _backupRepository.getCandidates(userId, onlyHashed: false);
final candidates = await _backupRepository.getCandidates(userId);
if (candidates.isEmpty) {
return;
}
@@ -392,10 +387,6 @@ class ForegroundUploadService {
);
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!);
} else if (result.isCancelled) {
_logger.warning(() => "Backup was cancelled by the user");
-10
View File
@@ -18,7 +18,6 @@ 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/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/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_lock_folder_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/set_album_cover.widget.dart';
@@ -76,7 +75,6 @@ enum ActionButtonType {
viewInTimeline,
download,
upload,
openInBrowser,
unstack,
archive,
unarchive,
@@ -151,7 +149,6 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.isStacked,
ActionButtonType.openInBrowser => context.asset.hasRemote && !context.isInLockedView,
ActionButtonType.likeActivity =>
!context.isInLockedView &&
context.currentAlbum != null &&
@@ -239,13 +236,6 @@ enum ActionButtonType {
),
ActionButtonType.likeActivity => LikeActivityActionButton(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(
assetId: (context.asset as RemoteAsset).id,
iconOnly: iconOnly,
+18 -3
View File
@@ -427,7 +427,11 @@ class SharedLinksApi {
/// * [String] id (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
final apiPath = r'/shared-links/{id}/assets'
.replaceAll('{id}', id);
@@ -439,6 +443,13 @@ class SharedLinksApi {
final headerParams = <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'];
@@ -462,8 +473,12 @@ class SharedLinksApi {
/// * [String] id (required):
///
/// * [AssetIdsDto] assetIdsDto (required):
Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto,) async {
final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,);
///
/// * [String] key:
///
/// * [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) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+6 -6
View File
@@ -1194,10 +1194,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1218,8 +1218,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
resolved-ref: cdf621bdb7edaf996e118a58a48f6441187d79c6
ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
resolved-ref: "0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2"
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
@@ -1897,10 +1897,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
thumbhash:
dependency: "direct main"
description:
+1 -1
View File
@@ -56,7 +56,7 @@ dependencies:
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: 'cdf621bdb7edaf996e118a58a48f6441187d79c6'
ref: '0a80cd0bd3ff61790d1e05ef15baa7cbe26264d2'
network_info_plus: ^6.1.3
octo_image: ^2.1.0
openapi:
+16 -1
View File
@@ -11605,6 +11605,22 @@
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
@@ -11661,7 +11677,6 @@
"state": "Stable"
}
],
"x-immich-permission": "sharedLink.update",
"x-immich-state": "Stable"
},
"put": {
+7 -2
View File
@@ -5987,14 +5987,19 @@ export function updateSharedLink({ id, sharedLinkEditDto }: {
/**
* Remove assets from a shared link
*/
export function removeSharedLinkAssets({ id, assetIdsDto }: {
export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: {
id: string;
key?: string;
slug?: string;
assetIdsDto: AssetIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetIdsResponseDto[];
}>(`/shared-links/${encodeURIComponent(id)}/assets`, oazapfts.json({
}>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.json({
...opts,
method: "DELETE",
body: assetIdsDto
+7 -13
View File
@@ -845,12 +845,6 @@ importers:
tabbable:
specifier: ^6.2.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:
specifier: ^0.1.1
version: 0.1.1
@@ -11258,8 +11252,8 @@ packages:
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
tailwind-variants@3.2.2:
resolution: {integrity: sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==}
@@ -14965,8 +14959,8 @@ snapshots:
simple-icons: 16.9.0
svelte: 5.53.7
svelte-highlight: 7.9.0
tailwind-merge: 3.5.0
tailwind-variants: 3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1)
tailwind-merge: 3.4.0
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1)
tailwindcss: 4.2.1
transitivePeerDependencies:
- '@sveltejs/kit'
@@ -24560,13 +24554,13 @@ snapshots:
tabbable@6.4.0: {}
tailwind-merge@3.5.0: {}
tailwind-merge@3.4.0: {}
tailwind-variants@3.2.2(tailwind-merge@3.5.0)(tailwindcss@4.2.1):
tailwind-variants@3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.2.1):
dependencies:
tailwindcss: 4.2.1
optionalDependencies:
tailwind-merge: 3.5.0
tailwind-merge: 3.4.0
tailwindcss-email-variants@3.0.5(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
@@ -1,8 +1,7 @@
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { Permission, SharedLinkType } from 'src/enum';
import { SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service';
import request from 'supertest';
import { factory } from 'test/small.factory';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(SharedLinkController.name, () => {
@@ -32,16 +31,4 @@ describe(SharedLinkController.name, () => {
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')
@Authenticated({ permission: Permission.SharedLinkUpdate })
@Authenticated({ sharedLink: true })
@Endpoint({
summary: 'Remove assets from a shared link',
description:
+15 -10
View File
@@ -154,11 +154,10 @@ export class StorageCore {
}
async moveAssetVideo(asset: StorageAsset) {
const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
return this.moveFile({
entityId: asset.id,
pathType: AssetPathType.EncodedVideo,
oldPath: encodedVideoFile?.path || null,
oldPath: asset.encodedVideoPath,
newPath: StorageCore.getEncodedVideoPath(asset),
});
}
@@ -304,15 +303,21 @@ export class StorageCore {
case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath });
}
case AssetFileType.FullSize:
case AssetFileType.EncodedVideo:
case AssetFileType.Thumbnail:
case AssetFileType.Preview:
case AssetFileType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
case AssetFileType.FullSize: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
}
case AssetFileType.Preview: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
}
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: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
}
case PersonPathType.Face: {
return this.personRepository.update({ id, thumbnailPath: newPath });
}
+1
View File
@@ -154,6 +154,7 @@ export type StorageAsset = {
id: string;
ownerId: string;
files: AssetFile[];
encodedVideoPath: string | null;
};
export type Stack = {
@@ -11,8 +11,6 @@ export class AssetMediaResponseDto {
status!: AssetMediaStatus;
@ApiProperty({ description: 'Asset media ID' })
id!: string;
@ApiPropertyOptional({ description: 'Asset checksum (SHA1 base64)' })
checksum?: string;
}
export enum AssetUploadAction {
+1
View File
@@ -153,6 +153,7 @@ export type MapAsset = {
duplicateId: string | null;
duration: string | null;
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
encodedVideoPath: string | null;
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
faces?: ShallowDehydrateObject<AssetFace>[];
fileCreatedAt: Date;
-1
View File
@@ -45,7 +45,6 @@ export enum AssetFileType {
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
EncodedVideo = 'encoded_video',
}
export enum AlbumUserRole {
@@ -3,10 +3,8 @@ import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { NextFunction, RequestHandler } from 'express';
import multer from 'multer';
import multer, { StorageEngine, diskStorage } from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { pipeline } from 'node:stream';
import { Observable } from 'rxjs';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
@@ -29,6 +27,8 @@ export function getFiles(files: UploadFiles) {
};
}
type DiskStorageCallback = (error: Error | null, result: string) => void;
type ImmichMulterFile = Express.Multer.File & { uuid: string };
interface Callback<T> {
@@ -36,12 +36,21 @@ interface Callback<T> {
(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()
export class FileUploadInterceptor implements NestInterceptor {
private handlers: {
userProfile: RequestHandler;
assetUpload: RequestHandler;
};
private defaultStorage: StorageEngine;
constructor(
private reflect: Reflector,
@@ -51,6 +60,11 @@ export class FileUploadInterceptor implements NestInterceptor {
) {
this.logger.setContext(FileUploadInterceptor.name);
this.defaultStorage = diskStorage({
filename: this.filename.bind(this),
destination: this.destination.bind(this),
});
const instance = multer({
fileFilter: this.fileFilter.bind(this),
storage: {
@@ -87,60 +101,77 @@ export class FileUploadInterceptor implements NestInterceptor {
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
try {
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
} catch (error: Error | any) {
callback(error);
}
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
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>>) {
(file as ImmichMulterFile).uuid = randomUUID();
request.on('error', (error) => {
this.logger.warn('Request error while uploading file, cleaning up', error);
this.assetService.onUploadError(request, file).catch(this.logger.error);
});
try {
(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 (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
hash?.destroy();
return callback(error);
}
callback(null, {
path,
size,
checksum: hash?.digest(),
});
// Multer does not sync files to disk after writing.
//
// TODO: use `flush: true` in multer when available: https://github.com/expressjs/multer/issues/1381
this.storageRepository
.datasync(info!.path!)
.then(() => callback(null, info!))
.catch((error) => callback(error));
});
} catch (error: Error | any) {
callback(error);
return;
}
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) {
this.storageRepository
.unlink(file.path)
.then(() => callback(null))
.catch(callback);
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(request, file, callback);
}
private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: {
return true;
}
}
return false;
}
private getHandler(route: RouteKey) {
+9 -27
View File
@@ -175,6 +175,7 @@ where
select
"asset"."id",
"asset"."ownerId",
"asset"."encodedVideoPath",
(
select
coalesce(json_agg(agg), '[]')
@@ -462,6 +463,7 @@ select
"asset"."libraryId",
"asset"."ownerId",
"asset"."livePhotoVideoId",
"asset"."encodedVideoPath",
"asset"."originalPath",
"asset"."isOffline",
to_json("asset_exif") as "exifInfo",
@@ -519,17 +521,12 @@ select
from
"asset"
where
"asset"."type" = 'VIDEO'
and not exists (
select
"asset_file"."id"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = 'encoded_video'
"asset"."type" = $1
and (
"asset"."encodedVideoPath" is null
or "asset"."encodedVideoPath" = $2
)
and "asset"."visibility" != 'hidden'
and "asset"."visibility" != $3
and "asset"."deletedAt" is null
-- AssetJobRepository.getForVideoConversion
@@ -537,27 +534,12 @@ select
"asset"."id",
"asset"."ownerId",
"asset"."originalPath",
(
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"
"asset"."encodedVideoPath"
from
"asset"
where
"asset"."id" = $1
and "asset"."type" = 'VIDEO'
and "asset"."type" = $2
-- AssetJobRepository.streamForMetadataExtraction
select
+4 -12
View File
@@ -629,21 +629,13 @@ order by
-- AssetRepository.getForVideo
select
"asset"."originalPath",
(
select
"asset_file"."path"
from
"asset_file"
where
"asset_file"."assetId" = "asset"."id"
and "asset_file"."type" = $1
) as "encodedVideoPath"
"asset"."encodedVideoPath",
"asset"."originalPath"
from
"asset"
where
"asset"."id" = $2
and "asset"."type" = $3
"asset"."id" = $1
and "asset"."type" = $2
-- AssetRepository.getForOcr
select
@@ -104,7 +104,7 @@ export class AssetJobRepository {
getForMigrationJob(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId'])
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
.select(withFiles)
.where('asset.id', '=', id)
.executeTakeFirst();
@@ -268,6 +268,7 @@ export class AssetJobRepository {
'asset.libraryId',
'asset.ownerId',
'asset.livePhotoVideoId',
'asset.encodedVideoPath',
'asset.originalPath',
'asset.isOffline',
])
@@ -309,21 +310,11 @@ export class AssetJobRepository {
return this.db
.selectFrom('asset')
.select(['asset.id'])
.where('asset.type', '=', sql.lit(AssetType.Video))
.where('asset.type', '=', AssetType.Video)
.$if(!force, (qb) =>
qb
.where((eb) =>
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((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
.where('asset.visibility', '!=', AssetVisibility.Hidden),
)
.where('asset.deletedAt', 'is', null)
.stream();
@@ -333,10 +324,9 @@ export class AssetJobRepository {
getForVideoConversion(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
.select(withFiles)
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
.where('asset.id', '=', id)
.where('asset.type', '=', sql.lit(AssetType.Video))
.where('asset.type', '=', AssetType.Video)
.executeTakeFirst();
}
+3 -18
View File
@@ -36,7 +36,6 @@ import {
withExif,
withFaces,
withFacesAndPeople,
withFilePath,
withFiles,
withLibrary,
withOwner,
@@ -1020,21 +1019,8 @@ export class AssetRepository {
.execute();
}
async deleteFile({
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 deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
}
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
@@ -1153,8 +1139,7 @@ export class AssetRepository {
async getForVideo(id: string) {
return this.db
.selectFrom('asset')
.select(['asset.originalPath'])
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
.select(['asset.encodedVideoPath', 'asset.originalPath'])
.where('asset.id', '=', id)
.where('asset.type', '=', AssetType.Video)
.executeTakeFirst();
@@ -431,6 +431,7 @@ export class DatabaseRepository {
.updateTable('asset')
.set((eb) => ({
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
}))
.execute();
@@ -162,7 +162,6 @@ export class EmailRepository {
host: options.host,
port: options.port,
tls: { rejectUnauthorized: !options.ignoreCert },
secure: options.secure,
auth:
options.username || options.password
? {
@@ -6,6 +6,7 @@ import { SourceType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { MediaRepository } from 'src/repositories/media.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { automock } from 'test/utils';
@@ -65,8 +66,11 @@ describe(MediaRepository.name, () => {
let sut: MediaRepository;
beforeEach(() => {
// eslint-disable-next-line no-sparse-arrays
sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
sut = new MediaRepository(
// eslint-disable-next-line no-sparse-arrays
automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }),
automock(StorageRepository, { args: [{ setContext: () => {} }], strict: false }),
);
});
describe('applyEdits (single actions)', () => {
+16 -4
View File
@@ -10,6 +10,7 @@ import { Exif } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import {
DecodeToBufferOptions,
GenerateThumbhashOptions,
@@ -45,7 +46,10 @@ export type ExtractResult = {
@Injectable()
export class MediaRepository {
constructor(private logger: LoggingRepository) {
constructor(
private logger: LoggingRepository,
private storageRepository: StorageRepository,
) {
this.logger.setContext(MediaRepository.name);
}
@@ -116,6 +120,7 @@ export class MediaRepository {
ignoreMinorErrors: true,
writeArgs: ['-overwrite_original'],
});
await this.storageRepository.datasync(output);
return true;
} catch (error: any) {
this.logger.warn(`Could not write exif data to image: ${error.message}`);
@@ -133,6 +138,7 @@ export class MediaRepository {
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
},
);
await this.storageRepository.datasync(target);
return true;
} catch (error: any) {
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
@@ -180,6 +186,7 @@ export class MediaRepository {
});
await decoded.toFile(output);
await this.storageRepository.datasync(output);
}
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
@@ -274,14 +281,18 @@ export class MediaRepository {
};
}
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
async transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
this.configureFfmpegCall(input, output, options)
.on('error', reject)
.on('end', () => resolve())
.run();
});
if (typeof output === 'string') {
await this.storageRepository.datasync(output);
}
return;
}
if (typeof output !== 'string') {
@@ -290,7 +301,7 @@ export class MediaRepository {
// two-pass allows for precise control of bitrate at the cost of running twice
// recommended for vp9 for better quality and compression
return new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
// first pass output is not saved as only the .log file is needed
this.configureFfmpegCall(input, '/dev/null', options)
.addOptions('-pass', '1')
@@ -310,6 +321,7 @@ export class MediaRepository {
})
.run();
});
await this.storageRepository.datasync(output);
}
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { mimeTypes } from 'src/utils/mime-types';
interface ExifDuration {
@@ -94,7 +95,10 @@ export class MetadataRepository {
taskTimeoutMillis: 2 * 60 * 1000,
});
constructor(private logger: LoggingRepository) {
constructor(
private logger: LoggingRepository,
private storageRepository: StorageRepository,
) {
this.logger.setContext(MetadataRepository.name);
}
@@ -121,6 +125,7 @@ export class MetadataRepository {
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
try {
await this.exiftool.write(path, tags);
await this.storageRepository.datasync(path);
} catch (error) {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
+1 -10
View File
@@ -70,16 +70,7 @@ export class OAuthRepository {
try {
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
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);
}
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
if (!profile.sub) {
throw new Error('Unexpected profile response, no `sub`');
}
@@ -4,7 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { DummyValue, GenerateSql } from 'src/decorators';
import { SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -249,20 +249,6 @@ export class SharedLinkRepository {
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] })
private getSharedLinks(id: string) {
return this.db
+15 -5
View File
@@ -50,8 +50,18 @@ export class StorageRepository {
return fs.readdir(folder);
}
copyFile(source: string, target: string) {
return fs.copyFile(source, target);
async copyFile(source: string, target: string) {
await 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) {
@@ -59,7 +69,7 @@ export class StorageRepository {
}
createFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer, { flag: 'wx' });
return fs.writeFile(filepath, buffer, { flag: 'wx', flush: true });
}
createWriteStream(filepath: string): Writable {
@@ -67,11 +77,11 @@ export class StorageRepository {
}
createOrOverwriteFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer, { flag: 'w' });
return fs.writeFile(filepath, buffer, { flag: 'w', flush: true });
}
overwriteFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer, { flag: 'r+' });
return fs.writeFile(filepath, buffer, { flag: 'r+', flush: true });
}
rename(source: string, target: string) {
@@ -1,25 +0,0 @@
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);
}
+3
View File
@@ -92,6 +92,9 @@ export class AssetTable {
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'character varying', nullable: true, default: '' })
encodedVideoPath!: string | null;
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum
+19 -26
View File
@@ -1,10 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto';
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 { newUuid, newUuids } from 'test/small.factory';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(ActivityService.name, () => {
@@ -26,7 +24,7 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
});
@@ -38,7 +36,7 @@ describe(ActivityService.name, () => {
mocks.activity.search.mockResolvedValue([]);
await expect(
sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
@@ -50,9 +48,7 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([]);
await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual(
[],
);
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
});
@@ -65,10 +61,7 @@ describe(ActivityService.name, () => {
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({
comments: 1,
likes: 3,
});
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 });
});
});
@@ -77,18 +70,18 @@ describe(ActivityService.name, () => {
const [albumId, assetId] = newUuids();
await expect(
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create a comment', async () => {
const [albumId, assetId, userId] = newUuids();
const activity = ActivityFactory.create({ albumId, assetId, userId });
const activity = factory.activity({ albumId, assetId, userId });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(getForActivity(activity));
await sut.create(AuthFactory.create({ id: userId }), {
await sut.create(factory.auth({ user: { id: userId } }), {
albumId,
assetId,
type: ReactionType.COMMENT,
@@ -106,38 +99,38 @@ describe(ActivityService.name, () => {
it('should fail because activity is disabled for the album', async () => {
const [albumId, assetId] = newUuids();
const activity = ActivityFactory.create({ albumId, assetId });
const activity = factory.activity({ albumId, assetId });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(getForActivity(activity));
await expect(
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create a like', async () => {
const [albumId, assetId, userId] = newUuids();
const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true });
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(getForActivity(activity));
mocks.activity.search.mockResolvedValue([]);
await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
});
it('should skip if like exists', async () => {
const [albumId, assetId] = newUuids();
const activity = ActivityFactory.create({ albumId, assetId, isLiked: true });
const activity = factory.activity({ albumId, assetId, isLiked: true });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE });
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
expect(mocks.activity.create).not.toHaveBeenCalled();
});
@@ -145,29 +138,29 @@ describe(ActivityService.name, () => {
describe('delete', () => {
it('should require access', async () => {
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.activity.delete).not.toHaveBeenCalled();
});
it('should let the activity owner delete a comment', async () => {
const activity = ActivityFactory.create();
const activity = factory.activity();
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
mocks.activity.delete.mockResolvedValue();
await sut.delete(AuthFactory.create(), activity.id);
await sut.delete(factory.auth(), activity.id);
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
});
it('should let the album owner delete a comment', async () => {
const activity = ActivityFactory.create();
const activity = factory.activity();
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
mocks.activity.delete.mockResolvedValue();
await sut.delete(AuthFactory.create(), activity.id);
await sut.delete(factory.auth(), activity.id);
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
});
+35 -42
View File
@@ -1,10 +1,7 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Permission } from 'src/enum';
import { ApiKeyService } from 'src/services/api-key.service';
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 { factory, newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(ApiKeyService.name, () => {
@@ -17,8 +14,8 @@ describe(ApiKeyService.name, () => {
describe('create', () => {
it('should create a new key', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] });
const key = 'super-secret';
mocks.crypto.randomBytesAsText.mockReturnValue(key);
@@ -37,8 +34,8 @@ describe(ApiKeyService.name, () => {
});
it('should not require a name', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const key = 'super-secret';
mocks.crypto.randomBytesAsText.mockReturnValue(key);
@@ -57,9 +54,7 @@ describe(ApiKeyService.name, () => {
});
it('should throw an error if the api key does not have sufficient permissions', async () => {
const auth = AuthFactory.from()
.apiKey({ permissions: [Permission.AssetRead] })
.build();
const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } });
await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf(
BadRequestException,
@@ -70,7 +65,7 @@ describe(ApiKeyService.name, () => {
describe('update', () => {
it('should throw an error if the key is not found', async () => {
const id = newUuid();
const auth = AuthFactory.create();
const auth = factory.auth();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -82,8 +77,8 @@ describe(ApiKeyService.name, () => {
});
it('should update a key', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const newName = 'New name';
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -98,8 +93,8 @@ describe(ApiKeyService.name, () => {
});
it('should update permissions', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate];
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -116,8 +111,8 @@ describe(ApiKeyService.name, () => {
describe('api key auth', () => {
it('should prevent adding Permission.all', async () => {
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
const auth = AuthFactory.from().apiKey({ permissions }).build();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
const auth = factory.auth({ apiKey: { permissions } });
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -130,8 +125,8 @@ describe(ApiKeyService.name, () => {
it('should prevent adding a new permission', async () => {
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
const auth = AuthFactory.from().apiKey({ permissions }).build();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
const auth = factory.auth({ apiKey: { permissions } });
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -143,10 +138,8 @@ describe(ApiKeyService.name, () => {
});
it('should allow removing permissions', async () => {
const auth = AuthFactory.from()
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] })
.build();
const apiKey = ApiKeyFactory.create({
const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } });
const apiKey = factory.apiKey({
userId: auth.user.id,
permissions: [Permission.AssetRead, Permission.AssetDelete],
});
@@ -165,10 +158,10 @@ describe(ApiKeyService.name, () => {
});
it('should allow adding new permissions', async () => {
const auth = AuthFactory.from()
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] })
.build();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] });
const auth = factory.auth({
apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] },
});
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] });
mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.update.mockResolvedValue(apiKey);
@@ -190,7 +183,7 @@ describe(ApiKeyService.name, () => {
describe('delete', () => {
it('should throw an error if the key is not found', async () => {
const auth = AuthFactory.create();
const auth = factory.auth();
const id = newUuid();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -201,8 +194,8 @@ describe(ApiKeyService.name, () => {
});
it('should delete a key', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.delete.mockResolvedValue();
@@ -215,8 +208,8 @@ describe(ApiKeyService.name, () => {
describe('getMine', () => {
it('should not work with a session token', async () => {
const session = SessionFactory.create();
const auth = AuthFactory.from().session(session).build();
const session = factory.session();
const auth = factory.auth({ session });
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -226,8 +219,8 @@ describe(ApiKeyService.name, () => {
});
it('should throw an error if the key is not found', async () => {
const apiKey = ApiKeyFactory.create();
const auth = AuthFactory.from().apiKey(apiKey).build();
const apiKey = factory.authApiKey();
const auth = factory.auth({ apiKey });
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -237,8 +230,8 @@ describe(ApiKeyService.name, () => {
});
it('should get a key by id', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -250,7 +243,7 @@ describe(ApiKeyService.name, () => {
describe('getById', () => {
it('should throw an error if the key is not found', async () => {
const auth = AuthFactory.create();
const auth = factory.auth();
const id = newUuid();
mocks.apiKey.getById.mockResolvedValue(void 0);
@@ -261,8 +254,8 @@ describe(ApiKeyService.name, () => {
});
it('should get a key by id', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
mocks.apiKey.getById.mockResolvedValue(apiKey);
@@ -274,8 +267,8 @@ describe(ApiKeyService.name, () => {
describe('getAll', () => {
it('should return all the keys for a user', async () => {
const auth = AuthFactory.create();
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);
@@ -163,6 +163,7 @@ const assetEntity = Object.freeze({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
isFavorite: false,
encodedVideoPath: '',
duration: '0:00:00.000000',
files: [] as AssetFile[],
exifInfo: {
@@ -710,18 +711,13 @@ describe(AssetMediaService.name, () => {
});
it('should return the encoded video path if available', async () => {
const asset = AssetFactory.from()
.file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' })
.build();
const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForVideo.mockResolvedValue({
originalPath: asset.originalPath,
encodedVideoPath: asset.files[0].path,
});
mocks.asset.getForVideo.mockResolvedValue(asset);
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/encoded/video.mp4',
path: asset.encodedVideoPath!,
cacheControl: CacheControl.PrivateWithCache,
contentType: 'video/mp4',
}),
@@ -731,10 +727,7 @@ describe(AssetMediaService.name, () => {
it('should fall back to the original path', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForVideo.mockResolvedValue({
originalPath: asset.originalPath,
encodedVideoPath: null,
});
mocks.asset.getForVideo.mockResolvedValue(asset);
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({
+2 -11
View File
@@ -151,13 +151,9 @@ export class AssetMediaService extends BaseService {
}
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);
return { id: asset.id, status: AssetMediaStatus.CREATED, checksum: file.checksum.toString('base64') };
return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
}
@@ -345,12 +341,7 @@ export class AssetMediaService extends BaseService {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
}
if (auth.sharedLink) {
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
}
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId, checksum: file.checksum.toString('base64') };
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
this.logger.error(`Error uploading file ${error}`, error?.stack);
+4 -5
View File
@@ -7,7 +7,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
import { factory, newUuid } from 'test/small.factory';
@@ -81,8 +80,8 @@ describe(AssetService.name, () => {
});
it('should not include partner assets if not in timeline', async () => {
const partner = PartnerFactory.create({ inTimeline: false });
const auth = AuthFactory.create({ id: partner.sharedWithId });
const partner = factory.partner({ inTimeline: false });
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
@@ -93,8 +92,8 @@ describe(AssetService.name, () => {
});
it('should include partner assets if in timeline', async () => {
const partner = PartnerFactory.create({ inTimeline: true });
const auth = AuthFactory.create({ id: partner.sharedWithId });
const partner = factory.partner({ inTimeline: true });
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
+1 -1
View File
@@ -370,7 +370,7 @@ export class AssetService extends BaseService {
assetFiles.editedFullsizeFile?.path,
assetFiles.editedPreviewFile?.path,
assetFiles.editedThumbnailFile?.path,
assetFiles.encodedVideoFile?.path,
asset.encodedVideoPath,
];
if (deleteOnDisk && !asset.isOffline) {
+88 -96
View File
@@ -6,13 +6,9 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service';
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 { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newUuid } from 'test/small.factory';
import { factory, newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = ({
@@ -95,8 +91,8 @@ describe(AuthService.name, () => {
});
it('should successfully log the user in', async () => {
const user = UserFactory.create({ password: 'immich_password' });
const session = SessionFactory.create();
const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
const session = factory.session();
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
@@ -117,8 +113,8 @@ describe(AuthService.name, () => {
describe('changePassword', () => {
it('should change the password', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
@@ -136,8 +132,8 @@ describe(AuthService.name, () => {
});
it('should throw when password does not match existing password', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.user();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.crypto.compareBcrypt.mockReturnValue(false);
@@ -148,8 +144,8 @@ describe(AuthService.name, () => {
});
it('should throw when user does not have a password', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.user();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password' };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
@@ -158,8 +154,8 @@ describe(AuthService.name, () => {
});
it('should change the password and logout other sessions', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true };
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
@@ -179,7 +175,7 @@ describe(AuthService.name, () => {
describe('logout', () => {
it('should return the end session endpoint', async () => {
const auth = AuthFactory.create();
const auth = factory.auth();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
@@ -190,7 +186,7 @@ describe(AuthService.name, () => {
});
it('should return the default redirect', async () => {
const auth = AuthFactory.create();
const auth = factory.auth();
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
successful: true,
@@ -266,11 +262,11 @@ describe(AuthService.name, () => {
});
it('should validate using authorization header', async () => {
const session = SessionFactory.create();
const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: UserFactory.create(),
user: factory.authUser(),
pinExpiresAt: null,
appVersion: null,
};
@@ -344,7 +340,7 @@ describe(AuthService.name, () => {
});
it('should accept a base64url key', async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
const sharedLink = { ...sharedLinkStub.valid, user } as any;
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
@@ -365,7 +361,7 @@ describe(AuthService.name, () => {
});
it('should accept a hex key', async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
const sharedLink = { ...sharedLinkStub.valid, user } as any;
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
@@ -400,7 +396,7 @@ describe(AuthService.name, () => {
});
it('should accept a valid slug', async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
@@ -432,11 +428,11 @@ describe(AuthService.name, () => {
});
it('should return an auth dto', async () => {
const session = SessionFactory.create();
const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: UserFactory.create(),
user: factory.authUser(),
pinExpiresAt: null,
appVersion: null,
};
@@ -459,11 +455,11 @@ describe(AuthService.name, () => {
});
it('should throw if admin route and not an admin', async () => {
const session = SessionFactory.create();
const session = factory.session();
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: UserFactory.create(),
user: factory.authUser(),
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
@@ -481,11 +477,11 @@ describe(AuthService.name, () => {
});
it('should update when access time exceeds an hour', async () => {
const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
const sessionWithToken = {
id: session.id,
updatedAt: session.updatedAt,
user: UserFactory.create(),
user: factory.authUser(),
isPendingSyncReset: false,
pinExpiresAt: null,
appVersion: null,
@@ -521,8 +517,8 @@ describe(AuthService.name, () => {
});
it('should throw an error if api key has insufficient permissions', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
@@ -537,8 +533,8 @@ describe(AuthService.name, () => {
});
it('should default to requiring the all permission when omitted', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] });
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
@@ -552,12 +548,10 @@ describe(AuthService.name, () => {
});
it('should not require any permission when metadata is set to `false`', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] })
.user(authUser)
.build();
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
const result = sut.authenticate({
headers: { 'x-api-key': 'auth_token' },
@@ -568,12 +562,10 @@ describe(AuthService.name, () => {
});
it('should return an auth dto', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] })
.user(authUser)
.build();
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
await expect(
sut.authenticate({
@@ -637,12 +629,12 @@ describe(AuthService.name, () => {
});
it('should link an existing user', async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -657,7 +649,7 @@ describe(AuthService.name, () => {
});
it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.user.getByEmail.mockResolvedValueOnce(user);
@@ -677,13 +669,13 @@ describe(AuthService.name, () => {
});
it('should allow auto registering by default', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -698,13 +690,13 @@ describe(AuthService.name, () => {
});
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
const user = UserFactory.create({ isAdmin: true });
const user = factory.userAdmin({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(
@@ -725,11 +717,11 @@ describe(AuthService.name, () => {
'app.immich:///oauth-callback?code=abc123',
]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -743,13 +735,13 @@ describe(AuthService.name, () => {
}
it('should use the default quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -763,14 +755,14 @@ describe(AuthService.name, () => {
});
it('should ignore an invalid storage quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -784,14 +776,14 @@ describe(AuthService.name, () => {
});
it('should ignore a negative quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -805,14 +797,14 @@ describe(AuthService.name, () => {
});
it('should set quota for 0 quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -833,15 +825,15 @@ describe(AuthService.name, () => {
});
it('should use a valid storage quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -863,7 +855,7 @@ describe(AuthService.name, () => {
it('should sync the profile picture', async () => {
const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
@@ -879,7 +871,7 @@ describe(AuthService.name, () => {
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -897,7 +889,7 @@ describe(AuthService.name, () => {
});
it('should not sync the profile picture if the user already has one', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue({
@@ -907,7 +899,7 @@ describe(AuthService.name, () => {
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -922,15 +914,15 @@ describe(AuthService.name, () => {
});
it('should only allow "admin" and "user" for the role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -951,14 +943,14 @@ describe(AuthService.name, () => {
});
it('should create an admin user if the role claim is set to admin', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -979,7 +971,7 @@ describe(AuthService.name, () => {
});
it('should accept a custom role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
const user = factory.userAdmin({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
@@ -988,7 +980,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.session.create.mockResolvedValue(factory.session());
await expect(
sut.callback(
@@ -1011,8 +1003,8 @@ describe(AuthService.name, () => {
describe('link', () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const user = factory.userAdmin();
const auth = factory.auth({ apiKey: { permissions: [] }, user });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(user);
@@ -1027,8 +1019,8 @@ describe(AuthService.name, () => {
});
it('should not link an already linked oauth.sub', async () => {
const authUser = UserFactory.create();
const authApiKey = ApiKeyFactory.create({ permissions: [] });
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
@@ -1044,8 +1036,8 @@ describe(AuthService.name, () => {
describe('unlink', () => {
it('should unlink an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const user = factory.userAdmin();
const auth = factory.auth({ user, apiKey: { permissions: [] } });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(user);
@@ -1058,8 +1050,8 @@ describe(AuthService.name, () => {
describe('setupPinCode', () => {
it('should setup a PIN code', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { pinCode: '123456' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
@@ -1073,8 +1065,8 @@ describe(AuthService.name, () => {
});
it('should fail if the user already has a PIN code', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
@@ -1084,8 +1076,8 @@ describe(AuthService.name, () => {
describe('changePinCode', () => {
it('should change the PIN code', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { pinCode: '123456', newPinCode: '012345' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
@@ -1099,37 +1091,37 @@ describe(AuthService.name, () => {
});
it('should fail if the PIN code does not match', async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(
sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }),
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
).rejects.toThrow('Wrong PIN code');
});
});
describe('resetPinCode', () => {
it('should reset the PIN code', async () => {
const currentSession = SessionFactory.create();
const user = UserFactory.create();
const currentSession = factory.session();
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
mocks.session.lockAll.mockResolvedValue(void 0);
mocks.session.update.mockResolvedValue(currentSession);
await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' });
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
});
it('should throw if the PIN code does not match', async () => {
const user = UserFactory.create();
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
});
});
});
+5 -5
View File
@@ -1,7 +1,7 @@
import { jwtVerify } from 'jose';
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { CliService } from 'src/services/cli.service';
import { UserFactory } from 'test/factories/user.factory';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe, it } from 'vitest';
@@ -15,7 +15,7 @@ describe(CliService.name, () => {
describe('listUsers', () => {
it('should list users', async () => {
mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]);
mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
});
@@ -32,10 +32,10 @@ describe(CliService.name, () => {
});
it('should default to a random password', async () => {
const admin = UserFactory.create({ isAdmin: true });
const admin = factory.userAdmin({ isAdmin: true });
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
const ask = vitest.fn().mockImplementation(() => {});
@@ -50,7 +50,7 @@ describe(CliService.name, () => {
});
it('should use the supplied password', async () => {
const admin = UserFactory.create({ isAdmin: true });
const admin = factory.userAdmin({ isAdmin: true });
mocks.user.getAdmin.mockResolvedValue(admin);
mocks.user.update.mockResolvedValue(admin);
+2 -2
View File
@@ -2,9 +2,9 @@ import { MapService } from 'src/services/map.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { userStub } from 'test/fixtures/user.stub';
import { getForAlbum, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(MapService.name, () => {
@@ -40,7 +40,7 @@ describe(MapService.name, () => {
it('should include partner assets', async () => {
const auth = AuthFactory.create();
const partner = PartnerFactory.create({ sharedWithId: auth.user.id });
const partner = factory.partner({ sharedWithId: auth.user.id });
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
+2 -4
View File
@@ -2254,9 +2254,7 @@ describe(MediaService.name, () => {
});
it('should delete existing transcode if current policy does not require transcoding', async () => {
const asset = AssetFactory.from({ type: AssetType.Video })
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' })
.build();
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
@@ -2266,7 +2264,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: ['/encoded/video/path.mp4'] },
data: { files: [asset.encodedVideoPath] },
});
});
+5 -11
View File
@@ -39,7 +39,7 @@ import {
VideoInterfaces,
VideoStreamInfo,
} from 'src/types';
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
import { getDimensions } from 'src/utils/asset.util';
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
@@ -605,11 +605,10 @@ export class MediaService extends BaseService {
let { ffmpeg } = await this.getConfig({ withCache: true });
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
if (encodedVideo) {
if (asset.encodedVideoPath) {
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: [encodedVideo.path] } });
await this.assetRepository.deleteFiles([encodedVideo]);
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } });
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
} else {
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
}
@@ -657,12 +656,7 @@ export class MediaService extends BaseService {
this.logger.log(`Successfully encoded ${asset.id}`);
await this.assetRepository.upsertFile({
assetId: asset.id,
type: AssetFileType.EncodedVideo,
path: output,
isEdited: false,
});
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
return JobStatus.Success;
}
+2 -2
View File
@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
width: null,
height: null,
});
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
width: null,
height: null,
});
+32 -21
View File
@@ -1,10 +1,9 @@
import { BadRequestException } from '@nestjs/common';
import { PartnerDirection } from 'src/repositories/partner.repository';
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 { getForPartner } from 'test/mappers';
import { getDehydrated, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe(PartnerService.name, () => {
@@ -23,9 +22,15 @@ describe(PartnerService.name, () => {
it("should return a list of partners with whom I've shared my library", async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
const auth = AuthFactory.create({ id: user1.id });
const sharedWithUser2 = factory.partner({
sharedBy: getDehydrated(user1),
sharedWith: getDehydrated(user2),
});
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)]);
@@ -36,9 +41,15 @@ describe(PartnerService.name, () => {
it('should return a list of partners who have shared their libraries with me', async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
const auth = AuthFactory.create({ id: user1.id });
const sharedWithUser2 = factory.partner({
sharedBy: getDehydrated(user1),
sharedWith: getDehydrated(user2),
});
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)]);
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
@@ -50,8 +61,8 @@ describe(PartnerService.name, () => {
it('should create a new partner', async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
const auth = AuthFactory.create({ id: user1.id });
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(getForPartner(partner));
@@ -67,8 +78,8 @@ describe(PartnerService.name, () => {
it('should throw an error when the partner already exists', async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
const auth = AuthFactory.create({ id: user1.id });
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(getForPartner(partner));
@@ -82,8 +93,8 @@ describe(PartnerService.name, () => {
it('should remove a partner', async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
const auth = AuthFactory.create({ id: user1.id });
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(getForPartner(partner));
@@ -93,8 +104,8 @@ describe(PartnerService.name, () => {
});
it('should throw an error when the partner does not exist', async () => {
const user2 = UserFactory.create();
const auth = AuthFactory.create();
const user2 = factory.user();
const auth = factory.auth();
mocks.partner.get.mockResolvedValue(void 0);
@@ -106,8 +117,8 @@ describe(PartnerService.name, () => {
describe('update', () => {
it('should require access', async () => {
const user2 = UserFactory.create();
const auth = AuthFactory.create();
const user2 = factory.user();
const auth = factory.auth();
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException);
});
@@ -115,8 +126,8 @@ describe(PartnerService.name, () => {
it('should update partner', async () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
const auth = AuthFactory.create({ id: user1.id });
const partner = factory.partner({ sharedBy: getDehydrated(user1), sharedWith: getDehydrated(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
mocks.partner.update.mockResolvedValue(getForPartner(partner));
+6 -7
View File
@@ -1,8 +1,7 @@
import { JobStatus } from 'src/enum';
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 { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
describe('SessionService', () => {
@@ -26,9 +25,9 @@ describe('SessionService', () => {
describe('getAll', () => {
it('should get the devices', async () => {
const currentSession = SessionFactory.create();
const otherSession = SessionFactory.create();
const auth = AuthFactory.from().session(currentSession).build();
const currentSession = factory.session();
const otherSession = factory.session();
const auth = factory.auth({ session: currentSession });
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
@@ -43,8 +42,8 @@ describe('SessionService', () => {
describe('logoutDevices', () => {
it('should logout all devices', async () => {
const currentSession = SessionFactory.create();
const auth = AuthFactory.from().session(currentSession).build();
const currentSession = factory.session();
const auth = factory.auth({ session: currentSession });
mocks.session.invalidate.mockResolvedValue();
@@ -150,12 +150,6 @@ export class SharedLinkService extends BaseService {
}
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);
if (sharedLink.type !== SharedLinkType.Individual) {
+1 -2
View File
@@ -1,7 +1,6 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SyncService } from 'src/services/sync.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
@@ -43,7 +42,7 @@ describe(SyncService.name, () => {
describe('getChangesForDeltaSync', () => {
it('should return a response requiring a full sync when partners are out of sync', async () => {
const partner = PartnerFactory.create();
const partner = factory.partner();
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
@@ -2,10 +2,9 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { mapUserAdmin } from 'src/dtos/user.dto';
import { JobName, UserStatus } from 'src/enum';
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 { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
import { describe } from 'vitest';
@@ -127,8 +126,8 @@ describe(UserAdminService.name, () => {
});
it('should not allow deleting own account', async () => {
const user = UserFactory.create({ isAdmin: false });
const auth = AuthFactory.create(user);
const user = factory.userAdmin({ isAdmin: false });
const auth = factory.auth({ user });
mocks.user.get.mockResolvedValue(user);
await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
+10 -11
View File
@@ -3,11 +3,10 @@ import { UserAdmin } from 'src/database';
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
import { UserService } from 'src/services/user.service';
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 { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const makeDeletedAt = (daysAgo: number) => {
@@ -29,8 +28,8 @@ describe(UserService.name, () => {
describe('getAll', () => {
it('admin should get all users', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
mocks.user.getList.mockResolvedValue([user]);
@@ -40,8 +39,8 @@ describe(UserService.name, () => {
});
it('non-admin should get all users when publicUsers enabled', async () => {
const user = UserFactory.create();
const auth = AuthFactory.create(user);
const user = factory.userAdmin();
const auth = factory.auth({ user });
mocks.user.getList.mockResolvedValue([user]);
@@ -106,7 +105,7 @@ describe(UserService.name, () => {
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 user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
mocks.user.get.mockResolvedValue(user);
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
@@ -114,7 +113,7 @@ describe(UserService.name, () => {
});
it('should delete the previous profile image', async () => {
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
const file = { path: '/profile/path' } as Express.Multer.File;
const files = [user.profileImagePath];
@@ -150,7 +149,7 @@ describe(UserService.name, () => {
});
it('should delete the profile image if user has one', async () => {
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
const files = [user.profileImagePath];
mocks.user.get.mockResolvedValue(user);
@@ -179,7 +178,7 @@ describe(UserService.name, () => {
});
it('should return the profile picture', async () => {
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
mocks.user.get.mockResolvedValue(user);
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
@@ -206,7 +205,7 @@ describe(UserService.name, () => {
});
it('should queue user ready for deletion', async () => {
const user = UserFactory.create();
const user = factory.user();
mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]);
await sut.handleUserDeleteCheck();
-2
View File
@@ -26,8 +26,6 @@ export const getAssetFiles = (files: AssetFile[]) => ({
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }),
});
export const addAssets = async (
+2 -19
View File
@@ -355,16 +355,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$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.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!))
.$if(!!options.originalPath, (qb) =>
qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
)
@@ -389,15 +380,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!))
.$if(options.isEncoded !== undefined, (qb) =>
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);
}),
qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
)
.$if(options.isMotion !== undefined, (qb) =>
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
-42
View File
@@ -1,42 +0,0 @@
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() };
}
}
-42
View File
@@ -1,42 +0,0 @@
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() };
}
}
+7 -8
View File
@@ -19,7 +19,7 @@ import {
UserLike,
} from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFactory {
#owner!: UserFactory;
@@ -43,12 +43,10 @@ export class AssetFactory {
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
let now = Date.now();
return new AssetFactory({
id,
createdAt: new Date(now++),
updatedAt: new Date(now++),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
@@ -57,14 +55,15 @@ export class AssetFactory {
deviceId: '',
duplicateId: null,
duration: null,
fileCreatedAt: new Date(now++),
fileModifiedAt: new Date(now++),
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: new Date(now),
localDateTime: newDate(),
originalFileName,
originalPath: `/data/library/${originalFileName}`,
ownerId: newUuid(),
+3 -14
View File
@@ -1,16 +1,12 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { build } from 'test/factories/builder.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newUuid } from 'test/small.factory';
export class AuthFactory {
#user: UserFactory;
#sharedLink?: SharedLinkFactory;
#apiKey?: ApiKeyFactory;
#session?: AuthDto['session'];
private constructor(user: UserFactory) {
this.#user = user;
@@ -24,8 +20,8 @@ export class AuthFactory {
return new AuthFactory(UserFactory.from(dto));
}
apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder<ApiKeyFactory>) {
this.#apiKey = build(ApiKeyFactory.from(dto), builder);
apiKey() {
// TODO
return this;
}
@@ -34,11 +30,6 @@ export class AuthFactory {
return this;
}
session(dto: Partial<AuthDto['session']> = {}) {
this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto };
return this;
}
build(): AuthDto {
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
@@ -52,8 +43,6 @@ export class AuthFactory {
quotaSizeInBytes,
},
sharedLink: this.#sharedLink?.build(),
apiKey: this.#apiKey?.build(),
session: this.#session,
};
}
}
-50
View File
@@ -1,50 +0,0 @@
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() };
}
}
-35
View File
@@ -1,35 +0,0 @@
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 };
}
}
-8
View File
@@ -1,17 +1,13 @@
import { Selectable } from 'kysely';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.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 { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.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 { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -30,7 +26,3 @@ export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
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>>;
+1
View File
@@ -183,6 +183,7 @@ export const getForAssetDeletion = (asset: ReturnType<AssetFactory['build']>) =>
libraryId: asset.libraryId,
ownerId: asset.ownerId,
livePhotoVideoId: asset.livePhotoVideoId,
encodedVideoPath: asset.encodedVideoPath,
originalPath: asset.originalPath,
isOffline: asset.isOffline,
exifInfo: asset.exifInfo ? getDehydrated(asset.exifInfo) : null,
@@ -72,6 +72,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
walk: vitest.fn().mockImplementation(async function* () {}),
rename: vitest.fn(),
copyFile: vitest.fn(),
datasync: vitest.fn(),
utimes: vitest.fn(),
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
};
+181 -2
View File
@@ -1,7 +1,26 @@
import { AuthApiKey, AuthSharedLink, AuthUser, Exif, Library, UserAdmin } from 'src/database';
import { ShallowDehydrateObject } from 'kysely';
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 { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto';
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
import { AssetFileType, Permission, UserStatus } from 'src/enum';
import { AssetFileType, AssetOrder, Permission, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { UserFactory } from 'test/factories/user.factory';
import { v4, v7 } from 'uuid';
export const newUuid = () => v4();
@@ -90,6 +109,49 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
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>) => ({
active: 0,
completed: 0,
@@ -100,6 +162,22 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
...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 {
id = newUuid(),
@@ -141,6 +219,34 @@ 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> = {}) => ({
id: newUuid(),
createdAt: newDate(),
@@ -222,15 +328,88 @@ const assetOcrFactory = (
...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 = {
activity: activityFactory,
apiKey: apiKeyFactory,
assetOcr: assetOcrFactory,
auth: authFactory,
authApiKey: authApiKeyFactory,
authUser: authUserFactory,
library: libraryFactory,
partner: partnerFactory,
queueStatistics: queueStatisticsFactory,
session: sessionFactory,
user: userFactory,
userAdmin: userAdminFactory,
versionHistory: versionHistoryFactory,
jobAssets: {
sidecarWrite: assetSidecarWriteFactory,
},
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,
album: albumFactory,
uuid: newUuid,
buffer: () => Buffer.from('this is a fake buffer'),
date: newDate,
-2
View File
@@ -60,8 +60,6 @@
"svelte-maplibre": "^1.2.5",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"uplot": "^1.6.32"
-20
View File
@@ -23,25 +23,7 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
node.addEventListener('wheel', 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';
// Prevent browser handling of touch gestures so zoom-image can manage them
node.style.touchAction = 'none';
return {
update(newOptions?: { disabled?: boolean }) {
options = newOptions;
@@ -52,8 +34,6 @@ export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolea
}
node.removeEventListener('wheel', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', onInteractionStart, { capture: true });
node.removeEventListener('pointerdown', trackPointerType, { capture: true });
node.removeEventListener('dblclick', suppressTouchDblClick, { capture: true });
zoomInstance.cleanup();
},
};
+1 -2
View File
@@ -162,9 +162,8 @@
<div class="relative h-full w-full overflow-hidden will-change-transform" bind:this={ref}>
{@render backdrop?.()}
<!-- pointer-events-none so events pass through to the container where zoom-image listens -->
<div
class="absolute inset-0 pointer-events-none"
class="absolute inset-0"
style:transform={zoomTransform}
style:transform-origin={zoomTransform ? '0 0' : undefined}
>
@@ -1,12 +1,11 @@
<script lang="ts">
import { cleanClass } from '$lib';
import type { ClassValue } from 'svelte/elements';
interface Props {
class?: ClassValue;
}
let { class: className }: Props = $props();
let { class: className = '' }: Props = $props();
</script>
<div class={cleanClass('absolute h-full w-full bg-gray-300 dark:bg-gray-700', className)}></div>
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
+1 -2
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { cleanClass } from '$lib';
import type { ClassValue } from 'svelte/elements';
interface Props {
@@ -9,7 +8,7 @@
let { class: className }: Props = $props();
</script>
<div class={cleanClass('delayed inline-flex items-center gap-1', className)}>
<div class="delayed inline-flex items-center gap-1 {className}">
{#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>
+2 -9
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { cleanClass } from '$lib';
import QueueCardBadge from '$lib/components/QueueCardBadge.svelte';
import QueueCardButton from '$lib/components/QueueCardButton.svelte';
import Badge from '$lib/elements/Badge.svelte';
@@ -106,10 +105,7 @@
<div class="mt-2 flex w-full max-w-md flex-col sm:flex-row">
<div
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',
)}
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"
>
<p>{$t('active')}</p>
<p class="text-2xl">
@@ -118,10 +114,7 @@
</div>
<div
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',
)}
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"
>
<p class="text-2xl">
{waitingCount.toLocaleString($locale)}

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