Compare commits

...

23 Commits

Author SHA1 Message Date
Alex The Bot
886e07604e Version v1.102.0 2024-04-19 20:08:02 +00:00
Mert
431ffebddd feat(server): use embedded preview from raw images (#8773)
* extract embedded

* update api

* add tests

* move temp file logic outside of media repo

* formatting

* revert `toSorted`

* disable by default

* clarify setting description

* wording

* wording

* update docs

* check extracted image dimensions

* test that it unlinks

* formatting

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-04-19 10:50:13 -05:00
Jason Rasmussen
74c921148b refactor(server): cookies (#8920) 2024-04-19 11:19:23 -04:00
martin
eaf9e5e477 feat(web): add an option to fill the screen with the slideshow view (#8909)
* feat: add an option to fill the screen with the slideshow view

* fix: rename var
2024-04-19 06:49:29 -04:00
Jason Rasmussen
4478e524f8 refactor(server): sessions (#8915)
* refactor: auth device => sessions

* chore: open api
2024-04-19 06:47:29 -04:00
renovate[bot]
e72e41a7aa chore(deps): update redis:6.2-alpine docker digest to 84882e8 (#8912)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-19 12:46:59 +02:00
martin
efd8f0d648 fix(web): notification number of people when editing faces (#7352)
* fix: notification number of people when editing faces

* fix: lint

* fix: use id instead of index

* rename
2024-04-18 22:55:11 -04:00
renovate[bot]
d2b5cc6a4a chore(deps): update registry.hub.docker.com/library/redis:6.2-alpine docker digest to 84882e8 (#8913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-18 22:44:37 -04:00
Mert
596c35dc00 fix(server): skip invisible assets for thumbnail generation and ml (#8891)
* skip invisible assets for thumbnail generation and ml

* no need to update job status

* fix thumbhash check order

* linting
2024-04-19 01:37:55 +00:00
martin
112d6d60ec feat(web): add page up and page down shortcuts (#8910)
feat: add page up and page down shortcuts
2024-04-18 21:11:54 -04:00
Ben McCann
c50241369a docs: link to storage label docs from storage template docs (#8911)
* docs: link to storage label docs from storage template docs

* docusaurus sucks
2024-04-18 21:08:09 -04:00
martyfuhry
b74f8273c2 fix:(mobile): Updates old IMMICH text from the mobile settings modal (#8906)
* fix: Removes old IMMICH text from the mobile settings modal

Removed old Snowburst One font from the pubspec

Removes SnowburstOne.ttf file

* Uses immich text now
2024-04-18 14:11:00 -05:00
Mert
8573c84605 fix(server): include archived images in face detection (#8892) 2024-04-17 23:47:24 -04:00
Alessandro Vitali
a4f805e99b Update oauth.md (#8794)
Removed closing brackets from oauth redirect URIs.
2024-04-17 18:59:09 +00:00
renovate[bot]
7db07bbe61 fix(deps): update dependency gunicorn to v22 [security] (#8863) 2024-04-17 11:23:24 -04:00
Jason Rasmussen
3a9df6dae8 refactor(server): immich-admin list-users (#8862) 2024-04-17 12:27:04 +00:00
Ethan Margaillan
c227f9893e feat(web): un-stack from the photos page ; fix stack count (#8419)
* feat(web): un-stack from the photos page ; fix stack count

* move stuff outside of try-catch block

* small optim
2024-04-17 07:55:07 -04:00
renovate[bot]
a3feca2580 chore(deps): update node.js to ec0c413 (#8833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 07:53:00 -04:00
renovate[bot]
b21566c2fc chore(deps): update node.js to d328c7b (#8829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-17 07:52:29 -04:00
Ben
1071396a4a fix(web,a11y): remove autofocus from input fields (#8857)
* fix(web,a11y): remove autofocus from input field

The autofocus attribute can cause the keyboard to unexpectedly appear
for mobile users, and override any other focus management that the
application is doing programatically.

* fix: always include people filter
2024-04-17 11:15:37 +02:00
Matthew Momjian
f58886514d docs: fix vectors grant... again (#8860) 2024-04-17 01:21:08 -04:00
Kevin Huang
17dc12cf7d fix(server): storage usage calculation for motion photos (#8722)
* ignore non external assets in external libraries during syncUsage

* only update storage usage if asset is from internal libraries

* update storage usage on motion photo video asset creation

* updated metadata service tests

* added a test

* simplified syncUsage condition

* check for library type upload instead of not external

* fixed broken sql

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-04-17 03:04:59 +00:00
renovate[bot]
6d4d0f86cf chore(deps): update base-image to v20240416 (major) (#8660)
* chore(deps): update base-image to v20240416

* fix e2e

* rename variable

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2024-04-16 22:55:05 -04:00
125 changed files with 1914 additions and 1337 deletions

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine3.19@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 as core FROM node:20-alpine3.19@sha256:ec0c413b1d84f3f7f67ec986ba885930c57b5318d2eb3abc6960ee05d4f2eb28 as core
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

6
cli/package-lock.json generated
View File

@@ -47,15 +47,15 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.101.0", "version": "1.102.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.7", "@types/node": "^20.11.0",
"typescript": "^5.4.5" "typescript": "^5.3.3"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {

View File

@@ -97,7 +97,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
database: database:
container_name: immich_postgres container_name: immich_postgres

View File

@@ -54,7 +54,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
restart: always restart: always
database: database:

View File

@@ -58,7 +58,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5 image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
restart: always restart: always
database: database:

View File

@@ -52,8 +52,8 @@ Before enabling OAuth in Immich, a new client application needs to be configured
Hostname Hostname
- `https://immich.example.com/auth/login`) - `https://immich.example.com/auth/login`
- `https://immich.example.com/user-settings`) - `https://immich.example.com/user-settings`
## Enable OAuth ## Enable OAuth

View File

@@ -56,8 +56,7 @@ ALTER DATABASE <immichdatabasename> OWNER TO <immichdbusername>;
CREATE EXTENSION vectors; CREATE EXTENSION vectors;
CREATE EXTENSION earthdistance CASCADE; CREATE EXTENSION earthdistance CASCADE;
ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors; ALTER DATABASE <immichdatabasename> SET search_path TO "$user", public, vectors;
GRANT USAGE ON SCHEMA vectors TO <immichdbusername>; ALTER SCHEMA vectors OWNER TO <immichdbusername>;
ALTER DEFAULT PRIVILEGES IN SCHEMA vectors GRANT SELECT ON TABLES TO <immichdbusername>;
COMMIT; COMMIT;
``` ```

View File

@@ -120,7 +120,8 @@ The default configuration looks like this:
"previewFormat": "jpeg", "previewFormat": "jpeg",
"previewSize": 1440, "previewSize": 1440,
"quality": 80, "quality": 80,
"colorspace": "p3" "colorspace": "p3",
"extractEmbedded": false
}, },
"newVersionCheck": { "newVersionCheck": {
"enabled": true "enabled": true

View File

@@ -1,4 +1,4 @@
Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level. Immich allows the admin user to set the uploaded filename pattern at the directory and filename level as well as the [storage label for a user](/docs/administration/user-management/#set-storage-label-for-user).
:::note new version :::note new version
On new machines running version 1.92.0 storage template engine is off by default, for [more info](https://github.com/immich-app/immich/releases/tag/v1.92.0#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further). On new machines running version 1.92.0 storage template engine is off by default, for [more info](https://github.com/immich-app/immich/releases/tag/v1.92.0#:~:text=the%20partner%E2%80%99s%20assets.-,Hardening%20storage%20template,-We%20have%20further).

View File

@@ -36,7 +36,7 @@ services:
<<: *server-common <<: *server-common
redis: redis:
image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70 image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
database: database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0

10
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.101.0", "version": "1.102.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.101.0", "version": "1.102.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@immich/cli": "file:../cli", "@immich/cli": "file:../cli",
@@ -81,15 +81,15 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.101.0", "version": "1.102.0",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.7", "@types/node": "^20.11.0",
"typescript": "^5.4.5" "typescript": "^5.3.3"
} }
}, },
"node_modules/@aashutoshrathi/word-wrap": { "node_modules/@aashutoshrathi/word-wrap": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.101.0", "version": "1.102.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@@ -1,7 +1,7 @@
import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk'; import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures'; import { loginDto, signupDto } from 'src/fixtures';
import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses'; import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils'; import { app, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
@@ -112,70 +112,29 @@ describe('/auth/*', () => {
const cookies = headers['set-cookie']; const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3); expect(cookies).toHaveLength(3);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`); expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;'); `immich_access_token=${token}`,
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;'); 'Max-Age=34560000',
}); 'Path=/',
}); expect.stringContaining('Expires='),
'HttpOnly',
describe('GET /auth/devices', () => { 'SameSite=Lax',
it('should require authentication', async () => { ]);
const { status, body } = await request(app).get('/auth/devices'); expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
expect(status).toBe(401); 'immich_auth_type=password',
expect(body).toEqual(errorDto.unauthorized); 'Max-Age=34560000',
}); 'Path=/',
expect.stringContaining('Expires='),
it('should get a list of authorized devices', async () => { 'HttpOnly',
const { status, body } = await request(app) 'SameSite=Lax',
.get('/auth/devices') ]);
.set('Authorization', `Bearer ${admin.accessToken}`); expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
expect(status).toBe(200); 'immich_is_authenticated=true',
expect(body).toEqual([deviceDto.current]); 'Max-Age=34560000',
}); 'Path=/',
}); expect.stringContaining('Expires='),
'SameSite=Lax',
describe('DELETE /auth/devices', () => { ]);
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/auth/devices`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
const { status } = await request(app).delete(`/auth/devices`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await expect(getAuthDevices({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(app)
.delete(`/auth/devices/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await getAuthDevices({
headers: asBearerAuth(admin.accessToken),
});
const { status } = await request(app)
.delete(`/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const response = await request(app)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.body).toEqual(errorDto.invalidToken);
expect(response.status).toBe(401);
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk'; import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -32,6 +32,9 @@ describe('/search', () => {
let assetGlarus: AssetFileUploadResponseDto; let assetGlarus: AssetFileUploadResponseDto;
let assetSprings: AssetFileUploadResponseDto; let assetSprings: AssetFileUploadResponseDto;
let assetLast: AssetFileUploadResponseDto; let assetLast: AssetFileUploadResponseDto;
let cities: string[];
let states: string[];
let countries: string[];
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@@ -79,7 +82,7 @@ describe('/search', () => {
} }
// note: the coordinates here are not the actual coordinates of the images and are random for most of them // note: the coordinates here are not the actual coordinates of the images and are random for most of them
const cities = [ const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris { latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 63.0695, longitude: -151.0074 }, // denali { latitude: 63.0695, longitude: -151.0074 }, // denali
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin { latitude: 52.524_37, longitude: 13.410_53 }, // berlin
@@ -101,7 +104,7 @@ describe('/search', () => {
]; ];
const updates = assets.map((asset, i) => const updates = assets.map((asset, i) =>
updateAsset({ id: asset.id, updateAssetDto: cities[i] }, { headers: asBearerAuth(admin.accessToken) }), updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }),
); );
await Promise.all(updates); await Promise.all(updates);
@@ -133,6 +136,12 @@ describe('/search', () => {
assetLast = assets.at(-1) as AssetFileUploadResponseDto; assetLast = assets.at(-1) as AssetFileUploadResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) });
const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id);
cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort();
states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort();
countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort();
}, 30_000); }, 30_000);
afterAll(async () => { afterAll(async () => {
@@ -452,21 +461,7 @@ describe('/search', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=country') .get('/search/suggestions?type=country')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual(countries);
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
@@ -474,23 +469,7 @@ describe('/search', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=state') .get('/search/suggestions?type=state')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual(states);
'Accra, Greater Accra',
'Berlin',
'Glarus, Glarus',
'Havana',
'Marrakech, Marrakesh-Safi',
'Mesa County, Colorado',
'Neshoba County, Mississippi',
'New York',
'Page County, Virginia',
'Paris, Île-de-France',
'Province of Córdoba, Andalusia',
'Shanghai Municipality, Shanghai',
'St.-Petersburg',
'Tbilisi',
'Tokyo',
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
@@ -498,24 +477,7 @@ describe('/search', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=city') .get('/search/suggestions?type=city')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual(cities);
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Palisade',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Singapore',
'Stanley',
'Tbilisi',
'Tokyo',
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });

View File

@@ -0,0 +1,75 @@
import { LoginResponseDto, getSessions, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { deviceDto, errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
describe('/sessions', () => {
let admin: LoginResponseDto;
beforeEach(async () => {
await utils.resetDatabase();
await signUpAdmin({ signUpDto: signupDto.admin });
admin = await login({ loginCredentialDto: loginDto.admin });
});
describe('GET /sessions', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/sessions');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(app).get('/sessions').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceDto.current]);
});
});
describe('DELETE /sessions', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(`/sessions`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await login({ loginCredentialDto: loginDto.admin });
}
await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(6);
const { status } = await request(app).delete(`/sessions`).set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await expect(getSessions({ headers: asBearerAuth(admin.accessToken) })).resolves.toHaveLength(1);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(app)
.delete(`/sessions/${uuidDto.notFound}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await getSessions({
headers: asBearerAuth(admin.accessToken),
});
const { status } = await request(app)
.delete(`/sessions/${device.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const response = await request(app)
.post('/auth/validateToken')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response.body).toEqual(errorDto.invalidToken);
expect(response.status).toBe(401);
});
});
});

View File

@@ -0,0 +1,19 @@
import { immichAdmin, utils } from 'src/utils';
import { beforeAll, describe, expect, it } from 'vitest';
describe(`immich-admin`, () => {
beforeAll(async () => {
await utils.resetDatabase();
await utils.adminSetup();
});
describe('list-users', () => {
it('should list the admin user', async () => {
const { stdout, stderr, exitCode } = await immichAdmin(['list-users']);
expect(exitCode).toBe(0);
expect(stderr).toBe('');
expect(stdout).toContain("email: 'admin@immich.cloud'");
expect(stdout).toContain("name: 'Immich Admin'");
});
});
});

View File

@@ -43,7 +43,7 @@ import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators'; import { makeRandomImage } from 'src/generators';
import request from 'supertest'; import request from 'supertest';
type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete'; type EventType = 'assetUpload' | 'assetUpdate' | 'assetDelete' | 'userDelete';
type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number }; type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
type AdminSetupOptions = { onboarding?: boolean }; type AdminSetupOptions = { onboarding?: boolean };
@@ -59,13 +59,15 @@ export const testAssetDirInternal = '/data/assets';
export const tempDir = tmpdir(); export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` }); export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
export const immichCli = async (args: string[]) => { export const immichCli = (args: string[]) =>
let _resolve: (value: CliResponse) => void; executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]);
const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve)); export const immichAdmin = (args: string[]) =>
const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]; executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
const child = spawn('node', _args, {
stdio: 'pipe', const executeCommand = (command: string, args: string[]) => {
}); let _resolve: (value: CommandResponse) => void;
const deferred = new Promise<CommandResponse>((resolve) => (_resolve = resolve));
const child = spawn(command, args, { stdio: 'pipe' });
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
@@ -138,7 +140,7 @@ export const utils = {
'asset_faces', 'asset_faces',
'activity', 'activity',
'api_keys', 'api_keys',
'user_token', 'sessions',
'users', 'users',
'system_metadata', 'system_metadata',
'system_config', 'system_config',

View File

@@ -10,7 +10,7 @@ try {
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['src/{api,cli}/specs/*.e2e-spec.ts'], include: ['src/{api,cli,immich-admin}/specs/*.e2e-spec.ts'],
globalSetup, globalSetup,
testTimeout: 15_000, testTimeout: 15_000,
poolOptions: { poolOptions: {

View File

@@ -1150,22 +1150,23 @@ test = ["objgraph", "psutil"]
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "21.2.0" version = "22.0.0"
description = "WSGI HTTP Server for UNIX" description = "WSGI HTTP Server for UNIX"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.7"
files = [ files = [
{file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"},
{file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"},
] ]
[package.dependencies] [package.dependencies]
packaging = "*" packaging = "*"
[package.extras] [package.extras]
eventlet = ["eventlet (>=0.24.1)"] eventlet = ["eventlet (>=0.24.1,!=0.36.0)"]
gevent = ["gevent (>=1.4.0)"] gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"] setproctitle = ["setproctitle"]
testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"]
tornado = ["tornado (>=0.2)"] tornado = ["tornado (>=0.2)"]
[[package]] [[package]]

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.101.0" version = "1.102.0"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 131, "android.injected.version.code" => 132,
"android.injected.version.name" => "1.101.0", "android.injected.version.name" => "1.102.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

Binary file not shown.

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.101.0" version_number: "1.102.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -39,27 +39,24 @@ class ImmichAppBarDialog extends HookConsumerWidget {
); );
buildTopRow() { buildTopRow() {
return Row( return Stack(
children: [ children: [
InkWell( Align(
onTap: () => context.pop(), alignment: Alignment.topLeft,
child: const Icon( child: InkWell(
Icons.close, onTap: () => context.pop(),
size: 20, child: const Icon(
Icons.close,
size: 20,
),
), ),
), ),
Expanded( Center(
child: Align( child: Image.asset(
alignment: Alignment.center, context.isDarkTheme
child: Text( ? 'assets/immich-text-dark.png'
'IMMICH', : 'assets/immich-text-light.png',
style: TextStyle( height: 16,
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
color: context.primaryColor,
fontSize: 16,
),
),
), ),
), ),
], ],

View File

@@ -41,7 +41,6 @@ doc/AssetTypeEnum.md
doc/AudioCodec.md doc/AudioCodec.md
doc/AuditApi.md doc/AuditApi.md
doc/AuditDeletesResponseDto.md doc/AuditDeletesResponseDto.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md doc/AuthenticationApi.md
doc/BulkIdResponseDto.md doc/BulkIdResponseDto.md
doc/BulkIdsDto.md doc/BulkIdsDto.md
@@ -142,6 +141,8 @@ doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md doc/ServerStatsResponseDto.md
doc/ServerThemeDto.md doc/ServerThemeDto.md
doc/ServerVersionResponseDto.md doc/ServerVersionResponseDto.md
doc/SessionResponseDto.md
doc/SessionsApi.md
doc/SharedLinkApi.md doc/SharedLinkApi.md
doc/SharedLinkCreateDto.md doc/SharedLinkCreateDto.md
doc/SharedLinkEditDto.md doc/SharedLinkEditDto.md
@@ -219,6 +220,7 @@ lib/api/partner_api.dart
lib/api/person_api.dart lib/api/person_api.dart
lib/api/search_api.dart lib/api/search_api.dart
lib/api/server_info_api.dart lib/api/server_info_api.dart
lib/api/sessions_api.dart
lib/api/shared_link_api.dart lib/api/shared_link_api.dart
lib/api/sync_api.dart lib/api/sync_api.dart
lib/api/system_config_api.dart lib/api/system_config_api.dart
@@ -267,7 +269,6 @@ lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart lib/model/asset_type_enum.dart
lib/model/audio_codec.dart lib/model/audio_codec.dart
lib/model/audit_deletes_response_dto.dart lib/model/audit_deletes_response_dto.dart
lib/model/auth_device_response_dto.dart
lib/model/bulk_id_response_dto.dart lib/model/bulk_id_response_dto.dart
lib/model/bulk_ids_dto.dart lib/model/bulk_ids_dto.dart
lib/model/change_password_dto.dart lib/model/change_password_dto.dart
@@ -357,6 +358,7 @@ lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart lib/model/server_stats_response_dto.dart
lib/model/server_theme_dto.dart lib/model/server_theme_dto.dart
lib/model/server_version_response_dto.dart lib/model/server_version_response_dto.dart
lib/model/session_response_dto.dart
lib/model/shared_link_create_dto.dart lib/model/shared_link_create_dto.dart
lib/model/shared_link_edit_dto.dart lib/model/shared_link_edit_dto.dart
lib/model/shared_link_response_dto.dart lib/model/shared_link_response_dto.dart
@@ -448,7 +450,6 @@ test/asset_type_enum_test.dart
test/audio_codec_test.dart test/audio_codec_test.dart
test/audit_api_test.dart test/audit_api_test.dart
test/audit_deletes_response_dto_test.dart test/audit_deletes_response_dto_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart test/authentication_api_test.dart
test/bulk_id_response_dto_test.dart test/bulk_id_response_dto_test.dart
test/bulk_ids_dto_test.dart test/bulk_ids_dto_test.dart
@@ -549,6 +550,8 @@ test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart test/server_stats_response_dto_test.dart
test/server_theme_dto_test.dart test/server_theme_dto_test.dart
test/server_version_response_dto_test.dart test/server_version_response_dto_test.dart
test/session_response_dto_test.dart
test/sessions_api_test.dart
test/shared_link_api_test.dart test/shared_link_api_test.dart
test/shared_link_create_dto_test.dart test/shared_link_create_dto_test.dart
test/shared_link_edit_dto_test.dart test/shared_link_edit_dto_test.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.101.0 - API version: 1.102.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements ## Requirements
@@ -117,11 +117,8 @@ Class | Method | HTTP request | Description
*AuditApi* | [**getAuditFiles**](doc//AuditApi.md#getauditfiles) | **GET** /audit/file-report | *AuditApi* | [**getAuditFiles**](doc//AuditApi.md#getauditfiles) | **GET** /audit/file-report |
*AuditApi* | [**getFileChecksums**](doc//AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum | *AuditApi* | [**getFileChecksums**](doc//AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
*AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login |
*AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout |
*AuthenticationApi* | [**logoutAuthDevice**](doc//AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} |
*AuthenticationApi* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices |
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | *DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive |
@@ -183,6 +180,9 @@ Class | Method | HTTP request | Description
*ServerInfoApi* | [**getTheme**](doc//ServerInfoApi.md#gettheme) | **GET** /server-info/theme | *ServerInfoApi* | [**getTheme**](doc//ServerInfoApi.md#gettheme) | **GET** /server-info/theme |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*ServerInfoApi* | [**setAdminOnboarding**](doc//ServerInfoApi.md#setadminonboarding) | **POST** /server-info/admin-onboarding | *ServerInfoApi* | [**setAdminOnboarding**](doc//ServerInfoApi.md#setadminonboarding) | **POST** /server-info/admin-onboarding |
*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions |
*SessionsApi* | [**deleteSession**](doc//SessionsApi.md#deletesession) | **DELETE** /sessions/{id} |
*SessionsApi* | [**getSessions**](doc//SessionsApi.md#getsessions) | **GET** /sessions |
*SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets | *SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets |
*SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link | *SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link |
*SharedLinkApi* | [**getAllSharedLinks**](doc//SharedLinkApi.md#getallsharedlinks) | **GET** /shared-link | *SharedLinkApi* | [**getAllSharedLinks**](doc//SharedLinkApi.md#getallsharedlinks) | **GET** /shared-link |
@@ -258,7 +258,6 @@ Class | Method | HTTP request | Description
- [AssetTypeEnum](doc//AssetTypeEnum.md) - [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md) - [AudioCodec](doc//AudioCodec.md)
- [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md) - [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md)
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md) - [BulkIdsDto](doc//BulkIdsDto.md)
- [CLIPConfig](doc//CLIPConfig.md) - [CLIPConfig](doc//CLIPConfig.md)
@@ -348,6 +347,7 @@ Class | Method | HTTP request | Description
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerThemeDto](doc//ServerThemeDto.md) - [ServerThemeDto](doc//ServerThemeDto.md)
- [ServerVersionResponseDto](doc//ServerVersionResponseDto.md) - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md) - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md) - [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

View File

@@ -10,11 +10,8 @@ All URIs are relative to */api*
Method | HTTP request | Description Method | HTTP request | Description
------------- | ------------- | ------------- ------------- | ------------- | -------------
[**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password | [**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
[**getAuthDevices**](AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
[**login**](AuthenticationApi.md#login) | **POST** /auth/login | [**login**](AuthenticationApi.md#login) | **POST** /auth/login |
[**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout | [**logout**](AuthenticationApi.md#logout) | **POST** /auth/logout |
[**logoutAuthDevice**](AuthenticationApi.md#logoutauthdevice) | **DELETE** /auth/devices/{id} |
[**logoutAuthDevices**](AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices |
[**signUpAdmin**](AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up | [**signUpAdmin**](AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
[**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | [**validateAccessToken**](AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
@@ -74,57 +71,6 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAuthDevices**
> List<AuthDeviceResponseDto> getAuthDevices()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AuthenticationApi();
try {
final result = api_instance.getAuthDevices();
print(result);
} catch (e) {
print('Exception when calling AuthenticationApi->getAuthDevices: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**List<AuthDeviceResponseDto>**](AuthDeviceResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **login** # **login**
> LoginResponseDto login(loginCredentialDto) > LoginResponseDto login(loginCredentialDto)
@@ -217,110 +163,6 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **logoutAuthDevice**
> logoutAuthDevice(id)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AuthenticationApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
api_instance.logoutAuthDevice(id);
} catch (e) {
print('Exception when calling AuthenticationApi->logoutAuthDevice: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **logoutAuthDevices**
> logoutAuthDevices()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AuthenticationApi();
try {
api_instance.logoutAuthDevices();
} catch (e) {
print('Exception when calling AuthenticationApi->logoutAuthDevices: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **signUpAdmin** # **signUpAdmin**
> UserResponseDto signUpAdmin(signUpDto) > UserResponseDto signUpAdmin(signUpDto)

View File

@@ -1,4 +1,4 @@
# openapi.model.AuthDeviceResponseDto # openapi.model.SessionResponseDto
## Load the model package ## Load the model package
```dart ```dart

171
mobile/openapi/doc/SessionsApi.md generated Normal file
View File

@@ -0,0 +1,171 @@
# openapi.api.SessionsApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**deleteAllSessions**](SessionsApi.md#deleteallsessions) | **DELETE** /sessions |
[**deleteSession**](SessionsApi.md#deletesession) | **DELETE** /sessions/{id} |
[**getSessions**](SessionsApi.md#getsessions) | **GET** /sessions |
# **deleteAllSessions**
> deleteAllSessions()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SessionsApi();
try {
api_instance.deleteAllSessions();
} catch (e) {
print('Exception when calling SessionsApi->deleteAllSessions: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **deleteSession**
> deleteSession(id)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SessionsApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
api_instance.deleteSession(id);
} catch (e) {
print('Exception when calling SessionsApi->deleteSession: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getSessions**
> List<SessionResponseDto> getSessions()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SessionsApi();
try {
final result = api_instance.getSessions();
print(result);
} catch (e) {
print('Exception when calling SessionsApi->getSessions: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**List<SessionResponseDto>**](SessionResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

View File

@@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes Name | Type | Description | Notes
------------ | ------------- | ------------- | ------------- ------------ | ------------- | ------------- | -------------
**colorspace** | [**Colorspace**](Colorspace.md) | | **colorspace** | [**Colorspace**](Colorspace.md) | |
**extractEmbedded** | **bool** | |
**previewFormat** | [**ImageFormat**](ImageFormat.md) | | **previewFormat** | [**ImageFormat**](ImageFormat.md) | |
**previewSize** | **int** | | **previewSize** | **int** | |
**quality** | **int** | | **quality** | **int** | |

View File

@@ -45,6 +45,7 @@ part 'api/partner_api.dart';
part 'api/person_api.dart'; part 'api/person_api.dart';
part 'api/search_api.dart'; part 'api/search_api.dart';
part 'api/server_info_api.dart'; part 'api/server_info_api.dart';
part 'api/sessions_api.dart';
part 'api/shared_link_api.dart'; part 'api/shared_link_api.dart';
part 'api/sync_api.dart'; part 'api/sync_api.dart';
part 'api/system_config_api.dart'; part 'api/system_config_api.dart';
@@ -86,7 +87,6 @@ part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart'; part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart'; part 'model/audio_codec.dart';
part 'model/audit_deletes_response_dto.dart'; part 'model/audit_deletes_response_dto.dart';
part 'model/auth_device_response_dto.dart';
part 'model/bulk_id_response_dto.dart'; part 'model/bulk_id_response_dto.dart';
part 'model/bulk_ids_dto.dart'; part 'model/bulk_ids_dto.dart';
part 'model/clip_config.dart'; part 'model/clip_config.dart';
@@ -176,6 +176,7 @@ part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart'; part 'model/server_stats_response_dto.dart';
part 'model/server_theme_dto.dart'; part 'model/server_theme_dto.dart';
part 'model/server_version_response_dto.dart'; part 'model/server_version_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/shared_link_create_dto.dart'; part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart'; part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart'; part 'model/shared_link_response_dto.dart';

View File

@@ -63,50 +63,6 @@ class AuthenticationApi {
return null; return null;
} }
/// Performs an HTTP 'GET /auth/devices' operation and returns the [Response].
Future<Response> getAuthDevicesWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/auth/devices';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<List<AuthDeviceResponseDto>?> getAuthDevices() async {
final response = await getAuthDevicesWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AuthDeviceResponseDto>') as List)
.cast<AuthDeviceResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'POST /auth/login' operation and returns the [Response]. /// Performs an HTTP 'POST /auth/login' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
@@ -195,79 +151,6 @@ class AuthenticationApi {
return null; return null;
} }
/// Performs an HTTP 'DELETE /auth/devices/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> logoutAuthDeviceWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/auth/devices/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> logoutAuthDevice(String id,) async {
final response = await logoutAuthDeviceWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'DELETE /auth/devices' operation and returns the [Response].
Future<Response> logoutAuthDevicesWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/auth/devices';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<void> logoutAuthDevices() async {
final response = await logoutAuthDevicesWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response]. /// Performs an HTTP 'POST /auth/admin-sign-up' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

135
mobile/openapi/lib/api/sessions_api.dart generated Normal file
View File

@@ -0,0 +1,135 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SessionsApi {
SessionsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'DELETE /sessions' operation and returns the [Response].
Future<Response> deleteAllSessionsWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/sessions';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<void> deleteAllSessions() async {
final response = await deleteAllSessionsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'DELETE /sessions/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteSessionWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/sessions/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteSession(String id,) async {
final response = await deleteSessionWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /sessions' operation and returns the [Response].
Future<Response> getSessionsWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/sessions';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<List<SessionResponseDto>?> getSessions() async {
final response = await getSessionsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<SessionResponseDto>') as List)
.cast<SessionResponseDto>()
.toList(growable: false);
}
return null;
}
}

View File

@@ -248,8 +248,6 @@ class ApiClient {
return AudioCodecTypeTransformer().decode(value); return AudioCodecTypeTransformer().decode(value);
case 'AuditDeletesResponseDto': case 'AuditDeletesResponseDto':
return AuditDeletesResponseDto.fromJson(value); return AuditDeletesResponseDto.fromJson(value);
case 'AuthDeviceResponseDto':
return AuthDeviceResponseDto.fromJson(value);
case 'BulkIdResponseDto': case 'BulkIdResponseDto':
return BulkIdResponseDto.fromJson(value); return BulkIdResponseDto.fromJson(value);
case 'BulkIdsDto': case 'BulkIdsDto':
@@ -428,6 +426,8 @@ class ApiClient {
return ServerThemeDto.fromJson(value); return ServerThemeDto.fromJson(value);
case 'ServerVersionResponseDto': case 'ServerVersionResponseDto':
return ServerVersionResponseDto.fromJson(value); return ServerVersionResponseDto.fromJson(value);
case 'SessionResponseDto':
return SessionResponseDto.fromJson(value);
case 'SharedLinkCreateDto': case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value); return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto': case 'SharedLinkEditDto':

View File

@@ -10,9 +10,9 @@
part of openapi.api; part of openapi.api;
class AuthDeviceResponseDto { class SessionResponseDto {
/// Returns a new [AuthDeviceResponseDto] instance. /// Returns a new [SessionResponseDto] instance.
AuthDeviceResponseDto({ SessionResponseDto({
required this.createdAt, required this.createdAt,
required this.current, required this.current,
required this.deviceOS, required this.deviceOS,
@@ -34,7 +34,7 @@ class AuthDeviceResponseDto {
String updatedAt; String updatedAt;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AuthDeviceResponseDto && bool operator ==(Object other) => identical(this, other) || other is SessionResponseDto &&
other.createdAt == createdAt && other.createdAt == createdAt &&
other.current == current && other.current == current &&
other.deviceOS == deviceOS && other.deviceOS == deviceOS &&
@@ -53,7 +53,7 @@ class AuthDeviceResponseDto {
(updatedAt.hashCode); (updatedAt.hashCode);
@override @override
String toString() => 'AuthDeviceResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, updatedAt=$updatedAt]'; String toString() => 'SessionResponseDto[createdAt=$createdAt, current=$current, deviceOS=$deviceOS, deviceType=$deviceType, id=$id, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -66,14 +66,14 @@ class AuthDeviceResponseDto {
return json; return json;
} }
/// Returns a new [AuthDeviceResponseDto] instance and imports its values from /// Returns a new [SessionResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AuthDeviceResponseDto? fromJson(dynamic value) { static SessionResponseDto? fromJson(dynamic value) {
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AuthDeviceResponseDto( return SessionResponseDto(
createdAt: mapValueOfType<String>(json, r'createdAt')!, createdAt: mapValueOfType<String>(json, r'createdAt')!,
current: mapValueOfType<bool>(json, r'current')!, current: mapValueOfType<bool>(json, r'current')!,
deviceOS: mapValueOfType<String>(json, r'deviceOS')!, deviceOS: mapValueOfType<String>(json, r'deviceOS')!,
@@ -85,11 +85,11 @@ class AuthDeviceResponseDto {
return null; return null;
} }
static List<AuthDeviceResponseDto> listFromJson(dynamic json, {bool growable = false,}) { static List<SessionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AuthDeviceResponseDto>[]; final result = <SessionResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AuthDeviceResponseDto.fromJson(row); final value = SessionResponseDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@@ -98,12 +98,12 @@ class AuthDeviceResponseDto {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AuthDeviceResponseDto> mapFromJson(dynamic json) { static Map<String, SessionResponseDto> mapFromJson(dynamic json) {
final map = <String, AuthDeviceResponseDto>{}; final map = <String, SessionResponseDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AuthDeviceResponseDto.fromJson(entry.value); final value = SessionResponseDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@@ -112,14 +112,14 @@ class AuthDeviceResponseDto {
return map; return map;
} }
// maps a json object with a list of AuthDeviceResponseDto-objects as value to a dart map // maps a json object with a list of SessionResponseDto-objects as value to a dart map
static Map<String, List<AuthDeviceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<SessionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AuthDeviceResponseDto>>{}; final map = <String, List<SessionResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AuthDeviceResponseDto.listFromJson(entry.value, growable: growable,); map[entry.key] = SessionResponseDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;

View File

@@ -14,6 +14,7 @@ class SystemConfigImageDto {
/// Returns a new [SystemConfigImageDto] instance. /// Returns a new [SystemConfigImageDto] instance.
SystemConfigImageDto({ SystemConfigImageDto({
required this.colorspace, required this.colorspace,
required this.extractEmbedded,
required this.previewFormat, required this.previewFormat,
required this.previewSize, required this.previewSize,
required this.quality, required this.quality,
@@ -23,6 +24,8 @@ class SystemConfigImageDto {
Colorspace colorspace; Colorspace colorspace;
bool extractEmbedded;
ImageFormat previewFormat; ImageFormat previewFormat;
int previewSize; int previewSize;
@@ -36,6 +39,7 @@ class SystemConfigImageDto {
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto &&
other.colorspace == colorspace && other.colorspace == colorspace &&
other.extractEmbedded == extractEmbedded &&
other.previewFormat == previewFormat && other.previewFormat == previewFormat &&
other.previewSize == previewSize && other.previewSize == previewSize &&
other.quality == quality && other.quality == quality &&
@@ -46,6 +50,7 @@ class SystemConfigImageDto {
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(colorspace.hashCode) + (colorspace.hashCode) +
(extractEmbedded.hashCode) +
(previewFormat.hashCode) + (previewFormat.hashCode) +
(previewSize.hashCode) + (previewSize.hashCode) +
(quality.hashCode) + (quality.hashCode) +
@@ -53,11 +58,12 @@ class SystemConfigImageDto {
(thumbnailSize.hashCode); (thumbnailSize.hashCode);
@override @override
String toString() => 'SystemConfigImageDto[colorspace=$colorspace, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'colorspace'] = this.colorspace; json[r'colorspace'] = this.colorspace;
json[r'extractEmbedded'] = this.extractEmbedded;
json[r'previewFormat'] = this.previewFormat; json[r'previewFormat'] = this.previewFormat;
json[r'previewSize'] = this.previewSize; json[r'previewSize'] = this.previewSize;
json[r'quality'] = this.quality; json[r'quality'] = this.quality;
@@ -75,6 +81,7 @@ class SystemConfigImageDto {
return SystemConfigImageDto( return SystemConfigImageDto(
colorspace: Colorspace.fromJson(json[r'colorspace'])!, colorspace: Colorspace.fromJson(json[r'colorspace'])!,
extractEmbedded: mapValueOfType<bool>(json, r'extractEmbedded')!,
previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!,
previewSize: mapValueOfType<int>(json, r'previewSize')!, previewSize: mapValueOfType<int>(json, r'previewSize')!,
quality: mapValueOfType<int>(json, r'quality')!, quality: mapValueOfType<int>(json, r'quality')!,
@@ -128,6 +135,7 @@ class SystemConfigImageDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'colorspace', 'colorspace',
'extractEmbedded',
'previewFormat', 'previewFormat',
'previewSize', 'previewSize',
'quality', 'quality',

View File

@@ -22,11 +22,6 @@ void main() {
// TODO // TODO
}); });
//Future<List<AuthDeviceResponseDto>> getAuthDevices() async
test('test getAuthDevices', () async {
// TODO
});
//Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async //Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async
test('test login', () async { test('test login', () async {
// TODO // TODO
@@ -37,16 +32,6 @@ void main() {
// TODO // TODO
}); });
//Future logoutAuthDevice(String id) async
test('test logoutAuthDevice', () async {
// TODO
});
//Future logoutAuthDevices() async
test('test logoutAuthDevices', () async {
// TODO
});
//Future<UserResponseDto> signUpAdmin(SignUpDto signUpDto) async //Future<UserResponseDto> signUpAdmin(SignUpDto signUpDto) async
test('test signUpAdmin', () async { test('test signUpAdmin', () async {
// TODO // TODO

View File

@@ -11,11 +11,11 @@
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
// tests for AuthDeviceResponseDto // tests for SessionResponseDto
void main() { void main() {
// final instance = AuthDeviceResponseDto(); // final instance = SessionResponseDto();
group('test AuthDeviceResponseDto', () { group('test SessionResponseDto', () {
// String createdAt // String createdAt
test('to test the property `createdAt`', () async { test('to test the property `createdAt`', () async {
// TODO // TODO

View File

@@ -0,0 +1,36 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
/// tests for SessionsApi
void main() {
// final instance = SessionsApi();
group('tests for SessionsApi', () {
//Future deleteAllSessions() async
test('test deleteAllSessions', () async {
// TODO
});
//Future deleteSession(String id) async
test('test deleteSession', () async {
// TODO
});
//Future<List<SessionResponseDto>> getSessions() async
test('test getSessions', () async {
// TODO
});
});
}

View File

@@ -21,6 +21,11 @@ void main() {
// TODO // TODO
}); });
// bool extractEmbedded
test('to test the property `extractEmbedded`', () async {
// TODO
});
// ImageFormat previewFormat // ImageFormat previewFormat
test('to test the property `previewFormat`', () async { test('to test the property `previewFormat`', () async {
// TODO // TODO

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.101.0+131 version: 1.102.0+132
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@@ -105,9 +105,6 @@ flutter:
- assets/ - assets/
- assets/i18n/ - assets/i18n/
fonts: fonts:
- family: SnowburstOne
fonts:
- asset: fonts/SnowburstOne.ttf
- family: Inconsolata - family: Inconsolata
fonts: fonts:
- asset: fonts/Inconsolata-Regular.ttf - asset: fonts/Inconsolata-Regular.ttf

View File

@@ -2530,99 +2530,6 @@
] ]
} }
}, },
"/auth/devices": {
"delete": {
"operationId": "logoutAuthDevices",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
},
"get": {
"operationId": "getAuthDevices",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AuthDeviceResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/devices/{id}": {
"delete": {
"operationId": "logoutAuthDevice",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/login": { "/auth/login": {
"post": { "post": {
"operationId": "login", "operationId": "login",
@@ -5184,6 +5091,99 @@
] ]
} }
}, },
"/sessions": {
"delete": {
"operationId": "deleteAllSessions",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
},
"get": {
"operationId": "getSessions",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/SessionResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/sessions/{id}": {
"delete": {
"operationId": "deleteSession",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Sessions"
]
}
},
"/shared-link": { "/shared-link": {
"get": { "get": {
"operationId": "getAllSharedLinks", "operationId": "getAllSharedLinks",
@@ -7006,7 +7006,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.101.0", "version": "1.102.0",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@@ -7892,37 +7892,6 @@
], ],
"type": "object" "type": "object"
}, },
"AuthDeviceResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"id": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"updatedAt"
],
"type": "object"
},
"BulkIdResponseDto": { "BulkIdResponseDto": {
"properties": { "properties": {
"error": { "error": {
@@ -10049,6 +10018,37 @@
], ],
"type": "object" "type": "object"
}, },
"SessionResponseDto": {
"properties": {
"createdAt": {
"type": "string"
},
"current": {
"type": "boolean"
},
"deviceOS": {
"type": "string"
},
"deviceType": {
"type": "string"
},
"id": {
"type": "string"
},
"updatedAt": {
"type": "string"
}
},
"required": [
"createdAt",
"current",
"deviceOS",
"deviceType",
"id",
"updatedAt"
],
"type": "object"
},
"SharedLinkCreateDto": { "SharedLinkCreateDto": {
"properties": { "properties": {
"albumId": { "albumId": {
@@ -10531,6 +10531,9 @@
"colorspace": { "colorspace": {
"$ref": "#/components/schemas/Colorspace" "$ref": "#/components/schemas/Colorspace"
}, },
"extractEmbedded": {
"type": "boolean"
},
"previewFormat": { "previewFormat": {
"$ref": "#/components/schemas/ImageFormat" "$ref": "#/components/schemas/ImageFormat"
}, },
@@ -10549,6 +10552,7 @@
}, },
"required": [ "required": [
"colorspace", "colorspace",
"extractEmbedded",
"previewFormat", "previewFormat",
"previewSize", "previewSize",
"quality", "quality",

View File

@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.101.0", "version": "1.102.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.101.0", "version": "1.102.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.101.0", "version": "1.102.0",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.101.0 * 1.102.0
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
@@ -346,14 +346,6 @@ export type ChangePasswordDto = {
newPassword: string; newPassword: string;
password: string; password: string;
}; };
export type AuthDeviceResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
updatedAt: string;
};
export type LoginCredentialDto = { export type LoginCredentialDto = {
email: string; email: string;
password: string; password: string;
@@ -791,6 +783,14 @@ export type ServerVersionResponseDto = {
minor: number; minor: number;
patch: number; patch: number;
}; };
export type SessionResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
updatedAt: string;
};
export type SharedLinkResponseDto = { export type SharedLinkResponseDto = {
album?: AlbumResponseDto; album?: AlbumResponseDto;
allowDownload: boolean; allowDownload: boolean;
@@ -864,6 +864,7 @@ export type SystemConfigFFmpegDto = {
}; };
export type SystemConfigImageDto = { export type SystemConfigImageDto = {
colorspace: Colorspace; colorspace: Colorspace;
extractEmbedded: boolean;
previewFormat: ImageFormat; previewFormat: ImageFormat;
previewSize: number; previewSize: number;
quality: number; quality: number;
@@ -1703,28 +1704,6 @@ export function changePassword({ changePasswordDto }: {
body: changePasswordDto body: changePasswordDto
}))); })));
} }
export function logoutAuthDevices(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/devices", {
...opts,
method: "DELETE"
}));
}
export function getAuthDevices(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AuthDeviceResponseDto[];
}>("/auth/devices", {
...opts
}));
}
export function logoutAuthDevice({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/auth/devices/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function login({ loginCredentialDto }: { export function login({ loginCredentialDto }: {
loginCredentialDto: LoginCredentialDto; loginCredentialDto: LoginCredentialDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
@@ -2413,6 +2392,28 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) {
...opts ...opts
})); }));
} }
export function deleteAllSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/sessions", {
...opts,
method: "DELETE"
}));
}
export function getSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SessionResponseDto[];
}>("/sessions", {
...opts
}));
}
export function deleteSession({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/sessions/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) { export function getAllSharedLinks(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;

View File

@@ -1,5 +1,5 @@
# dev build # dev build
FROM ghcr.io/immich-app/base-server-dev:20240326@sha256:d945aba864051b30888617f36446f86b72c4bc7ad6476b9dd2aaa0c4c4e3c945 as dev FROM ghcr.io/immich-app/base-server-dev:20240416@sha256:ff2aadf54298e8ceca94031c6fed143236d8d82640fbbf422e0a9d2978e45923 as dev
RUN apt-get install --no-install-recommends -yqq tini RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app WORKDIR /usr/src/app
@@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build # web build
FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e as web FROM node:iron-alpine3.18@sha256:d328c7bc3305e1ab26491817936c8151a47a8861ad617c16c1eeaa9c8075c8f6 as web
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
@@ -41,7 +41,7 @@ RUN npm run build
# prod build # prod build
FROM ghcr.io/immich-app/base-server-prod:20240326@sha256:28ad98fed8d746b5f92de49ff776cfdff7399df163ebeda2f37a01f473965841 FROM ghcr.io/immich-app/base-server-prod:20240416@sha256:138f4d6fb74b282256583070339eaba6f39fcffa3569ae05b6823d5c37098242
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \

View File

@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.101.0", "version": "1.102.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.101.0", "version": "1.102.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^10.0.1", "@nestjs/bullmq": "^10.0.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.101.0", "version": "1.102.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -1,5 +1,4 @@
import { Command, CommandRunner } from 'nest-commander'; import { Command, CommandRunner } from 'nest-commander';
import { UserEntity } from 'src/entities/user.entity';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
@Command({ @Command({
@@ -13,16 +12,7 @@ export class ListUsersCommand extends CommandRunner {
async run(): Promise<void> { async run(): Promise<void> {
try { try {
const users = await this.userService.getAll( const users = await this.userService.listUsers();
{
user: {
id: 'cli',
email: 'cli@immich.app',
isAdmin: true,
} as UserEntity,
},
true,
);
console.dir(users); console.dir(users);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -26,12 +26,7 @@ export const geodataCities500Path = join(GEODATA_ROOT_PATH, citiesFile);
export const MOBILE_REDIRECT = 'app.immich:/'; export const MOBILE_REDIRECT = 'app.immich:/';
export const LOGIN_URL = '/auth/login?autoLaunch=0'; export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
export const IMMICH_IS_AUTHENTICATED = 'immich_is_authenticated';
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
export const IMMICH_API_KEY_NAME = 'api_key';
export const IMMICH_API_KEY_HEADER = 'x-api-key';
export const IMMICH_SHARED_LINK_ACCESS_COOKIE = 'immich_shared_link_token';
export enum AuthType { export enum AuthType {
PASSWORD = 'password', PASSWORD = 'password',
OAUTH = 'oauth', OAUTH = 'oauth',

View File

@@ -1,11 +1,11 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req, Res } from '@nestjs/common'; import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants'; import { AuthType } from 'src/constants';
import { import {
AuthDeviceResponseDto,
AuthDto, AuthDto,
ChangePasswordDto, ChangePasswordDto,
ImmichCookie,
LoginCredentialDto, LoginCredentialDto,
LoginResponseDto, LoginResponseDto,
LogoutResponseDto, LogoutResponseDto,
@@ -15,7 +15,7 @@ import {
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UUIDParamDto } from 'src/validation'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@ApiTags('Authentication') @ApiTags('Authentication')
@Controller('auth') @Controller('auth')
@@ -30,9 +30,15 @@ export class AuthController {
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(loginCredential, loginDetails); const body = await this.service.login(loginCredential, loginDetails);
res.header('Set-Cookie', cookie); return respondWithCookie(res, body, {
return response; isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken },
{ key: ImmichCookie.AUTH_TYPE, value: AuthType.PASSWORD },
{ key: ImmichCookie.IS_AUTHENTICATED, value: 'true' },
],
});
} }
@PublicRoute() @PublicRoute()
@@ -41,23 +47,6 @@ export class AuthController {
return this.service.adminSignUp(dto); return this.service.adminSignUp(dto);
} }
@Get('devices')
getAuthDevices(@Auth() auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
return this.service.getDevices(auth);
}
@Delete('devices')
@HttpCode(HttpStatus.NO_CONTENT)
logoutAuthDevices(@Auth() auth: AuthDto): Promise<void> {
return this.service.logoutDevices(auth);
}
@Delete('devices/:id')
@HttpCode(HttpStatus.NO_CONTENT)
logoutAuthDevice(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.logoutDevice(auth, id);
}
@Post('validateToken') @Post('validateToken')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
validateAccessToken(): ValidateAccessTokenResponseDto { validateAccessToken(): ValidateAccessTokenResponseDto {
@@ -72,15 +61,18 @@ export class AuthController {
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
logout( async logout(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
): Promise<LogoutResponseDto> { ): Promise<LogoutResponseDto> {
res.clearCookie(IMMICH_ACCESS_COOKIE); const authType = (request.cookies || {})[ImmichCookie.AUTH_TYPE];
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
res.clearCookie(IMMICH_IS_AUTHENTICATED);
return this.service.logout(auth, (request.cookies || {})[IMMICH_AUTH_TYPE_COOKIE]); const body = await this.service.logout(auth, authType);
return respondWithoutCookie(res, body, [
ImmichCookie.ACCESS_TOKEN,
ImmichCookie.AUTH_TYPE,
ImmichCookie.IS_AUTHENTICATED,
]);
} }
} }

View File

@@ -16,6 +16,7 @@ import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller'; import { PersonController } from 'src/controllers/person.controller';
import { SearchController } from 'src/controllers/search.controller'; import { SearchController } from 'src/controllers/search.controller';
import { ServerInfoController } from 'src/controllers/server-info.controller'; import { ServerInfoController } from 'src/controllers/server-info.controller';
import { SessionController } from 'src/controllers/session.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller'; import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SyncController } from 'src/controllers/sync.controller'; import { SyncController } from 'src/controllers/sync.controller';
import { SystemConfigController } from 'src/controllers/system-config.controller'; import { SystemConfigController } from 'src/controllers/system-config.controller';
@@ -43,6 +44,7 @@ export const controllers = [
PartnerController, PartnerController,
SearchController, SearchController,
ServerInfoController, ServerInfoController,
SessionController,
SharedLinkController, SharedLinkController,
SyncController, SyncController,
SystemConfigController, SystemConfigController,

View File

@@ -1,8 +1,10 @@
import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { AuthType } from 'src/constants';
import { import {
AuthDto, AuthDto,
ImmichCookie,
LoginResponseDto, LoginResponseDto,
OAuthAuthorizeResponseDto, OAuthAuthorizeResponseDto,
OAuthCallbackDto, OAuthCallbackDto,
@@ -11,6 +13,7 @@ import {
import { UserResponseDto } from 'src/dtos/user.dto'; import { UserResponseDto } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags('OAuth') @ApiTags('OAuth')
@Controller('oauth') @Controller('oauth')
@@ -41,9 +44,15 @@ export class OAuthController {
@Body() dto: OAuthCallbackDto, @Body() dto: OAuthCallbackDto,
@GetLoginDetails() loginDetails: LoginDetails, @GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> { ): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.callback(dto, loginDetails); const body = await this.service.callback(dto, loginDetails);
res.header('Set-Cookie', cookie); return respondWithCookie(res, body, {
return response; isSecure: loginDetails.isSecure,
values: [
{ key: ImmichCookie.ACCESS_TOKEN, value: body.accessToken },
{ key: ImmichCookie.AUTH_TYPE, value: AuthType.OAUTH },
{ key: ImmichCookie.IS_AUTHENTICATED, value: 'true' },
],
});
} }
@Post('link') @Post('link')

View File

@@ -0,0 +1,31 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto } from 'src/dtos/session.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { SessionService } from 'src/services/session.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Sessions')
@Controller('sessions')
@Authenticated()
export class SessionController {
constructor(private service: SessionService) {}
@Get()
getSessions(@Auth() auth: AuthDto): Promise<SessionResponseDto[]> {
return this.service.getAll(auth);
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllSessions(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteAll(auth);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deleteSession(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View File

@@ -1,18 +1,19 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { IMMICH_SHARED_LINK_ACCESS_COOKIE } from 'src/constants';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto';
import { import {
SharedLinkCreateDto, SharedLinkCreateDto,
SharedLinkEditDto, SharedLinkEditDto,
SharedLinkPasswordDto, SharedLinkPasswordDto,
SharedLinkResponseDto, SharedLinkResponseDto,
} from 'src/dtos/shared-link.dto'; } from 'src/dtos/shared-link.dto';
import { Auth, Authenticated, SharedLinkRoute } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails, SharedLinkRoute } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { respondWithCookie } from 'src/utils/response';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
@ApiTags('Shared Link') @ApiTags('Shared Link')
@@ -33,20 +34,17 @@ export class SharedLinkController {
@Query() dto: SharedLinkPasswordDto, @Query() dto: SharedLinkPasswordDto,
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> { ): Promise<SharedLinkResponseDto> {
const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE]; const sharedLinkToken = request.cookies?.[ImmichCookie.SHARED_LINK_TOKEN];
if (sharedLinkToken) { if (sharedLinkToken) {
dto.token = sharedLinkToken; dto.token = sharedLinkToken;
} }
const response = await this.service.getMine(auth, dto); const body = await this.service.getMine(auth, dto);
if (response.token) { return respondWithCookie(res, body, {
res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, response.token, { isSecure: loginDetails.isSecure,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24), values: body.token ? [{ key: ImmichCookie.SHARED_LINK_TOKEN, value: body.token }] : [],
httpOnly: true, });
sameSite: 'lax',
});
}
return response;
} }
@Get(':id') @Get(':id')

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants'; import { APP_MEDIA_LOCATION } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
@@ -308,4 +309,8 @@ export class StorageCore {
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string { static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
return join(this.getNestedFolder(folder, ownerId, filename), filename); return join(this.getNestedFolder(folder, ownerId, filename), filename);
} }
static getTempPathInDir(dir: string): string {
return join(dir, `${randomUUID()}.tmp`);
}
} }

View File

@@ -120,6 +120,7 @@ export const defaults = Object.freeze<SystemConfig>({
previewSize: 1440, previewSize: 1440,
quality: 80, quality: 80,
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
extractEmbedded: false,
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,

View File

@@ -2,16 +2,35 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { APIKeyEntity } from 'src/entities/api-key.entity'; import { APIKeyEntity } from 'src/entities/api-key.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
export enum ImmichCookie {
ACCESS_TOKEN = 'immich_access_token',
AUTH_TYPE = 'immich_auth_type',
IS_AUTHENTICATED = 'immich_is_authenticated',
SHARED_LINK_TOKEN = 'immich_shared_link_token',
}
export enum ImmichHeader {
API_KEY = 'x-api-key',
USER_TOKEN = 'x-immich-user-token',
SESSION_TOKEN = 'x-immich-session-token',
SHARED_LINK_TOKEN = 'x-immich-share-key',
}
export type CookieResponse = {
isSecure: boolean;
values: Array<{ key: ImmichCookie; value: string }>;
};
export class AuthDto { export class AuthDto {
user!: UserEntity; user!: UserEntity;
apiKey?: APIKeyEntity; apiKey?: APIKeyEntity;
sharedLink?: SharedLinkEntity; sharedLink?: SharedLinkEntity;
userToken?: UserTokenEntity; session?: SessionEntity;
} }
export class LoginCredentialDto { export class LoginCredentialDto {
@@ -39,7 +58,7 @@ export class LoginResponseDto {
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto { export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
return { return {
accessToken: accessToken, accessToken,
userId: entity.id, userId: entity.id,
userEmail: entity.email, userEmail: entity.email,
name: entity.name, name: entity.name,
@@ -78,24 +97,6 @@ export class ValidateAccessTokenResponseDto {
authStatus!: boolean; authStatus!: boolean;
} }
export class AuthDeviceResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
}
export const mapUserToken = (entity: UserTokenEntity, currentId?: string): AuthDeviceResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,
});
export class OAuthCallbackDto { export class OAuthCallbackDto {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()

View File

@@ -0,0 +1,19 @@
import { SessionEntity } from 'src/entities/session.entity';
export class SessionResponseDto {
id!: string;
createdAt!: string;
updatedAt!: string;
current!: boolean;
deviceType!: string;
deviceOS!: string;
}
export const mapSession = (entity: SessionEntity, currentId?: string): SessionResponseDto => ({
id: entity.id,
createdAt: entity.createdAt.toISOString(),
updatedAt: entity.updatedAt.toISOString(),
current: currentId === entity.id,
deviceOS: entity.deviceOS,
deviceType: entity.deviceType,
});

View File

@@ -417,6 +417,9 @@ class SystemConfigImageDto {
@IsEnum(Colorspace) @IsEnum(Colorspace)
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
colorspace!: Colorspace; colorspace!: Colorspace;
@ValidateBoolean()
extractEmbedded!: boolean;
} }
class SystemConfigTrashDto { class SystemConfigTrashDto {

View File

@@ -13,13 +13,13 @@ import { MemoryEntity } from 'src/entities/memory.entity';
import { MoveEntity } from 'src/entities/move.entity'; import { MoveEntity } from 'src/entities/move.entity';
import { PartnerEntity } from 'src/entities/partner.entity'; import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { SystemConfigEntity } from 'src/entities/system-config.entity'; import { SystemConfigEntity } from 'src/entities/system-config.entity';
import { SystemMetadataEntity } from 'src/entities/system-metadata.entity'; import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
export const entities = [ export const entities = [
@@ -44,6 +44,6 @@ export const entities = [
SystemMetadataEntity, SystemMetadataEntity,
TagEntity, TagEntity,
UserEntity, UserEntity,
UserTokenEntity, SessionEntity,
LibraryEntity, LibraryEntity,
]; ];

View File

@@ -1,8 +1,8 @@
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('user_token') @Entity('sessions')
export class UserTokenEntity { export class SessionEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;

View File

@@ -114,6 +114,7 @@ export const SystemConfigKey = {
IMAGE_PREVIEW_SIZE: 'image.previewSize', IMAGE_PREVIEW_SIZE: 'image.previewSize',
IMAGE_QUALITY: 'image.quality', IMAGE_QUALITY: 'image.quality',
IMAGE_COLORSPACE: 'image.colorspace', IMAGE_COLORSPACE: 'image.colorspace',
IMAGE_EXTRACT_EMBEDDED: 'image.extractEmbedded',
TRASH_ENABLED: 'trash.enabled', TRASH_ENABLED: 'trash.enabled',
TRASH_DAYS: 'trash.days', TRASH_DAYS: 'trash.days',
@@ -284,6 +285,7 @@ export interface SystemConfig {
previewSize: number; previewSize: number;
quality: number; quality: number;
colorspace: Colorspace; colorspace: Colorspace;
extractEmbedded: boolean;
}; };
newVersionCheck: { newVersionCheck: {
enabled: boolean; enabled: boolean;

View File

@@ -34,6 +34,11 @@ export interface VideoFormat {
bitrate: number; bitrate: number;
} }
export interface ImageDimensions {
width: number;
height: number;
}
export interface VideoInfo { export interface VideoInfo {
format: VideoFormat; format: VideoFormat;
videoStreams: VideoStreamInfo[]; videoStreams: VideoStreamInfo[];
@@ -70,9 +75,11 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig {
export interface IMediaRepository { export interface IMediaRepository {
// image // image
extract(input: string, output: string): Promise<boolean>;
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
crop(input: string, options: CropOptions): Promise<Buffer>; crop(input: string, options: CropOptions): Promise<Buffer>;
generateThumbhash(imagePath: string): Promise<Buffer>; generateThumbhash(imagePath: string): Promise<Buffer>;
getImageDimensions(input: string): Promise<ImageDimensions>;
// video // video
probe(input: string): Promise<VideoInfo>; probe(input: string): Promise<VideoInfo>;

View File

@@ -0,0 +1,11 @@
import { SessionEntity } from 'src/entities/session.entity';
export const ISessionRepository = 'ISessionRepository';
export interface ISessionRepository {
create(dto: Partial<SessionEntity>): Promise<SessionEntity>;
update(dto: Partial<SessionEntity>): Promise<SessionEntity>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<SessionEntity | null>;
getByUserId(userId: string): Promise<SessionEntity[]>;
}

View File

@@ -1,11 +0,0 @@
import { UserTokenEntity } from 'src/entities/user-token.entity';
export const IUserTokenRepository = 'IUserTokenRepository';
export interface IUserTokenRepository {
create(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
save(dto: Partial<UserTokenEntity>): Promise<UserTokenEntity>;
delete(id: string): Promise<void>;
getByToken(token: string): Promise<UserTokenEntity | null>;
getAll(userId: string): Promise<UserTokenEntity[]>;
}

View File

@@ -10,7 +10,6 @@ import {
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express'; import { Request } from 'express';
import { IMMICH_API_KEY_NAME } from 'src/constants';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AuthService, LoginDetails } from 'src/services/auth.service'; import { AuthService, LoginDetails } from 'src/services/auth.service';
@@ -21,6 +20,7 @@ export enum Metadata {
ADMIN_ROUTE = 'admin_route', ADMIN_ROUTE = 'admin_route',
SHARED_ROUTE = 'shared_route', SHARED_ROUTE = 'shared_route',
PUBLIC_SECURITY = 'public_security', PUBLIC_SECURITY = 'public_security',
API_KEY_SECURITY = 'api_key',
} }
export interface AuthenticatedOptions { export interface AuthenticatedOptions {
@@ -32,7 +32,7 @@ export const Authenticated = (options: AuthenticatedOptions = {}) => {
const decorators: MethodDecorator[] = [ const decorators: MethodDecorator[] = [
ApiBearerAuth(), ApiBearerAuth(),
ApiCookieAuth(), ApiCookieAuth(),
ApiSecurity(IMMICH_API_KEY_NAME), ApiSecurity(Metadata.API_KEY_SECURITY),
SetMetadata(Metadata.AUTH_ROUTE, true), SetMetadata(Metadata.AUTH_ROUTE, true),
]; ];

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameSessionsTable1713490844785 implements MigrationInterface {
name = 'RenameSessionsTable1713490844785';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_token" RENAME TO "sessions"`);
await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" to "FK_57de40bc620f456c7311aa3a1e6"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "sessions" RENAME CONSTRAINT "FK_57de40bc620f456c7311aa3a1e6" to "FK_d37db50eecdf9b8ce4eedd2f918"`);
await queryRunner.query(`ALTER TABLE "sessions" RENAME TO "user_token"`);
}
}

View File

@@ -173,13 +173,13 @@ WHERE
-- AccessRepository.authDevice.checkOwnerAccess -- AccessRepository.authDevice.checkOwnerAccess
SELECT SELECT
"UserTokenEntity"."id" AS "UserTokenEntity_id" "SessionEntity"."id" AS "SessionEntity_id"
FROM FROM
"user_token" "UserTokenEntity" "sessions" "SessionEntity"
WHERE WHERE
( (
("UserTokenEntity"."userId" = $1) ("SessionEntity"."userId" = $1)
AND ("UserTokenEntity"."id" IN ($2)) AND ("SessionEntity"."id" IN ($2))
) )
-- AccessRepository.library.checkOwnerAccess -- AccessRepository.library.checkOwnerAccess

View File

@@ -0,0 +1,48 @@
-- NOTE: This file is auto generated by ./sql-generator
-- SessionRepository.getByToken
SELECT DISTINCT
"distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id"
FROM
(
SELECT
"SessionEntity"."id" AS "SessionEntity_id",
"SessionEntity"."userId" AS "SessionEntity_userId",
"SessionEntity"."createdAt" AS "SessionEntity_createdAt",
"SessionEntity"."updatedAt" AS "SessionEntity_updatedAt",
"SessionEntity"."deviceType" AS "SessionEntity_deviceType",
"SessionEntity"."deviceOS" AS "SessionEntity_deviceOS",
"SessionEntity__SessionEntity_user"."id" AS "SessionEntity__SessionEntity_user_id",
"SessionEntity__SessionEntity_user"."name" AS "SessionEntity__SessionEntity_user_name",
"SessionEntity__SessionEntity_user"."avatarColor" AS "SessionEntity__SessionEntity_user_avatarColor",
"SessionEntity__SessionEntity_user"."isAdmin" AS "SessionEntity__SessionEntity_user_isAdmin",
"SessionEntity__SessionEntity_user"."email" AS "SessionEntity__SessionEntity_user_email",
"SessionEntity__SessionEntity_user"."storageLabel" AS "SessionEntity__SessionEntity_user_storageLabel",
"SessionEntity__SessionEntity_user"."oauthId" AS "SessionEntity__SessionEntity_user_oauthId",
"SessionEntity__SessionEntity_user"."profileImagePath" AS "SessionEntity__SessionEntity_user_profileImagePath",
"SessionEntity__SessionEntity_user"."shouldChangePassword" AS "SessionEntity__SessionEntity_user_shouldChangePassword",
"SessionEntity__SessionEntity_user"."createdAt" AS "SessionEntity__SessionEntity_user_createdAt",
"SessionEntity__SessionEntity_user"."deletedAt" AS "SessionEntity__SessionEntity_user_deletedAt",
"SessionEntity__SessionEntity_user"."status" AS "SessionEntity__SessionEntity_user_status",
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
"SessionEntity__SessionEntity_user"."memoriesEnabled" AS "SessionEntity__SessionEntity_user_memoriesEnabled",
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes"
FROM
"sessions" "SessionEntity"
LEFT JOIN "users" "SessionEntity__SessionEntity_user" ON "SessionEntity__SessionEntity_user"."id" = "SessionEntity"."userId"
AND (
"SessionEntity__SessionEntity_user"."deletedAt" IS NULL
)
WHERE
(("SessionEntity"."token" = $1))
) "distinctAlias"
ORDER BY
"SessionEntity_id" ASC
LIMIT
1
-- SessionRepository.delete
DELETE FROM "sessions"
WHERE
"id" = $1

View File

@@ -159,10 +159,12 @@ SET
COALESCE(SUM(exif."fileSizeInByte"), 0) COALESCE(SUM(exif."fileSizeInByte"), 0)
FROM FROM
"assets" "assets" "assets" "assets"
LEFT JOIN "libraries" "library" ON "library"."id" = "assets"."libraryId"
AND ("library"."deletedAt" IS NULL)
LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id" LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id"
WHERE WHERE
"assets"."ownerId" = users.id "assets"."ownerId" = users.id
AND NOT "assets"."isExternal" AND "library"."type" = 'UPLOAD'
), ),
"updatedAt" = CURRENT_TIMESTAMP "updatedAt" = CURRENT_TIMESTAMP
WHERE WHERE

View File

@@ -1,48 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- UserTokenRepository.getByToken
SELECT DISTINCT
"distinctAlias"."UserTokenEntity_id" AS "ids_UserTokenEntity_id"
FROM
(
SELECT
"UserTokenEntity"."id" AS "UserTokenEntity_id",
"UserTokenEntity"."userId" AS "UserTokenEntity_userId",
"UserTokenEntity"."createdAt" AS "UserTokenEntity_createdAt",
"UserTokenEntity"."updatedAt" AS "UserTokenEntity_updatedAt",
"UserTokenEntity"."deviceType" AS "UserTokenEntity_deviceType",
"UserTokenEntity"."deviceOS" AS "UserTokenEntity_deviceOS",
"UserTokenEntity__UserTokenEntity_user"."id" AS "UserTokenEntity__UserTokenEntity_user_id",
"UserTokenEntity__UserTokenEntity_user"."name" AS "UserTokenEntity__UserTokenEntity_user_name",
"UserTokenEntity__UserTokenEntity_user"."avatarColor" AS "UserTokenEntity__UserTokenEntity_user_avatarColor",
"UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin",
"UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email",
"UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel",
"UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId",
"UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath",
"UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword",
"UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt",
"UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt",
"UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status",
"UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt",
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled",
"UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes",
"UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes"
FROM
"user_token" "UserTokenEntity"
LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId"
AND (
"UserTokenEntity__UserTokenEntity_user"."deletedAt" IS NULL
)
WHERE
(("UserTokenEntity"."token" = $1))
) "distinctAlias"
ORDER BY
"UserTokenEntity_id" ASC
LIMIT
1
-- UserTokenRepository.delete
DELETE FROM "user_token"
WHERE
"id" = $1

View File

@@ -9,8 +9,8 @@ import { LibraryEntity } from 'src/entities/library.entity';
import { MemoryEntity } from 'src/entities/memory.entity'; import { MemoryEntity } from 'src/entities/memory.entity';
import { PartnerEntity } from 'src/entities/partner.entity'; import { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { Brackets, In, Repository } from 'typeorm'; import { Brackets, In, Repository } from 'typeorm';
@@ -286,7 +286,7 @@ class AssetAccess implements IAssetAccess {
} }
class AuthDeviceAccess implements IAuthDeviceAccess { class AuthDeviceAccess implements IAuthDeviceAccess {
constructor(private tokenRepository: Repository<UserTokenEntity>) {} constructor(private sessionRepository: Repository<SessionEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 }) @ChunkedSet({ paramIndex: 1 })
@@ -295,7 +295,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
return new Set(); return new Set();
} }
return this.tokenRepository return this.sessionRepository
.find({ .find({
select: { id: true }, select: { id: true },
where: { where: {
@@ -457,12 +457,12 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>, @InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>, @InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>, @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>, @InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
) { ) {
this.activity = new ActivityAccess(activityRepository, albumRepository); this.activity = new ActivityAccess(activityRepository, albumRepository);
this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository);
this.authDevice = new AuthDeviceAccess(tokenRepository); this.authDevice = new AuthDeviceAccess(sessionRepository);
this.library = new LibraryAccess(libraryRepository); this.library = new LibraryAccess(libraryRepository);
this.memory = new MemoryAccess(memoryRepository); this.memory = new MemoryAccess(memoryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository); this.person = new PersonAccess(assetFaceRepository, personRepository);

View File

@@ -22,12 +22,12 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface'; import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
@@ -53,12 +53,12 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository'; import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository'; import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkRepository } from 'src/repositories/shared-link.repository'; import { SharedLinkRepository } from 'src/repositories/shared-link.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository'; import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository'; import { TagRepository } from 'src/repositories/tag.repository';
import { UserTokenRepository } from 'src/repositories/user-token.repository';
import { UserRepository } from 'src/repositories/user.repository'; import { UserRepository } from 'src/repositories/user.repository';
export const repositories = [ export const repositories = [
@@ -86,11 +86,11 @@ export const repositories = [
{ provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISearchRepository, useClass: SearchRepository }, { provide: ISearchRepository, useClass: SearchRepository },
{ provide: ISessionRepository, useClass: SessionRepository },
{ provide: IStorageRepository, useClass: StorageRepository }, { provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository }, { provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository }, { provide: IMediaRepository, useClass: MediaRepository },
{ provide: IUserRepository, useClass: UserRepository }, { provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
]; ];

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
@@ -9,6 +10,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { import {
CropOptions, CropOptions,
IMediaRepository, IMediaRepository,
ImageDimensions,
ResizeOptions, ResizeOptions,
TranscodeOptions, TranscodeOptions,
VideoInfo, VideoInfo,
@@ -26,6 +28,23 @@ export class MediaRepository implements IMediaRepository {
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
this.logger.setContext(MediaRepository.name); this.logger.setContext(MediaRepository.name);
} }
async extract(input: string, output: string): Promise<boolean> {
try {
await exiftool.extractJpgFromRaw(input, output);
} catch (error: any) {
this.logger.debug('Could not extract JPEG from image, trying preview', error.message);
try {
await exiftool.extractPreview(input, output);
} catch (error: any) {
this.logger.debug('Could not extract preview from image', error.message);
return false;
}
}
return true;
}
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> { crop(input: string | Buffer, options: CropOptions): Promise<Buffer> {
return sharp(input, { failOn: 'none' }) return sharp(input, { failOn: 'none' })
.pipelineColorspace('rgb16') .pipelineColorspace('rgb16')
@@ -133,6 +152,11 @@ export class MediaRepository implements IMediaRepository {
return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data)); return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data));
} }
async getImageDimensions(input: string): Promise<ImageDimensions> {
const { width = 0, height = 0 } = await sharp(input).metadata();
return { width, height };
}
private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) { private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
return ffmpeg(input, { niceness: 10 }) return ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions) .inputOptions(options.inputOptions)
@@ -140,9 +164,4 @@ export class MediaRepository implements IMediaRepository {
.output(output) .output(output)
.on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); .on('error', (error, stdout, stderr) => this.logger.error(stderr || error));
} }
private chainPath(existing: string, path: string) {
const separator = existing.endsWith(':') ? '' : ':';
return `${existing}${separator}${path}`;
}
} }

View File

@@ -1,22 +1,22 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { UserTokenEntity } from 'src/entities/user-token.entity'; import { SessionEntity } from 'src/entities/session.entity';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface'; import { ISessionRepository } from 'src/interfaces/session.interface';
import { Instrumentation } from 'src/utils/instrumentation'; import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
@Instrumentation() @Instrumentation()
@Injectable() @Injectable()
export class UserTokenRepository implements IUserTokenRepository { export class SessionRepository implements ISessionRepository {
constructor(@InjectRepository(UserTokenEntity) private repository: Repository<UserTokenEntity>) {} constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {}
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<UserTokenEntity | null> { getByToken(token: string): Promise<SessionEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } }); return this.repository.findOne({ where: { token }, relations: { user: true } });
} }
getAll(userId: string): Promise<UserTokenEntity[]> { getByUserId(userId: string): Promise<SessionEntity[]> {
return this.repository.find({ return this.repository.find({
where: { where: {
userId, userId,
@@ -31,12 +31,12 @@ export class UserTokenRepository implements IUserTokenRepository {
}); });
} }
create(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { create(session: Partial<SessionEntity>): Promise<SessionEntity> {
return this.repository.save(userToken); return this.repository.save(session);
} }
save(userToken: Partial<UserTokenEntity>): Promise<UserTokenEntity> { update(session: Partial<SessionEntity>): Promise<SessionEntity> {
return this.repository.save(userToken); return this.repository.save(session);
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryType } from 'src/entities/library.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { import {
IUserRepository, IUserRepository,
@@ -117,11 +118,14 @@ export class UserRepository implements IUserRepository {
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async syncUsage(id?: string) { async syncUsage(id?: string) {
// we can't use parameters with getQuery, hence the template string
const subQuery = this.assetRepository const subQuery = this.assetRepository
.createQueryBuilder('assets') .createQueryBuilder('assets')
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)') .select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
.leftJoin('assets.library', 'library')
.leftJoin('assets.exifInfo', 'exif') .leftJoin('assets.exifInfo', 'exif')
.where('assets.ownerId = users.id AND NOT assets.isExternal') .where('assets.ownerId = users.id')
.andWhere(`library.type = '${LibraryType.UPLOAD}'`)
.withDeleted(); .withDeleted();
const query = this.userRepository const query = this.userRepository

View File

@@ -392,7 +392,9 @@ export class AssetService {
} }
await this.assetRepository.remove(asset); await this.assetRepository.remove(asset);
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0)); if (asset.library.type === LibraryType.UPLOAD) {
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
}
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id); this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
// TODO refactor this to use cascades // TODO refactor this to use cascades

View File

@@ -9,25 +9,25 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { keyStub } from 'test/fixtures/api-key.stub'; import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub, loginResponseStub } from 'test/fixtures/auth.stub'; import { authStub, loginResponseStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userTokenStub } from 'test/fixtures/user-token.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
import { newUserTokenRepositoryMock } from 'test/repositories/user-token.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mock, Mocked, vitest } from 'vitest'; import { Mock, Mocked, vitest } from 'vitest';
@@ -65,7 +65,7 @@ describe('AuthService', () => {
let libraryMock: Mocked<ILibraryRepository>; let libraryMock: Mocked<ILibraryRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let configMock: Mocked<ISystemConfigRepository>; let configMock: Mocked<ISystemConfigRepository>;
let userTokenMock: Mocked<IUserTokenRepository>; let sessionMock: Mocked<ISessionRepository>;
let shareMock: Mocked<ISharedLinkRepository>; let shareMock: Mocked<ISharedLinkRepository>;
let keyMock: Mocked<IKeyRepository>; let keyMock: Mocked<IKeyRepository>;
@@ -98,7 +98,7 @@ describe('AuthService', () => {
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
configMock = newSystemConfigRepositoryMock(); configMock = newSystemConfigRepositoryMock();
userTokenMock = newUserTokenRepositoryMock(); sessionMock = newSessionRepositoryMock();
shareMock = newSharedLinkRepositoryMock(); shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock(); keyMock = newKeyRepositoryMock();
@@ -109,7 +109,7 @@ describe('AuthService', () => {
libraryMock, libraryMock,
loggerMock, loggerMock,
userMock, userMock,
userTokenMock, sessionMock,
shareMock, shareMock,
keyMock, keyMock,
); );
@@ -139,24 +139,10 @@ describe('AuthService', () => {
it('should successfully log the user in', async () => { it('should successfully log the user in', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.getByEmail.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken); sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password); await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual(loginResponseStub.user1password);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
it('should generate the cookie headers (insecure)', async () => {
userMock.getByEmail.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
await expect(
sut.login(fixtures.login, {
clientIp: '127.0.0.1',
isSecure: false,
deviceOS: '',
deviceType: '',
}),
).resolves.toEqual(loginResponseStub.user1insecure);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
}); });
describe('changePassword', () => { describe('changePassword', () => {
@@ -231,14 +217,14 @@ describe('AuthService', () => {
}); });
it('should delete the access token', async () => { it('should delete the access token', async () => {
const auth = { user: { id: '123' }, userToken: { id: 'token123' } } as AuthDto; const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({ await expect(sut.logout(auth, AuthType.PASSWORD)).resolves.toEqual({
successful: true, successful: true,
redirectUri: '/auth/login?autoLaunch=0', redirectUri: '/auth/login?autoLaunch=0',
}); });
expect(userTokenMock.delete).toHaveBeenCalledWith('token123'); expect(sessionMock.delete).toHaveBeenCalledWith('token123');
}); });
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
@@ -282,11 +268,11 @@ describe('AuthService', () => {
it('should validate using authorization header', async () => { it('should validate using authorization header', async () => {
userMock.get.mockResolvedValue(userStub.user1); userMock.get.mockResolvedValue(userStub.user1);
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({ await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({
user: userStub.user1, user: userStub.user1,
userToken: userTokenStub.userToken, session: sessionStub.valid,
}); });
}); });
}); });
@@ -336,37 +322,29 @@ describe('AuthService', () => {
describe('validate - user token', () => { describe('validate - user token', () => {
it('should throw if no token is found', async () => { it('should throw if no token is found', async () => {
userTokenMock.getByToken.mockResolvedValue(null); sessionMock.getByToken.mockResolvedValue(null);
const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' }; const headers: IncomingHttpHeaders = { 'x-immich-user-token': 'auth_token' };
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
}); });
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken); sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({ await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1, user: userStub.user1,
userToken: userTokenStub.userToken, session: sessionStub.valid,
}); });
}); });
it('should update when access time exceeds an hour', async () => { it('should update when access time exceeds an hour', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken); sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
userTokenMock.save.mockResolvedValue(userTokenStub.userToken); sessionMock.update.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' }; const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({ await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1, user: userStub.user1,
userToken: userTokenStub.userToken, session: sessionStub.valid,
});
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
id: 'not_active',
token: 'auth_token',
userId: 'user-id',
createdAt: new Date('2021-01-01'),
updatedAt: expect.any(Date),
deviceOS: 'Android',
deviceType: 'Mobile',
}); });
expect(sessionMock.update.mock.calls[0][0]).toMatchObject({ id: 'not_active', updatedAt: expect.any(Date) });
}); });
}); });
@@ -386,55 +364,6 @@ describe('AuthService', () => {
}); });
}); });
describe('getDevices', () => {
it('should get the devices', async () => {
userTokenMock.getAll.mockResolvedValue([userTokenStub.userToken, userTokenStub.inactiveToken]);
await expect(sut.getDevices(authStub.user1)).resolves.toEqual([
{
createdAt: '2021-01-01T00:00:00.000Z',
current: true,
deviceOS: '',
deviceType: '',
id: 'token-id',
updatedAt: expect.any(String),
},
{
createdAt: '2021-01-01T00:00:00.000Z',
current: false,
deviceOS: 'Android',
deviceType: 'Mobile',
id: 'not_active',
updatedAt: expect.any(String),
},
]);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('logoutDevices', () => {
it('should logout all devices', async () => {
userTokenMock.getAll.mockResolvedValue([userTokenStub.inactiveToken, userTokenStub.userToken]);
await sut.logoutDevices(authStub.user1);
expect(userTokenMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
expect(userTokenMock.delete).toHaveBeenCalledWith('not_active');
expect(userTokenMock.delete).not.toHaveBeenCalledWith('token-id');
});
});
describe('logoutDevice', () => {
it('should logout the device', async () => {
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
await sut.logoutDevice(authStub.user1, 'token-1');
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
expect(userTokenMock.delete).toHaveBeenCalledWith('token-1');
});
});
describe('getMobileRedirect', () => { describe('getMobileRedirect', () => {
it('should pass along the query params', () => { it('should pass along the query params', () => {
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456'); expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
@@ -463,7 +392,7 @@ describe('AuthService', () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister); configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userStub.user1); userMock.getByEmail.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken); sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth, loginResponseStub.user1oauth,
@@ -478,7 +407,7 @@ describe('AuthService', () => {
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken); sessionMock.create.mockResolvedValue(sessionStub.valid);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth, loginResponseStub.user1oauth,
@@ -491,7 +420,7 @@ describe('AuthService', () => {
it('should use the mobile redirect override', async () => { it('should use the mobile redirect override', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override); configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1); userMock.getByOAuthId.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken); sessionMock.create.mockResolvedValue(sessionStub.valid);
await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails); await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
@@ -501,7 +430,7 @@ describe('AuthService', () => {
it('should use the mobile redirect override for ios urls with multiple slashes', async () => { it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override); configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1); userMock.getByOAuthId.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken); sessionMock.create.mockResolvedValue(sessionStub.valid);
await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails); await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);

View File

@@ -10,31 +10,22 @@ import cookieParser from 'cookie';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http'; import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { import { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants';
AuthType, import { AccessCore } from 'src/cores/access.core';
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_AUTH_TYPE_COOKIE,
IMMICH_IS_AUTHENTICATED,
LOGIN_URL,
MOBILE_REDIRECT,
} from 'src/constants';
import { AccessCore, Permission } from 'src/cores/access.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core'; import { UserCore } from 'src/cores/user.core';
import { import {
AuthDeviceResponseDto,
AuthDto, AuthDto,
ChangePasswordDto, ChangePasswordDto,
ImmichCookie,
ImmichHeader,
LoginCredentialDto, LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto, LogoutResponseDto,
OAuthAuthorizeResponseDto, OAuthAuthorizeResponseDto,
OAuthCallbackDto, OAuthCallbackDto,
OAuthConfigDto, OAuthConfigDto,
SignUpDto, SignUpDto,
mapLoginResponse, mapLoginResponse,
mapUserToken,
} from 'src/dtos/auth.dto'; } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { SystemConfig } from 'src/entities/system-config.entity'; import { SystemConfig } from 'src/entities/system-config.entity';
@@ -44,9 +35,9 @@ import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
@@ -57,11 +48,6 @@ export interface LoginDetails {
deviceOS: string; deviceOS: string;
} }
interface LoginResponse {
response: LoginResponseDto;
cookie: string[];
}
interface OAuthProfile extends UserinfoResponse { interface OAuthProfile extends UserinfoResponse {
email: string; email: string;
} }
@@ -85,7 +71,7 @@ export class AuthService {
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository, @Inject(ISessionRepository) private sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository, @Inject(IKeyRepository) private keyRepository: IKeyRepository,
) { ) {
@@ -97,7 +83,7 @@ export class AuthService {
custom.setHttpOptionsDefaults({ timeout: 30_000 }); custom.setHttpOptionsDefaults({ timeout: 30_000 });
} }
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> { async login(dto: LoginCredentialDto, details: LoginDetails) {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
if (!config.passwordLogin.enabled) { if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled'); throw new UnauthorizedException('Password login has been disabled');
@@ -116,12 +102,12 @@ export class AuthService {
throw new UnauthorizedException('Incorrect email or password'); throw new UnauthorizedException('Incorrect email or password');
} }
return this.createLoginResponse(user, AuthType.PASSWORD, details); return this.createLoginResponse(user, details);
} }
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> { async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
if (auth.userToken) { if (auth.session) {
await this.userTokenRepository.delete(auth.userToken.id); await this.sessionRepository.delete(auth.session.id);
} }
return { return {
@@ -163,19 +149,20 @@ export class AuthService {
} }
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> { async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string; const shareKey = (headers[ImmichHeader.SHARED_LINK_TOKEN] || params.key) as string;
const userToken = (headers['x-immich-user-token'] || const session = (headers[ImmichHeader.USER_TOKEN] ||
params.userToken || headers[ImmichHeader.SESSION_TOKEN] ||
params.sessionKey ||
this.getBearerToken(headers) || this.getBearerToken(headers) ||
this.getCookieToken(headers)) as string; this.getCookieToken(headers)) as string;
const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string; const apiKey = (headers[ImmichHeader.API_KEY] || params.apiKey) as string;
if (shareKey) { if (shareKey) {
return this.validateSharedLink(shareKey); return this.validateSharedLink(shareKey);
} }
if (userToken) { if (session) {
return this.validateUserToken(userToken); return this.validateSession(session);
} }
if (apiKey) { if (apiKey) {
@@ -185,26 +172,6 @@ export class AuthService {
throw new UnauthorizedException('Authentication required'); throw new UnauthorizedException('Authentication required');
} }
async getDevices(auth: AuthDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenRepository.getAll(auth.user.id);
return userTokens.map((userToken) => mapUserToken(userToken, auth.userToken?.id));
}
async logoutDevice(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
await this.userTokenRepository.delete(id);
}
async logoutDevices(auth: AuthDto): Promise<void> {
const devices = await this.userTokenRepository.getAll(auth.user.id);
for (const device of devices) {
if (device.id === auth.userToken?.id) {
continue;
}
await this.userTokenRepository.delete(device.id);
}
}
getMobileRedirect(url: string) { getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`; return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
} }
@@ -225,10 +192,7 @@ export class AuthService {
return { url }; return { url };
} }
async callback( async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
const profile = await this.getOAuthProfile(config, dto.url); const profile = await this.getOAuthProfile(config, dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`); this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@@ -277,7 +241,7 @@ export class AuthService {
}); });
} }
return this.createLoginResponse(user, AuthType.OAUTH, loginDetails); return this.createLoginResponse(user, loginDetails);
} }
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> { async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
@@ -374,7 +338,7 @@ export class AuthService {
private getCookieToken(headers: IncomingHttpHeaders): string | null { private getCookieToken(headers: IncomingHttpHeaders): string | null {
const cookies = cookieParser.parse(headers.cookie || ''); const cookies = cookieParser.parse(headers.cookie || '');
return cookies[IMMICH_ACCESS_COOKIE] || null; return cookies[ImmichCookie.ACCESS_TOKEN] || null;
} }
async validateSharedLink(key: string | string[]): Promise<AuthDto> { async validateSharedLink(key: string | string[]): Promise<AuthDto> {
@@ -408,57 +372,36 @@ export class AuthService {
return this.cryptoRepository.compareBcrypt(inputPassword, user.password); return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
} }
private async validateUserToken(tokenValue: string): Promise<AuthDto> { private async validateSession(tokenValue: string): Promise<AuthDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue); const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let userToken = await this.userTokenRepository.getByToken(hashedToken); let session = await this.sessionRepository.getByToken(hashedToken);
if (userToken?.user) { if (session?.user) {
const now = DateTime.now(); const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(userToken.updatedAt); const updatedAt = DateTime.fromJSDate(session.updatedAt);
const diff = now.diff(updatedAt, ['hours']); const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) { if (diff.hours > 1) {
userToken = await this.userTokenRepository.save({ ...userToken, updatedAt: new Date() }); session = await this.sessionRepository.update({ id: session.id, updatedAt: new Date() });
} }
return { user: userToken.user, userToken }; return { user: session.user, session: session };
} }
throw new UnauthorizedException('Invalid user token'); throw new UnauthorizedException('Invalid user token');
} }
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) { private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32); const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key); const token = this.cryptoRepository.hashSha256(key);
await this.userTokenRepository.create({ await this.sessionRepository.create({
token, token,
user, user,
deviceOS: loginDetails.deviceOS, deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType, deviceType: loginDetails.deviceType,
}); });
const response = mapLoginResponse(user, key); return mapLoginResponse(user, key);
const cookie = this.getCookies(response, authType, loginDetails);
return { response, cookie };
}
private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
const maxAge = 400 * 24 * 3600; // 400 days
let authTypeCookie = '';
let accessTokenCookie = '';
let isAuthenticatedCookie = '';
if (isSecure) {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
} else {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
isAuthenticatedCookie = `${IMMICH_IS_AUTHENTICATED}=true; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
}
return [accessTokenCookie, authTypeCookie, isAuthenticatedCookie];
} }
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T { private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {

View File

@@ -18,6 +18,7 @@ import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service'; import { PersonService } from 'src/services/person.service';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { ServerInfoService } from 'src/services/server-info.service'; import { ServerInfoService } from 'src/services/server-info.service';
import { SessionService } from 'src/services/session.service';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { SmartInfoService } from 'src/services/smart-info.service'; import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
@@ -50,6 +51,7 @@ export const services = [
PersonService, PersonService,
SearchService, SearchService,
ServerInfoService, ServerInfoService,
SessionService,
SharedLinkService, SharedLinkService,
SmartInfoService, SmartInfoService,
StorageService, StorageService,

View File

@@ -225,6 +225,15 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
it('should skip invisible assets', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
expect(await sut.handleGeneratePreview({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.resize).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
@@ -353,6 +362,15 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith(); expect(assetMock.update).not.toHaveBeenCalledWith();
}); });
it('should skip invisible assets', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
expect(await sut.handleGenerateThumbnail({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.resize).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it.each(Object.values(ImageFormat))( it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified', 'should generate a %s thumbnail for an image when specified',
async (format) => { async (format) => {
@@ -375,14 +393,12 @@ describe(MediaService.name, () => {
}); });
it('should generate a P3 thumbnail for a wide gamut image', async () => { it('should generate a P3 thumbnail for a wide gamut image', async () => {
assetMock.getByIds.mockResolvedValue([ assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
{ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity },
]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id }); await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith( expect(mediaMock.resize).toHaveBeenCalledWith(
'/original/path.jpg', assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp', 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{ {
format: ImageFormat.WEBP, format: ImageFormat.WEBP,
@@ -397,7 +413,96 @@ describe(MediaService.name, () => {
}); });
}); });
describe('handleGenerateThumbhashThumbnail', () => { it('should extract embedded image if enabled and available', async () => {
mediaMock.extract.mockResolvedValue(true);
mediaMock.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 });
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]);
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
expect(mediaMock.resize.mock.calls).toEqual([
[
extractedPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
},
],
]);
expect(extractedPath?.endsWith('.tmp')).toBe(true);
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
});
it('should resize original image if embedded image is too small', async () => {
mediaMock.extract.mockResolvedValue(true);
mediaMock.getImageDimensions.mockResolvedValue({ width: 1000, height: 1000 });
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]);
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize.mock.calls).toEqual([
[
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
},
],
]);
const extractedPath = mediaMock.extract.mock.calls.at(-1)?.[1].toString();
expect(extractedPath?.endsWith('.tmp')).toBe(true);
expect(storageMock.unlink).toHaveBeenCalledWith(extractedPath);
});
it('should resize original image if embedded image not found', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: true }]);
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.resize).toHaveBeenCalledWith(
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
},
);
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
});
it('should resize original image if embedded image extraction is not enabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_EXTRACT_EMBEDDED, value: false }]);
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(mediaMock.extract).not.toHaveBeenCalled();
expect(mediaMock.resize).toHaveBeenCalledWith(
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
size: 250,
quality: 80,
colorspace: Colorspace.P3,
},
);
expect(mediaMock.getImageDimensions).not.toHaveBeenCalled();
});
describe('handleGenerateThumbhash', () => {
it('should skip thumbhash generation if asset not found', async () => { it('should skip thumbhash generation if asset not found', async () => {
assetMock.getByIds.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateThumbhash({ id: assetStub.image.id }); await sut.handleGenerateThumbhash({ id: assetStub.image.id });
@@ -410,6 +515,15 @@ describe(MediaService.name, () => {
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled(); expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
}); });
it('should skip invisible assets', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
expect(await sut.handleGenerateThumbhash({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should generate a thumbhash', async () => { it('should generate a thumbhash', async () => {
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
import { dirname } from 'node:path';
import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
@@ -42,6 +43,7 @@ import {
VAAPIConfig, VAAPIConfig,
VP9Config, VP9Config,
} from 'src/utils/media'; } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
@@ -77,7 +79,7 @@ export class MediaService {
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination) ? this.assetRepository.getAll(pagination, { isVisible: true })
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL); : this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
}); });
@@ -178,6 +180,10 @@ export class MediaService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
await this.assetRepository.update({ id: asset.id, previewPath }); await this.assetRepository.update({ id: asset.id, previewPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
@@ -191,9 +197,21 @@ export class MediaService {
switch (asset.type) { switch (asset.type) {
case AssetType.IMAGE: { case AssetType.IMAGE: {
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath);
const imageOptions = { format, size, colorspace, quality: image.quality }; const extractedPath = StorageCore.getTempPathInDir(dirname(path));
await this.mediaRepository.resize(asset.originalPath, path, imageOptions); const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath));
try {
const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize));
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = { format, size, colorspace, quality: image.quality };
await this.mediaRepository.resize(useExtracted ? extractedPath : asset.originalPath, path, imageOptions);
} finally {
if (didExtract) {
await this.storageRepository.unlink(extractedPath);
}
}
break; break;
} }
@@ -230,6 +248,10 @@ export class MediaService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
await this.assetRepository.update({ id: asset.id, thumbnailPath }); await this.assetRepository.update({ id: asset.id, thumbnailPath });
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
@@ -237,7 +259,15 @@ export class MediaService {
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> { async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset?.previewPath) { if (!asset) {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
if (!asset.previewPath) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
@@ -511,7 +541,7 @@ export class MediaService {
} }
} }
parseBitrateToBps(bitrateString: string) { private parseBitrateToBps(bitrateString: string) {
const bitrateValue = Number.parseInt(bitrateString); const bitrateValue = Number.parseInt(bitrateString);
if (Number.isNaN(bitrateValue)) { if (Number.isNaN(bitrateValue)) {
@@ -526,4 +556,11 @@ export class MediaService {
return bitrateValue; return bitrateValue;
} }
} }
private async shouldUseExtractedImage(extractedPath: string, targetSize: number) {
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPath);
const extractedSize = Math.min(width, height);
return extractedSize >= targetSize;
}
} }

View File

@@ -18,6 +18,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { MetadataService, Orientation } from 'src/services/metadata.service'; import { MetadataService, Orientation } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
@@ -35,6 +36,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(MetadataService.name, () => { describe(MetadataService.name, () => {
@@ -50,6 +52,7 @@ describe(MetadataService.name, () => {
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let eventMock: Mocked<IEventRepository>; let eventMock: Mocked<IEventRepository>;
let databaseMock: Mocked<IDatabaseRepository>; let databaseMock: Mocked<IDatabaseRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
let sut: MetadataService; let sut: MetadataService;
@@ -66,6 +69,7 @@ describe(MetadataService.name, () => {
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
mediaMock = newMediaRepositoryMock(); mediaMock = newMediaRepositoryMock();
databaseMock = newDatabaseRepositoryMock(); databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new MetadataService( sut = new MetadataService(
@@ -81,6 +85,7 @@ describe(MetadataService.name, () => {
personMock, personMock,
storageMock, storageMock,
configMock, configMock,
userMock,
loggerMock, loggerMock,
); );
}); });
@@ -372,6 +377,7 @@ describe(MetadataService.name, () => {
); );
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, { expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
@@ -400,6 +406,7 @@ describe(MetadataService.name, () => {
); );
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, { expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
@@ -426,6 +433,7 @@ describe(MetadataService.name, () => {
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object)); expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, { expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoStillAsset.id, id: assetStub.livePhotoStillAsset.id,
@@ -444,6 +452,8 @@ describe(MetadataService.name, () => {
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null); assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset })); assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(jobMock.queue).toHaveBeenNthCalledWith(2, { expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
@@ -452,7 +462,7 @@ describe(MetadataService.name, () => {
}); });
}); });
it('should not create a new motionphoto video asset if the of the extracted video matches an existing asset', async () => { it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]); assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
metadataMock.readTags.mockResolvedValue({ metadataMock.readTags.mockResolvedValue({
Directory: 'foo/bar/', Directory: 'foo/bar/',
@@ -462,6 +472,8 @@ describe(MetadataService.name, () => {
}); });
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512)); cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(assetMock.create).toHaveBeenCalledTimes(0); expect(assetMock.create).toHaveBeenCalledTimes(0);
@@ -495,6 +507,26 @@ describe(MetadataService.name, () => {
}); });
}); });
it('should not update storage usage if motion photo is external', async () => {
assetMock.getByIds.mockResolvedValue([
{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null, isExternal: true },
]);
metadataMock.readTags.mockResolvedValue({
Directory: 'foo/bar/',
MotionPhoto: 1,
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(userMock.updateUsage).not.toHaveBeenCalled();
});
it('should save all metadata', async () => { it('should save all metadata', async () => {
const tags: ImmichTags = { const tags: ImmichTags = {
BitsPerSample: 1, BitsPerSample: 1,

View File

@@ -32,6 +32,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface'; import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -114,6 +115,7 @@ export class MetadataService {
@Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(MetadataService.name); this.logger.setContext(MetadataService.name);
@@ -446,10 +448,14 @@ export class MetadataService {
this.storageCore.ensureFolders(motionPath); this.storageCore.ensureFolders(motionPath);
await this.storageRepository.writeFile(motionAsset.originalPath, video); await this.storageRepository.writeFile(motionAsset.originalPath, video);
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
if (!asset.isExternal) {
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
}
} }
if (asset.livePhotoVideoId !== motionAsset.id) { if (asset.livePhotoVideoId !== motionAsset.id) {
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id }); await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
// If the asset already had an associated livePhotoVideo, delete it, because // If the asset already had an associated livePhotoVideo, delete it, because
// its checksum doesn't match the checksum of the motionAsset we just extracted // its checksum doesn't match the checksum of the motionAsset we just extracted
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId) // (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)

View File

@@ -292,7 +292,12 @@ export class PersonService {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true }) ? this.assetRepository.getAll(pagination, {
orderDirection: 'DESC',
withFaces: true,
withArchived: true,
isVisible: true,
})
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES); : this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
}); });
@@ -322,6 +327,10 @@ export class PersonService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const faces = await this.machineLearningRepository.detectFaces( const faces = await this.machineLearningRepository.detectFaces(
machineLearning.url, machineLearning.url,
{ imagePath: asset.previewPath }, { imagePath: asset.previewPath },
@@ -424,7 +433,7 @@ export class PersonService {
this.logger.debug(`Face ${id} has ${matches.length} matches`); this.logger.debug(`Face ${id} has ${matches.length} matches`);
const isCore = matches.length >= machineLearning.facialRecognition.minFaces; const isCore = matches.length >= machineLearning.facialRecognition.minFaces && !face.asset.isArchived;
if (!isCore && !deferred) { if (!isCore && !deferred) {
this.logger.debug(`Deferring non-core face ${id} for later processing`); this.logger.debug(`Deferring non-core face ${id} for later processing`);
await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } });

View File

@@ -0,0 +1,77 @@
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { SessionService } from 'src/services/session.service';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { Mocked } from 'vitest';
describe('SessionService', () => {
let sut: SessionService;
let accessMock: Mocked<IAccessRepositoryMock>;
let loggerMock: Mocked<ILoggerRepository>;
let sessionMock: Mocked<ISessionRepository>;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sessionMock = newSessionRepositoryMock();
sut = new SessionService(accessMock, loggerMock, sessionMock);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it('should get the devices', async () => {
sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
{
createdAt: '2021-01-01T00:00:00.000Z',
current: true,
deviceOS: '',
deviceType: '',
id: 'token-id',
updatedAt: expect.any(String),
},
{
createdAt: '2021-01-01T00:00:00.000Z',
current: false,
deviceOS: 'Android',
deviceType: 'Mobile',
id: 'not_active',
updatedAt: expect.any(String),
},
]);
expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('logoutDevices', () => {
it('should logout all devices', async () => {
sessionMock.getByUserId.mockResolvedValue([sessionStub.inactive, sessionStub.valid]);
await sut.deleteAll(authStub.user1);
expect(sessionMock.getByUserId).toHaveBeenCalledWith(authStub.user1.user.id);
expect(sessionMock.delete).toHaveBeenCalledWith('not_active');
expect(sessionMock.delete).not.toHaveBeenCalledWith('token-id');
});
});
describe('logoutDevice', () => {
it('should logout the device', async () => {
accessMock.authDevice.checkOwnerAccess.mockResolvedValue(new Set(['token-1']));
await sut.delete(authStub.user1, 'token-1');
expect(accessMock.authDevice.checkOwnerAccess).toHaveBeenCalledWith(authStub.user1.user.id, new Set(['token-1']));
expect(sessionMock.delete).toHaveBeenCalledWith('token-1');
});
});
});

View File

@@ -0,0 +1,41 @@
import { Inject, Injectable } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
@Injectable()
export class SessionService {
private access: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
) {
this.logger.setContext(SessionService.name);
this.access = AccessCore.create(accessRepository);
}
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
return sessions.map((session) => mapSession(session, auth.session?.id));
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.access.requirePermission(auth, Permission.AUTH_DEVICE_DELETE, id);
await this.sessionRepository.delete(id);
}
async deleteAll(auth: AuthDto): Promise<void> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
for (const session of sessions) {
if (session.id === auth.session?.id) {
continue;
}
await this.sessionRepository.delete(session.id);
}
}
}

View File

@@ -1,8 +1,7 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { SystemConfigKey } from 'src/entities/system-config.entity'; import { SystemConfigKey } from 'src/entities/system-config.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface'; import { ISearchRepository } from 'src/interfaces/search.interface';
@@ -19,11 +18,6 @@ import { newSearchRepositoryMock } from 'test/repositories/search.repository.moc
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock'; import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
const asset = {
id: 'asset-1',
previewPath: 'path/to/resize.ext',
} as AssetEntity;
describe(SmartInfoService.name, () => { describe(SmartInfoService.name, () => {
let sut: SmartInfoService; let sut: SmartInfoService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
@@ -44,7 +38,7 @@ describe(SmartInfoService.name, () => {
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock); sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock, loggerMock);
assetMock.getByIds.mockResolvedValue([asset]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
}); });
it('should work', () => { it('should work', () => {
@@ -92,17 +86,16 @@ describe(SmartInfoService.name, () => {
it('should do nothing if machine learning is disabled', async () => { it('should do nothing if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
await sut.handleEncodeClip({ id: '123' }); expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled();
}); });
it('should skip assets without a resize path', async () => { it('should skip assets without a resize path', async () => {
const asset = { previewPath: '' } as AssetEntity; assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]);
assetMock.getByIds.mockResolvedValue([asset]);
await sut.handleEncodeClip({ id: asset.id }); expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED);
expect(searchMock.upsert).not.toHaveBeenCalled(); expect(searchMock.upsert).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled();
@@ -111,14 +104,23 @@ describe(SmartInfoService.name, () => {
it('should save the returned objects', async () => { it('should save the returned objects', async () => {
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
await sut.handleEncodeClip({ id: asset.id }); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
expect(machineMock.encodeImage).toHaveBeenCalledWith( expect(machineMock.encodeImage).toHaveBeenCalledWith(
'http://immich-machine-learning:3003', 'http://immich-machine-learning:3003',
{ imagePath: 'path/to/resize.ext' }, { imagePath: assetStub.image.previewPath },
{ enabled: true, modelName: 'ViT-B-32__openai' }, { enabled: true, modelName: 'ViT-B-32__openai' },
); );
expect(searchMock.upsert).toHaveBeenCalledWith('asset-1', [0.01, 0.02, 0.03]); expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);
});
it('should skip invisible assets', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(machineMock.encodeImage).not.toHaveBeenCalled();
expect(searchMock.upsert).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -60,7 +60,7 @@ export class SmartInfoService {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination) ? this.assetRepository.getAll(pagination, { isVisible: true })
: this.assetRepository.getWithout(pagination, WithoutProperty.SMART_SEARCH); : this.assetRepository.getWithout(pagination, WithoutProperty.SMART_SEARCH);
}); });
@@ -84,6 +84,10 @@ export class SmartInfoService {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
if (!asset.previewPath) { if (!asset.previewPath) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }

View File

@@ -129,6 +129,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
previewSize: 1440, previewSize: 1440,
quality: 80, quality: 80,
colorspace: Colorspace.P3, colorspace: Colorspace.P3,
extractEmbedded: false,
}, },
newVersionCheck: { newVersionCheck: {
enabled: true, enabled: true,

View File

@@ -37,6 +37,11 @@ export class UserService {
this.configCore = SystemConfigCore.create(configRepository, this.logger); this.configCore = SystemConfigCore.create(configRepository, this.logger);
} }
async listUsers(): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUser(user));
}
async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> { async getAll(auth: AuthDto, isAll: boolean): Promise<UserResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: !isAll }); const users = await this.userRepository.getList({ withDeleted: !isAll });
return users.map((user) => mapUser(user)); return users.map((user) => mapUser(user));

View File

@@ -106,12 +106,6 @@ describe('mimeTypes', () => {
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
}); });
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.profile);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
for (const [extension, v] of Object.entries(mimeTypes.profile)) { for (const [extension, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${extension}`, () => { it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]); expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
@@ -128,12 +122,6 @@ describe('mimeTypes', () => {
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase())); expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
}); });
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.image);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only image mime types', () => { it('should contain only image mime types', () => {
const values = Object.values(mimeTypes.image).flat(); const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/'))); expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/')));
@@ -157,7 +145,6 @@ describe('mimeTypes', () => {
it('should be a sorted list', () => { it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video); const keys = Object.keys(mimeTypes.video);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort()); expect(keys).toEqual([...keys].sort());
}); });
@@ -184,7 +171,6 @@ describe('mimeTypes', () => {
it('should be a sorted list', () => { it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar); const keys = Object.keys(mimeTypes.sidecar);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort()); expect(keys).toEqual([...keys].sort());
}); });
@@ -198,4 +184,20 @@ describe('mimeTypes', () => {
}); });
} }
}); });
describe('raw', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.raw);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.raw).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
for (const [extension, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${extension}`, () => {
expect(mimeTypes.lookup(`test.${extension}`)).toEqual(v[0]);
});
}
});
}); });

View File

@@ -1,12 +1,10 @@
import { extname } from 'node:path'; import { extname } from 'node:path';
import { AssetType } from 'src/entities/asset.entity'; import { AssetType } from 'src/entities/asset.entity';
const image: Record<string, string[]> = { const raw: Record<string, string[]> = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
'.ari': ['image/ari', 'image/x-arriflex-ari'], '.ari': ['image/ari', 'image/x-arriflex-ari'],
'.arw': ['image/arw', 'image/x-sony-arw'], '.arw': ['image/arw', 'image/x-sony-arw'],
'.avif': ['image/avif'],
'.bmp': ['image/bmp'],
'.cap': ['image/cap', 'image/x-phaseone-cap'], '.cap': ['image/cap', 'image/x-phaseone-cap'],
'.cin': ['image/cin', 'image/x-phantom-cin'], '.cin': ['image/cin', 'image/x-phantom-cin'],
'.cr2': ['image/cr2', 'image/x-canon-cr2'], '.cr2': ['image/cr2', 'image/x-canon-cr2'],
@@ -16,16 +14,7 @@ const image: Record<string, string[]> = {
'.dng': ['image/dng', 'image/x-adobe-dng'], '.dng': ['image/dng', 'image/x-adobe-dng'],
'.erf': ['image/erf', 'image/x-epson-erf'], '.erf': ['image/erf', 'image/x-epson-erf'],
'.fff': ['image/fff', 'image/x-hasselblad-fff'], '.fff': ['image/fff', 'image/x-hasselblad-fff'],
'.gif': ['image/gif'],
'.heic': ['image/heic'],
'.heif': ['image/heif'],
'.hif': ['image/hif'],
'.iiq': ['image/iiq', 'image/x-phaseone-iiq'], '.iiq': ['image/iiq', 'image/x-phaseone-iiq'],
'.insp': ['image/jpeg'],
'.jpe': ['image/jpeg'],
'.jpeg': ['image/jpeg'],
'.jpg': ['image/jpeg'],
'.jxl': ['image/jxl'],
'.k25': ['image/k25', 'image/x-kodak-k25'], '.k25': ['image/k25', 'image/x-kodak-k25'],
'.kdc': ['image/kdc', 'image/x-kodak-kdc'], '.kdc': ['image/kdc', 'image/x-kodak-kdc'],
'.mrw': ['image/mrw', 'image/x-minolta-mrw'], '.mrw': ['image/mrw', 'image/x-minolta-mrw'],
@@ -33,7 +22,6 @@ const image: Record<string, string[]> = {
'.orf': ['image/orf', 'image/x-olympus-orf'], '.orf': ['image/orf', 'image/x-olympus-orf'],
'.ori': ['image/ori', 'image/x-olympus-ori'], '.ori': ['image/ori', 'image/x-olympus-ori'],
'.pef': ['image/pef', 'image/x-pentax-pef'], '.pef': ['image/pef', 'image/x-pentax-pef'],
'.png': ['image/png'],
'.psd': ['image/psd', 'image/vnd.adobe.photoshop'], '.psd': ['image/psd', 'image/vnd.adobe.photoshop'],
'.raf': ['image/raf', 'image/x-fuji-raf'], '.raf': ['image/raf', 'image/x-fuji-raf'],
'.raw': ['image/raw', 'image/x-panasonic-raw'], '.raw': ['image/raw', 'image/x-panasonic-raw'],
@@ -42,11 +30,27 @@ const image: Record<string, string[]> = {
'.sr2': ['image/sr2', 'image/x-sony-sr2'], '.sr2': ['image/sr2', 'image/x-sony-sr2'],
'.srf': ['image/srf', 'image/x-sony-srf'], '.srf': ['image/srf', 'image/x-sony-srf'],
'.srw': ['image/srw', 'image/x-samsung-srw'], '.srw': ['image/srw', 'image/x-samsung-srw'],
'.x3f': ['image/x3f', 'image/x-sigma-x3f'],
};
const image: Record<string, string[]> = {
...raw,
'.avif': ['image/avif'],
'.bmp': ['image/bmp'],
'.gif': ['image/gif'],
'.heic': ['image/heic'],
'.heif': ['image/heif'],
'.hif': ['image/hif'],
'.insp': ['image/jpeg'],
'.jpe': ['image/jpeg'],
'.jpeg': ['image/jpeg'],
'.jpg': ['image/jpeg'],
'.jxl': ['image/jxl'],
'.png': ['image/png'],
'.svg': ['image/svg'], '.svg': ['image/svg'],
'.tif': ['image/tiff'], '.tif': ['image/tiff'],
'.tiff': ['image/tiff'], '.tiff': ['image/tiff'],
'.webp': ['image/webp'], '.webp': ['image/webp'],
'.x3f': ['image/x3f', 'image/x-sigma-x3f'],
}; };
const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']); const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']);
@@ -77,22 +81,25 @@ const sidecar: Record<string, string[]> = {
'.xmp': ['application/xml', 'text/xml'], '.xmp': ['application/xml', 'text/xml'],
}; };
const types = { ...image, ...video, ...sidecar };
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r; const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
const lookup = (filename: string) => const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream';
({ ...image, ...video, ...sidecar })[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream';
export const mimeTypes = { export const mimeTypes = {
image, image,
profile, profile,
sidecar, sidecar,
video, video,
raw,
isAsset: (filename: string) => isType(filename, image) || isType(filename, video), isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
isImage: (filename: string) => isType(filename, image), isImage: (filename: string) => isType(filename, image),
isProfile: (filename: string) => isType(filename, profile), isProfile: (filename: string) => isType(filename, profile),
isSidecar: (filename: string) => isType(filename, sidecar), isSidecar: (filename: string) => isType(filename, sidecar),
isVideo: (filename: string) => isType(filename, video), isVideo: (filename: string) => isType(filename, video),
isRaw: (filename: string) => isType(filename, raw),
lookup, lookup,
assetType: (filename: string) => { assetType: (filename: string) => {
const contentType = lookup(filename); const contentType = lookup(filename);

View File

@@ -10,13 +10,8 @@ import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.inte
import _ from 'lodash'; import _ from 'lodash';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { import { CLIP_MODEL_INFO, serverVersion } from 'src/constants';
CLIP_MODEL_INFO, import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto';
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME,
serverVersion,
} from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Metadata } from 'src/middleware/auth.guard'; import { Metadata } from 'src/middleware/auth.guard';
@@ -143,14 +138,14 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
scheme: 'Bearer', scheme: 'Bearer',
in: 'header', in: 'header',
}) })
.addCookieAuth(IMMICH_ACCESS_COOKIE) .addCookieAuth(ImmichCookie.ACCESS_TOKEN)
.addApiKey( .addApiKey(
{ {
type: 'apiKey', type: 'apiKey',
in: 'header', in: 'header',
name: IMMICH_API_KEY_HEADER, name: ImmichHeader.API_KEY,
}, },
IMMICH_API_KEY_NAME, Metadata.API_KEY_SECURITY,
) )
.addServer('/api') .addServer('/api')
.build(); .build();

View File

@@ -0,0 +1,36 @@
import { CookieOptions, Response } from 'express';
import { Duration } from 'luxon';
import { CookieResponse, ImmichCookie } from 'src/dtos/auth.dto';
export const respondWithCookie = <T>(res: Response, body: T, { isSecure, values }: CookieResponse) => {
const defaults: CookieOptions = {
path: '/',
sameSite: 'lax',
httpOnly: true,
secure: isSecure,
maxAge: Duration.fromObject({ days: 400 }).toMillis(),
};
const cookieOptions: Record<ImmichCookie, CookieOptions> = {
[ImmichCookie.AUTH_TYPE]: defaults,
[ImmichCookie.ACCESS_TOKEN]: defaults,
// no httpOnly so that the client can know the auth state
[ImmichCookie.IS_AUTHENTICATED]: { ...defaults, httpOnly: false },
[ImmichCookie.SHARED_LINK_TOKEN]: { ...defaults, maxAge: Duration.fromObject({ days: 1 }).toMillis() },
};
for (const { key, value } of values) {
const options = cookieOptions[key];
res.cookie(key, value, options);
}
return body;
};
export const respondWithoutCookie = <T>(res: Response, body: T, cookies: ImmichCookie[]) => {
for (const cookie of cookies) {
res.clearCookie(cookie);
}
return body;
};

View File

@@ -757,4 +757,45 @@ export const assetStub = {
fileSizeInByte: 5000, fileSizeInByte: 5000,
} as ExifEntity, } as ExifEntity,
}), }),
imageDng: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.dng',
previewPath: '/uploads/user-id/thumbs/path.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
thumbnailPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
isOffline: false,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
} as ExifEntity,
}),
}; };

View File

@@ -1,6 +1,6 @@
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
export const adminSignupStub = { export const adminSignupStub = {
@@ -35,9 +35,9 @@ export const authStub = {
email: 'immich@test.com', email: 'immich@test.com',
isAdmin: false, isAdmin: false,
} as UserEntity, } as UserEntity,
userToken: { session: {
id: 'token-id', id: 'token-id',
} as UserTokenEntity, } as SessionEntity,
}), }),
user2: Object.freeze<AuthDto>({ user2: Object.freeze<AuthDto>({
user: { user: {
@@ -45,9 +45,9 @@ export const authStub = {
email: 'user2@immich.app', email: 'user2@immich.app',
isAdmin: false, isAdmin: false,
} as UserEntity, } as UserEntity,
userToken: { session: {
id: 'token-id', id: 'token-id',
} as UserTokenEntity, } as SessionEntity,
}), }),
external1: Object.freeze<AuthDto>({ external1: Object.freeze<AuthDto>({
user: { user: {
@@ -55,9 +55,9 @@ export const authStub = {
email: 'immich@test.com', email: 'immich@test.com',
isAdmin: false, isAdmin: false,
} as UserEntity, } as UserEntity,
userToken: { session: {
id: 'token-id', id: 'token-id',
} as UserTokenEntity, } as SessionEntity,
}), }),
adminSharedLink: Object.freeze<AuthDto>({ adminSharedLink: Object.freeze<AuthDto>({
user: { user: {
@@ -129,51 +129,21 @@ export const loginResponseStub = {
}, },
}, },
user1oauth: { user1oauth: {
response: { accessToken: 'cmFuZG9tLWJ5dGVz',
accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id',
userId: 'user-id', userEmail: 'immich@test.com',
userEmail: 'immich@test.com', name: 'immich_name',
name: 'immich_name', profileImagePath: '',
profileImagePath: '', isAdmin: false,
isAdmin: false, shouldChangePassword: false,
shouldChangePassword: false,
},
cookie: [
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
],
}, },
user1password: { user1password: {
response: { accessToken: 'cmFuZG9tLWJ5dGVz',
accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'user-id',
userId: 'user-id', userEmail: 'immich@test.com',
userEmail: 'immich@test.com', name: 'immich_name',
name: 'immich_name', profileImagePath: '',
profileImagePath: '', isAdmin: false,
isAdmin: false, shouldChangePassword: false,
shouldChangePassword: false,
},
cookie: [
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_is_authenticated=true; Secure; Path=/; Max-Age=34560000; SameSite=Lax;',
],
},
user1insecure: {
response: {
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: 'user-id',
userEmail: 'immich@test.com',
name: 'immich_name',
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
},
cookie: [
'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;',
'immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;',
],
}, },
}; };

View File

@@ -1,8 +1,8 @@
import { UserTokenEntity } from 'src/entities/user-token.entity'; import { SessionEntity } from 'src/entities/session.entity';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
export const userTokenStub = { export const sessionStub = {
userToken: Object.freeze<UserTokenEntity>({ valid: Object.freeze<SessionEntity>({
id: 'token-id', id: 'token-id',
token: 'auth_token', token: 'auth_token',
userId: userStub.user1.id, userId: userStub.user1.id,
@@ -12,7 +12,7 @@ export const userTokenStub = {
deviceType: '', deviceType: '',
deviceOS: '', deviceOS: '',
}), }),
inactiveToken: Object.freeze<UserTokenEntity>({ inactive: Object.freeze<SessionEntity>({
id: 'not_active', id: 'not_active',
token: 'auth_token', token: 'auth_token',
userId: userStub.user1.id, userId: userStub.user1.id,

View File

@@ -4,9 +4,11 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => { export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return { return {
generateThumbhash: vitest.fn(), generateThumbhash: vitest.fn(),
extract: vitest.fn().mockResolvedValue(false),
resize: vitest.fn(), resize: vitest.fn(),
crop: vitest.fn(), crop: vitest.fn(),
probe: vitest.fn(), probe: vitest.fn(),
transcode: vitest.fn(), transcode: vitest.fn(),
getImageDimensions: vitest.fn(),
}; };
}; };

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