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
143 changed files with 1916 additions and 2002 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
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": {
"name": "@immich/sdk",
"version": "1.101.0",
"version": "1.102.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.12.7",
"typescript": "^5.4.5"
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ services:
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
database:

View File

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

View File

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

View File

@@ -120,7 +120,8 @@ The default configuration looks like this:
"previewFormat": "jpeg",
"previewSize": 1440,
"quality": 80,
"colorspace": "p3"
"colorspace": "p3",
"extractEmbedded": false
},
"newVersionCheck": {
"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
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
redis:
image: redis:6.2-alpine@sha256:3fcb624d83a9c478357f16dc173c58ded325ccc5fd2a4375f3916c04cc579f70
image: redis:6.2-alpine@sha256:84882e87b54734154586e5f8abd4dce69fe7311315e2fc6d67c29614c8de2672
database:
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",
"version": "1.101.0",
"version": "1.102.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.101.0",
"version": "1.102.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
@@ -81,15 +81,15 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.101.0",
"version": "1.102.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^20.12.7",
"typescript": "^5.4.5"
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
},
"node_modules/@aashutoshrathi/word-wrap": {

View File

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

View File

@@ -1,7 +1,7 @@
import { LoginResponseDto, getAuthDevices, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto, uuidDto } from 'src/fixtures';
import { deviceDto, errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import { LoginResponseDto, login, signUpAdmin } from '@immich/sdk';
import { loginDto, signupDto } from 'src/fixtures';
import { errorDto, loginResponseDto, signupResponseDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -112,70 +112,29 @@ describe('/auth/*', () => {
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(3);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
expect(cookies[2]).toEqual('immich_is_authenticated=true; Path=/; Max-Age=34560000; SameSite=Lax;');
});
});
describe('GET /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/auth/devices');
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('/auth/devices')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceDto.current]);
});
});
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);
expect(cookies[0].split(';').map((item) => item.trim())).toEqual([
`immich_access_token=${token}`,
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[1].split(';').map((item) => item.trim())).toEqual([
'immich_auth_type=password',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'HttpOnly',
'SameSite=Lax',
]);
expect(cookies[2].split(';').map((item) => item.trim())).toEqual([
'immich_is_authenticated=true',
'Max-Age=34560000',
'Path=/',
expect.stringContaining('Expires='),
'SameSite=Lax',
]);
});
});

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 { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@@ -32,6 +32,9 @@ describe('/search', () => {
let assetGlarus: AssetFileUploadResponseDto;
let assetSprings: AssetFileUploadResponseDto;
let assetLast: AssetFileUploadResponseDto;
let cities: string[];
let states: string[];
let countries: string[];
beforeAll(async () => {
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
const cities = [
const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 63.0695, longitude: -151.0074 }, // denali
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
@@ -101,7 +104,7 @@ describe('/search', () => {
];
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);
@@ -133,6 +136,12 @@ describe('/search', () => {
assetLast = assets.at(-1) as AssetFileUploadResponseDto;
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);
afterAll(async () => {
@@ -452,21 +461,7 @@ describe('/search', () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=country')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
]);
expect(body).toEqual(countries);
expect(status).toBe(200);
});
@@ -474,23 +469,7 @@ describe('/search', () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=state')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'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(body).toEqual(states);
expect(status).toBe(200);
});
@@ -498,24 +477,7 @@ describe('/search', () => {
const { status, body } = await request(app)
.get('/search/suggestions?type=city')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Palisade',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Singapore',
'Stanley',
'Tbilisi',
'Tokyo',
]);
expect(body).toEqual(cities);
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 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 WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
type AdminSetupOptions = { onboarding?: boolean };
@@ -59,13 +59,15 @@ export const testAssetDirInternal = '/data/assets';
export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
export const asKeyAuth = (key: string) => ({ 'x-api-key': key });
export const immichCli = async (args: string[]) => {
let _resolve: (value: CliResponse) => void;
const deferred = new Promise<CliResponse>((resolve) => (_resolve = resolve));
const _args = ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args];
const child = spawn('node', _args, {
stdio: 'pipe',
});
export const immichCli = (args: string[]) =>
executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]);
export const immichAdmin = (args: string[]) =>
executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]);
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 stderr = '';
@@ -138,7 +140,7 @@ export const utils = {
'asset_faces',
'activity',
'api_keys',
'user_token',
'sessions',
'users',
'system_metadata',
'system_config',

View File

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

View File

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

View File

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

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 131,
"android.injected.version.name" => "1.101.0",
"android.injected.version.code" => 132,
"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')

Binary file not shown.

View File

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

View File

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

View File

@@ -41,7 +41,6 @@ doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuditApi.md
doc/AuditDeletesResponseDto.md
doc/AuthDeviceResponseDto.md
doc/AuthenticationApi.md
doc/BulkIdResponseDto.md
doc/BulkIdsDto.md
@@ -142,6 +141,8 @@ doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerThemeDto.md
doc/ServerVersionResponseDto.md
doc/SessionResponseDto.md
doc/SessionsApi.md
doc/SharedLinkApi.md
doc/SharedLinkCreateDto.md
doc/SharedLinkEditDto.md
@@ -219,6 +220,7 @@ lib/api/partner_api.dart
lib/api/person_api.dart
lib/api/search_api.dart
lib/api/server_info_api.dart
lib/api/sessions_api.dart
lib/api/shared_link_api.dart
lib/api/sync_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/audio_codec.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_ids_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_theme_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_edit_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/audit_api_test.dart
test/audit_deletes_response_dto_test.dart
test/auth_device_response_dto_test.dart
test/authentication_api_test.dart
test/bulk_id_response_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_theme_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_create_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:
- API version: 1.101.0
- API version: 1.102.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -117,11 +117,8 @@ Class | Method | HTTP request | Description
*AuditApi* | [**getAuditFiles**](doc//AuditApi.md#getauditfiles) | **GET** /audit/file-report |
*AuditApi* | [**getFileChecksums**](doc//AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum |
*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* | [**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* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*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* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*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* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link |
*SharedLinkApi* | [**getAllSharedLinks**](doc//SharedLinkApi.md#getallsharedlinks) | **GET** /shared-link |
@@ -258,7 +258,6 @@ Class | Method | HTTP request | Description
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
- [AuditDeletesResponseDto](doc//AuditDeletesResponseDto.md)
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
- [BulkIdsDto](doc//BulkIdsDto.md)
- [CLIPConfig](doc//CLIPConfig.md)
@@ -348,6 +347,7 @@ Class | Method | HTTP request | Description
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerThemeDto](doc//ServerThemeDto.md)
- [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

View File

@@ -10,11 +10,8 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**changePassword**](AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
[**getAuthDevices**](AuthenticationApi.md#getauthdevices) | **GET** /auth/devices |
[**login**](AuthenticationApi.md#login) | **POST** /auth/login |
[**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 |
[**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)
# **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**
> 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)
# **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**
> UserResponseDto signUpAdmin(signUpDto)

View File

@@ -1,4 +1,4 @@
# openapi.model.AuthDeviceResponseDto
# openapi.model.SessionResponseDto
## Load the model package
```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
------------ | ------------- | ------------- | -------------
**colorspace** | [**Colorspace**](Colorspace.md) | |
**extractEmbedded** | **bool** | |
**previewFormat** | [**ImageFormat**](ImageFormat.md) | |
**previewSize** | **int** | |
**quality** | **int** | |

View File

@@ -45,6 +45,7 @@ part 'api/partner_api.dart';
part 'api/person_api.dart';
part 'api/search_api.dart';
part 'api/server_info_api.dart';
part 'api/sessions_api.dart';
part 'api/shared_link_api.dart';
part 'api/sync_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/audio_codec.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_ids_dto.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_theme_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_edit_dto.dart';
part 'model/shared_link_response_dto.dart';

View File

@@ -63,50 +63,6 @@ class AuthenticationApi {
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].
/// Parameters:
///
@@ -195,79 +151,6 @@ class AuthenticationApi {
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].
/// 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);
case 'AuditDeletesResponseDto':
return AuditDeletesResponseDto.fromJson(value);
case 'AuthDeviceResponseDto':
return AuthDeviceResponseDto.fromJson(value);
case 'BulkIdResponseDto':
return BulkIdResponseDto.fromJson(value);
case 'BulkIdsDto':
@@ -428,6 +426,8 @@ class ApiClient {
return ServerThemeDto.fromJson(value);
case 'ServerVersionResponseDto':
return ServerVersionResponseDto.fromJson(value);
case 'SessionResponseDto':
return SessionResponseDto.fromJson(value);
case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':

View File

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

View File

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

View File

@@ -22,11 +22,6 @@ void main() {
// TODO
});
//Future<List<AuthDeviceResponseDto>> getAuthDevices() async
test('test getAuthDevices', () async {
// TODO
});
//Future<LoginResponseDto> login(LoginCredentialDto loginCredentialDto) async
test('test login', () async {
// TODO
@@ -37,16 +32,6 @@ void main() {
// 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
test('test signUpAdmin', () async {
// TODO

View File

@@ -11,11 +11,11 @@
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AuthDeviceResponseDto
// tests for SessionResponseDto
void main() {
// final instance = AuthDeviceResponseDto();
// final instance = SessionResponseDto();
group('test AuthDeviceResponseDto', () {
group('test SessionResponseDto', () {
// String createdAt
test('to test the property `createdAt`', () async {
// 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
});
// bool extractEmbedded
test('to test the property `extractEmbedded`', () async {
// TODO
});
// ImageFormat previewFormat
test('to test the property `previewFormat`', () async {
// TODO

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.101.0+131
version: 1.102.0+132
environment:
sdk: '>=3.0.0 <4.0.0'
@@ -105,9 +105,6 @@ flutter:
- assets/
- assets/i18n/
fonts:
- family: SnowburstOne
fonts:
- asset: fonts/SnowburstOne.ttf
- family: Inconsolata
fonts:
- 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": {
"post": {
"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": {
"get": {
"operationId": "getAllSharedLinks",
@@ -7006,7 +7006,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.101.0",
"version": "1.102.0",
"contact": {}
},
"tags": [],
@@ -7892,37 +7892,6 @@
],
"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": {
"properties": {
"error": {
@@ -10049,6 +10018,37 @@
],
"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": {
"properties": {
"albumId": {
@@ -10531,6 +10531,9 @@
"colorspace": {
"$ref": "#/components/schemas/Colorspace"
},
"extractEmbedded": {
"type": "boolean"
},
"previewFormat": {
"$ref": "#/components/schemas/ImageFormat"
},
@@ -10549,6 +10552,7 @@
},
"required": [
"colorspace",
"extractEmbedded",
"previewFormat",
"previewSize",
"quality",

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.101.0
* 1.102.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -346,14 +346,6 @@ export type ChangePasswordDto = {
newPassword: string;
password: string;
};
export type AuthDeviceResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
updatedAt: string;
};
export type LoginCredentialDto = {
email: string;
password: string;
@@ -791,6 +783,14 @@ export type ServerVersionResponseDto = {
minor: number;
patch: number;
};
export type SessionResponseDto = {
createdAt: string;
current: boolean;
deviceOS: string;
deviceType: string;
id: string;
updatedAt: string;
};
export type SharedLinkResponseDto = {
album?: AlbumResponseDto;
allowDownload: boolean;
@@ -864,6 +864,7 @@ export type SystemConfigFFmpegDto = {
};
export type SystemConfigImageDto = {
colorspace: Colorspace;
extractEmbedded: boolean;
previewFormat: ImageFormat;
previewSize: number;
quality: number;
@@ -1703,28 +1704,6 @@ export function changePassword({ 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 }: {
loginCredentialDto: LoginCredentialDto;
}, opts?: Oazapfts.RequestOpts) {
@@ -2413,6 +2392,28 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) {
...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) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View File

@@ -1,5 +1,5 @@
# 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
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
# 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
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
@@ -41,7 +41,7 @@ RUN npm run 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
ENV NODE_ENV=production \

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
import { Command, CommandRunner } from 'nest-commander';
import { UserEntity } from 'src/entities/user.entity';
import { UserService } from 'src/services/user.service';
@Command({
@@ -13,16 +12,7 @@ export class ListUsersCommand extends CommandRunner {
async run(): Promise<void> {
try {
const users = await this.userService.getAll(
{
user: {
id: 'cli',
email: 'cli@immich.app',
isAdmin: true,
} as UserEntity,
},
true,
);
const users = await this.userService.listUsers();
console.dir(users);
} catch (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 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 {
PASSWORD = 'password',
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 { Request, Response } from 'express';
import { IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE, IMMICH_IS_AUTHENTICATED } from 'src/constants';
import { AuthType } from 'src/constants';
import {
AuthDeviceResponseDto,
AuthDto,
ChangePasswordDto,
ImmichCookie,
LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto,
@@ -15,7 +15,7 @@ import {
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/middleware/auth.guard';
import { AuthService, LoginDetails } from 'src/services/auth.service';
import { UUIDParamDto } from 'src/validation';
import { respondWithCookie, respondWithoutCookie } from 'src/utils/response';
@ApiTags('Authentication')
@Controller('auth')
@@ -30,9 +30,15 @@ export class AuthController {
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<LoginResponseDto> {
const { response, cookie } = await this.service.login(loginCredential, loginDetails);
res.header('Set-Cookie', cookie);
return response;
const body = await this.service.login(loginCredential, loginDetails);
return respondWithCookie(res, body, {
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()
@@ -41,23 +47,6 @@ export class AuthController {
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')
@HttpCode(HttpStatus.OK)
validateAccessToken(): ValidateAccessTokenResponseDto {
@@ -72,15 +61,18 @@ export class AuthController {
@Post('logout')
@HttpCode(HttpStatus.OK)
logout(
async logout(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@Auth() auth: AuthDto,
): Promise<LogoutResponseDto> {
res.clearCookie(IMMICH_ACCESS_COOKIE);
res.clearCookie(IMMICH_AUTH_TYPE_COOKIE);
res.clearCookie(IMMICH_IS_AUTHENTICATED);
const authType = (request.cookies || {})[ImmichCookie.AUTH_TYPE];
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

@@ -14,9 +14,9 @@ import { MemoryController } from 'src/controllers/memory.controller';
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { PluginController } from 'src/controllers/plugin.controller';
import { SearchController } from 'src/controllers/search.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 { SyncController } from 'src/controllers/sync.controller';
import { SystemConfigController } from 'src/controllers/system-config.controller';
@@ -42,9 +42,9 @@ export const controllers = [
MemoryController,
OAuthController,
PartnerController,
PluginController,
SearchController,
ServerInfoController,
SessionController,
SharedLinkController,
SyncController,
SystemConfigController,

View File

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

View File

@@ -1,49 +0,0 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginImportDto, PluginResponseDto, PluginUpdateDto, SearchPluginDto } from 'src/dtos/plugin.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Plugin')
@Controller('plugins')
@Authenticated()
export class PluginController {
constructor(private service: PluginService) {}
@Get()
searchPlugins(@Auth() auth: AuthDto, @Query() dto: SearchPluginDto): Promise<PluginResponseDto[]> {
return this.service.search(auth, dto);
}
@Post()
importPlugin(@Auth() auth: AuthDto, @Body() dto: PluginImportDto): Promise<PluginResponseDto> {
return this.service.import(auth, dto);
}
@Put(':id')
updatePlugin(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: PluginUpdateDto,
): Promise<PluginResponseDto> {
return this.service.update(auth, id, dto);
}
@Post(':id/install')
installPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.install(auth, id);
}
@Post(':id/uninstall')
uninstallPlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.uninstall(auth, id);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deletePlugin(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

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 { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { IMMICH_SHARED_LINK_ACCESS_COOKIE } from 'src/constants';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AuthDto, ImmichCookie } from 'src/dtos/auth.dto';
import {
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkPasswordDto,
SharedLinkResponseDto,
} 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 { respondWithCookie } from 'src/utils/response';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Shared Link')
@@ -33,20 +34,17 @@ export class SharedLinkController {
@Query() dto: SharedLinkPasswordDto,
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> {
const sharedLinkToken = request.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE];
const sharedLinkToken = request.cookies?.[ImmichCookie.SHARED_LINK_TOKEN];
if (sharedLinkToken) {
dto.token = sharedLinkToken;
}
const response = await this.service.getMine(auth, dto);
if (response.token) {
res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, response.token, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24),
httpOnly: true,
sameSite: 'lax',
});
}
return response;
const body = await this.service.getMine(auth, dto);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: body.token ? [{ key: ImmichCookie.SHARED_LINK_TOKEN, value: body.token }] : [],
});
}
@Get(':id')

View File

@@ -43,13 +43,6 @@ export enum Permission {
PERSON_CREATE = 'person.create',
PERSON_REASSIGN = 'person.reassign',
PLUGIN_READ = 'plugin.read',
PLUGIN_WRITE = 'plugin.write',
PLUGIN_DELETE = 'plugin.delete',
PLUGIN_ADMIN = 'plugin.admin',
PLUGIN_INSTALL = 'plugin.install',
PLUGIN_UNINSTALL = 'plugin.uninstall',
PARTNER_UPDATE = 'partner.update',
}

View File

@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
@@ -308,4 +309,8 @@ export class StorageCore {
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
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,
quality: 80,
colorspace: Colorspace.P3,
extractEmbedded: false,
},
newVersionCheck: {
enabled: true,

View File

@@ -2,16 +2,35 @@ import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserTokenEntity } from 'src/entities/user-token.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 {
user!: UserEntity;
apiKey?: APIKeyEntity;
sharedLink?: SharedLinkEntity;
userToken?: UserTokenEntity;
session?: SessionEntity;
}
export class LoginCredentialDto {
@@ -39,7 +58,7 @@ export class LoginResponseDto {
export function mapLoginResponse(entity: UserEntity, accessToken: string): LoginResponseDto {
return {
accessToken: accessToken,
accessToken,
userId: entity.id,
userEmail: entity.email,
name: entity.name,
@@ -78,24 +97,6 @@ export class ValidateAccessTokenResponseDto {
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 {
@IsNotEmpty()
@IsString()

View File

@@ -1,58 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsString } from 'class-validator';
import { PluginEntity } from 'src/entities/plugin.entity';
import { Optional, ValidateBoolean } from 'src/validation';
export class SearchPluginDto {
@ValidateBoolean({ optional: true })
isEnabled?: boolean;
@ValidateBoolean({ optional: true })
isOfficial?: boolean;
@ValidateBoolean({ optional: true })
isInstalled?: boolean;
@IsString()
@Optional()
name?: string;
}
export class PluginImportDto {
url!: string;
install!: boolean;
isEnabled!: boolean;
isOfficial!: boolean;
}
export class PluginUpdateDto {
@IsBoolean()
isEnabled!: boolean;
}
export class PluginResponseDto {
id!: string;
createdAt!: Date;
updatedAt!: Date;
packageId!: string;
@ApiProperty({ type: 'integer' })
version!: number;
name!: string;
description!: string;
isEnabled!: boolean;
isInstalled!: boolean;
isTrusted!: boolean;
}
export const mapPlugin = (plugin: PluginEntity): PluginResponseDto => ({
id: plugin.id,
createdAt: plugin.createdAt,
updatedAt: plugin.updatedAt,
packageId: plugin.packageId,
version: plugin.version,
name: plugin.name,
description: plugin.description,
isEnabled: plugin.isEnabled,
isInstalled: plugin.isInstalled,
isTrusted: plugin.isTrusted,
});

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)
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
colorspace!: Colorspace;
@ValidateBoolean()
extractEmbedded!: boolean;
}
class SystemConfigTrashDto {

View File

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

View File

@@ -1,40 +0,0 @@
import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('plugins')
export class PluginEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
deletedAt!: Date | null;
@Column({ unique: true })
packageId!: string;
@Column()
version!: number;
@Column()
name!: string;
@Column()
description!: string;
@Column({ type: 'boolean', default: true })
isEnabled!: boolean;
@Column({ type: 'boolean', default: false })
isInstalled!: boolean;
@Column({ type: 'boolean', default: false })
isTrusted!: boolean;
@Column({ nullable: true })
installPath!: string | null;
}

View File

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

View File

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

View File

@@ -89,9 +89,6 @@ export enum JobName {
SIDECAR_DISCOVERY = 'sidecar-discovery',
SIDECAR_SYNC = 'sidecar-sync',
SIDECAR_WRITE = 'sidecar-write',
// workflows
WORKFLOW_TRIGGER = 'workflow-trigger',
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@@ -138,17 +135,6 @@ export interface IDeferrableJob extends IEntityJob {
deferred?: boolean;
}
export enum WorkflowTriggerType {
ASSET_UPLOAD = 'asset.upload',
ASSET_UPDATE = 'asset.update',
ASSET_TRASH = 'asset.trash',
ASSET_DELETE = 'asset.delete',
}
export type IWorkflowTriggerJob =
| { type: WorkflowTriggerType.ASSET_UPLOAD; data: { assetId: string } }
| { type: WorkflowTriggerType.ASSET_UPDATE; data: { asset2Id: string } };
export interface JobCounts {
active: number;
completed: number;
@@ -230,8 +216,7 @@ export type JobItem =
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
| { name: JobName.WORKFLOW_TRIGGER; data: IWorkflowTriggerJob };
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
export enum JobStatus {
SUCCESS = 'success',

View File

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

View File

@@ -1,116 +0,0 @@
import { PluginEntity } from 'src/entities/plugin.entity';
export const IPluginRepository = 'IPluginRepository';
export interface PluginSearchOptions {
id?: string;
namespace?: string;
version?: number;
name?: string;
isEnabled?: boolean;
isInstalled?: boolean;
isOfficial?: boolean;
}
export interface IPluginRepository {
search(options: PluginSearchOptions): Promise<PluginEntity[]>;
create(dto: Partial<PluginEntity>): Promise<PluginEntity>;
get(id: string): Promise<PluginEntity | null>;
update(dto: Partial<PluginEntity>): Promise<PluginEntity>;
delete(id: string): Promise<void>;
download(url: string, downloadPath: string): Promise<void>;
load(pluginPath: string): Promise<PluginLike>;
}
export type PluginFactory = {
register: () => MaybePromise<Plugin>;
};
export type PluginLike = Plugin | PluginFactory | { default: Plugin } | { plugin: Plugin };
export interface Plugin<T extends PluginConfig | undefined = undefined> {
version: 1;
id: string;
name: string;
description: string;
actions: PluginAction<T>[];
}
export type PluginAction<T extends PluginConfig | undefined = undefined> = {
id: string;
name: string;
description: string;
events?: EventType[];
config?: T;
} & (
| { type: ActionType.ASSET; onAction: OnAction<T, AssetDto> }
| { type: ActionType.ALBUM; onAction: OnAction<T, AlbumDto> }
| { type: ActionType.ALBUM_ASSET; onAction: OnAction<T, { asset: AssetDto; album: AlbumDto }> }
);
export type OnAction<T extends PluginConfig | undefined, D = PluginActionData> = T extends undefined
? (ctx: PluginContext, data: D) => MaybePromise<void>
: (ctx: PluginContext, data: D, config: InferConfig<T>) => MaybePromise<void>;
export interface PluginContext {
updateAsset: (asset: { id: string; isArchived: boolean }) => Promise<void>;
}
export type PluginActionData = { data: { asset?: AssetDto; album?: AlbumDto } } & (
| { type: EventType.ASSET_UPLOAD; data: { asset: AssetDto } }
| { type: EventType.ASSET_UPDATE; data: { asset: AssetDto } }
| { type: EventType.ASSET_TRASH; data: { asset: AssetDto } }
| { type: EventType.ASSET_DELETE; data: { asset: AssetDto } }
| { type: EventType.ALBUM_CREATE; data: { album: AlbumDto } }
| { type: EventType.ALBUM_UPDATE; data: { album: AlbumDto } }
);
export type PluginConfig = Record<string, ConfigItem>;
export type ConfigItem = {
name: string;
description: string;
required?: boolean;
} & { [K in Types]: { type: K; default?: InferType<K> } }[Types];
export type InferType<T extends Types> = T extends 'string'
? string
: T extends 'date'
? Date
: T extends 'number'
? number
: T extends 'boolean'
? boolean
: never;
type Types = 'string' | 'boolean' | 'number' | 'date';
type MaybePromise<T = void> = Promise<T> | T;
type IfRequired<T extends ConfigItem, Type> = T['required'] extends true ? Type : Type | undefined;
type InferConfig<T> = T extends PluginConfig
? {
[K in keyof T]: IfRequired<T[K], InferType<T[K]['type']>>;
}
: never;
export enum ActionType {
ASSET = 'asset',
ALBUM = 'album',
ALBUM_ASSET = 'album-asset',
}
export enum EventType {
ASSET_UPLOAD = 'asset.upload',
ASSET_UPDATE = 'asset.update',
ASSET_TRASH = 'asset.trash',
ASSET_DELETE = 'asset.delete',
ASSET_ARCHIVE = 'asset.archvie',
ASSET_UNARCHIVE = 'asset.unarchive',
ALBUM_CREATE = 'album.create',
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
}
export type AssetDto = { id: string; type: 'asset' };
export type AlbumDto = { id: string; type: 'album' };

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

@@ -1,55 +0,0 @@
import { ActionType, AssetDto, Plugin, PluginContext } from 'src/interfaces/plugin.interface';
const onAsset = async (ctx: PluginContext, asset: AssetDto) => {
await ctx.updateAsset({ id: asset.id, isArchived: true });
};
export const plugin: Plugin = {
version: 1,
id: 'immich-plugin-asset',
name: 'Asset Plugin',
description: 'Immich asset plugin',
actions: [
{
id: 'asset.favorite',
name: '',
type: ActionType.ASSET,
description: '',
onAction: async (ctx, asset) => {
await ctx.updateAsset({ id: asset.id, isArchived: false });
},
},
{
id: 'asset.unfavorite',
name: '',
type: ActionType.ASSET,
description: '',
onAction: () => {
console.log('Unfavorite');
},
},
{
id: 'asset.action',
name: '',
type: ActionType.ASSET,
description: '',
onAction: (ctx, asset) => onAsset(ctx, asset),
},
{
id: 'album-asset.action',
name: '',
type: ActionType.ALBUM_ASSET,
description: '',
onAction: (ctx, { asset }) => onAsset(ctx, asset),
},
{
id: 'asset.unarchive',
name: '',
type: ActionType.ASSET,
description: '',
onAction: () => {
console.log('Unarchive');
},
},
],
};

View File

@@ -173,13 +173,13 @@ WHERE
-- AccessRepository.authDevice.checkOwnerAccess
SELECT
"UserTokenEntity"."id" AS "UserTokenEntity_id"
"SessionEntity"."id" AS "SessionEntity_id"
FROM
"user_token" "UserTokenEntity"
"sessions" "SessionEntity"
WHERE
(
("UserTokenEntity"."userId" = $1)
AND ("UserTokenEntity"."id" IN ($2))
("SessionEntity"."userId" = $1)
AND ("SessionEntity"."id" IN ($2))
)
-- 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)
FROM
"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"
WHERE
"assets"."ownerId" = users.id
AND NOT "assets"."isExternal"
AND "library"."type" = 'UPLOAD'
),
"updatedAt" = CURRENT_TIMESTAMP
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 { PartnerEntity } from 'src/entities/partner.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SessionEntity } from 'src/entities/session.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 { Instrumentation } from 'src/utils/instrumentation';
import { Brackets, In, Repository } from 'typeorm';
@@ -286,7 +286,7 @@ class AssetAccess implements IAssetAccess {
}
class AuthDeviceAccess implements IAuthDeviceAccess {
constructor(private tokenRepository: Repository<UserTokenEntity>) {}
constructor(private sessionRepository: Repository<SessionEntity>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
@@ -295,7 +295,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess {
return new Set();
}
return this.tokenRepository
return this.sessionRepository
.find({
select: { id: true },
where: {
@@ -457,12 +457,12 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(PersonEntity) personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(UserTokenEntity) tokenRepository: Repository<UserTokenEntity>,
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
) {
this.activity = new ActivityAccess(activityRepository, albumRepository);
this.album = new AlbumAccess(albumRepository, 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.memory = new MemoryAccess(memoryRepository);
this.person = new PersonAccess(assetFaceRepository, personRepository);

View File

@@ -20,15 +20,14 @@ import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IPluginRepository } from 'src/interfaces/plugin.interface';
import { ISearchRepository } from 'src/interfaces/search.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 { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserTokenRepository } from 'src/interfaces/user-token.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@@ -52,15 +51,14 @@ import { MetricRepository } from 'src/repositories/metric.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { SearchRepository } from 'src/repositories/search.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 { StorageRepository } from 'src/repositories/storage.repository';
import { SystemConfigRepository } from 'src/repositories/system-config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { UserTokenRepository } from 'src/repositories/user-token.repository';
import { UserRepository } from 'src/repositories/user.repository';
export const repositories = [
@@ -85,15 +83,14 @@ export const repositories = [
{ provide: IMoveRepository, useClass: MoveRepository },
{ provide: IPartnerRepository, useClass: PartnerRepository },
{ provide: IPersonRepository, useClass: PersonRepository },
{ provide: IPluginRepository, useClass: PluginRepository },
{ provide: IServerInfoRepository, useClass: ServerInfoRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISearchRepository, useClass: SearchRepository },
{ provide: ISessionRepository, useClass: SessionRepository },
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
];

View File

@@ -77,9 +77,6 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.LIBRARY_REMOVE_OFFLINE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SCAN_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
// workflows
[JobName.WORKFLOW_TRIGGER]: QueueName.BACKGROUND_TASK,
};
@Instrumentation()

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { exiftool } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import fs from 'node:fs/promises';
import { Writable } from 'node:stream';
@@ -9,6 +10,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
CropOptions,
IMediaRepository,
ImageDimensions,
ResizeOptions,
TranscodeOptions,
VideoInfo,
@@ -26,6 +28,23 @@ export class MediaRepository implements IMediaRepository {
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
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> {
return sharp(input, { failOn: 'none' })
.pipelineColorspace('rgb16')
@@ -133,6 +152,11 @@ export class MediaRepository implements IMediaRepository {
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) {
return ffmpeg(input, { niceness: 10 })
.inputOptions(options.inputOptions)
@@ -140,9 +164,4 @@ export class MediaRepository implements IMediaRepository {
.output(output)
.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,56 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { writeFile } from 'node:fs/promises';
import { PluginEntity } from 'src/entities/plugin.entity';
import { IPluginRepository, PluginLike, PluginSearchOptions } from 'src/interfaces/plugin.interface';
import { ImmichLogger } from 'src/utils/logger';
import { Repository } from 'typeorm';
@Injectable()
export class PluginRepository implements IPluginRepository {
private logger = new ImmichLogger(PluginRepository.name);
constructor(@InjectRepository(PluginEntity) private repository: Repository<PluginEntity>) {}
search(options: PluginSearchOptions): Promise<PluginEntity[]> {
return this.repository.find({
where: {
id: options.id,
packageId: options.namespace,
version: options.version,
name: options.name,
isEnabled: options.isEnabled,
isInstalled: options.isInstalled,
isTrusted: options.isOfficial,
},
});
}
create(dto: Partial<PluginEntity>): Promise<PluginEntity> {
return this.repository.save(dto);
}
get(id: string): Promise<PluginEntity | null> {
return this.repository.findOne({ where: { id } });
}
update(dto: Partial<PluginEntity>): Promise<PluginEntity> {
return this.repository.save(dto);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
async download(url: string, downloadPath: string): Promise<void> {
try {
const { json } = await fetch(url);
await writeFile(downloadPath, await json());
} catch (error) {
this.logger.error(`Error downloading the plugin from ${url}. ${error}`);
}
}
load(pluginPath: string): Promise<PluginLike> {
return import(pluginPath);
}
}

View File

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

View File

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

View File

@@ -392,7 +392,9 @@ export class AssetService {
}
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);
// 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 { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.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 { AuthService } from 'src/services/auth.service';
import { keyStub } from 'test/fixtures/api-key.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 { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userTokenStub } from 'test/fixtures/user-token.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.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 { 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 { Mock, Mocked, vitest } from 'vitest';
@@ -65,7 +65,7 @@ describe('AuthService', () => {
let libraryMock: Mocked<ILibraryRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let configMock: Mocked<ISystemConfigRepository>;
let userTokenMock: Mocked<IUserTokenRepository>;
let sessionMock: Mocked<ISessionRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let keyMock: Mocked<IKeyRepository>;
@@ -98,7 +98,7 @@ describe('AuthService', () => {
libraryMock = newLibraryRepositoryMock();
loggerMock = newLoggerRepositoryMock();
configMock = newSystemConfigRepositoryMock();
userTokenMock = newUserTokenRepositoryMock();
sessionMock = newSessionRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
@@ -109,7 +109,7 @@ describe('AuthService', () => {
libraryMock,
loggerMock,
userMock,
userTokenMock,
sessionMock,
shareMock,
keyMock,
);
@@ -139,24 +139,10 @@ describe('AuthService', () => {
it('should successfully log the user in', async () => {
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);
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', () => {
@@ -231,14 +217,14 @@ describe('AuthService', () => {
});
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({
successful: true,
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 () => {
@@ -282,11 +268,11 @@ describe('AuthService', () => {
it('should validate using authorization header', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userTokenMock.getByToken.mockResolvedValue(userTokenStub.userToken);
sessionMock.getByToken.mockResolvedValue(sessionStub.valid);
const client = { request: { headers: { authorization: 'Bearer auth_token' } } };
await expect(sut.validate((client as Socket).request.headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
session: sessionStub.valid,
});
});
});
@@ -336,37 +322,29 @@ describe('AuthService', () => {
describe('validate - user token', () => {
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' };
await expect(sut.validate(headers, {})).rejects.toBeInstanceOf(UnauthorizedException);
});
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' };
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
session: sessionStub.valid,
});
});
it('should update when access time exceeds an hour', async () => {
userTokenMock.getByToken.mockResolvedValue(userTokenStub.inactiveToken);
userTokenMock.save.mockResolvedValue(userTokenStub.userToken);
sessionMock.getByToken.mockResolvedValue(sessionStub.inactive);
sessionMock.update.mockResolvedValue(sessionStub.valid);
const headers: IncomingHttpHeaders = { cookie: 'immich_access_token=auth_token' };
await expect(sut.validate(headers, {})).resolves.toEqual({
user: userStub.user1,
userToken: userTokenStub.userToken,
});
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',
session: sessionStub.valid,
});
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', () => {
it('should pass along the query params', () => {
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);
userMock.getByEmail.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(
loginResponseStub.user1oauth,
@@ -478,7 +407,7 @@ describe('AuthService', () => {
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.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(
loginResponseStub.user1oauth,
@@ -491,7 +420,7 @@ describe('AuthService', () => {
it('should use the mobile redirect override', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
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 () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userStub.user1);
userTokenMock.create.mockResolvedValue(userTokenStub.userToken);
sessionMock.create.mockResolvedValue(sessionStub.valid);
await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);

View File

@@ -10,31 +10,22 @@ import cookieParser from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { ClientMetadata, Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import {
AuthType,
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 { AuthType, LOGIN_URL, MOBILE_REDIRECT } from 'src/constants';
import { AccessCore } from 'src/cores/access.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { UserCore } from 'src/cores/user.core';
import {
AuthDeviceResponseDto,
AuthDto,
ChangePasswordDto,
ImmichCookie,
ImmichHeader,
LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto,
OAuthAuthorizeResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
SignUpDto,
mapLoginResponse,
mapUserToken,
} from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
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 { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.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 { HumanReadableSize } from 'src/utils/bytes';
@@ -57,11 +48,6 @@ export interface LoginDetails {
deviceOS: string;
}
interface LoginResponse {
response: LoginResponseDto;
cookie: string[];
}
interface OAuthProfile extends UserinfoResponse {
email: string;
}
@@ -85,7 +71,7 @@ export class AuthService {
@Inject(ILibraryRepository) libraryRepository: ILibraryRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
@@ -97,7 +83,7 @@ export class AuthService {
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();
if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
@@ -116,12 +102,12 @@ export class AuthService {
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> {
if (auth.userToken) {
await this.userTokenRepository.delete(auth.userToken.id);
if (auth.session) {
await this.sessionRepository.delete(auth.session.id);
}
return {
@@ -163,19 +149,20 @@ export class AuthService {
}
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthDto> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
const userToken = (headers['x-immich-user-token'] ||
params.userToken ||
const shareKey = (headers[ImmichHeader.SHARED_LINK_TOKEN] || params.key) as string;
const session = (headers[ImmichHeader.USER_TOKEN] ||
headers[ImmichHeader.SESSION_TOKEN] ||
params.sessionKey ||
this.getBearerToken(headers) ||
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) {
return this.validateSharedLink(shareKey);
}
if (userToken) {
return this.validateUserToken(userToken);
if (session) {
return this.validateSession(session);
}
if (apiKey) {
@@ -185,26 +172,6 @@ export class AuthService {
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) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
@@ -225,10 +192,7 @@ export class AuthService {
return { url };
}
async callback(
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
async callback(dto: OAuthCallbackDto, loginDetails: LoginDetails) {
const config = await this.configCore.getConfig();
const profile = await this.getOAuthProfile(config, dto.url);
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> {
@@ -374,7 +338,7 @@ export class AuthService {
private getCookieToken(headers: IncomingHttpHeaders): string | null {
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> {
@@ -408,57 +372,36 @@ export class AuthService {
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);
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 updatedAt = DateTime.fromJSDate(userToken.updatedAt);
const updatedAt = DateTime.fromJSDate(session.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
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');
}
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
private async createLoginResponse(user: UserEntity, loginDetails: LoginDetails) {
const key = this.cryptoRepository.newPassword(32);
const token = this.cryptoRepository.hashSha256(key);
await this.userTokenRepository.create({
await this.sessionRepository.create({
token,
user,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
});
const response = 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];
return mapLoginResponse(user, key);
}
private getClaim<T>(profile: OAuthProfile, options: ClaimOptions<T>): T {

View File

@@ -16,9 +16,9 @@ import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { PluginService } from 'src/services/plugin.service';
import { SearchService } from 'src/services/search.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 { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
@@ -29,7 +29,6 @@ import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { WorkflowService } from 'src/services/workflow.service';
export const services = [
ApiService,
@@ -50,9 +49,9 @@ export const services = [
MetadataService,
PartnerService,
PersonService,
PluginService,
SearchService,
ServerInfoService,
SessionService,
SharedLinkService,
SmartInfoService,
StorageService,
@@ -63,5 +62,4 @@ export const services = [
TimelineService,
TrashService,
UserService,
WorkflowService,
];

View File

@@ -16,7 +16,6 @@ import {
JobStatus,
QueueCleanType,
QueueName,
WorkflowTriggerType,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
@@ -295,13 +294,6 @@ export class JobService {
if (asset && asset.isVisible) {
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
}
if (asset) {
await this.jobRepository.queue({
name: JobName.WORKFLOW_TRIGGER,
data: { type: WorkflowTriggerType.ASSET_UPLOAD, data: { assetId: asset.id } },
});
}
break;
}

View File

@@ -225,6 +225,15 @@ describe(MediaService.name, () => {
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) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
@@ -353,6 +362,15 @@ describe(MediaService.name, () => {
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))(
'should generate a %s thumbnail for an image when specified',
async (format) => {
@@ -375,14 +393,12 @@ describe(MediaService.name, () => {
});
it('should generate a P3 thumbnail for a wide gamut image', async () => {
assetMock.getByIds.mockResolvedValue([
{ ...assetStub.image, exifInfo: { profileDescription: 'Adobe RGB', bitsPerSample: 14 } as ExifEntity },
]);
assetMock.getByIds.mockResolvedValue([assetStub.imageDng]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith(
'/original/path.jpg',
assetStub.imageDng.originalPath,
'upload/thumbs/user-id/as/se/asset-id-thumbnail.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 () => {
assetMock.getByIds.mockResolvedValue([]);
await sut.handleGenerateThumbhash({ id: assetStub.image.id });
@@ -410,6 +515,15 @@ describe(MediaService.name, () => {
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 () => {
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
assetMock.getByIds.mockResolvedValue([assetStub.image]);

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
import { dirname } from 'node:path';
import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
@@ -42,6 +43,7 @@ import {
VAAPIConfig,
VP9Config,
} from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination';
@Injectable()
@@ -77,7 +79,7 @@ export class MediaService {
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
? this.assetRepository.getAll(pagination, { isVisible: true })
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
});
@@ -178,6 +180,10 @@ export class MediaService {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
await this.assetRepository.update({ id: asset.id, previewPath });
return JobStatus.SUCCESS;
@@ -191,9 +197,21 @@ export class MediaService {
switch (asset.type) {
case AssetType.IMAGE: {
const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace;
const imageOptions = { format, size, colorspace, quality: image.quality };
await this.mediaRepository.resize(asset.originalPath, path, imageOptions);
const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath);
const extractedPath = StorageCore.getTempPathInDir(dirname(path));
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;
}
@@ -230,6 +248,10 @@ export class MediaService {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
await this.assetRepository.update({ id: asset.id, thumbnailPath });
return JobStatus.SUCCESS;
@@ -237,7 +259,15 @@ export class MediaService {
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> {
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;
}
@@ -511,7 +541,7 @@ export class MediaService {
}
}
parseBitrateToBps(bitrateString: string) {
private parseBitrateToBps(bitrateString: string) {
const bitrateValue = Number.parseInt(bitrateString);
if (Number.isNaN(bitrateValue)) {
@@ -526,4 +556,11 @@ export class MediaService {
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 { IStorageRepository } from 'src/interfaces/storage.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 { assetStub } from 'test/fixtures/asset.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 { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(MetadataService.name, () => {
@@ -50,6 +52,7 @@ describe(MetadataService.name, () => {
let storageMock: Mocked<IStorageRepository>;
let eventMock: Mocked<IEventRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let sut: MetadataService;
@@ -66,6 +69,7 @@ describe(MetadataService.name, () => {
storageMock = newStorageRepositoryMock();
mediaMock = newMediaRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new MetadataService(
@@ -81,6 +85,7 @@ describe(MetadataService.name, () => {
personMock,
storageMock,
configMock,
userMock,
loggerMock,
);
});
@@ -372,6 +377,7 @@ describe(MetadataService.name, () => {
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
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(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoStillAsset.id,
@@ -400,6 +406,7 @@ describe(MetadataService.name, () => {
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
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(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoStillAsset.id,
@@ -426,6 +433,7 @@ describe(MetadataService.name, () => {
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
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(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoStillAsset.id,
@@ -444,6 +452,8 @@ describe(MetadataService.name, () => {
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
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]);
metadataMock.readTags.mockResolvedValue({
Directory: 'foo/bar/',
@@ -462,6 +472,8 @@ describe(MetadataService.name, () => {
});
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
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 () => {
const tags: ImmichTags = {
BitsPerSample: 1,

View File

@@ -32,6 +32,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { handlePromiseError } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@@ -114,6 +115,7 @@ export class MetadataService {
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(MetadataService.name);
@@ -446,10 +448,14 @@ export class MetadataService {
this.storageCore.ensureFolders(motionPath);
await this.storageRepository.writeFile(motionAsset.originalPath, video);
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) {
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
// If the asset already had an associated livePhotoVideo, delete it, because
// 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)

View File

@@ -13,7 +13,6 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { UserService } from 'src/services/user.service';
import { WorkflowService } from 'src/services/workflow.service';
import { otelSDK } from 'src/utils/instrumentation';
@Injectable()
@@ -32,7 +31,6 @@ export class MicroservicesService {
private storageService: StorageService,
private userService: UserService,
private databaseService: DatabaseService,
private workflowService: WorkflowService,
) {}
async init() {
@@ -79,7 +77,6 @@ export class MicroservicesService {
[JobName.LIBRARY_REMOVE_OFFLINE]: (data) => this.libraryService.handleOfflineRemoval(data),
[JobName.LIBRARY_QUEUE_SCAN_ALL]: (data) => this.libraryService.handleQueueAllScan(data),
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.WORKFLOW_TRIGGER]: (data) => this.workflowService.handleTrigger(data),
});
await this.metadataService.init();

View File

@@ -292,7 +292,12 @@ export class PersonService {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
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);
});
@@ -322,6 +327,10 @@ export class PersonService {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
return JobStatus.SKIPPED;
}
const faces = await this.machineLearningRepository.detectFaces(
machineLearning.url,
{ imagePath: asset.previewPath },
@@ -424,7 +433,7 @@ export class PersonService {
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) {
this.logger.debug(`Deferring non-core face ${id} for later processing`);
await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } });

View File

@@ -1,108 +0,0 @@
import { BadRequestException, Inject } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginImportDto, PluginUpdateDto, SearchPluginDto, mapPlugin } from 'src/dtos/plugin.dto';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPluginRepository, Plugin, PluginFactory } from 'src/interfaces/plugin.interface';
export class PluginService {
private access: AccessCore;
constructor(
@Inject(IPluginRepository) private pluginRepository: IPluginRepository,
@Inject(IAccessRepository) accessRepository: IAccessRepository,
) {
this.access = AccessCore.create(accessRepository);
}
search(auth: AuthDto, dto: SearchPluginDto) {
// await this.access.requirePermission(authUser, Permission.PLUGIN_READ);
return this.pluginRepository.search(dto);
}
async import(auth: AuthDto, dto: PluginImportDto) {
// await this.access.requirePermission(authUser, Permission.PLUGIN_ADMIN);
if (dto.url.startsWith('http')) {
//
}
const pluginPath = '/path/to/plugin';
await this.pluginRepository.download(dto.url, pluginPath);
const { version, id, name, description } = await this.load(pluginPath);
const response = await this.pluginRepository.create({
version,
packageId: id,
name,
description,
isInstalled: false,
isEnabled: dto.isEnabled,
isTrusted: false,
installPath: pluginPath,
});
return mapPlugin(response);
}
async install(auth: AuthDto, id: string) {
await this.access.requirePermission(auth, Permission.PLUGIN_INSTALL, id);
// TODO
return this.pluginRepository.update({ id, isInstalled: true });
}
async uninstall(auth: AuthDto, id: string) {
await this.access.requirePermission(auth, Permission.PLUGIN_UNINSTALL, id);
// TODO
return this.pluginRepository.update({ id, isInstalled: false, installPath: null });
}
async update(auth: AuthDto, id: string, dto: PluginUpdateDto) {
await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id);
return this.pluginRepository.update({
id,
isEnabled: dto.isEnabled,
});
}
async delete(auth: AuthDto, id: string) {
await this.access.requirePermission(auth, Permission.PLUGIN_WRITE, id);
await this.findOrFail(id);
await this.pluginRepository.delete(id);
}
private async findOrFail(id: string) {
const plugin = await this.pluginRepository.get(id);
if (!plugin) {
throw new BadRequestException('Plugin not found');
}
return plugin;
}
// TODO security implications
private async load(pluginPath: string): Promise<Plugin> {
const pluginLike = await this.pluginRepository.load(pluginPath);
let plugin: Plugin | undefined;
const pluginModule = pluginLike as { default: Plugin };
if (pluginModule.default) {
plugin = pluginModule.default;
}
const pluginExport = pluginLike as { plugin: Plugin };
if (pluginExport.plugin) {
plugin = pluginExport.plugin;
}
const pluginFactory = pluginLike as PluginFactory;
if (pluginFactory) {
plugin = await pluginFactory.register();
}
// TODO use class-validator
const isPlugin = plugin && !!plugin.version && Array.isArray(plugin.actions);
if (isPlugin) {
return plugin as Plugin;
}
throw new Error('Unable to load plugin');
}
}

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);
}
}
}

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